feat: add TV player UI components including controls, loading error card, and track selection

This commit is contained in:
2026-04-07 19:25:23 +02:00
parent bc41116118
commit b47ebda8e6
7 changed files with 984 additions and 724 deletions

View File

@@ -5,55 +5,15 @@ import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.ArrowBack
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.Forward30
import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language
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.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
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.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -64,36 +24,26 @@ 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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.player.model.MarkerType
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.tv.player.components.TvPlayerQueuePanel
import hu.bbara.purefin.tv.player.components.TvTrackPanelType
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
@OptIn(UnstableApi::class)
@Composable
@@ -109,7 +59,7 @@ fun TvPlayerScreen(
val uiState by viewModel.uiState.collectAsState()
val controlsVisible by viewModel.controlsVisible.collectAsState()
var showQueuePanel by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TrackPanelType?>(null) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
val context = LocalContext.current
LaunchedEffect(uiState.isPlaying) {
@@ -131,7 +81,9 @@ fun TvPlayerScreen(
}
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) viewModel.hideControls()
if (uiState.isPlaying) {
viewModel.hideControls()
}
}
val hiddenControlFocusRequester = remember { FocusRequester() }
@@ -157,19 +109,23 @@ fun TvPlayerScreen(
viewModel.seekBy(-10_000)
true
}
Key.DirectionRight -> {
viewModel.seekBy(10_000)
true
}
Key.DirectionUp, Key.DirectionDown -> {
viewModel.showControls()
true
}
Key.DirectionCenter, Key.Enter -> {
viewModel.togglePlayPause()
viewModel.showControls()
true
}
else -> false
}
} else {
@@ -207,19 +163,22 @@ fun TvPlayerScreen(
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onOpenAudioPanel = { trackPanelType = TrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TrackPanelType.QUALITY },
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onOpenQueue = { showQueuePanel = true }
)
}
TvPlayerStateCard(
TvPlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center),
uiState = uiState,
onRetry = { viewModel.retry() },
onNext = { viewModel.next() },
onReplay = { viewModel.seekTo(0L); viewModel.togglePlayPause() },
onReplay = {
viewModel.seekTo(0L)
viewModel.togglePlayPause()
},
onDismissError = { viewModel.clearError() }
)
@@ -247,7 +206,7 @@ fun TvPlayerScreen(
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
TvQueuePanel(
TvPlayerQueuePanel(
uiState = uiState,
onSelect = { id ->
viewModel.playQueueItem(id)
@@ -259,664 +218,3 @@ fun TvPlayerScreen(
}
}
}
private enum class TrackPanelType { AUDIO, SUBTITLES, QUALITY }
@Composable
private fun TvPlayerControlsOverlay(
uiState: PlayerUiState,
focusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color.Black.copy(alpha = 0.5f),
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
) {
TvPlayerTopBar(
title = uiState.title ?: "Playing",
subtitle = uiState.subtitle,
onOpenQueue = onOpenQueue,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
TvPlayerBottomSection(
uiState = uiState,
focusRequester = focusRequester,
onPlayPause = onPlayPause,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
onSeekLiveEdge = onSeekLiveEdge,
onNext = onNext,
onPrevious = onPrevious,
onOpenAudioPanel = onOpenAudioPanel,
onOpenSubtitlesPanel = onOpenSubtitlesPanel,
onOpenQualityPanel = onOpenQualityPanel,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
}
}
@Composable
private fun TvPlayerTopBar(
title: String,
subtitle: String?,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Column {
Text(
text = title,
color = scheme.onBackground,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (subtitle != null) {
Text(
text = subtitle,
color = scheme.onBackground.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
TvIconButton(
icon = Icons.Outlined.PlaylistPlay,
contentDescription = "Queue",
onClick = onOpenQueue
)
}
}
@Composable
private fun TvPlayerBottomSection(
uiState: PlayerUiState,
focusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTime(uiState.positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
if (uiState.isLive) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "LIVE", color = scheme.primary, fontWeight = FontWeight.Bold)
Text(
text = "Catch up",
color = scheme.onSurface,
modifier = Modifier.clickable { onSeekLiveEdge() }
)
}
} else {
Text(
text = formatTime(uiState.durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
}
TvPlayerSeekBar(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
togglePlayState = onPlayPause,
focusRequester = focusRequester
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
) {
Row(
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.SkipPrevious,
contentDescription = "Previous",
onClick = onPrevious,
size = 64
)
TvIconButton(
icon = Icons.Outlined.Replay10,
contentDescription = "Seek backward 10 seconds",
onClick = { onSeekRelative(-10_000) },
size = 64
)
TvIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
onClick = onPlayPause,
size = 72
)
TvIconButton(
icon = Icons.Outlined.Forward30,
contentDescription = "Seek forward 30 seconds",
onClick = { onSeekRelative(30_000) },
size = 64
)
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Next",
onClick = onNext,
size = 64
)
}
Row(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.HighQuality,
contentDescription = "Quality",
onClick = onOpenQualityPanel
)
TvIconButton(
icon = Icons.Outlined.Language,
contentDescription = "Audio",
onClick = onOpenAudioPanel
)
TvIconButton(
icon = Icons.Outlined.ClosedCaption,
contentDescription = "Subtitles",
onClick = onOpenSubtitlesPanel
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun TvPlayerSeekBar(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
togglePlayState: () -> Unit,
focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
val position = positionMs.coerceIn(0, safeDuration)
val bufferRatio = (bufferedMs.toFloat() / safeDuration).coerceIn(0f, 1f)
val progressRatio = (position.toFloat() / safeDuration).coerceIn(0f, 1f)
val combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } +
adMarkers.map { it.copy(type = MarkerType.AD) }
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.height(32.dp)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
when (event.key) {
Key.DirectionLeft -> {
onSeekRelative(-10_000)
true
}
Key.DirectionRight -> {
onSeekRelative(10_000)
true
}
Key.Enter -> {
togglePlayState()
true
}
else -> false
}
} else {
false
}
}
.focusable(),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 2.dp, vertical = 10.dp)
) {
val trackHeight = if (isFocused) 6f else 4f
val trackTop = size.height / 2 - trackHeight / 2
drawRect(
color = scheme.onSurface.copy(alpha = 0.2f),
size = Size(width = size.width, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
drawRect(
color = scheme.onSurface.copy(alpha = 0.4f),
size = Size(width = bufferRatio * size.width, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val progressWidth = progressRatio * size.width
drawRect(
color = scheme.primary,
size = Size(width = progressWidth, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val thumbRadius = if (isFocused) 9.dp.toPx() else 7.dp.toPx()
drawCircle(
color = if (isFocused) scheme.primary else scheme.primary,
radius = thumbRadius,
center = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
)
if (isFocused) {
drawCircle(
color = scheme.primary.copy(alpha = 0.3f),
radius = thumbRadius + 4.dp.toPx(),
center = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
)
}
combinedMarkers.forEach { marker ->
val x = (marker.positionMs.toFloat() / safeDuration) * size.width
val color = if (marker.type == MarkerType.AD) scheme.secondary else scheme.primary
drawRect(
color = color,
topLeft = Offset(x - 1f, size.height / 2 - 6f),
size = Size(width = 2f, height = 12f)
)
}
}
}
}
@Composable
private fun TvPlayerStateCard(
uiState: PlayerUiState,
onRetry: () -> Unit,
onNext: () -> Unit,
onReplay: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(modifier = modifier) {
AnimatedVisibility(visible = uiState.isBuffering && uiState.error == null) {
CircularProgressIndicator(color = scheme.primary)
}
AnimatedVisibility(visible = uiState.error != null) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.92f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
uiState.error?.detailText?.let { detail ->
Text(
text = detail,
color = scheme.onBackground.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodySmall,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) { Text("Retry") }
Button(
onClick = onDismissError,
colors = ButtonDefaults.buttonColors(containerColor = scheme.surface)
) {
Text("Dismiss", color = scheme.onSurface)
}
}
}
}
AnimatedVisibility(visible = uiState.isEnded && uiState.error == null && !uiState.isBuffering) {
val nextUp = uiState.queue.getOrNull(
uiState.queue.indexOfFirst { it.isCurrent }.takeIf { it >= 0 }?.plus(1) ?: -1
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.92f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (nextUp != null) {
Text(
text = "Up next",
color = scheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = nextUp.title,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onNext) { Text("Play next") }
} else {
Text(
text = "Playback finished",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onReplay) { Text("Replay") }
}
}
}
}
}
@Composable
private fun TvTrackSelectionPanel(
panelType: TrackPanelType,
uiState: PlayerUiState,
onSelect: (TrackOption) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val (title, options, selectedId) = when (panelType) {
TrackPanelType.AUDIO -> Triple("Audio", uiState.audioTracks, uiState.selectedAudioTrackId)
TrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
TrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId)
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TvIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Close",
onClick = onClose
)
}
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.heightIn(max = 500.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
options.forEach { option ->
val selected = option.id == selectedId
var isTrackFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.then(if (isTrackFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier)
.clip(RoundedCornerShape(12.dp))
.background(
if (isTrackFocused) scheme.primary.copy(alpha = 0.3f)
else if (selected) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.6f)
)
.onFocusChanged { isTrackFocused = it.isFocused }
.clickable { onSelect(option) }
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
Text(
text = option.label,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
}
}
}
}
@Composable
private fun TvQueuePanel(
uiState: PlayerUiState,
onSelect: (String) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Up next",
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TvIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Close",
onClick = onClose
)
}
if (uiState.queue.isEmpty()) {
Text(
text = "No items in queue",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.queue) { item ->
var isQueueFocused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (isQueueFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier)
.clip(RoundedCornerShape(12.dp))
.background(
if (isQueueFocused) scheme.primary.copy(alpha = 0.35f)
else if (item.isCurrent) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.8f)
)
.onFocusChanged { isQueueFocused = it.isFocused }
.clickable { onSelect(item.id) }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.width(72.dp)
.clip(RoundedCornerShape(10.dp))
.background(scheme.surfaceVariant)
) {
if (item.artworkUrl != null) {
PurefinAsyncImage(
model = item.artworkUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (item.isCurrent) FontWeight.Bold else FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
color = scheme.onSurface.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun TvIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
size: Int = 52,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border")
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.widthIn(min = size.dp)
.height(size.dp)
.border(if (isFocused) 2.dp else 0.dp, borderColor, RoundedCornerShape(50))
.clip(RoundedCornerShape(50))
.background(if (isFocused) scheme.primary.copy(alpha = 0.5f) else scheme.background.copy(alpha = 0.65f))
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground,
modifier = Modifier.padding(8.dp)
)
}
}
private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt()
val minutes = ((totalSeconds / 60) % 60).toInt()
val hours = (totalSeconds / 3600).toInt()
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}

