feat: add player screen to TV app via compose navigation

- Add PlayerRoute to the Route sealed interface
- Refactor PlayerViewModel to expose loadMedia() for external callers
- Add onPlay() to EpisodeScreenViewModel and MovieScreenViewModel
- Wire play/resume buttons in TV episode and movie screens
- Create TvPlayerScreen with TV-optimized controls: seek bar, playback
  buttons, track selection panels, queue panel, and state cards
- Register PlayerRoute in TvRouteEntryBuilder and TvNavigationModule
- Add media3-ui dependency to app-tv module
This commit is contained in:
2026-02-21 11:09:15 +01:00
parent f85ecc04c7
commit 6f34190ed0
12 changed files with 872 additions and 2 deletions

View File

@@ -73,6 +73,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.medi3.exoplayer)
implementation(libs.medi3.ui)
implementation(libs.medi3.ffmpeg.decoder)
implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime)

View File

@@ -64,6 +64,7 @@ internal fun EpisodeTopBar(
@Composable
internal fun EpisodeDetails(
episode: Episode,
onPlay: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -109,7 +110,7 @@ internal fun EpisodeDetails(
MediaResumeButton(
text = if (episode.progress == null) "Play" else "Resume",
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
onClick = {},
onClick = onPlay,
modifier = Modifier.sizeIn(maxWidth = 200.dp)
)
VerticalDivider(

View File

@@ -45,6 +45,7 @@ fun EpisodeScreen(
EpisodeScreenInternal(
episode = episode.value!!,
onBack = viewModel::onBack,
onPlay = viewModel::onPlay,
modifier = modifier
)
}
@@ -53,6 +54,7 @@ fun EpisodeScreen(
private fun EpisodeScreenInternal(
episode: Episode,
onBack: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -79,6 +81,7 @@ private fun EpisodeScreenInternal(
)
EpisodeDetails(
episode = episode,
onPlay = onPlay,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)

View File

@@ -65,6 +65,7 @@ internal fun MovieTopBar(
@Composable
internal fun MovieDetails(
movie: MovieUiModel,
onPlay: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -103,7 +104,7 @@ internal fun MovieDetails(
MediaResumeButton(
text = if (movie.progress == null) "Play" else "Resume",
progress = movie.progress?.div(100)?.toFloat() ?: 0f,
onClick = {},
onClick = onPlay,
modifier = Modifier.sizeIn(maxWidth = 200.dp)
)
VerticalDivider(

View File

@@ -34,6 +34,7 @@ fun MovieScreen(
MovieScreenInternal(
movie = movieItem.value!!,
onBack = viewModel::onBack,
onPlay = viewModel::onPlay,
modifier = modifier
)
} else {
@@ -45,6 +46,7 @@ fun MovieScreen(
private fun MovieScreenInternal(
movie: MovieUiModel,
onBack: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -70,6 +72,7 @@ private fun MovieScreenInternal(
)
MovieDetails(
movie = movie,
onPlay = onPlay,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)

View File

@@ -41,4 +41,10 @@ object TvNavigationModule {
fun provideTvEpisodeEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvEpisodeSection()
}
@IntoSet
@Provides
fun provideTvPlayerEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvPlayerSection()
}
}

View File

@@ -4,9 +4,11 @@ import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.app.content.episode.EpisodeScreen
import hu.bbara.purefin.app.content.movie.MovieScreen
import hu.bbara.purefin.app.content.series.SeriesScreen
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.tv.home.TvHomePage
import hu.bbara.purefin.tv.player.TvPlayerScreen
fun EntryProviderScope<Route>.tvHomeSection() {
entry<Route.Home> {
@@ -37,3 +39,13 @@ fun EntryProviderScope<Route>.tvEpisodeSection() {
EpisodeScreen(episode = route.item)
}
}
fun EntryProviderScope<Route>.tvPlayerSection() {
entry<Route.PlayerRoute> { route ->
val navigationManager = LocalNavigationManager.current
TvPlayerScreen(
mediaId = route.mediaId,
onBack = { navigationManager.pop() }
)
}
}

View File

@@ -0,0 +1,821 @@
package hu.bbara.purefin.tv.player
import android.app.Activity
import android.view.WindowManager
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
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.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.Canvas
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.Slider
import androidx.compose.material3.SliderDefaults
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
import androidx.compose.runtime.collectAsState
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.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.vector.ImageVector
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
@OptIn(UnstableApi::class)
@Composable
fun TvPlayerScreen(
mediaId: String,
viewModel: PlayerViewModel = hiltViewModel(),
onBack: () -> Unit
) {
LaunchedEffect(mediaId) {
viewModel.loadMedia(mediaId)
}
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) }
val context = LocalContext.current
LaunchedEffect(uiState.isPlaying) {
val activity = context as? Activity
if (uiState.isPlaying) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
DisposableEffect(Unit) {
onDispose {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) showQueuePanel = false
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player
}
},
update = { it.player = viewModel.player },
modifier = Modifier
.fillMaxHeight()
.align(Alignment.Center)
)
AnimatedVisibility(
visible = controlsVisible || uiState.isEnded || uiState.error != null,
enter = fadeIn(),
exit = fadeOut()
) {
TvPlayerControlsOverlay(
modifier = Modifier.fillMaxSize(),
uiState = uiState,
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 },
onOpenQueue = { showQueuePanel = true }
)
}
TvPlayerStateCard(
modifier = Modifier.align(Alignment.Center),
uiState = uiState,
onRetry = { viewModel.retry() },
onNext = { viewModel.next() },
onReplay = { viewModel.seekTo(0L); viewModel.togglePlayPause() },
onDismissError = { viewModel.clearError() }
)
AnimatedVisibility(
visible = trackPanelType != null,
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,
uiState = uiState,
onSelect = { track ->
viewModel.selectTrack(track)
trackPanelType = null
},
onClose = { trackPanelType = null },
modifier = Modifier.fillMaxSize()
)
}
}
AnimatedVisibility(
visible = showQueuePanel,
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
TvQueuePanel(
uiState = uiState,
onSelect = { id ->
viewModel.playQueueItem(id)
showQueuePanel = false
},
onClose = { showQueuePanel = false },
modifier = Modifier.fillMaxSize()
)
}
}
}
private enum class TrackPanelType { AUDIO, SUBTITLES, QUALITY }
@Composable
private fun TvPlayerControlsOverlay(
uiState: PlayerUiState,
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,
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,
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
)
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,
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) }
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.height(32.dp),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 2.dp, vertical = 10.dp)
) {
val trackHeight = 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 = 7.dp.toPx()
drawCircle(
color = scheme.primary,
radius = thumbRadius,
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)
)
}
}
Slider(
value = position.toFloat(),
onValueChange = { onSeek(it.toLong()) },
valueRange = 0f..safeDuration.toFloat(),
colors = SliderDefaults.colors(
thumbColor = Color.Transparent,
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent
),
modifier = Modifier.fillMaxSize()
)
}
}
@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 ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
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
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(
if (selected) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.6f)
)
.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 ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(
if (item.isCurrent) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.8f)
)
.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
Box(
modifier = modifier
.widthIn(min = size.dp)
.height(size.dp)
.clip(RoundedCornerShape(50))
.background(scheme.background.copy(alpha = 0.65f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground,
modifier = Modifier.padding(8.dp)
)
}
}
private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt()
val minutes = ((totalSeconds / 60) % 60).toInt()
val hours = (totalSeconds / 3600).toInt()
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}

