mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -51,6 +51,7 @@ object VideoPlayerModule {
|
||||
)
|
||||
.build()
|
||||
return ExoPlayer.Builder(application)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setPauseAtEndOfMediaItems(true)
|
||||
.setLoadControl(loadControl)
|
||||
.setSeekParameters(SeekParameters.CLOSEST_SYNC)
|
||||
|
||||
209
app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt
Normal file
209
app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user