implement advanced custom video player UI and logic

- Replace basic `AndroidView` player with a comprehensive `PlayerScreen` including custom controls, gestures, and state management.
- Implement `PlayerViewModel` logic for playback state, track selection (audio, subtitles, quality), progress updates, and auto-hiding controls.
- Add `PlayerUiState` and related models to track buffering, playback speed, tracks, and media queue.
- Create several reusable player components:
    - `PlayerControlsOverlay`: Top/center/bottom bars for navigation, playback actions, and time info.
    - `PlayerGesturesLayer`: Support for double-tap to seek and vertical drags for brightness/volume.
    - `PlayerSeekBar`: Custom seek bar with buffer visualization and chapter/ad markers.
    - `PlayerSettingsSheet`: Bottom sheet for adjusting playback speed and selecting media tracks.
    - `PlayerQueuePanel`: Slide-out panel to view and navigate the current playlist.
    - `PlayerSideSliders`: Visual overlays for brightness and volume adjustments.
- Update `PlayerActivity` to support immersive mode and use a dark theme for playback.
- Enable `trackSelector` in `VideoPlayerModule` to facilitate manual track switching.
This commit is contained in:
2026-01-25 18:43:43 +01:00
parent 9c8614df6d
commit f835d9ea26
12 changed files with 1696 additions and 37 deletions

View File