View File

@@ -0,0 +1,77 @@
package hu.bbara.purefin.tv.player.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
internal fun TvIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
size: Int = 52,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.1f else 1.0f,
label = "scale"
)
val borderColor by animateColorAsState(
targetValue = if (isFocused) scheme.primary else Color.Transparent,
label = "border"
)
Box(
modifier = modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.widthIn(min = size.dp)
.height(size.dp)
.border(
width = if (isFocused) 2.dp else 0.dp,
color = borderColor,
shape = RoundedCornerShape(50)
)
.clip(RoundedCornerShape(50))
.background(
if (isFocused) scheme.primary.copy(alpha = 0.5f)
else scheme.background.copy(alpha = 0.65f)
)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground,
modifier = Modifier.padding(8.dp)
)
}
}

View File

@@ -0,0 +1,260 @@
package hu.bbara.purefin.tv.player.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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.PlaylistPlay
import androidx.compose.material.icons.outlined.Forward30
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Replay10
import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable
internal fun TvPlayerControlsOverlay(
uiState: PlayerUiState,
focusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color.Black.copy(alpha = 0.5f),
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
) {
TvPlayerTopBar(
title = uiState.title ?: "Playing",
subtitle = uiState.subtitle,
onOpenQueue = onOpenQueue,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
TvPlayerBottomSection(
uiState = uiState,
focusRequester = focusRequester,
onPlayPause = onPlayPause,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
onSeekLiveEdge = onSeekLiveEdge,
onNext = onNext,
onPrevious = onPrevious,
onOpenAudioPanel = onOpenAudioPanel,
onOpenSubtitlesPanel = onOpenSubtitlesPanel,
onOpenQualityPanel = onOpenQualityPanel,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
}
}
@Composable
private fun TvPlayerTopBar(
title: String,
subtitle: String?,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = title,
color = scheme.onBackground,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (subtitle != null) {
Text(
text = subtitle,
color = scheme.onBackground.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyMedium
)
}
}
TvIconButton(
icon = Icons.AutoMirrored.Outlined.PlaylistPlay,
contentDescription = "Queue",
onClick = onOpenQueue
)
}
}
@Composable
private fun TvPlayerBottomSection(
uiState: PlayerUiState,
focusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTime(uiState.positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
if (uiState.isLive) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "LIVE",
color = scheme.primary,
fontWeight = FontWeight.Bold
)
Text(
text = "Catch up",
color = scheme.onSurface,
modifier = Modifier.clickable { onSeekLiveEdge() }
)
}
} else {
Text(
text = formatTime(uiState.durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
}
TvPlayerSeekBar(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
togglePlayState = onPlayPause,
focusRequester = focusRequester
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
) {
Row(
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.SkipPrevious,
contentDescription = "Previous",
onClick = onPrevious,
size = 64
)
TvIconButton(
icon = Icons.Outlined.Replay10,
contentDescription = "Seek backward 10 seconds",
onClick = { onSeekRelative(-10_000) },
size = 64
)
TvIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
onClick = onPlayPause,
size = 72
)
TvIconButton(
icon = Icons.Outlined.Forward30,
contentDescription = "Seek forward 30 seconds",
onClick = { onSeekRelative(30_000) },
size = 64
)
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Next",
onClick = onNext,
size = 64
)
}
Row(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvQualitySelectionButton(onClick = onOpenQualityPanel)
TvAudioSelectionButton(onClick = onOpenAudioPanel)
TvSubtitlesSelectionButton(onClick = onOpenSubtitlesPanel)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt()
val minutes = ((totalSeconds / 60) % 60).toInt()
val hours = (totalSeconds / 3600).toInt()
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}

