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.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<Long?>(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()
)
}
}

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

View File

@@ -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<TrackOption>,
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<TrackOption>,
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<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>,
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) }
)
}
}
}