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]