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.
This commit is contained in:
2026-02-15 16:55:58 +01:00
parent 02393c868a
commit 9e4a9c64cc
4 changed files with 197 additions and 120 deletions

View File

@@ -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.PlayerGesturesLayer
import hu.bbara.purefin.player.ui.components.PlayerLoadingErrorEndCard import hu.bbara.purefin.player.ui.components.PlayerLoadingErrorEndCard
import hu.bbara.purefin.player.ui.components.PlayerQueuePanel 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 hu.bbara.purefin.player.viewmodel.PlayerViewModel
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -66,6 +68,7 @@ fun PlayerScreen(
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) }
var showQueuePanel by remember { mutableStateOf(false) } var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) } var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
val overlayController = rememberPersistentOverlayController()
LaunchedEffect(uiState.isPlaying) { LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) { if (uiState.isPlaying) {
@@ -175,6 +178,7 @@ fun PlayerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
uiState = uiState, uiState = uiState,
showControls = controlsVisible, showControls = controlsVisible,
overlayController = overlayController,
onBack = onBack, onBack = onBack,
onPlayPause = { viewModel.togglePlayPause() }, onPlayPause = { viewModel.togglePlayPause() },
onSeek = { viewModel.seekTo(it) }, onSeek = { viewModel.seekTo(it) },
@@ -215,6 +219,11 @@ fun PlayerScreen(
.fillMaxSize() .fillMaxSize()
) )
} }
PersistentOverlayContainer(
controller = overlayController,
modifier = Modifier.fillMaxSize()
)
} }
} }

View File

@@ -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() }
}

View File

