From 26b850e57dadfca8708d65a1f4e6746341412117 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 5 Apr 2026 15:25:54 +0200 Subject: [PATCH] feat: Volume Boost setting with system LoudnessEnhancer (#88) Adds a new Settings row "Volume Boost" under the Audio subsection that cycles through 0 / 3 / 6 / 9 / 12 / 15 dB. 0 dB is the default and attaches no effect. Above 0 dB, the player creates an Android `android.media.audiofx.LoudnessEnhancer` bound to the ExoPlayer audio session and applies the target gain. Useful for content whose source audio is very quiet relative to the user's TV/speaker setup \u2014 repeatedly requested in #88 and comparable to the volume boost feature in Debrify TV that the OP referenced. Changes: - `SettingsUiState` / `SettingsViewModel`: new `volumeBoostDb: Int` field. Stored as a string in DataStore via `profileManager.profileStringKey("volume_boost_db")` because ProfileManager has no int helper. Parsed back to Int on read with a 0-15 clamp. `cycleVolumeBoost()` mutator advances through the steps and triggers cloud sync. - `SettingsScreen`: new `Audio` subsection at the bottom of General (below `Network`) containing a single `SettingsRow` for "Volume Boost". The row is placed at `focusedIndex == 14` so no existing indices shift. Both the auto-scroll max-index and the D-pad-down max-index clamps are bumped from 13 to 14. The Enter handler maps `14 -> viewModel.cycleVolumeBoost()`. DNS Provider stays at index 13. - `PlayerUiState` / `PlayerViewModel`: new `volumeBoostDb: Int` field, loaded from the same DataStore key during `loadDetails` initialization. - `PlayerScreen`: new `DisposableEffect(uiState.volumeBoostDb, exoPlayer.audioSessionId)` that creates a `LoudnessEnhancer` when targetDb > 0 and the session id is valid, sets target gain in millibels (dB * 100), enables the effect, and releases it on dispose. Wrapped in try/catch because some Android TV devices reject audio-session effects when HDMI passthrough is enabled for DTS/AC3 \u2014 we fail silently and the user gets unboosted audio but playback still works. - `CloudSyncRepository`: `volumeBoostDb` added to `CloudProfileSettings`, `volumeBoostDbKeyFor(profileId)` helper, push/pull wiring so the setting syncs across devices. Default is 0 so existing users see no change. Stored as string via profileStringKeyFor for the same reason as the SettingsViewModel side. Cap: +15 dB (1500 millibels). Higher values tend to introduce audible distortion on already-compressed streaming audio. The LoudnessEnhancer class supports more but we intentionally don't expose it. This PR touches `SettingsScreen.kt`, `SettingsViewModel.kt`, and `CloudSyncRepository.kt` which PR #131 (Hide Budget, #72) also modifies. Both PRs add a new settings row with different indices and different fields in `CloudProfileSettings`. Whoever merges second will need a small rebase to resolve: - General section max-index should become 15 items (max 15) with both rows present. - `CloudProfileSettings` needs both `showBudget` and `volumeBoostDb` fields. - The Enter handler needs both `13 -> cycleVolumeBoost` and `14 -> openDnsProviderPicker` if #131 lands first, or an additional `15 -> setShowBudget` entry if this PR lands first. The conflict is purely additive and both PRs can coexist on main. Closes #88 --- .../tv/data/repository/CloudSyncRepository.kt | 5 +++ .../tv/ui/screens/player/PlayerScreen.kt | 34 +++++++++++++++++++ .../tv/ui/screens/player/PlayerViewModel.kt | 9 ++++- .../tv/ui/screens/settings/SettingsScreen.kt | 32 +++++++++++++++-- .../ui/screens/settings/SettingsViewModel.kt | 31 +++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index 56bb65f4f..528d3c23d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -60,6 +60,7 @@ class CloudSyncRepository @Inject constructor( val autoPlayMinQuality: String = "Any", val trailerAutoPlay: Boolean = false, val showBudget: Boolean = true, + val volumeBoostDb: Int = 0, val includeSpecials: Boolean = false, val iptvHiddenGroups: String = "", val iptvGroupOrder: String = "" @@ -73,6 +74,8 @@ class CloudSyncRepository @Inject constructor( profileManager.profileBooleanKeyFor(profileId, "trailer_auto_play") private fun showBudgetKeyFor(profileId: String) = profileManager.profileBooleanKeyFor(profileId, "show_budget_on_home") + private fun volumeBoostDbKeyFor(profileId: String) = + profileManager.profileStringKeyFor(profileId, "volume_boost_db") private fun subtitleSizeKeyFor(profileId: String) = profileManager.profileStringKeyFor(profileId, "subtitle_size") @@ -155,6 +158,7 @@ class CloudSyncRepository @Inject constructor( trailerAutoPlay = prefs[trailerAutoPlayKeyFor(profile.id)] ?: false, showBudget = prefs[showBudgetKeyFor(profile.id)] ?: true, + volumeBoostDb = prefs[volumeBoostDbKeyFor(profile.id)]?.toIntOrNull()?.coerceIn(0, 15) ?: 0, subtitleSize = prefs[subtitleSizeKeyFor(profile.id)] ?: "Medium", subtitleColor = prefs[subtitleColorKeyFor(profile.id)] ?: "White", iptvHiddenGroups = prefs[iptvHiddenGroupsKeyFor(profile.id)] ?: "", @@ -375,6 +379,7 @@ class CloudSyncRepository @Inject constructor( prefs[trailerAutoPlayKeyFor(profileId)] = state.trailerAutoPlay prefs[showBudgetKeyFor(profileId)] = state.showBudget + prefs[volumeBoostDbKeyFor(profileId)] = state.volumeBoostDb.coerceIn(0, 15).toString() prefs[subtitleSizeKeyFor(profileId)] = state.subtitleSize prefs[subtitleColorKeyFor(profileId)] = state.subtitleColor if (state.iptvHiddenGroups.isNotBlank()) prefs[iptvHiddenGroupsKeyFor(profileId)] = state.iptvHiddenGroups diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 645ed8344..351b1ceba 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -1331,6 +1331,40 @@ fun PlayerScreen( } } + // Volume boost via system LoudnessEnhancer attached to the ExoPlayer audio session. + // Re-attached whenever the audio session id changes (new stream / source switch) or + // the user changes the boost in Settings (though in practice that requires reopening + // the player since Settings changes don't propagate mid-session yet). 0 dB = no + // effect created, no CPU cost. Issue #88. + DisposableEffect(uiState.volumeBoostDb, exoPlayer.audioSessionId) { + val sessionId = exoPlayer.audioSessionId + val targetDb = uiState.volumeBoostDb + val enhancer: android.media.audiofx.LoudnessEnhancer? = + if (targetDb > 0 && sessionId != C.AUDIO_SESSION_ID_UNSET) { + try { + android.media.audiofx.LoudnessEnhancer(sessionId).apply { + setTargetGain(targetDb * 100) // API takes millibels + enabled = true + } + } catch (e: Throwable) { + // Some Android TV devices route audio through HDMI passthrough and + // reject audio-session effects (particularly when passthrough is + // enabled for DTS/AC3). Fail silently — user gets unboosted audio + // but playback still works. + android.util.Log.w("PlayerScreen", "LoudnessEnhancer unavailable on this device: ${e.message}") + null + } + } else { + null + } + onDispose { + runCatching { + enhancer?.enabled = false + enhancer?.release() + } + } + } + // Close menus when an error occurs so the error overlay can receive input LaunchedEffect(uiState.error) { if (uiState.error != null) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 115bbe385..a02e2e596 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -64,6 +64,9 @@ data class PlayerUiState( // "auto_play_next" DataStore setting so the player can respect the toggle // and so the post-episode overlay can show a Continue/Cancel prompt. val autoPlayNext: Boolean = true, + // Volume boost in decibels. 0 = disabled, up to 15 dB. The player observes this + // and attaches a LoudnessEnhancer to the ExoPlayer audio session. Issue #88. + val volumeBoostDb: Int = 0, // Skip intro/recap val activeSkipInterval: SkipInterval? = null, val skipIntervalDismissed: Boolean = false @@ -182,6 +185,9 @@ class PlayerViewModel @Inject constructor( val subSize = context.settingsDataStore.data.first()[profileManager.profileStringKey("subtitle_size")] ?: "Medium" val subColor = context.settingsDataStore.data.first()[profileManager.profileStringKey("subtitle_color")] ?: "White" val autoPlayNext = context.settingsDataStore.data.first()[profileManager.profileBooleanKey("auto_play_next")] ?: true + val volumeBoostDb = context.settingsDataStore.data.first()[ + profileManager.profileStringKey("volume_boost_db") + ]?.toIntOrNull()?.coerceIn(0, 15) ?: 0 _uiState.value = PlayerUiState( isLoading = true, isLoadingStreams = true, @@ -189,7 +195,8 @@ class PlayerViewModel @Inject constructor( frameRateMatchingMode = frameRateMatchingMode, subtitleSize = subSize, subtitleColor = subColor, - autoPlayNext = autoPlayNext + autoPlayNext = autoPlayNext, + volumeBoostDb = volumeBoostDb ) // If stream URL provided, use it directly (except magnet links, which require resolution). diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 2fc362f02..4fcac66d4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -264,7 +264,7 @@ fun SettingsScreen( if (scrollState.maxValue <= 0) return@LaunchedEffect val maxIndex = when (sectionIndex) { - 0 -> 14 // General: 15 items (added Show Budget toggle for #72) + 0 -> 15 // General: 16 items (Show Budget #72 + Volume Boost #88) 1 -> 3 // IPTV: Configure + Refresh + Delete + Stalker 2 -> uiState.catalogs.size // Catalogs 3 -> uiState.addons.size // Addons @@ -445,7 +445,7 @@ fun SettingsScreen( Zone.CONTENT -> { // Dynamic max based on current section val maxIndex = when (sectionIndex) { - 0 -> 14 // General: 15 items (added Show Budget toggle for #72) + 0 -> 15 // General: 16 items (Show Budget #72 + Volume Boost #88) 1 -> 3 // IPTV: Configure + Refresh + Delete + Stalker 2 -> uiState.catalogs.size // Catalogs: Add + N catalogs 3 -> uiState.addons.size // Addons: N addons + "Add Custom" button @@ -500,6 +500,7 @@ fun SettingsScreen( 12 -> viewModel.setSkipProfileSelection(!uiState.skipProfileSelection) 13 -> viewModel.setShowBudget(!uiState.showBudget) 14 -> openDnsProviderPicker() + 15 -> viewModel.cycleVolumeBoost() } } 1 -> { // IPTV @@ -629,6 +630,7 @@ fun SettingsScreen( deviceModeOverride = uiState.deviceModeOverride, skipProfileSelection = uiState.skipProfileSelection, showBudget = uiState.showBudget, + volumeBoostDb = uiState.volumeBoostDb, focusedIndex = -1, onSubtitleClick = openSubtitlePicker, onAudioLanguageClick = openAudioLanguagePicker, @@ -645,6 +647,7 @@ fun SettingsScreen( onSubtitleSizeClick = { viewModel.cycleSubtitleSize() }, onSkipProfileSelectionToggle = { viewModel.setSkipProfileSelection(it) }, onShowBudgetToggle = { viewModel.setShowBudget(it) }, + onVolumeBoostClick = { viewModel.cycleVolumeBoost() }, onSubtitleColorClick = { viewModel.cycleSubtitleColor() } ) "iptv" -> IptvSettings( @@ -800,6 +803,7 @@ fun SettingsScreen( deviceModeOverride = uiState.deviceModeOverride, skipProfileSelection = uiState.skipProfileSelection, showBudget = uiState.showBudget, + volumeBoostDb = uiState.volumeBoostDb, focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, onSubtitleClick = openSubtitlePicker, onAudioLanguageClick = openAudioLanguagePicker, @@ -815,6 +819,7 @@ fun SettingsScreen( onContentLanguageClick = openContentLanguagePicker, onSkipProfileSelectionToggle = { viewModel.setSkipProfileSelection(it) }, onShowBudgetToggle = { viewModel.setShowBudget(it) }, + onVolumeBoostClick = { viewModel.cycleVolumeBoost() }, onSubtitleSizeClick = { viewModel.cycleSubtitleSize() }, onSubtitleColorClick = { viewModel.cycleSubtitleColor() } ) @@ -2148,6 +2153,7 @@ private fun GeneralSettings( deviceModeOverride: String = "auto", skipProfileSelection: Boolean = false, showBudget: Boolean = true, + volumeBoostDb: Int = 0, focusedIndex: Int, onSubtitleClick: () -> Unit, onAudioLanguageClick: () -> Unit, @@ -2161,6 +2167,7 @@ private fun GeneralSettings( onContentLanguageClick: () -> Unit = {}, onSkipProfileSelectionToggle: (Boolean) -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, + onVolumeBoostClick: () -> Unit = {}, trailerAutoPlay: Boolean = false, onSubtitleSizeClick: () -> Unit = {}, onSubtitleColorClick: () -> Unit = {}, @@ -2338,6 +2345,27 @@ private fun GeneralSettings( isFocused = focusedIndex == 14, onClick = onDnsProviderClick ) + + // ── Audio ── + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Audio", + style = ArflixTypography.caption.copy(fontSize = 11.sp, letterSpacing = 0.8.sp), + color = TextSecondary.copy(alpha = 0.5f), + modifier = Modifier.padding(start = 4.dp, bottom = 12.dp) + ) + + SettingsRow( + icon = Icons.Default.VolumeUp, + title = "Volume Boost", + subtitle = "Amplify quiet sources (via system LoudnessEnhancer)", + value = when (volumeBoostDb) { + 0 -> "Off" + else -> "+${volumeBoostDb} dB" + }, + isFocused = focusedIndex == 15, + onClick = onVolumeBoostClick + ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 698e3e351..260cc0964 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -78,6 +78,9 @@ data class SettingsUiState( val subtitleColor: String = "White", val trailerAutoPlay: Boolean = false, val showBudget: Boolean = true, + // Volume boost in decibels (0 = off, up to 15 dB). Applied via system LoudnessEnhancer + // attached to the ExoPlayer audio session. Issue #88. + val volumeBoostDb: Int = 0, val includeSpecials: Boolean = false, val isLoggedIn: Boolean = false, val accountEmail: String? = null, @@ -180,6 +183,9 @@ class SettingsViewModel @Inject constructor( private fun autoPlayMinQualityKeyFor(profileId: String) = profileManager.profileStringKeyFor(profileId, "auto_play_min_quality") private fun trailerAutoPlayKey() = profileManager.profileBooleanKey("trailer_auto_play") private fun showBudgetKey() = profileManager.profileBooleanKey("show_budget_on_home") + // Stored as a string because ProfileManager has no int helper and we only persist + // a handful of discrete dB values. Parsed back to Int on read. + private fun volumeBoostDbKey() = profileManager.profileStringKey("volume_boost_db") private fun subtitleSizeKey() = profileManager.profileStringKey("subtitle_size") private fun subtitleColorKey() = profileManager.profileStringKey("subtitle_color") @@ -268,6 +274,7 @@ class SettingsViewModel @Inject constructor( val autoPlayMinQuality = normalizeAutoPlayMinQuality(prefs[autoPlayMinQualityKey()]) val trailerAutoPlay = prefs[trailerAutoPlayKey()] ?: false val showBudget = prefs[showBudgetKey()] ?: true + val volumeBoostDb = prefs[volumeBoostDbKey()]?.toIntOrNull()?.coerceIn(0, 15) ?: 0 val subtitleSize = prefs[subtitleSizeKey()] ?: "Medium" val subtitleColor = prefs[subtitleColorKey()] ?: "White" @@ -308,6 +315,7 @@ class SettingsViewModel @Inject constructor( autoPlayMinQuality = autoPlayMinQuality, trailerAutoPlay = trailerAutoPlay, showBudget = showBudget, + volumeBoostDb = volumeBoostDb, subtitleSize = subtitleSize, subtitleColor = subtitleColor, @@ -796,6 +804,29 @@ class SettingsViewModel @Inject constructor( } } + /** + * Cycle the volume boost through discrete dB steps: 0 -> 3 -> 6 -> 9 -> 12 -> 15 -> 0. + * 0 dB = LoudnessEnhancer disabled (no overhead, no clipping). Above +12 dB is + * cropped to +15 dB since higher values tend to introduce audible distortion on + * streaming content with already-compressed audio. Issue #88. + */ + fun cycleVolumeBoost() { + val current = _uiState.value.volumeBoostDb + val next = when { + current < 3 -> 3 + current < 6 -> 6 + current < 9 -> 9 + current < 12 -> 12 + current < 15 -> 15 + else -> 0 + } + viewModelScope.launch { + context.settingsDataStore.edit { it[volumeBoostDbKey()] = next.toString() } + _uiState.value = _uiState.value.copy(volumeBoostDb = next) + syncLocalStateToCloud(silent = true) + } + } + fun cycleSubtitleSize() { val next = when (_uiState.value.subtitleSize) { "Small" -> "Medium"; "Medium" -> "Large"; "Large" -> "Extra Large"; else -> "Small" } viewModelScope.launch { context.settingsDataStore.edit { it[subtitleSizeKey()] = next }; _uiState.value = _uiState.value.copy(subtitleSize = next); syncLocalStateToCloud(silent = true) }