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
This commit is contained in:
2026-06-18 16:34:39 +00:00
parent ccd5a7f50e
commit 615e95db5c
8 changed files with 341 additions and 4 deletions

View File

@@ -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,

View File

@@ -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"