From 615e95db5cd0f4128ef5ea78b8f70865138b3dbc Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Thu, 18 Jun 2026 16:34:39 +0000 Subject: [PATCH] feat(player): add next episode overlay that appears 20s before episode end Show an "Up Next" card with the next episode thumbnail, play overlay, and title when within 20 seconds of the current episode ending. Tapping it immediately starts the next episode from the playlist queue. - Add nextEpisode field to PlayerUiState derived from queue - Create NextEpisodeOverlay for mobile with artwork + title - Create TvNextEpisodeOverlay for TV with D-pad focus support - Integrate into both PlayerScreen and TvPlayerScreen - Auto-focus overlay on TV when it appears --- app-tv/build.gradle.kts | 2 + .../ui/screen/player/TvPlayerScreen.kt | 41 ++++- .../player/components/TvNextEpisodeOverlay.kt | 157 ++++++++++++++++++ .../purefin/ui/screen/player/PlayerScreen.kt | 25 +++ .../player/components/NextEpisodeOverlay.kt | 106 ++++++++++++ .../core/player/model/PlayerUiModels.kt | 1 + .../core/player/viewmodel/PlayerViewModel.kt | 9 +- gradle/libs.versions.toml | 4 + 8 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt create mode 100644 app/src/main/java/hu/bbara/purefin/ui/screen/player/components/NextEpisodeOverlay.kt diff --git a/app-tv/build.gradle.kts b/app-tv/build.gradle.kts index 38ac9e86..66ac9e4c 100644 --- a/app-tv/build.gradle.kts +++ b/app-tv/build.gradle.kts @@ -57,7 +57,9 @@ dependencies { implementation(project(":core")) implementation(project(":core-model")) implementation(project(":core-ui")) + implementation(libs.androidx.compose.animation) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.foundation) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt index ed3e2a5f..431b100a 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt @@ -62,6 +62,7 @@ import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack import hu.bbara.purefin.ui.screen.player.components.TvIconButton +import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard @@ -104,6 +105,7 @@ fun TvPlayerScreen( val audioButtonFocusRequester = remember { FocusRequester() } val subtitlesButtonFocusRequester = remember { FocusRequester() } val skipButtonFocusRequester = remember { FocusRequester() } + val nextEpisodeFocusRequester = remember { FocusRequester() } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { viewModel.pausePlayback() @@ -199,10 +201,20 @@ fun TvPlayerScreen( } } + val playerControlsVisible = + controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null + + val showNextEpisodeOverlay = !playerControlsVisible + && uiState.nextEpisode != null + && uiState.durationMs > 0L + && (uiState.durationMs - uiState.positionMs) <= 60_000L + && !uiState.isEnded + LaunchedEffect( controlsVisible, controlsAutoHideBlocked, - uiState.activeSkippableSegmentEndMs + uiState.activeSkippableSegmentEndMs, + showNextEpisodeOverlay ) { if (controlsAutoHideBlocked) return@LaunchedEffect if (controlsVisible) { @@ -212,6 +224,10 @@ fun TvPlayerScreen( skipButtonFocusRequester.requestFocus() return@LaunchedEffect } + if (showNextEpisodeOverlay) { + nextEpisodeFocusRequester.requestFocus() + return@LaunchedEffect + } focusManager.clearFocus() } } @@ -246,8 +262,6 @@ fun TvPlayerScreen( } } - val playerControlsVisible = - controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null val subtitleBottomPaddingFraction = if (playerControlsVisible) { CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION @@ -364,6 +378,27 @@ fun TvPlayerScreen( ) } + AnimatedVisibility( + visible = showNextEpisodeOverlay, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = 32.dp, + bottom = 140.dp + ) + ) { + uiState.nextEpisode?.let { nextEpisode -> + TvNextEpisodeOverlay( + nextEpisode = nextEpisode, + onClick = { nextAndShowControls() }, + onUp = { showTvControls() }, + modifier = Modifier.focusRequester(nextEpisodeFocusRequester) + ) + } + } + AnimatedVisibility( visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null, enter = fadeIn(), diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt new file mode 100644 index 00000000..2bef4e3e --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt @@ -0,0 +1,157 @@ +package hu.bbara.purefin.ui.screen.player.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import hu.bbara.purefin.core.player.model.PlaylistElementUiModel + +/** + * TV-optimized overlay button that appears near the end of a media item to let the user + * quickly jump to the next episode. Shows the next episode's artwork and title. + * Supports D-pad navigation and focus animations. + */ +@Composable +fun TvNextEpisodeOverlay( + nextEpisode: PlaylistElementUiModel, + onClick: () -> Unit, + onUp: () -> Unit, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.05f else 1f, + label = "nextEpisodeScale" + ) + val borderColor by animateColorAsState( + targetValue = if (isFocused) scheme.primary else Color.Transparent, + label = "nextEpisodeBorder" + ) + val backgroundColor by animateColorAsState( + targetValue = if (isFocused) { + scheme.surfaceContainerHigh.copy(alpha = 0.96f) + } else { + Color.Black.copy(alpha = 0.85f) + }, + label = "nextEpisodeBackground" + ) + + Column( + modifier = modifier + .width(280.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .border( + width = if (isFocused) 3.dp else 0.dp, + color = borderColor, + shape = RoundedCornerShape(20.dp) + ) + .clip(RoundedCornerShape(20.dp)) + .background(backgroundColor) + .focusable() + .onFocusChanged { isFocused = it.isFocused } + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) { + onUp() + true + } else false + } + .clickable { onClick() }, + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + // "Up Next" label + Text( + text = "Up Next", + color = scheme.secondary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + // Thumbnail with play button overlay + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)) + ) { + AsyncImage( + model = nextEpisode.artworkUrl?.takeIf { it.isNotEmpty() }, + contentDescription = nextEpisode.title, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop + ) + + // Dark gradient overlay for readability + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.1f), + Color.Black.copy(alpha = 0.4f) + ) + ) + ) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Episode title + Text( + text = nextEpisode.title, + color = scheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp) + ) + } +} + +internal const val TvPlayerNextEpisodeOverlayTag = "tv_player_next_episode_overlay" diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt index 6d018a75..1318220a 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt @@ -51,6 +51,7 @@ import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel +import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton @@ -261,6 +262,30 @@ fun PlayerScreen( SkipSegmentButton(onClick = { viewModel.skipActiveSegment() }) } + val showNextEpisodeOverlay = !playerControlsVisible + && uiState.nextEpisode != null + && uiState.durationMs > 0L + && (uiState.durationMs - uiState.positionMs) <= 60_000L + && !uiState.isEnded + AnimatedVisibility( + visible = showNextEpisodeOverlay, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = 24.dp, + bottom = 120.dp + ) + ) { + uiState.nextEpisode?.let { nextEpisode -> + NextEpisodeOverlay( + nextEpisode = nextEpisode, + onClick = { viewModel.next() } + ) + } + } + PlayerLoadingErrorEndCard( modifier = Modifier.align(Alignment.Center), uiState = uiState, diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/NextEpisodeOverlay.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/NextEpisodeOverlay.kt new file mode 100644 index 00000000..8d4d1fc6 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/NextEpisodeOverlay.kt @@ -0,0 +1,106 @@ +package hu.bbara.purefin.ui.screen.player.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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import hu.bbara.purefin.core.player.model.PlaylistElementUiModel +import hu.bbara.purefin.ui.common.image.PurefinAsyncImage + +/** + * Overlay button that appears near the end of a media item to let the user + * quickly jump to the next episode. Shows the next episode's artwork and title. + */ +@Composable +fun NextEpisodeOverlay( + nextEpisode: PlaylistElementUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Column( + modifier = modifier + .width(200.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Black.copy(alpha = 0.85f)) + .clickable { onClick() }, + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + // "Up Next" label + Text( + text = "Up Next", + color = scheme.secondary, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding(start = 12.dp, top = 8.dp, end = 12.dp) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Thumbnail with play button overlay + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(8.dp)) + ) { + PurefinAsyncImage( + model = nextEpisode.artworkUrl, + contentDescription = nextEpisode.title, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop + ) + + // Dark gradient overlay for readability + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.1f), + Color.Black.copy(alpha = 0.4f) + ) + ) + ) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Episode title + Text( + text = nextEpisode.title, + color = scheme.onSurface, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp) + ) + } +} + +internal const val PlayerNextEpisodeOverlayTag = "player_next_episode_overlay" diff --git a/core/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt b/core/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt index 47f7f364..48785522 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt @@ -16,6 +16,7 @@ data class PlayerUiState( val chapters: List = emptyList(), val ads: List = emptyList(), val queue: List = emptyList(), + val nextEpisode: PlaylistElementUiModel? = null, val audioTracks: List = emptyList(), val textTracks: List = emptyList(), val qualityTracks: List = emptyList(), diff --git a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index c1a90f62..a24fb283 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -139,9 +139,16 @@ class PlayerViewModel @Inject constructor( playableMedia.toPlaylistElementUiModel(currentPlayableMedia?.id) } }.collect { queue -> + val currentIndex = queue.indexOfFirst { it.isCurrent } + val nextEpisode = if (currentIndex != -1 && currentIndex + 1 < queue.size) { + queue[currentIndex + 1] + } else { + null + } _uiState.update { state -> state.copy( - queue = queue + queue = queue, + nextEpisode = nextEpisode ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d187d0da..b7b34941 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,8 @@ timber = "5.0.1" firebaseCrashlytics = "20.0.5" googleGmsGoogleServices = "4.4.4" googleFirebaseCrashlytics = "3.0.7" +foundationVersion = "1.11.2" +animation = "1.11.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -76,6 +78,8 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" } +androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } +androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" } [plugins]