Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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")
Expand Down Expand Up @@ -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)] ?: "",
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,14 +185,18 @@ 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,
preferredAudioLanguage = preferredAudioLanguage,
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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -500,6 +500,7 @@ fun SettingsScreen(
12 -> viewModel.setSkipProfileSelection(!uiState.skipProfileSelection)
13 -> viewModel.setShowBudget(!uiState.showBudget)
14 -> openDnsProviderPicker()
15 -> viewModel.cycleVolumeBoost()
}
}
1 -> { // IPTV
Expand Down Expand Up @@ -629,6 +630,7 @@ fun SettingsScreen(
deviceModeOverride = uiState.deviceModeOverride,
skipProfileSelection = uiState.skipProfileSelection,
showBudget = uiState.showBudget,
volumeBoostDb = uiState.volumeBoostDb,
focusedIndex = -1,
onSubtitleClick = openSubtitlePicker,
onAudioLanguageClick = openAudioLanguagePicker,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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() }
)
Expand Down Expand Up @@ -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,
Expand All @@ -2161,6 +2167,7 @@ private fun GeneralSettings(
onContentLanguageClick: () -> Unit = {},
onSkipProfileSelectionToggle: (Boolean) -> Unit = {},
onShowBudgetToggle: (Boolean) -> Unit = {},
onVolumeBoostClick: () -> Unit = {},
trailerAutoPlay: Boolean = false,
onSubtitleSizeClick: () -> Unit = {},
onSubtitleColorClick: () -> Unit = {},
Expand Down Expand Up @@ -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
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -308,6 +315,7 @@ class SettingsViewModel @Inject constructor(
autoPlayMinQuality = autoPlayMinQuality,
trailerAutoPlay = trailerAutoPlay,
showBudget = showBudget,
volumeBoostDb = volumeBoostDb,

subtitleSize = subtitleSize,
subtitleColor = subtitleColor,
Expand Down Expand Up @@ -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) }
Expand Down
Loading