From 02393c868abbcb147670477d0a98d276eda68bdd Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 15 Feb 2026 16:41:35 +0100 Subject: [PATCH] feat: implement track selection buttons for quality, audio, and subtitles --- .../bbara/purefin/player/ui/PlayerScreen.kt | 21 +- .../ui/components/PlayerControlsOverlay.kt | 33 +-- .../ui/components/PlayerSettingsSheet.kt | 196 ---------------- .../ui/components/TrackSelectionButtons.kt | 220 ++++++++++++++++++ 4 files changed, 238 insertions(+), 232 deletions(-) delete mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/TrackSelectionButtons.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 4555e61..1129b63 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,7 +44,6 @@ 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.PlayerSettingsSheet import hu.bbara.purefin.player.viewmodel.PlayerViewModel import kotlin.math.abs import kotlin.math.roundToInt @@ -65,13 +64,11 @@ fun PlayerScreen( val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) } var volume by remember { mutableStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) } var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } - var showSettings by remember { mutableStateOf(false) } var showQueuePanel by remember { mutableStateOf(false) } var horizontalSeekFeedback by remember { mutableStateOf(null) } LaunchedEffect(uiState.isPlaying) { if (uiState.isPlaying) { - showSettings = false showQueuePanel = false } } @@ -185,15 +182,7 @@ fun PlayerScreen( onSeekLiveEdge = { viewModel.seekToLiveEdge() }, onNext = { viewModel.next() }, onPrevious = { viewModel.previous() }, - onToggleCaptions = { - val off = uiState.textTracks.firstOrNull { it.isOff } - val currentId = uiState.selectedTextTrackId - val next = if (currentId == off?.id) { - uiState.textTracks.firstOrNull { !it.isOff } - } else off - next?.let { viewModel.selectTrack(it) } - }, - onShowSettings = { showSettings = true }, + onSelectTrack = { viewModel.selectTrack(it) }, onQueueSelected = { viewModel.playQueueItem(it) }, onOpenQueue = { showQueuePanel = true } ) @@ -210,14 +199,6 @@ fun PlayerScreen( onDismissError = { viewModel.clearError() } ) - PlayerSettingsSheet( - visible = showSettings, - uiState = uiState, - onDismiss = { showSettings = false }, - onSelectTrack = { viewModel.selectTrack(it) }, - onSpeedSelected = { viewModel.setPlaybackSpeed(it) } - ) - AnimatedVisibility( visible = showQueuePanel, enter = slideInHorizontally { it }, 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 1f8ea9a..9292fbc 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 @@ -20,10 +20,8 @@ import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PlaylistPlay import androidx.compose.material.icons.outlined.Replay10 -import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material.icons.outlined.SkipPrevious -import androidx.compose.material.icons.outlined.Subtitles import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,6 +38,7 @@ import androidx.compose.ui.unit.dp import hu.bbara.purefin.common.ui.components.GhostIconButton import hu.bbara.purefin.common.ui.components.PurefinIconButton import hu.bbara.purefin.player.model.PlayerUiState +import hu.bbara.purefin.player.model.TrackOption @Composable fun PlayerControlsOverlay( @@ -52,8 +51,7 @@ fun PlayerControlsOverlay( onSeekLiveEdge: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, - onToggleCaptions: () -> Unit, - onShowSettings: () -> Unit, + onSelectTrack: (TrackOption) -> Unit, onQueueSelected: (String) -> Unit, onOpenQueue: () -> Unit, modifier: Modifier = Modifier @@ -100,8 +98,7 @@ fun PlayerControlsOverlay( onSeekForward = { onSeekRelative(30_000) }, onSeekBackward = { onSeekRelative(-10_000) }, onSeekLiveEdge = onSeekLiveEdge, - onToggleCaptions = onToggleCaptions, - onShowSettings = onShowSettings, + onSelectTrack = onSelectTrack, onQueueSelected = onQueueSelected, modifier = Modifier.align(Alignment.BottomCenter) ) @@ -163,8 +160,7 @@ private fun BottomSection( onSeekForward: () -> Unit, onSeekBackward: () -> Unit, onSeekLiveEdge: () -> Unit, - onToggleCaptions: () -> Unit, - onShowSettings: () -> Unit, + onSelectTrack: (TrackOption) -> Unit, onQueueSelected: (String) -> Unit, modifier: Modifier = Modifier ) { @@ -259,15 +255,20 @@ private fun BottomSection( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - PurefinIconButton( - icon = Icons.Outlined.Subtitles, - contentDescription = "Captions", - onClick = onToggleCaptions + QualitySelectionButton( + options = uiState.qualityTracks, + selectedId = uiState.selectedQualityTrackId, + onSelect = onSelectTrack ) - PurefinIconButton( - icon = Icons.Outlined.Settings, - contentDescription = "Settings", - onClick = onShowSettings + AudioSelectionButton( + options = uiState.audioTracks, + selectedId = uiState.selectedAudioTrackId, + onSelect = onSelectTrack + ) + SubtitlesSelectionButton( + options = uiState.textTracks, + selectedId = uiState.selectedTextTrackId, + onSelect = onSelectTrack ) } } diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt deleted file mode 100644 index a024b64..0000000 --- a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt +++ /dev/null @@ -1,196 +0,0 @@ -package hu.bbara.purefin.player.ui.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material.icons.outlined.ClosedCaption -import androidx.compose.material.icons.outlined.HighQuality -import androidx.compose.material.icons.outlined.Language -import androidx.compose.material.icons.outlined.Speed -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import hu.bbara.purefin.player.model.PlayerUiState -import hu.bbara.purefin.player.model.TrackOption - -@Composable -fun PlayerSettingsSheet( - visible: Boolean, - uiState: PlayerUiState, - onDismiss: () -> Unit, - onSelectTrack: (TrackOption) -> Unit, - onSpeedSelected: (Float) -> Unit -) { - val scheme = MaterialTheme.colorScheme - AnimatedVisibility( - visible = visible, - enter = slideInVertically(initialOffsetY = { it }) + androidx.compose.animation.fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + androidx.compose.animation.fadeOut() - ) { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) { - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)), - color = scheme.surface.copy(alpha = 0.98f) - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "Playback settings", color = scheme.onSurface, style = MaterialTheme.typography.titleMedium) - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = "Close", - tint = scheme.onSurface, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { onDismiss() } - .padding(8.dp) - ) - } - Spacer(modifier = Modifier.height(8.dp)) - TrackGroup( - label = "Audio track", - icon = { Icon(Icons.Outlined.Language, contentDescription = null, tint = scheme.onSurface) }, - options = uiState.audioTracks, - selectedId = uiState.selectedAudioTrackId, - onSelect = onSelectTrack - ) - Spacer(modifier = Modifier.height(8.dp)) - TrackGroup( - label = "Subtitles", - icon = { Icon(Icons.Outlined.ClosedCaption, contentDescription = null, tint = scheme.onSurface) }, - options = uiState.textTracks, - selectedId = uiState.selectedTextTrackId, - onSelect = onSelectTrack - ) - Spacer(modifier = Modifier.height(8.dp)) - TrackGroup( - label = "Quality", - icon = { Icon(Icons.Outlined.HighQuality, contentDescription = null, tint = scheme.onSurface) }, - options = uiState.qualityTracks, - selectedId = uiState.selectedQualityTrackId, - onSelect = onSelectTrack - ) - Spacer(modifier = Modifier.height(8.dp)) - SpeedGroup( - selectedSpeed = uiState.playbackSpeed, - onSpeedSelected = onSpeedSelected - ) - Spacer(modifier = Modifier.height(4.dp)) - } - } - } - } -} - -@Composable -private fun TrackGroup( - label: String, - icon: @Composable () -> Unit, - options: List, - selectedId: String?, - onSelect: (TrackOption) -> Unit -) { - val scheme = MaterialTheme.colorScheme - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - icon() - Text(text = label, color = scheme.onSurface, style = MaterialTheme.typography.titleSmall) - } - FlowChips( - items = options, - selectedId = selectedId, - onSelect = onSelect - ) - } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun FlowChips( - items: List, - selectedId: String?, - onSelect: (TrackOption) -> Unit -) { - val scheme = MaterialTheme.colorScheme - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items.forEach { option -> - val selected = option.id == selectedId - Text( - text = option.label, - color = if (selected) scheme.onPrimary else scheme.onSurface, - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(if (selected) scheme.primary else scheme.surfaceVariant) - .clickable { onSelect(option) } - .padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - } -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun SpeedGroup( - selectedSpeed: Float, - onSpeedSelected: (Float) -> Unit -) { - val options = listOf(0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f) - val scheme = MaterialTheme.colorScheme - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Outlined.Speed, contentDescription = null, tint = scheme.onSurface) - Text(text = "Playback speed", color = scheme.onSurface, style = MaterialTheme.typography.titleSmall) - } - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - options.forEach { speed -> - val selected = speed == selectedSpeed - Text( - text = "${speed}x", - color = if (selected) scheme.onPrimary else scheme.onSurface, - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(if (selected) scheme.primary else scheme.surfaceVariant) - .clickable { onSpeedSelected(speed) } - .padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - } - } -} 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 new file mode 100644 index 0000000..3d31ae5 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/TrackSelectionButtons.kt @@ -0,0 +1,220 @@ +package hu.bbara.purefin.player.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import hu.bbara.purefin.common.ui.components.PurefinIconButton +import hu.bbara.purefin.player.model.TrackOption + +@Composable +fun QualitySelectionButton( + 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.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 + } + ) + } + } + } + } + } +} + +@Composable +fun AudioSelectionButton( + 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.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 + } + ) + } + } + } + } + } +} + +@Composable +fun SubtitlesSelectionButton( + 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 }, + 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 + } + ) + } + } + } + } + } +} + +@Composable +private fun TrackOptionItem( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background( + if (selected) { + scheme.primary.copy(alpha = 0.15f) + } else { + scheme.surfaceVariant.copy(alpha = 0.6f) + } + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = label, + color = scheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) + } +}