@@ -3,18 +3,12 @@ package hu.bbara.purefin.player
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.ui.PlayerView
import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.player.ui.PlayerScreen
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.ui.theme.PurefinTheme
@@ -23,26 +17,24 @@ class PlayerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PurefinTheme(darkTheme = false) {
val viewModel = hiltViewModel<PlayerViewModel>()
Box(
modifier = Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
AndroidView(
factory = { context ->
PlayerView(context).also {
it.player = viewModel.player
}
},
modifier = Modifier.fillMaxHeight()
.align(Alignment.Center)
.aspectRatio(16f / 9f)
enterImmersiveMode()
setContent {
PurefinTheme(darkTheme = true) {
val viewModel = hiltViewModel<PlayerViewModel>()
PlayerScreen(
viewModel = viewModel,
onBack = { finish() }
)
}
}
}
private fun enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}

View File

@@ -0,0 +1,55 @@
package hu.bbara.purefin.player.model
data class PlayerUiState(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val isEnded: Boolean = false,
val isLive: Boolean = false,
val title: String? = null,
val subtitle: String? = null,
val durationMs: Long = 0L,
val positionMs: Long = 0L,
val bufferedMs: Long = 0L,
val error: String? = null,
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(),
val queue: List<QueueItemUi> = emptyList(),
val audioTracks: List<TrackOption> = emptyList(),
val textTracks: List<TrackOption> = emptyList(),
val qualityTracks: List<TrackOption> = emptyList(),
val selectedAudioTrackId: String? = null,
val selectedTextTrackId: String? = null,
val selectedQualityTrackId: String? = null,
)
data class TrackOption(
val id: String,
val label: String,
val language: String?,
val bitrate: Int?,
val channelCount: Int?,
val height: Int?,
val groupIndex: Int,
val trackIndex: Int,
val type: TrackType,
val isOff: Boolean
)
enum class TrackType { AUDIO, TEXT, VIDEO }
data class TimedMarker(
val positionMs: Long,
val type: MarkerType,
val label: String? = null
)
enum class MarkerType { CHAPTER, AD }
data class QueueItemUi(
val id: String,
val title: String,
val subtitle: String?,
val artworkUrl: String?,
val isCurrent: Boolean
)

View File

@@ -51,6 +51,7 @@ object VideoPlayerModule {
)
.build()
return ExoPlayer.Builder(application)
.setTrackSelector(trackSelector)
.setPauseAtEndOfMediaItems(true)
.setLoadControl(loadControl)
.setSeekParameters(SeekParameters.CLOSEST_SYNC)

View File

@@ -0,0 +1,209 @@
package hu.bbara.purefin.player.ui
import android.app.Activity
import android.content.Context
import android.media.AudioManager
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.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import hu.bbara.purefin.player.ui.components.PlayerControlsOverlay
import hu.bbara.purefin.player.ui.components.PlayerGesturesLayer
import hu.bbara.purefin.player.ui.components.PlayerLoadingErrorEndCard
import hu.bbara.purefin.player.ui.components.PlayerQueuePanel
import hu.bbara.purefin.player.ui.components.PlayerSettingsSheet
import hu.bbara.purefin.player.ui.components.PlayerSideSliders
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
@Composable
fun PlayerScreen(
viewModel: PlayerViewModel,
onBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val controlsVisible by viewModel.controlsVisible.collectAsState()
val context = LocalContext.current
val activity = context as? Activity
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
var volume by remember { mutableStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) }
var showSettings by remember { mutableStateOf(false) }
var brightnessOverlayVisible by remember { mutableStateOf(false) }
var volumeOverlayVisible by remember { mutableStateOf(false) }
var showQueuePanel by remember { mutableStateOf(false) }
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) {
showSettings = false
showQueuePanel = false
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
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)
)
PlayerGesturesLayer(
modifier = Modifier.fillMaxSize(),
onTap = { viewModel.toggleControlsVisibility() },
onSeekForward = { viewModel.seekBy(10_000) },
onSeekBackward = { viewModel.seekBy(-10_000) },
onVerticalDragLeft = { delta ->
val diff = (-delta / 800f)
brightness = (brightness + diff).coerceIn(0f, 1f)
brightnessOverlayVisible = true
applyBrightness(activity, brightness)
},
onVerticalDragRight = { delta ->
val diff = (-delta / 800f)
volume = (volume + diff).coerceIn(0f, 1f)
volumeOverlayVisible = true
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
}
)
AnimatedVisibility(
visible = volumeOverlayVisible || brightnessOverlayVisible,
enter = fadeIn(),
exit = fadeOut()
) {
PlayerSideSliders(
modifier = Modifier
.fillMaxSize(),
brightness = brightness,
volume = volume,
showBrightness = brightnessOverlayVisible,
showVolume = volumeOverlayVisible,
onHide = {
brightnessOverlayVisible = false
volumeOverlayVisible = false
}
)
}
AnimatedVisibility(
visible = controlsVisible || uiState.isBuffering || uiState.isEnded || uiState.error != null,
enter = fadeIn(),
exit = fadeOut()
) {
PlayerControlsOverlay(
modifier = Modifier.fillMaxSize(),
uiState = uiState,
showControls = controlsVisible,
onBack = onBack,
onPlayPause = { viewModel.togglePlayPause() },
onSeek = { viewModel.seekTo(it) },
onSeekRelative = { delta -> viewModel.seekBy(delta) },
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onToggleCaptions = {
val off = uiState.textTracks.firstOrNull { it.isOff }
val currentId = uiState.selectedTextTrackId
val next = if (currentId == off?.id) {
uiState.textTracks.firstOrNull { !it.isOff }
} else off
next?.let { viewModel.selectTrack(it) }
},
onShowSettings = { showSettings = true },
onQueueSelected = { viewModel.playQueueItem(it) },
onOpenQueue = { showQueuePanel = true }
)
}
PlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center),
uiState = uiState,
onRetry = {
viewModel.retry()
},
onNext = { viewModel.next() },
onReplay = { viewModel.seekTo(0L); viewModel.togglePlayPause() },
onDismissError = { viewModel.clearError() }
)
PlayerSettingsSheet(
visible = showSettings,
uiState = uiState,
onDismiss = { showSettings = false },
onSelectTrack = { viewModel.selectTrack(it) },
onSpeedSelected = { viewModel.setPlaybackSpeed(it) }
)
AnimatedVisibility(
visible = showQueuePanel,
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
PlayerQueuePanel(
uiState = uiState,
onSelect = { id ->
viewModel.playQueueItem(id)
showQueuePanel = false
},
onClose = { showQueuePanel = false },
modifier = Modifier
.fillMaxSize()
)
}
}
}
private fun readCurrentBrightness(activity: Activity?): Float {
val current = activity?.window?.attributes?.screenBrightness
return if (current != null && current >= 0) current else 0.5f
}
private fun applyBrightness(activity: Activity?, value: Float) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = value
activity.window.attributes = params
}

