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 android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
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.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.media3.ui.PlayerView
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import hu.bbara.purefin.player.ui.PlayerScreen
|
||||||
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
||||||
import hu.bbara.purefin.ui.theme.PurefinTheme
|
import hu.bbara.purefin.ui.theme.PurefinTheme
|
||||||
|
|
||||||
@@ -23,26 +17,24 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setContent {
|
enterImmersiveMode()
|
||||||
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)
|
|
||||||
|
|
||||||
)
|
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()
|
.build()
|
||||||
return ExoPlayer.Builder(application)
|
return ExoPlayer.Builder(application)
|
||||||
|
.setTrackSelector(trackSelector)
|
||||||
.setPauseAtEndOfMediaItems(true)
|
.setPauseAtEndOfMediaItems(true)
|
||||||
.setLoadControl(loadControl)
|
.setLoadControl(loadControl)
|
||||||
.setSeekParameters(SeekParameters.CLOSEST_SYNC)
|
.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.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.client.JellyfinApiClient
|
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.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 kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
@@ -24,20 +39,73 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
val mediaId: String? = savedStateHandle["MEDIA_ID"]
|
val mediaId: String? = savedStateHandle["MEDIA_ID"]
|
||||||
private val videoUris = savedStateHandle.getStateFlow("videoUris", emptyList<Uri>())
|
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 {
|
init {
|
||||||
player.prepare()
|
observePlayer()
|
||||||
loadMedia()
|
loadMedia()
|
||||||
|
startProgressUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observePlayer() {
|
||||||
|
player.addListener(playerListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMedia() {
|
fun loadMedia() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val mediaSources: List<MediaSourceInfo> = jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!))
|
val mediaSources: List<MediaSourceInfo> =
|
||||||
|
jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!))
|
||||||
val contentUriString =
|
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 {
|
contentUriString?.toUri()?.let {
|
||||||
_contentUri.value = it
|
|
||||||
playVideo(it)
|
playVideo(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,13 +117,298 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playVideo(uri: Uri) {
|
fun playVideo(uri: Uri) {
|
||||||
player.setMediaItem(
|
val mediaItem = MediaItem.Builder()
|
||||||
MediaItem.fromUri(uri)
|
.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() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
autoHideJob?.cancel()
|
||||||
|
player.removeListener(playerListener)
|
||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user