From f835d9ea2683375c72b7b15d72047001fa2303f4 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 25 Jan 2026 18:43:43 +0100 Subject: [PATCH] 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. --- .../hu/bbara/purefin/player/PlayerActivity.kt | 48 +-- .../purefin/player/model/PlayerUiModels.kt | 55 +++ .../player/module/VideoPlayerModule.kt | 1 + .../bbara/purefin/player/ui/PlayerScreen.kt | 209 ++++++++++ .../ui/components/PlayerControlsOverlay.kt | 360 +++++++++++++++++ .../ui/components/PlayerGesturesLayer.kt | 47 +++ .../components/PlayerLoadingErrorEndCard.kt | 102 +++++ .../player/ui/components/PlayerQueuePanel.kt | 161 ++++++++ .../player/ui/components/PlayerSeekBar.kt | 89 +++++ .../ui/components/PlayerSettingsSheet.kt | 196 +++++++++ .../player/ui/components/PlayerSideSliders.kt | 94 +++++ .../player/viewmodel/PlayerViewModel.kt | 371 +++++++++++++++++- 12 files changed, 1696 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerGesturesLayer.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerQueuePanel.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSeekBar.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt create mode 100644 app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSideSliders.kt diff --git a/app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt b/app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt index fe0563e..aef3ae7 100644 --- a/app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt +++ b/app/src/main/java/hu/bbara/purefin/player/PlayerActivity.kt @@ -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() - 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() + 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 + } + } } diff --git a/app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt b/app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt new file mode 100644 index 0000000..d02a450 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt @@ -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 = emptyList(), + val ads: List = emptyList(), + val queue: List = emptyList(), + val audioTracks: List = emptyList(), + val textTracks: List = emptyList(), + val qualityTracks: List = 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 +) diff --git a/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt index 6c92412..594b726 100644 --- a/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt +++ b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt @@ -51,6 +51,7 @@ object VideoPlayerModule { ) .build() return ExoPlayer.Builder(application) + .setTrackSelector(trackSelector) .setPauseAtEndOfMediaItems(true) .setLoadControl(loadControl) .setSeekParameters(SeekParameters.CLOSEST_SYNC) diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt b/app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt new file mode 100644 index 0000000..973143b --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/PlayerScreen.kt @@ -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 +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt new file mode 100644 index 0000000..8df42c7 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerControlsOverlay.kt @@ -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) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerGesturesLayer.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerGesturesLayer.kt new file mode 100644 index 0000000..debf890 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerGesturesLayer.kt @@ -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) + } + } + } + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt new file mode 100644 index 0000000..fec89ae --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt @@ -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") + } + } + } + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerQueuePanel.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerQueuePanel.kt new file mode 100644 index 0000000..999dfc2 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerQueuePanel.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSeekBar.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSeekBar.kt new file mode 100644 index 0000000..2c1df75 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSeekBar.kt @@ -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, + adMarkers: List, + 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() + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt new file mode 100644 index 0000000..a024b64 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSettingsSheet.kt @@ -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, + 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, + 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) + ) + } + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSideSliders.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSideSliders.kt new file mode 100644 index 0000000..07529f8 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerSideSliders.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt index 47aa969..41a0118 100644 --- a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt @@ -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()) - private val _contentUri = MutableStateFlow(null) + private val _uiState = MutableStateFlow(PlayerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _controlsVisible = MutableStateFlow(true) + val controlsVisible: StateFlow = _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 = jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!)) + val mediaSources: List = + 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() + val text = mutableListOf() + val video = mutableListOf() + 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() + 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() } -} \ No newline at end of file +}