View File

@@ -0,0 +1,360 @@
package hu.bbara.purefin.player.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Forward10
import androidx.compose.material.icons.outlined.Forward30
import androidx.compose.material.icons.outlined.LiveTv
import androidx.compose.material.icons.outlined.MoreVert
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.Replay30
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material.icons.outlined.Subtitles
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.player.model.PlayerUiState
@Composable
fun PlayerControlsOverlay(
uiState: PlayerUiState,
showControls: Boolean,
onBack: () -> Unit,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onToggleCaptions: () -> Unit,
onShowSettings: () -> Unit,
onQueueSelected: (String) -> Unit,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var scrubbing by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color.Black.copy(alpha = 0.45f),
Color.Transparent,
Color.Black.copy(alpha = 0.6f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
TopBar(
title = uiState.title ?: "Playing",
subtitle = uiState.subtitle,
onBack = onBack,
onCast = { },
onMore = { },
onOpenQueue = onOpenQueue
)
CenterControls(
isPlaying = uiState.isPlaying,
isLive = uiState.isLive,
onPlayPause = onPlayPause,
onSeekForward = { onSeekRelative(10_000) },
onSeekBackward = { onSeekRelative(-10_000) },
onLongSeekForward = { onSeekRelative(30_000) },
onLongSeekBackward = { onSeekRelative(-30_000) },
onSeekLiveEdge = onSeekLiveEdge
)
BottomSection(
uiState = uiState,
scrubbing = scrubbing,
onScrubStart = { scrubbing = true },
onScrub = onSeek,
onScrubFinished = { scrubbing = false },
onNext = onNext,
onPrevious = onPrevious,
onToggleCaptions = onToggleCaptions,
onShowSettings = onShowSettings,
onQueueSelected = onQueueSelected
)
}
}
}
@Composable
private fun TopBar(
title: String,
subtitle: String?,
onBack: () -> Unit,
onCast: () -> Unit,
onMore: () -> Unit,
onOpenQueue: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
Column {
Text(text = title, color = scheme.onBackground, fontWeight = FontWeight.Bold)
if (subtitle != null) {
Text(text = subtitle, color = scheme.onBackground.copy(alpha = 0.7f), style = MaterialTheme.typography.bodySmall)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GhostIconButton(
icon = Icons.Outlined.PlaylistPlay,
contentDescription = "Queue",
onClick = onOpenQueue
)
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = onCast)
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = onMore)
}
}
}
@Composable
private fun CenterControls(
isPlaying: Boolean,
isLive: Boolean,
onPlayPause: () -> Unit,
onSeekForward: () -> Unit,
onSeekBackward: () -> Unit,
onLongSeekForward: () -> Unit,
onLongSeekBackward: () -> Unit,
onSeekLiveEdge: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
OverlayActionButton(
icon = Icons.Outlined.Replay10,
label = "-10",
onClick = onSeekBackward
)
OverlayActionButton(
icon = Icons.Outlined.Replay30,
label = "-30",
onClick = onLongSeekBackward
)
Box(
modifier = Modifier
.clip(CircleShape)
.background(scheme.primary.copy(alpha = 0.9f))
) {
val icon = if (isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow
GhostIconButton(
icon = icon,
contentDescription = "Play/Pause",
onClick = onPlayPause,
modifier = Modifier.padding(6.dp)
)
}
OverlayActionButton(
icon = Icons.Outlined.Forward30,
label = "+30",
onClick = onLongSeekForward
)
OverlayActionButton(
icon = Icons.Outlined.Forward10,
label = "+10",
onClick = onSeekForward
)
}
Spacer(modifier = Modifier.height(8.dp))
if (isLive) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Outlined.LiveTv, contentDescription = null, tint = scheme.primary)
Text(
text = "Live",
color = scheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(CircleShape)
.background(scheme.primary.copy(alpha = 0.15f))
.padding(horizontal = 10.dp, vertical = 6.dp)
)
Text(
text = "Catch up",
color = scheme.onSurface,
modifier = Modifier
.clip(CircleShape)
.background(scheme.surfaceVariant.copy(alpha = 0.7f))
.clickable { onSeekLiveEdge() }
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
}
}
@Composable
private fun BottomSection(
uiState: PlayerUiState,
scrubbing: Boolean,
onScrubStart: () -> Unit,
onScrub: (Long) -> Unit,
onScrubFinished: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onToggleCaptions: () -> Unit,
onShowSettings: () -> Unit,
onQueueSelected: (String) -> Unit
) {
val scheme = MaterialTheme.colorScheme
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatTime(uiState.positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodySmall
)
if (uiState.isLive) {
Text(text = "LIVE", color = scheme.primary, fontWeight = FontWeight.Bold)
} else {
Text(
text = formatTime(uiState.durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodySmall
)
}
}
PlayerSeekBar(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onScrub,
onScrubStarted = onScrubStart,
onScrubFinished = onScrubFinished
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
PurefinIconButton(
icon = Icons.Outlined.SkipPrevious,
contentDescription = "Previous",
onClick = onPrevious
)
PurefinIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Next",
onClick = onNext
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
PurefinIconButton(
icon = Icons.Outlined.Subtitles,
contentDescription = "Captions",
onClick = onToggleCaptions
)
PurefinIconButton(
icon = Icons.Outlined.Settings,
contentDescription = "Settings",
onClick = onShowSettings
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
}
@Composable
private fun OverlayActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
Column(horizontalAlignment = Alignment.CenterHorizontally) {
GhostIconButton(
icon = icon,
contentDescription = label,
onClick = onClick
)
Text(text = label, color = scheme.onSurface, style = MaterialTheme.typography.labelSmall)
}
}
private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt()
val minutes = ((totalSeconds / 60) % 60).toInt()
val hours = (totalSeconds / 3600).toInt()
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}

