mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
5 Commits
69ae10c761
...
b47ebda8e6
| Author | SHA1 | Date | |
|---|---|---|---|
| b47ebda8e6 | |||
| bc41116118 | |||
| fb1d099ec1 | |||
| bcbad6f262 | |||
| 8a6c745332 |
@@ -109,7 +109,7 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.20)
|
||||
.maxSizePercent(context, 0.08)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
|
||||
@@ -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() }
|
||||
@@ -155,20 +107,25 @@ fun TvPlayerScreen(
|
||||
when (event.key) {
|
||||
Key.DirectionLeft -> {
|
||||
viewModel.seekBy(-10_000)
|
||||
viewModel.showControls()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
viewModel.seekBy(10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
viewModel.showControls()
|
||||
true
|
||||
}
|
||||
Key.DirectionUp, Key.DirectionDown,
|
||||
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
viewModel.togglePlayPause()
|
||||
viewModel.showControls()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
@@ -200,26 +157,28 @@ fun TvPlayerScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
onBack = onBack,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
onSeek = { viewModel.seekTo(it) },
|
||||
onSeekRelative = { viewModel.seekBy(it) },
|
||||
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,672 +218,3 @@ fun TvPlayerScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class TrackPanelType { AUDIO, SUBTITLES, QUALITY }
|
||||
|
||||
@Composable
|
||||
private fun TvPlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
onBack: () -> Unit,
|
||||
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,
|
||||
onBack = onBack,
|
||||
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?,
|
||||
onBack: () -> Unit,
|
||||
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)
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,12 @@ object VideoPlayerModule {
|
||||
)
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
25_000,
|
||||
55_000,
|
||||
5_000,
|
||||
5_000
|
||||
15_000, // minBufferMs
|
||||
50_000, // maxBufferMs
|
||||
2_500, // bufferForPlaybackMs
|
||||
5_000 // bufferForPlaybackAfterRebufferMs
|
||||
)
|
||||
.setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build()
|
||||
|
||||
// Configure RenderersFactory to use all available decoders and enable passthrough
|
||||
|
||||
Reference in New Issue
Block a user