@@ -44,6 +44,7 @@ import hu.bbara.purefin.player.model.TrackOption
fun PlayerControlsOverlay( fun PlayerControlsOverlay(
uiState: PlayerUiState, uiState: PlayerUiState,
showControls: Boolean, showControls: Boolean,
overlayController: PersistentOverlayController,
onBack: () -> Unit, onBack: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
@@ -89,6 +90,7 @@ fun PlayerControlsOverlay(
BottomSection( BottomSection(
uiState = uiState, uiState = uiState,
scrubbing = scrubbing, scrubbing = scrubbing,
overlayController = overlayController,
onScrubStart = { scrubbing = true }, onScrubStart = { scrubbing = true },
onScrub = onSeek, onScrub = onSeek,
onScrubFinished = { scrubbing = false }, onScrubFinished = { scrubbing = false },
@@ -151,6 +153,7 @@ private fun TopBar(
private fun BottomSection( private fun BottomSection(
uiState: PlayerUiState, uiState: PlayerUiState,
scrubbing: Boolean, scrubbing: Boolean,
overlayController: PersistentOverlayController,
onScrubStart: () -> Unit, onScrubStart: () -> Unit,
onScrub: (Long) -> Unit, onScrub: (Long) -> Unit,
onScrubFinished: () -> Unit, onScrubFinished: () -> Unit,
@@ -258,17 +261,20 @@ private fun BottomSection(
QualitySelectionButton( QualitySelectionButton(
options = uiState.qualityTracks, options = uiState.qualityTracks,
selectedId = uiState.selectedQualityTrackId, selectedId = uiState.selectedQualityTrackId,
onSelect = onSelectTrack onSelect = onSelectTrack,
overlayController = overlayController
) )
AudioSelectionButton( AudioSelectionButton(
options = uiState.audioTracks, options = uiState.audioTracks,
selectedId = uiState.selectedAudioTrackId, selectedId = uiState.selectedAudioTrackId,
onSelect = onSelectTrack onSelect = onSelectTrack,
overlayController = overlayController
) )
SubtitlesSelectionButton( SubtitlesSelectionButton(
options = uiState.textTracks, options = uiState.textTracks,
selectedId = uiState.selectedTextTrackId, selectedId = uiState.selectedTextTrackId,
onSelect = onSelectTrack onSelect = onSelectTrack,
overlayController = overlayController
) )
} }
} }

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin.player.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.ClosedCaption
import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Language
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -36,51 +34,29 @@ fun QualitySelectionButton(
options: List<TrackOption>, options: List<TrackOption>,
selectedId: String?, selectedId: String?,
onSelect: (TrackOption) -> Unit, onSelect: (TrackOption) -> Unit,
overlayController: PersistentOverlayController,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var expanded by remember { mutableStateOf(false) }
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box(modifier = modifier) { PurefinIconButton(
PurefinIconButton( icon = Icons.Outlined.HighQuality,
icon = Icons.Outlined.HighQuality, contentDescription = "Quality",
contentDescription = "Quality", onClick = {
onClick = { expanded = true } overlayController.show {
) TrackSelectionPanel(
title = "Quality",
DropdownMenu( options = options,
expanded = expanded, selectedId = selectedId,
onDismissRequest = { expanded = false }, onSelect = { option ->
modifier = Modifier onSelect(option)
.widthIn(min = 160.dp, max = 280.dp) overlayController.hide()
) {
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
}
)
} }
} )
} }
} },
} modifier = modifier
)
} }
@Composable @Composable
@@ -88,99 +64,107 @@ fun AudioSelectionButton(
options: List<TrackOption>, options: List<TrackOption>,
selectedId: String?, selectedId: String?,
onSelect: (TrackOption) -> Unit, onSelect: (TrackOption) -> Unit,
overlayController: PersistentOverlayController,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var expanded by remember { mutableStateOf(false) } PurefinIconButton(
val scheme = MaterialTheme.colorScheme icon = Icons.Outlined.Language,
contentDescription = "Audio",
Box(modifier = modifier) { onClick = {
PurefinIconButton( overlayController.show {
icon = Icons.Outlined.Language, TrackSelectionPanel(
contentDescription = "Audio", title = "Audio",
onClick = { expanded = true } options = options,
) selectedId = selectedId,
onSelect = { option ->
DropdownMenu( onSelect(option)
expanded = expanded, overlayController.hide()
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
}
)
} }
} )
} }
} },
} modifier = modifier
)
} }
@Composable @Composable
fun SubtitlesSelectionButton( fun SubtitlesSelectionButton(
options: List<TrackOption>,
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<TrackOption>, options: List<TrackOption>,
selectedId: String?, selectedId: String?,
onSelect: (TrackOption) -> Unit, onSelect: (TrackOption) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var expanded by remember { mutableStateOf(false) }
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box(modifier = modifier) { Box(
PurefinIconButton( modifier = modifier
icon = Icons.Outlined.ClosedCaption, .fillMaxWidth()
contentDescription = "Subtitles", .padding(16.dp)
onClick = { expanded = true } .clickable(
) interactionSource = remember { MutableInteractionSource() },
indication = null,
DropdownMenu( onClick = { /* Prevent clicks from bubbling */ }
expanded = expanded, ),
onDismissRequest = { expanded = false }, contentAlignment = Alignment.BottomEnd
) {
Column(
modifier = Modifier 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 modifier = Modifier
.background(scheme.surface.copy(alpha = 0.98f)) .heightIn(max = 400.dp)
.widthIn(min = 160.dp, max = 280.dp) .verticalScroll(rememberScrollState()),
.heightIn(max = 280.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Column( options.forEach { option ->
modifier = Modifier val selected = option.id == selectedId
.verticalScroll(rememberScrollState()) TrackOptionItem(
.padding(vertical = 8.dp, horizontal = 8.dp), label = option.label,
verticalArrangement = Arrangement.spacedBy(4.dp) selected = selected,
) { onClick = { onSelect(option) }
options.forEach { option -> )
val selected = option.id == selectedId
TrackOptionItem(
label = option.label,
selected = selected,
onClick = {
onSelect(option)
expanded = false
}
)
}
} }
} }
} }