View File

@@ -0,0 +1,47 @@
package hu.bbara.purefin.player.ui.components
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
@Composable
fun PlayerGesturesLayer(
modifier: Modifier = Modifier,
onTap: () -> Unit,
onSeekForward: () -> Unit,
onSeekBackward: () -> Unit,
onVerticalDragLeft: (delta: Float) -> Unit,
onVerticalDragRight: (delta: Float) -> Unit
) {
Box(
modifier = modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onTap = { onTap() },
onDoubleTap = { offset ->
val half = size.width / 2
if (offset.x < half) {
onSeekBackward()
} else {
onSeekForward()
}
}
)
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
val horizontalThreshold = size.width / 2
if (change.position.x < horizontalThreshold) {
onVerticalDragLeft(dragAmount.y)
} else {
onVerticalDragRight(dragAmount.y)
}
}
}
)
}

View File

@@ -0,0 +1,102 @@
package hu.bbara.purefin.player.ui.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.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState
@Composable
fun PlayerLoadingErrorEndCard(
modifier: Modifier = Modifier,
uiState: PlayerUiState,
onRetry: () -> Unit,
onNext: () -> Unit,
onReplay: () -> Unit,
onDismissError: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
Box(modifier = modifier) {
AnimatedVisibility(visible = uiState.isBuffering) {
CircularProgressIndicator(color = scheme.primary)
}
AnimatedVisibility(visible = uiState.error != null) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.9f))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = uiState.error ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
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.9f))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (nextUp != null) {
Text(
text = "Up next",
color = scheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = nextUp.title,
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
Button(onClick = onNext) {
Text("Play next")
}
} else {
Text(text = "Playback finished", color = scheme.onBackground)
Button(onClick = onReplay) {
Text("Replay")
}
}
}
}
}
}

View File

