From 9e4a9c64cceefd49b7bc24d27397fd8ae9066e83 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 15 Feb 2026 16:55:58 +0100 Subject: [PATCH] fix: prevent track selection overlay from auto-hiding on tap Separated track selection UI from player controls overlay to prevent the gesture layer from dismissing the track selection panel when selecting options. Created PersistentOverlayContainer that manages its own visibility state independent of player controls. --- .../bbara/purefin/player/ui/PlayerScreen.kt | 9 + .../components/PersistentOverlayContainer.kt | 78 +++++++ .../ui/components/PlayerControlsOverlay.kt | 12 +- .../ui/components/TrackSelectionButtons.kt | 218 ++++++++---------- 4 files changed, 197 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PersistentOverlayContainer.kt diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt b/app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt index 1129b63..a23b3b9 100644 --- a/app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt @@ -44,6 +44,8 @@ import hu.bbara.purefin.player.ui.components.PlayerControlsOverlay import hu.bbara.purefin.player.ui.components.PlayerGesturesLayer import hu.bbara.purefin.player.ui.components.PlayerLoadingErrorEndCard import hu.bbara.purefin.player.ui.components.PlayerQueuePanel +import hu.bbara.purefin.player.ui.components.PersistentOverlayContainer +import hu.bbara.purefin.player.ui.components.rememberPersistentOverlayController import hu.bbara.purefin.player.viewmodel.PlayerViewModel import kotlin.math.abs import kotlin.math.roundToInt @@ -66,6 +68,7 @@ fun PlayerScreen( var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } var showQueuePanel by remember { mutableStateOf(false) } var horizontalSeekFeedback by remember { mutableStateOf(null) } + val overlayController = rememberPersistentOverlayController() LaunchedEffect(uiState.isPlaying) { if (uiState.isPlaying) { @@ -175,6 +178,7 @@ fun PlayerScreen( modifier = Modifier.fillMaxSize(), uiState = uiState, showControls = controlsVisible, + overlayController = overlayController, onBack = onBack, onPlayPause = { viewModel.togglePlayPause() }, onSeek = { viewModel.seekTo(it) }, @@ -215,6 +219,11 @@ fun PlayerScreen( .fillMaxSize() ) } + + PersistentOverlayContainer( + controller = overlayController, + modifier = Modifier.fillMaxSize() + ) } } diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PersistentOverlayContainer.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PersistentOverlayContainer.kt new file mode 100644 index 0000000..d00c31f --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PersistentOverlayContainer.kt @@ -0,0 +1,78 @@ +package hu.bbara.purefin.player.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * Controller for the persistent overlay that allows components to show content + * independently of the player controls visibility. + */ +class PersistentOverlayController { + private var _content by mutableStateOf<(@Composable () -> Unit)?>(null) + + val isVisible: Boolean + get() = _content != null + + val content: (@Composable () -> Unit)? + get() = _content + + fun show(content: @Composable () -> Unit) { + _content = content + } + + fun hide() { + _content = null + } +} + +/** + * A persistent overlay container that sits above the player controls and gesture layer. + * This allows components to display content (like track selection panels) without being + * affected by the player controls visibility state. + * + * @param controller The controller that manages the overlay's visibility and content + * @param modifier Modifier to be applied to the container + */ +@Composable +fun PersistentOverlayContainer( + controller: PersistentOverlayController, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = controller.isVisible, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { controller.hide() } + ) + ) { + controller.content?.invoke() + } + } +} + +@Composable +fun rememberPersistentOverlayController(): PersistentOverlayController { + return remember { PersistentOverlayController() } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt index 9292fbc..f9ffd31 100644 --- a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt @@ -44,6 +44,7 @@ import hu.bbara.purefin.player.model.TrackOption fun PlayerControlsOverlay( uiState: PlayerUiState, showControls: Boolean, + overlayController: PersistentOverlayController, onBack: () -> Unit, onPlayPause: () -> Unit, onSeek: (Long) -> Unit, @@ -89,6 +90,7 @@ fun PlayerControlsOverlay( BottomSection( uiState = uiState, scrubbing = scrubbing, + overlayController = overlayController, onScrubStart = { scrubbing = true }, onScrub = onSeek, onScrubFinished = { scrubbing = false }, @@ -151,6 +153,7 @@ private fun TopBar( private fun BottomSection( uiState: PlayerUiState, scrubbing: Boolean, + overlayController: PersistentOverlayController, onScrubStart: () -> Unit, onScrub: (Long) -> Unit, onScrubFinished: () -> Unit, @@ -258,17 +261,20 @@ private fun BottomSection( QualitySelectionButton( options = uiState.qualityTracks, selectedId = uiState.selectedQualityTrackId, - onSelect = onSelectTrack + onSelect = onSelectTrack, + overlayController = overlayController ) AudioSelectionButton( options = uiState.audioTracks, selectedId = uiState.selectedAudioTrackId, - onSelect = onSelectTrack + onSelect = onSelectTrack, + overlayController = overlayController ) SubtitlesSelectionButton( options = uiState.textTracks, selectedId = uiState.selectedTextTrackId, - onSelect = onSelectTrack + onSelect = onSelectTrack, + overlayController = overlayController ) } } diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/TrackSelectionButtons.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/TrackSelectionButtons.kt index 3d31ae5..7a267f1 100644 --- a/app/src/main/java/hu/bbara/purefin/player/ui/components/TrackSelectionButtons.kt +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/TrackSelectionButtons.kt @@ -2,6 +2,7 @@ package hu.bbara.purefin.player.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,14 +17,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.Language -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight @@ -36,51 +34,29 @@ fun QualitySelectionButton( options: List, selectedId: String?, onSelect: (TrackOption) -> Unit, + overlayController: PersistentOverlayController, modifier: Modifier = Modifier ) { - var expanded by remember { mutableStateOf(false) } val scheme = MaterialTheme.colorScheme - Box(modifier = modifier) { - PurefinIconButton( - icon = Icons.Outlined.HighQuality, - contentDescription = "Quality", - onClick = { expanded = true } - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier - .widthIn(min = 160.dp, max = 280.dp) - ) { - Box( - modifier = Modifier - .background(scheme.surface.copy(alpha = 0.98f)) - .widthIn(min = 160.dp, max = 280.dp) - .heightIn(max = 280.dp) - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp, horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - options.forEach { option -> - val selected = option.id == selectedId - TrackOptionItem( - label = option.label, - selected = selected, - onClick = { - onSelect(option) - expanded = false - } - ) + PurefinIconButton( + icon = Icons.Outlined.HighQuality, + contentDescription = "Quality", + onClick = { + overlayController.show { + TrackSelectionPanel( + title = "Quality", + options = options, + selectedId = selectedId, + onSelect = { option -> + onSelect(option) + overlayController.hide() } - } + ) } - } - } + }, + modifier = modifier + ) } @Composable @@ -88,99 +64,107 @@ fun AudioSelectionButton( options: List, selectedId: String?, onSelect: (TrackOption) -> Unit, + overlayController: PersistentOverlayController, modifier: Modifier = Modifier ) { - var expanded by remember { mutableStateOf(false) } - val scheme = MaterialTheme.colorScheme - - Box(modifier = modifier) { - PurefinIconButton( - icon = Icons.Outlined.Language, - contentDescription = "Audio", - onClick = { expanded = true } - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier - .widthIn(min = 160.dp, max = 280.dp) - ) { - Box( - modifier = Modifier - .background(scheme.surface.copy(alpha = 0.98f)) - .widthIn(min = 160.dp, max = 280.dp) - .heightIn(max = 280.dp) - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp, horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - options.forEach { option -> - val selected = option.id == selectedId - TrackOptionItem( - label = option.label, - selected = selected, - onClick = { - onSelect(option) - expanded = false - } - ) + PurefinIconButton( + icon = Icons.Outlined.Language, + contentDescription = "Audio", + onClick = { + overlayController.show { + TrackSelectionPanel( + title = "Audio", + options = options, + selectedId = selectedId, + onSelect = { option -> + onSelect(option) + overlayController.hide() } - } + ) } - } - } + }, + modifier = modifier + ) } @Composable fun SubtitlesSelectionButton( + options: List, + selectedId: String?, + onSelect: (TrackOption) -> Unit, + overlayController: PersistentOverlayController, + modifier: Modifier = Modifier +) { + PurefinIconButton( + icon = Icons.Outlined.ClosedCaption, + contentDescription = "Subtitles", + onClick = { + overlayController.show { + TrackSelectionPanel( + title = "Subtitles", + options = options, + selectedId = selectedId, + onSelect = { option -> + onSelect(option) + overlayController.hide() + } + ) + } + }, + modifier = modifier + ) +} + +@Composable +private fun TrackSelectionPanel( + title: String, options: List, selectedId: String?, onSelect: (TrackOption) -> Unit, modifier: Modifier = Modifier ) { - var expanded by remember { mutableStateOf(false) } val scheme = MaterialTheme.colorScheme - Box(modifier = modifier) { - PurefinIconButton( - icon = Icons.Outlined.ClosedCaption, - contentDescription = "Subtitles", - onClick = { expanded = true } - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, + Box( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { /* Prevent clicks from bubbling */ } + ), + contentAlignment = Alignment.BottomEnd + ) { + Column( modifier = Modifier - .widthIn(min = 160.dp, max = 280.dp) + .widthIn(min = 200.dp, max = 320.dp) + .clip(RoundedCornerShape(12.dp)) + .background(scheme.surface.copy(alpha = 0.98f)) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Box( + Text( + text = title, + color = scheme.onSurface, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + Column( modifier = Modifier - .background(scheme.surface.copy(alpha = 0.98f)) - .widthIn(min = 160.dp, max = 280.dp) - .heightIn(max = 280.dp) + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp, horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - options.forEach { option -> - val selected = option.id == selectedId - TrackOptionItem( - label = option.label, - selected = selected, - onClick = { - onSelect(option) - expanded = false - } - ) - } + options.forEach { option -> + val selected = option.id == selectedId + TrackOptionItem( + label = option.label, + selected = selected, + onClick = { onSelect(option) } + ) } } }