mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +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 {
|
||||||
MemoryCache.Builder()
|
MemoryCache.Builder()
|
||||||
.maxSizePercent(context, 0.20)
|
.maxSizePercent(context, 0.08)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.diskCache {
|
.diskCache {
|
||||||
|
|||||||
@@ -5,55 +5,15 @@ import android.view.WindowManager
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -64,36 +24,26 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
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.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)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -109,7 +59,7 @@ fun TvPlayerScreen(
|
|||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
||||||
var showQueuePanel by remember { mutableStateOf(false) }
|
var showQueuePanel by remember { mutableStateOf(false) }
|
||||||
var trackPanelType by remember { mutableStateOf<TrackPanelType?>(null) }
|
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(uiState.isPlaying) {
|
LaunchedEffect(uiState.isPlaying) {
|
||||||
@@ -131,7 +81,9 @@ fun TvPlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(uiState.isPlaying) {
|
LaunchedEffect(uiState.isPlaying) {
|
||||||
if (uiState.isPlaying) viewModel.hideControls()
|
if (uiState.isPlaying) {
|
||||||
|
viewModel.hideControls()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val hiddenControlFocusRequester = remember { FocusRequester() }
|
val hiddenControlFocusRequester = remember { FocusRequester() }
|
||||||
@@ -155,20 +107,25 @@ fun TvPlayerScreen(
|
|||||||
when (event.key) {
|
when (event.key) {
|
||||||
Key.DirectionLeft -> {
|
Key.DirectionLeft -> {
|
||||||
viewModel.seekBy(-10_000)
|
viewModel.seekBy(-10_000)
|
||||||
viewModel.showControls()
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionRight -> {
|
Key.DirectionRight -> {
|
||||||
viewModel.seekBy(10_000)
|
viewModel.seekBy(10_000)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
Key.DirectionUp, Key.DirectionDown -> {
|
||||||
viewModel.showControls()
|
viewModel.showControls()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Key.DirectionUp, Key.DirectionDown,
|
|
||||||
Key.DirectionCenter, Key.Enter -> {
|
Key.DirectionCenter, Key.Enter -> {
|
||||||
viewModel.togglePlayPause()
|
viewModel.togglePlayPause()
|
||||||
viewModel.showControls()
|
viewModel.showControls()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -200,26 +157,28 @@ fun TvPlayerScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
focusRequester = controlsFocusRequester,
|
focusRequester = controlsFocusRequester,
|
||||||
onBack = onBack,
|
|
||||||
onPlayPause = { viewModel.togglePlayPause() },
|
onPlayPause = { viewModel.togglePlayPause() },
|
||||||
onSeek = { viewModel.seekTo(it) },
|
onSeek = { viewModel.seekTo(it) },
|
||||||
onSeekRelative = { viewModel.seekBy(it) },
|
onSeekRelative = { viewModel.seekBy(it) },
|
||||||
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
|
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
|
||||||
onNext = { viewModel.next() },
|
onNext = { viewModel.next() },
|
||||||
onPrevious = { viewModel.previous() },
|
onPrevious = { viewModel.previous() },
|
||||||
onOpenAudioPanel = { trackPanelType = TrackPanelType.AUDIO },
|
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||||
onOpenSubtitlesPanel = { trackPanelType = TrackPanelType.SUBTITLES },
|
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||||
onOpenQualityPanel = { trackPanelType = TrackPanelType.QUALITY },
|
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||||
onOpenQueue = { showQueuePanel = true }
|
onOpenQueue = { showQueuePanel = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TvPlayerStateCard(
|
TvPlayerLoadingErrorEndCard(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onRetry = { viewModel.retry() },
|
onRetry = { viewModel.retry() },
|
||||||
onNext = { viewModel.next() },
|
onNext = { viewModel.next() },
|
||||||
onReplay = { viewModel.seekTo(0L); viewModel.togglePlayPause() },
|
onReplay = {
|
||||||
|
viewModel.seekTo(0L)
|
||||||
|
viewModel.togglePlayPause()
|
||||||
|
},
|
||||||
onDismissError = { viewModel.clearError() }
|
onDismissError = { viewModel.clearError() }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,7 +206,7 @@ fun TvPlayerScreen(
|
|||||||
enter = slideInHorizontally { it },
|
enter = slideInHorizontally { it },
|
||||||
exit = slideOutHorizontally { it }
|
exit = slideOutHorizontally { it }
|
||||||
) {
|
) {
|
||||||
TvQueuePanel(
|
TvPlayerQueuePanel(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onSelect = { id ->
|
onSelect = { id ->
|
||||||
viewModel.playQueueItem(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()
|
val loadControl = DefaultLoadControl.Builder()
|
||||||
.setBufferDurationsMs(
|
.setBufferDurationsMs(
|
||||||
25_000,
|
15_000, // minBufferMs
|
||||||
55_000,
|
50_000, // maxBufferMs
|
||||||
5_000,
|
2_500, // bufferForPlaybackMs
|
||||||
5_000
|
5_000 // bufferForPlaybackAfterRebufferMs
|
||||||
)
|
)
|
||||||
|
.setPrioritizeTimeOverSizeThresholds(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Configure RenderersFactory to use all available decoders and enable passthrough
|
// Configure RenderersFactory to use all available decoders and enable passthrough
|
||||||
|
|||||||
Reference in New Issue
Block a user