@@ -0,0 +1,161 @@
package hu.bbara.purefin.player.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.fillMaxHeight
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.outlined.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.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.player.model.PlayerUiState
@Composable
fun PlayerQueuePanel(
uiState: PlayerUiState,
onSelect: (String) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(280.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.96f)
) {
Column(
modifier = Modifier.padding(16.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.titleMedium,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Close queue",
tint = scheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable { onClose() }
.padding(8.dp)
)
}
AnimatedVisibility(
visible = uiState.queue.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut()
) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.queue) { item ->
QueueRow(
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
)
}
}
}
}
}
@Composable
private fun QueueRow(
title: String,
subtitle: String?,
artworkUrl: String?,
isCurrent: Boolean,
onClick: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(
if (isCurrent) scheme.primary.copy(alpha = 0.15f) else scheme.surfaceVariant.copy(
alpha = 0.8f
)
)
.clickable { onClick() }
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.width(64.dp)
.clip(RoundedCornerShape(10.dp))
.background(scheme.surfaceVariant)
) {
if (artworkUrl != null) {
PurefinAsyncImage(
model = artworkUrl,
contentDescription = null,
modifier = Modifier.fillMaxWidth()
)
}
}
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
)
if (subtitle != null) {
Text(
text = subtitle,
color = scheme.onSurface.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -0,0 +1,89 @@
package hu.bbara.purefin.player.ui.components
import androidx.compose.foundation.Canvas
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.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.MarkerType
import hu.bbara.purefin.player.model.TimedMarker
@Composable
fun PlayerSeekBar(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onScrubStarted: () -> Unit,
onScrubFinished: () -> 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 combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } + adMarkers.map { it.copy(type = MarkerType.AD) }
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.height(32.dp)
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 2.dp, vertical = 10.dp)
) {
// Buffered bar
val bufferWidth = bufferRatio * size.width
drawRect(
color = scheme.onSurface.copy(alpha = 0.2f),
size = Size(width = size.width, height = 4f),
topLeft = Offset(0f, size.height / 2 - 2f)
)
drawRect(
color = scheme.onSurface.copy(alpha = 0.4f),
size = Size(width = bufferWidth, height = 4f),
topLeft = Offset(0f, size.height / 2 - 2f)
)
// Markers
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 = { newValue ->
onScrubStarted()
onSeek(newValue.toLong())
},
onValueChangeFinished = onScrubFinished,
valueRange = 0f..safeDuration.toFloat(),
colors = SliderDefaults.colors(
thumbColor = scheme.primary,
activeTrackColor = scheme.primary,
inactiveTrackColor = Color.Transparent
),
modifier = Modifier.fillMaxSize()
)
}
}

View File

