Support auto play next up

This commit is contained in:
Damontecres 2025-10-06 14:04:36 -04:00
parent a963074f07
commit 73bb96adfb
No known key found for this signature in database
6 changed files with 144 additions and 23 deletions

View file

@ -200,6 +200,38 @@ sealed interface AppPreference<T> {
summaryOff = R.string.hide,
)
val AutoPlayNextUp =
AppSwitchPreference(
title = R.string.auto_play_next,
defaultValue = true,
getter = { it.playbackPreferences.autoPlayNext },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { autoPlayNext = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val AutoPlayNextDelay =
AppSliderPreference(
title = R.string.auto_play_next_delay,
defaultValue = 15,
min = 0,
max = 60,
interval = 5,
getter = { it.playbackPreferences.autoPlayNextDelaySeconds },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { autoPlayNextDelaySeconds = value }
},
summarizer = { value ->
if (value == 0L) {
"Immediate"
} else {
"$value seconds"
}
},
)
val InstalledVersion =
AppClickablePreference(
title = R.string.installed_version,
@ -256,6 +288,8 @@ val basicPreferences =
AppPreference.SkipForward,
AppPreference.SkipBack,
AppPreference.ControllerTimeout,
AppPreference.AutoPlayNextUp,
AppPreference.AutoPlayNextDelay,
AppPreference.PlaybackDebugInfo,
),
),

View file

@ -26,6 +26,9 @@ class AppPreferencesSerializer
controllerTimeoutMs = AppPreference.ControllerTimeout.defaultValue
seekBarSteps = AppPreference.SeekBarSteps.defaultValue.toInt()
showDebugInfo = AppPreference.PlaybackDebugInfo.defaultValue
autoPlayNext = AppPreference.AutoPlayNextUp.defaultValue
autoPlayNextDelaySeconds =
AppPreference.AutoPlayNextDelay.defaultValue
}.build()
homePagePreferences =
HomePagePreferences

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
@ -22,17 +23,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.ui.AppColors
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 kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
fun NextUpEpisode(
@ -40,6 +46,7 @@ fun NextUpEpisode(
description: String?,
imageUrl: String?,
onClick: () -> Unit,
timeLeft: Duration?,
modifier: Modifier = Modifier,
aspectRatio: Float = 16f / 9,
) {
@ -67,28 +74,16 @@ fun NextUpEpisode(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp),
) {
Box {
BannerCard(
NextUpCard(
imageUrl = imageUrl,
onClick = onClick,
onLongClick = {},
cardHeight = 100.dp,
aspectRatio = aspectRatio,
timeLeft = timeLeft,
modifier =
Modifier
.focusRequester(focusRequester)
.align(Alignment.Center),
.fillMaxWidth(.4f)
.fillMaxHeight()
.focusRequester(focusRequester),
)
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.border,
modifier =
Modifier
.size(60.dp)
.align(Alignment.Center),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxHeight(),
@ -114,6 +109,56 @@ fun NextUpEpisode(
}
}
@Composable
fun NextUpCard(
imageUrl: String?,
onClick: () -> Unit,
timeLeft: Duration?,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
onClick = onClick,
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
if (timeLeft != null && timeLeft > Duration.ZERO) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.background(
AppColors.TransparentBlack50,
shape = CircleShape,
),
) {
Text(
text = timeLeft.toString(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(8.dp),
)
}
} else {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.size(60.dp)
.align(Alignment.Center),
)
}
}
}
}
@PreviewTvSpec
@Composable
fun NextUpEpisodePreview() {
@ -124,6 +169,7 @@ fun NextUpEpisodePreview() {
imageUrl = "",
onClick = {},
aspectRatio = 4f / 3,
timeLeft = 30.seconds,
modifier =
Modifier
.padding(16.dp)

View file

@ -62,8 +62,10 @@ import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.DeviceProfile
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class)
@Composable
@ -322,12 +324,44 @@ fun PlaybackContent(
.align(Alignment.BottomCenter),
) {
nextUp?.let {
var autoPlayEnabled by
remember {
mutableStateOf(
preferences.appPreferences.playbackPreferences.autoPlayNext,
)
}
var timeLeft by remember {
mutableLongStateOf(
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
)
}
// TODO need extra back press for some reason
BackHandler(timeLeft > 0 && autoPlayEnabled) {
timeLeft = -1
autoPlayEnabled = false
}
if (autoPlayEnabled) {
LaunchedEffect(Unit) {
if (timeLeft == 0L) {
viewModel.playUpNextEpisode()
} else {
while (timeLeft > 0) {
delay(1.seconds)
timeLeft--
}
if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playUpNextEpisode()
}
}
}
}
NextUpEpisode(
title = it.name,
description = it.data.overview,
imageUrl = it.imageUrl,
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
onClick = { viewModel.playUpNextEpisode() },
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier =
Modifier
.padding(8.dp)

View file

@ -9,6 +9,8 @@ message PlaybackPreferences {
int64 controller_timeout_ms = 3;
int32 seek_bar_steps = 4;
bool show_debug_info = 5;
bool auto_play_next = 6;
int64 auto_play_next_delay_seconds = 7;
}
message HomePagePreferences{

View file

@ -45,4 +45,6 @@
<string name="play_theme_music">Play theme music</string>
<string name="license_info">License information</string>
<string name="playback_debug_info">Show playback debug info</string>
<string name="auto_play_next_delay">Delay before playing next up</string>
<string name="auto_play_next">Auto play next up</string>
</resources>