View File

@@ -21,4 +21,7 @@ sealed interface Route : NavKey {
@Serializable
data object LoginRoute : Route
@Serializable
data class PlayerRoute(val mediaId: String) : Route
}

View File

@@ -124,6 +124,15 @@ class PlayerViewModel @Inject constructor(
private fun loadInitialMedia() {
val id = mediaId ?: return
loadMediaById(id)
}
fun loadMedia(id: String) {
if (mediaId != null) return // Already loading from SavedStateHandle
loadMediaById(id)
}
private fun loadMediaById(id: String) {
val uuid = id.toUuidOrNull()
if (uuid == null) {
dataErrorMessage = "Invalid media id"

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.Episode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -38,6 +39,11 @@ class EpisodeScreenViewModel @Inject constructor(
navigationManager.pop()
}
fun onPlay() {
val id = _episodeId.value?.toString() ?: return
navigationManager.navigate(Route.PlayerRoute(mediaId = id))
}
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
_episodeId.value = episodeId
}

View File

@@ -33,6 +33,10 @@ class MovieScreenViewModel @Inject constructor(
navigationManager.pop()
}
fun onPlay() {
val id = _movie.value?.id?.toString() ?: return
navigationManager.navigate(Route.PlayerRoute(mediaId = id))
}
fun onGoHome() {
navigationManager.replaceAll(Route.Home)