@@ -0,0 +1,196 @@
package hu.bbara.purefin.player.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.TrackOption
@Composable
fun PlayerSettingsSheet(
visible: Boolean,
uiState: PlayerUiState,
onDismiss: () -> Unit,
onSelectTrack: (TrackOption) -> Unit,
onSpeedSelected: (Float) -> Unit
) {
val scheme = MaterialTheme.colorScheme
AnimatedVisibility(
visible = visible,
enter = slideInVertically(initialOffsetY = { it }) + androidx.compose.animation.fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + androidx.compose.animation.fadeOut()
) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)),
color = scheme.surface.copy(alpha = 0.98f)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Playback settings", color = scheme.onSurface, style = MaterialTheme.typography.titleMedium)
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Close",
tint = scheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable { onDismiss() }
.padding(8.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
TrackGroup(
label = "Audio track",
icon = { Icon(Icons.Outlined.Language, contentDescription = null, tint = scheme.onSurface) },
options = uiState.audioTracks,
selectedId = uiState.selectedAudioTrackId,
onSelect = onSelectTrack
)
Spacer(modifier = Modifier.height(8.dp))
TrackGroup(
label = "Subtitles",
icon = { Icon(Icons.Outlined.ClosedCaption, contentDescription = null, tint = scheme.onSurface) },
options = uiState.textTracks,
selectedId = uiState.selectedTextTrackId,
onSelect = onSelectTrack
)
Spacer(modifier = Modifier.height(8.dp))
TrackGroup(
label = "Quality",
icon = { Icon(Icons.Outlined.HighQuality, contentDescription = null, tint = scheme.onSurface) },
options = uiState.qualityTracks,
selectedId = uiState.selectedQualityTrackId,
onSelect = onSelectTrack
)
Spacer(modifier = Modifier.height(8.dp))
SpeedGroup(
selectedSpeed = uiState.playbackSpeed,
onSpeedSelected = onSpeedSelected
)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
}
}
@Composable
private fun TrackGroup(
label: String,
icon: @Composable () -> Unit,
options: List<TrackOption>,
selectedId: String?,
onSelect: (TrackOption) -> Unit
) {
val scheme = MaterialTheme.colorScheme
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
icon()
Text(text = label, color = scheme.onSurface, style = MaterialTheme.typography.titleSmall)
}
FlowChips(
items = options,
selectedId = selectedId,
onSelect = onSelect
)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun FlowChips(
items: List<TrackOption>,
selectedId: String?,
onSelect: (TrackOption) -> Unit
) {
val scheme = MaterialTheme.colorScheme
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items.forEach { option ->
val selected = option.id == selectedId
Text(
text = option.label,
color = if (selected) scheme.onPrimary else scheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(if (selected) scheme.primary else scheme.surfaceVariant)
.clickable { onSelect(option) }
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun SpeedGroup(
selectedSpeed: Float,
onSpeedSelected: (Float) -> Unit
) {
val options = listOf(0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f)
val scheme = MaterialTheme.colorScheme
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Speed, contentDescription = null, tint = scheme.onSurface)
Text(text = "Playback speed", color = scheme.onSurface, style = MaterialTheme.typography.titleSmall)
}
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
options.forEach { speed ->
val selected = speed == selectedSpeed
Text(
text = "${speed}x",
color = if (selected) scheme.onPrimary else scheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(if (selected) scheme.primary else scheme.surfaceVariant)
.clickable { onSpeedSelected(speed) }
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
}

View File

@@ -0,0 +1,94 @@
package hu.bbara.purefin.player.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BrightnessMedium
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun PlayerSideSliders(
modifier: Modifier = Modifier,
brightness: Float,
volume: Float,
showBrightness: Boolean,
showVolume: Boolean,
onHide: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
LaunchedEffect(showBrightness, showVolume) {
if (showBrightness || showVolume) {
delay(800)
onHide()
}
}
Box(modifier = modifier.fillMaxWidth()) {
if (showBrightness) {
SideOverlay(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp),
icon = { Icon(Icons.Outlined.BrightnessMedium, contentDescription = null, tint = scheme.onBackground) },
progress = brightness,
scheme = scheme
)
}
if (showVolume) {
SideOverlay(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp),
icon = { Icon(Icons.Outlined.VolumeUp, contentDescription = null, tint = scheme.onBackground) },
progress = volume,
scheme = scheme
)
}
}
}
@Composable
private fun SideOverlay(
modifier: Modifier = Modifier,
icon: @Composable () -> Unit,
progress: Float,
scheme: androidx.compose.material3.ColorScheme
) {
Column(
modifier = modifier
.fillMaxHeight(0.3f)
.wrapContentHeight(align = Alignment.CenterVertically)
.clip(RoundedCornerShape(18.dp))
.background(scheme.background.copy(alpha = 0.8f))
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
icon()
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp)),
color = scheme.primary,
trackColor = scheme.onBackground.copy(alpha = 0.2f)
)
}
}

View File

