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 3d46fad4a..19aa76163 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 1f26a2abc..2329951af 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 fdec03774..88d36d630 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 b2367ce88..85c944982 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 e86cf7016..779bbdaf6 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 8f62f4ad9..a53f6c401 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 000000000..ca2f2818f --- /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 57bb8acd1..b0c64ba03 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 255e8e972..89079ad8a 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 c92641dec..e1ecca1c9 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 000000000..9b7e8b454 --- /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 de8e97445..ae8dcd5e8 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 eff41a530..8f6191ceb 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 7013ba27e..53395265d 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 188f24b9c..3cbf441ee 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 57b1212e4..1b6eba7b4 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 97c384933..665de99c6 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 80e08b69c..9c6966039 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 51e02d7e8..09baece9d 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 2e5ddb3f7..3979e8919 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 0ff96ae8e..27d79aeee 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 19c9f0e93..2f4ccf75f 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 e6aba8350..5da298e27 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 f8f594499..487f75b21 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 b852e4349..ac0070dbd 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 d1bf04103..ac1648952 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 9a2a3e94a..e36d48cda 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 b840bd864..0e1818436 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 2be9199c5..215b07800 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 0c4fd8f94..f63d46fa0 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 3d6dd2224..427ab6964 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,