Better next up layout

This commit is contained in:
Damontecres 2025-10-06 13:11:04 -04:00
parent 628771211d
commit a963074f07
No known key found for this signature in database
4 changed files with 125 additions and 43 deletions

View file

@ -31,12 +31,12 @@ import com.github.damontecres.dolphin.ui.isNotNullOrBlank
@Composable @Composable
fun BannerCard( fun BannerCard(
imageUrl: String?, imageUrl: String?,
cornerText: String?,
played: Boolean,
playPercent: Double,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cornerText: String? = null,
played: Boolean = false,
playPercent: Double = 0.0,
cardHeight: Dp = 140.dp * .85f, cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9, aspectRatio: Float = 16f / 9,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,

View file

@ -1,12 +1,20 @@
package com.github.damontecres.dolphin.ui.playback package com.github.damontecres.dolphin.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -17,60 +25,86 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ui.PreviewTvSpec
import com.github.damontecres.dolphin.ui.cards.BannerCard
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
@Composable @Composable
fun NextUpEpisode( fun NextUpEpisode(
ep: BaseItem, title: String?,
description: String?,
imageUrl: String?,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
aspectRatio: Float = 16f / 9,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Card( Box(
onClick = onClick, modifier = modifier,
modifier = modifier.focusRequester(focusRequester),
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(8.dp), modifier =
Modifier
.fillMaxSize()
.padding(8.dp),
) { ) {
Text( Text(
text = "Up Next...", text = "Up Next...",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier, modifier = Modifier.padding(8.dp),
) { ) {
Box { Box {
AsyncImage( BannerCard(
model = ep.imageUrl, imageUrl = imageUrl,
contentDescription = ep.name, onClick = onClick,
onLongClick = {},
cardHeight = 100.dp,
aspectRatio = aspectRatio,
modifier =
Modifier
.focusRequester(focusRequester)
.align(Alignment.Center),
)
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.border,
modifier =
Modifier
.size(60.dp)
.align(Alignment.Center),
) )
} }
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
) { ) {
Text( Text(
text = ep.data.name ?: "", text = title ?: "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
Text( Text(
text = ep.data.overview ?: "", text = description ?: "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
@ -79,3 +113,28 @@ fun NextUpEpisode(
} }
} }
} }
@PreviewTvSpec
@Composable
fun NextUpEpisodePreview() {
DolphinTheme(true) {
NextUpEpisode(
title = "Episode Title",
description = "This is the description of the episode. It might be long.",
imageUrl = "",
onClick = {},
aspectRatio = 4f / 3,
modifier =
Modifier
.padding(16.dp)
.height(200.dp)
.width(400.dp)
// .fillMaxWidth(.4f)
// .align(Alignment.BottomCenter)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
)
}
}

View file

@ -11,11 +11,13 @@ import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -53,6 +55,7 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberPresentationState import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.media3.ui.compose.state.rememberPreviousButtonState import androidx.media3.ui.compose.state.rememberPreviousButtonState
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.LoadingPage
@ -320,14 +323,22 @@ fun PlaybackContent(
) { ) {
nextUp?.let { nextUp?.let {
NextUpEpisode( NextUpEpisode(
ep = it, title = it.name,
description = it.data.overview,
imageUrl = it.imageUrl,
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
onClick = { viewModel.playUpNextEpisode() }, onClick = { viewModel.playUpNextEpisode() },
modifier = modifier =
Modifier Modifier
.padding(16.dp) .padding(8.dp)
.height(128.dp) // .height(128.dp)
.fillMaxWidth(.4f) .fillMaxHeight(.3f)
.align(Alignment.BottomCenter), .fillMaxWidth(.5f)
.align(Alignment.BottomCenter)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
) )
} }
} }

View file

@ -30,8 +30,6 @@ import com.github.damontecres.dolphin.util.subtitleMimeTypes
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -54,9 +52,6 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
enum class TranscodeType { enum class TranscodeType {
DIRECT_PLAY, DIRECT_PLAY,
@ -451,23 +446,40 @@ class PlaybackViewModel
Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}") Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}")
val nextIndex = currentEpisodeIndex + 1 val nextIndex = currentEpisodeIndex + 1
if (nextIndex < episodes.size) { if (nextIndex < episodes.size) {
viewModelScope.launch(Dispatchers.IO) { val listener =
while (this.isActive) { object : Player.Listener {
delay(5.seconds) override fun onPlaybackStateChanged(playbackState: Int) {
val remaining = if (playbackState == Player.STATE_ENDED) {
withContext(Dispatchers.Main) { viewModelScope.launch(Dispatchers.IO) {
(player.duration - player.currentPosition).milliseconds val nextItem = episodes.getBlocking(nextIndex)
Timber.v("Setting next up episode to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUpEpisode.value = nextItem
}
}
player.removeListener(this)
} }
if (remaining < 2.minutes) { // TODO time & preference
val nextItem = episodes.getBlocking(nextIndex)
Timber.v("Setting next up episode to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUpEpisode.value = nextItem
}
break
} }
} }
} player.addListener(listener)
// viewModelScope.launch(Dispatchers.IO) {
// while (this.isActive) {
// delay(5.seconds)
// val remaining =
// withContext(Dispatchers.Main) {
// (player.duration - player.currentPosition).milliseconds
// }
// if (remaining < 2.minutes) { // TODO time & preference
// val nextItem = episodes.getBlocking(nextIndex)
// Timber.v("Setting next up episode to ${nextItem?.id}")
// withContext(Dispatchers.Main) {
// nextUpEpisode.value = nextItem
// }
// break
// }
// }
// }
} }
} }
} }