@@ -5,11 +5,26 @@ import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.QueueItemUi
import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.player.model.TrackType
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.MediaSourceInfo
@@ -24,20 +39,73 @@ class PlayerViewModel @Inject constructor(
val mediaId: String? = savedStateHandle["MEDIA_ID"]
private val videoUris = savedStateHandle.getStateFlow("videoUris", emptyList<Uri>())
private val _contentUri = MutableStateFlow<Uri?>(null)
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
private val _controlsVisible = MutableStateFlow(true)
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private var autoHideJob: Job? = null
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_uiState.update { it.copy(isPlaying = isPlaying, isBuffering = false, isEnded = false) }
if (isPlaying) {
scheduleAutoHide()
} else {
showControls()
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
val buffering = playbackState == Player.STATE_BUFFERING
val ended = playbackState == Player.STATE_ENDED
_uiState.update { state ->
state.copy(
isBuffering = buffering,
isEnded = ended,
error = if (playbackState == Player.STATE_IDLE) state.error else null
)
}
if (buffering || ended) showControls()
if (ended) player.pause()
}
override fun onPlayerError(error: PlaybackException) {
_uiState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
showControls()
}
override fun onTracksChanged(tracks: Tracks) {
updateTracks(tracks)
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateMetadata(mediaItem)
updateQueue()
}
}
init {
player.prepare()
observePlayer()
loadMedia()
startProgressUpdates()
}
private fun observePlayer() {
player.addListener(playerListener)
}
fun loadMedia() {
viewModelScope.launch {
val mediaSources: List<MediaSourceInfo> = jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!))
val mediaSources: List<MediaSourceInfo> =
jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!))
val contentUriString =
jellyfinApiClient.getMediaPlaybackInfo(mediaId = UUID.fromString(mediaId), mediaSourceId = mediaSources.first().id)
jellyfinApiClient.getMediaPlaybackInfo(
mediaId = UUID.fromString(mediaId),
mediaSourceId = mediaSources.first().id
)
contentUriString?.toUri()?.let {
_contentUri.value = it
playVideo(it)
}
}
@@ -49,13 +117,298 @@ class PlayerViewModel @Inject constructor(
}
fun playVideo(uri: Uri) {
player.setMediaItem(
MediaItem.fromUri(uri)
val mediaItem = MediaItem.Builder()
.setUri(uri)
.setMediaId(mediaId ?: uri.toString())
.build()
player.setMediaItem(mediaItem)
player.prepare()
player.playWhenReady = true
updateQueue()
updateMetadata(mediaItem)
updateTracks()
_uiState.update { it.copy(isEnded = false, error = null) }
}
fun togglePlayPause() {
if (player.isPlaying) player.pause() else player.play()
}
fun seekTo(positionMs: Long) {
player.seekTo(positionMs)
scheduleAutoHide()
}
fun seekBy(deltaMs: Long) {
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L)
seekTo(target)
}
fun seekToLiveEdge() {
if (player.isCurrentMediaItemLive) {
player.seekToDefaultPosition()
player.play()
}
}
fun showControls() {
_controlsVisible.value = true
scheduleAutoHide()
}
fun toggleControlsVisibility() {
_controlsVisible.value = !_controlsVisible.value
if (_controlsVisible.value) scheduleAutoHide()
}
private fun scheduleAutoHide() {
autoHideJob?.cancel()
if (!player.isPlaying) return
autoHideJob = viewModelScope.launch {
delay(3500)
_controlsVisible.value = false
}
}
fun next() {
if (player.hasNextMediaItem()) {
player.seekToNextMediaItem()
showControls()
}
}
fun previous() {
if (player.hasPreviousMediaItem()) {
player.seekToPreviousMediaItem()
showControls()
}
}
fun selectTrack(option: TrackOption) {
val builder = player.trackSelectionParameters.buildUpon()
when (option.type) {
TrackType.TEXT -> {
if (option.isOff) {
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
builder.clearOverridesOfType(C.TRACK_TYPE_TEXT)
} else {
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
builder.clearOverridesOfType(C.TRACK_TYPE_TEXT)
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
builder.addOverride(
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
)
}
}
TrackType.AUDIO -> {
builder.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
builder.addOverride(
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
)
}
TrackType.VIDEO -> {
builder.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
builder.addOverride(
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
)
}
}
player.trackSelectionParameters = builder.build()
updateTracks()
}
fun setPlaybackSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
_uiState.update { it.copy(playbackSpeed = speed) }
}
fun retry() {
player.prepare()
player.playWhenReady = true
}
fun playQueueItem(id: String) {
val items = _uiState.value.queue
val targetIndex = items.indexOfFirst { it.id == id }
if (targetIndex >= 0) {
player.seekToDefaultPosition(targetIndex)
player.playWhenReady = true
showControls()
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
private fun startProgressUpdates() {
viewModelScope.launch {
while (isActive) {
val duration = player.duration.takeIf { it > 0 } ?: _uiState.value.durationMs
val position = player.currentPosition
val buffered = player.bufferedPosition
_uiState.update {
it.copy(
durationMs = duration,
positionMs = position,
bufferedMs = buffered,
isLive = player.isCurrentMediaItemLive
)
}
delay(500)
}
}
}
private fun updateTracks(tracks: Tracks = player.currentTracks) {
val audio = mutableListOf<TrackOption>()
val text = mutableListOf<TrackOption>()
val video = mutableListOf<TrackOption>()
var selectedAudio: String? = null
var selectedText: String? = null
var selectedVideo: String? = null
tracks.groups.forEachIndexed { groupIndex, group ->
when (group.type) {
C.TRACK_TYPE_AUDIO -> {
repeat(group.length) { trackIndex ->
val format = group.getTrackFormat(trackIndex)
val id = "a_${groupIndex}_$trackIndex"
val label = format.label
?: format.language
?: "${format.channelCount}ch"
?: "Audio $trackIndex"
val option = TrackOption(
id = id,
label = label,
language = format.language,
bitrate = format.bitrate,
channelCount = format.channelCount,
height = null,
groupIndex = groupIndex,
trackIndex = trackIndex,
type = TrackType.AUDIO,
isOff = false
)
audio.add(option)
if (group.isTrackSelected(trackIndex)) selectedAudio = id
}
}
C.TRACK_TYPE_TEXT -> {
repeat(group.length) { trackIndex ->
val format = group.getTrackFormat(trackIndex)
val id = "t_${groupIndex}_$trackIndex"
val label = format.label
?: format.language
?: "Subtitle $trackIndex"
val option = TrackOption(
id = id,
label = label,
language = format.language,
bitrate = null,
channelCount = null,
height = null,
groupIndex = groupIndex,
trackIndex = trackIndex,
type = TrackType.TEXT,
isOff = false
)
text.add(option)
if (group.isTrackSelected(trackIndex)) selectedText = id
}
}
C.TRACK_TYPE_VIDEO -> {
repeat(group.length) { trackIndex ->
val format = group.getTrackFormat(trackIndex)
val id = "v_${groupIndex}_$trackIndex"
val res = if (format.height != Format.NO_VALUE) "${format.height}p" else null
val label = res ?: format.label ?: "Video $trackIndex"
val option = TrackOption(
id = id,
label = label,
language = null,
bitrate = format.bitrate,
channelCount = null,
height = format.height.takeIf { it > 0 },
groupIndex = groupIndex,
trackIndex = trackIndex,
type = TrackType.VIDEO,
isOff = false
)
video.add(option)
if (group.isTrackSelected(trackIndex)) selectedVideo = id
}
}
}
}
if (text.isNotEmpty()) {
text.add(
0,
TrackOption(
id = "text_off",
label = "Off",
language = null,
bitrate = null,
channelCount = null,
height = null,
groupIndex = -1,
trackIndex = -1,
type = TrackType.TEXT,
isOff = true
)
)
}
_uiState.update {
it.copy(
audioTracks = audio,
textTracks = text,
qualityTracks = video,
selectedAudioTrackId = selectedAudio,
selectedTextTrackId = selectedText ?: text.firstOrNull { option -> option.isOff }?.id,
selectedQualityTrackId = selectedVideo
)
}
}
private fun updateQueue() {
val items = mutableListOf<QueueItemUi>()
for (i in 0 until player.mediaItemCount) {
val mediaItem = player.getMediaItemAt(i)
items.add(
QueueItemUi(
id = mediaItem.mediaId.ifEmpty { i.toString() },
title = mediaItem.mediaMetadata.title?.toString() ?: "Item ${i + 1}",
subtitle = mediaItem.mediaMetadata.subtitle?.toString(),
artworkUrl = mediaItem.mediaMetadata.artworkUri?.toString(),
isCurrent = i == player.currentMediaItemIndex
)
)
}
_uiState.update { it.copy(queue = items) }
}
private fun updateMetadata(mediaItem: MediaItem?) {
mediaItem ?: return
_uiState.update {
it.copy(
title = mediaItem.mediaMetadata.title?.toString(),
subtitle = mediaItem.mediaMetadata.subtitle?.toString()
)
}
}
override fun onCleared() {
super.onCleared()
autoHideJob?.cancel()
player.removeListener(playerListener)
player.release()
}
}