feat: implement track selection buttons for quality, audio, and subtitles

This commit is contained in:
2026-02-15 16:41:35 +01:00
parent f9e8775034
commit 02393c868a
4 changed files with 238 additions and 232 deletions

View File

@@ -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.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.PlayerSettingsSheet
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
@@ -65,13 +64,11 @@ fun PlayerScreen(
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) } val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
var volume by remember { mutableStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) } var volume by remember { mutableStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) }
var showSettings by remember { mutableStateOf(false) }
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) }
LaunchedEffect(uiState.isPlaying) { LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) { if (uiState.isPlaying) {
showSettings = false
showQueuePanel = false showQueuePanel = false
} }
} }
@@ -185,15 +182,7 @@ fun PlayerScreen(
onSeekLiveEdge = { viewModel.seekToLiveEdge() }, onSeekLiveEdge = { viewModel.seekToLiveEdge() },
onNext = { viewModel.next() }, onNext = { viewModel.next() },
onPrevious = { viewModel.previous() }, onPrevious = { viewModel.previous() },
onToggleCaptions = { onSelectTrack = { viewModel.selectTrack(it) },
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 },
onQueueSelected = { viewModel.playQueueItem(it) }, onQueueSelected = { viewModel.playQueueItem(it) },
onOpenQueue = { showQueuePanel = true } onOpenQueue = { showQueuePanel = true }
) )
@@ -210,14 +199,6 @@ fun PlayerScreen(
onDismissError = { viewModel.clearError() } onDismissError = { viewModel.clearError() }
) )
PlayerSettingsSheet(
visible = showSettings,
uiState = uiState,
onDismiss = { showSettings = false },
onSelectTrack = { viewModel.selectTrack(it) },
onSpeedSelected = { viewModel.setPlaybackSpeed(it) }
)
AnimatedVisibility( AnimatedVisibility(
visible = showQueuePanel, visible = showQueuePanel,
enter = slideInHorizontally { it }, enter = slideInHorizontally { it },

View File

@@ -20,10 +20,8 @@ import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.PlaylistPlay import androidx.compose.material.icons.outlined.PlaylistPlay
import androidx.compose.material.icons.outlined.Replay10 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.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material.icons.outlined.Subtitles
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
@@ -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.GhostIconButton
import hu.bbara.purefin.common.ui.components.PurefinIconButton import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.TrackOption
@Composable @Composable
fun PlayerControlsOverlay( fun PlayerControlsOverlay(
@@ -52,8 +51,7 @@ fun PlayerControlsOverlay(
onSeekLiveEdge: () -> Unit, onSeekLiveEdge: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onToggleCaptions: () -> Unit, onSelectTrack: (TrackOption) -> Unit,
onShowSettings: () -> Unit,
onQueueSelected: (String) -> Unit, onQueueSelected: (String) -> Unit,
onOpenQueue: () -> Unit, onOpenQueue: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -100,8 +98,7 @@ fun PlayerControlsOverlay(
onSeekForward = { onSeekRelative(30_000) }, onSeekForward = { onSeekRelative(30_000) },
onSeekBackward = { onSeekRelative(-10_000) }, onSeekBackward = { onSeekRelative(-10_000) },
onSeekLiveEdge = onSeekLiveEdge, onSeekLiveEdge = onSeekLiveEdge,
onToggleCaptions = onToggleCaptions, onSelectTrack = onSelectTrack,
onShowSettings = onShowSettings,
onQueueSelected = onQueueSelected, onQueueSelected = onQueueSelected,
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
) )
@@ -163,8 +160,7 @@ private fun BottomSection(
onSeekForward: () -> Unit, onSeekForward: () -> Unit,
onSeekBackward: () -> Unit, onSeekBackward: () -> Unit,
onSeekLiveEdge: () -> Unit, onSeekLiveEdge: () -> Unit,
onToggleCaptions: () -> Unit, onSelectTrack: (TrackOption) -> Unit,
onShowSettings: () -> Unit,
onQueueSelected: (String) -> Unit, onQueueSelected: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -259,15 +255,20 @@ private fun BottomSection(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
PurefinIconButton( QualitySelectionButton(
icon = Icons.Outlined.Subtitles, options = uiState.qualityTracks,
contentDescription = "Captions", selectedId = uiState.selectedQualityTrackId,
onClick = onToggleCaptions onSelect = onSelectTrack
) )
PurefinIconButton( AudioSelectionButton(
icon = Icons.Outlined.Settings, options = uiState.audioTracks,
contentDescription = "Settings", selectedId = uiState.selectedAudioTrackId,
onClick = onShowSettings onSelect = onSelectTrack
)
SubtitlesSelectionButton(
options = uiState.textTracks,
selectedId = uiState.selectedTextTrackId,
onSelect = onSelectTrack
) )
} }
} }

View File

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

View File

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