View File

@@ -0,0 +1,123 @@
package hu.bbara.purefin.tv.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable
internal fun TvPlayerLoadingErrorEndCard(
uiState: PlayerUiState,
onRetry: () -> Unit,
onNext: () -> Unit,
onReplay: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(modifier = modifier) {
AnimatedVisibility(visible = uiState.isBuffering && uiState.error == null) {
CircularProgressIndicator(color = scheme.primary)
}
AnimatedVisibility(visible = uiState.error != null) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.92f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
uiState.error?.detailText?.let { detail ->
Text(
text = detail,
color = scheme.onBackground.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodySmall,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) {
Text("Retry")
}
Button(
onClick = onDismissError,
colors = ButtonDefaults.buttonColors(
containerColor = scheme.surface
)
) {
Text("Dismiss", color = scheme.onSurface)
}
}
}
}
AnimatedVisibility(visible = uiState.isEnded && uiState.error == null && !uiState.isBuffering) {
val nextUp = uiState.queue.getOrNull(
uiState.queue.indexOfFirst { it.isCurrent }
.takeIf { it >= 0 }
?.plus(1) ?: -1
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.92f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (nextUp != null) {
Text(
text = "Up next",
color = scheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = nextUp.title,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onNext) {
Text("Play next")
}
} else {
Text(
text = "Playback finished",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onReplay) {
Text("Replay")
}
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
package hu.bbara.purefin.tv.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.focus.onFocusChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable
internal fun TvPlayerQueuePanel(
uiState: PlayerUiState,
onSelect: (String) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Up next",
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TvIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Close",
onClick = onClose
)
}
AnimatedVisibility(visible = uiState.queue.isNotEmpty()) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.queue) { item ->
TvQueueRow(
title = item.title,
subtitle = item.subtitle,
artworkUrl = item.artworkUrl,
isCurrent = item.isCurrent,
onClick = { onSelect(item.id) }
)
}
}
}
if (uiState.queue.isEmpty()) {
Text(
text = "No items in queue",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
@Composable
private fun TvQueueRow(
title: String,
subtitle: String?,
artworkUrl: String?,
isCurrent: Boolean,
onClick: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (isFocused) {
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
} else {
Modifier
}
)
.clip(RoundedCornerShape(12.dp))
.background(
if (isFocused) scheme.primary.copy(alpha = 0.35f)
else if (isCurrent) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.8f)
)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.width(72.dp)
.clip(RoundedCornerShape(10.dp))
.background(scheme.surfaceVariant)
) {
if (artworkUrl != null) {
PurefinAsyncImage(
model = artworkUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
subtitle?.let { value ->
Text(
text = value,
color = scheme.onSurface.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -0,0 +1,136 @@
package hu.bbara.purefin.tv.player.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.MarkerType
import hu.bbara.purefin.core.player.model.TimedMarker
@Composable
internal fun TvPlayerSeekBar(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
togglePlayState: () -> Unit,
focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
val position = positionMs.coerceIn(0, safeDuration)
val bufferRatio = (bufferedMs.toFloat() / safeDuration).coerceIn(0f, 1f)
val progressRatio = (position.toFloat() / safeDuration).coerceIn(0f, 1f)
val combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } +
adMarkers.map { it.copy(type = MarkerType.AD) }
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.height(32.dp)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
when (event.key) {
Key.DirectionLeft -> {
onSeekRelative(-10_000)
true
}
Key.DirectionRight -> {
onSeekRelative(10_000)
true
}
Key.Enter -> {
togglePlayState()
true
}
else -> false
}
} else {
false
}
}
.focusable(),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 2.dp, vertical = 10.dp)
) {
val trackHeight = if (isFocused) 6f else 4f
val trackTop = size.height / 2 - trackHeight / 2
drawRect(
color = scheme.onSurface.copy(alpha = 0.2f),
size = Size(width = size.width, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
drawRect(
color = scheme.onSurface.copy(alpha = 0.4f),
size = Size(width = bufferRatio * size.width, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val progressWidth = progressRatio * size.width
drawRect(
color = scheme.primary,
size = Size(width = progressWidth, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val thumbRadius = if (isFocused) 9.dp.toPx() else 7.dp.toPx()
val thumbCenter = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
drawCircle(
color = scheme.primary,
radius = thumbRadius,
center = thumbCenter
)
if (isFocused) {
drawCircle(
color = scheme.primary.copy(alpha = 0.3f),
radius = thumbRadius + 4.dp.toPx(),
center = thumbCenter
)
}
combinedMarkers.forEach { marker ->
val x = (marker.positionMs.toFloat() / safeDuration) * size.width
val color = if (marker.type == MarkerType.AD) scheme.secondary else scheme.primary
drawRect(
color = color,
topLeft = Offset(x - 1f, size.height / 2 - 6f),
size = Size(width = 2f, height = 12f)
)
}
}
}
}

View File

@@ -0,0 +1,191 @@
package hu.bbara.purefin.tv.player.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.automirrored.outlined.ArrowBack
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.MaterialTheme
import androidx.compose.material3.Surface
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.focus.onFocusChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption
internal enum class TvTrackPanelType {
AUDIO,
SUBTITLES,
QUALITY
}
@Composable
internal fun TvQualitySelectionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
TvIconButton(
icon = Icons.Outlined.HighQuality,
contentDescription = "Quality",
onClick = onClick,
modifier = modifier
)
}
@Composable
internal fun TvAudioSelectionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
TvIconButton(
icon = Icons.Outlined.Language,
contentDescription = "Audio",
onClick = onClick,
modifier = modifier
)
}
@Composable
internal fun TvSubtitlesSelectionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
TvIconButton(
icon = Icons.Outlined.ClosedCaption,
contentDescription = "Subtitles",
onClick = onClick,
modifier = modifier
)
}
@Composable
internal fun TvTrackSelectionPanel(
panelType: TvTrackPanelType,
uiState: PlayerUiState,
onSelect: (TrackOption) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val (title, options, selectedId) = when (panelType) {
TvTrackPanelType.AUDIO -> Triple("Audio", uiState.audioTracks, uiState.selectedAudioTrackId)
TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
TvTrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId)
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TvIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Close",
onClick = onClose
)
}
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.heightIn(max = 500.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
options.forEach { option ->
TvTrackOptionRow(
label = option.label,
selected = option.id == selectedId,
onClick = { onSelect(option) }
)
}
}
}
}
}
}
@Composable
private fun TvTrackOptionRow(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.then(
if (isFocused) {
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
} else {
Modifier
}
)
.clip(RoundedCornerShape(12.dp))
.background(
if (isFocused) scheme.primary.copy(alpha = 0.3f)
else if (selected) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.6f)
)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() }
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
Text(
text = label,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}