From 8dba5613766a6edfcbf325e1e2f98f71bacc589f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 14 Oct 2025 14:15:07 -0400 Subject: [PATCH] Show queue during playback --- .../dolphin/data/model/Playlist.kt | 12 ++ .../damontecres/dolphin/ui/UiConstants.kt | 1 + .../dolphin/ui/cards/SeasonCard.kt | 4 +- .../dolphin/ui/playback/PlaybackOverlay.kt | 156 +++++++++++++++--- .../dolphin/ui/playback/PlaybackPage.kt | 4 + .../dolphin/ui/playback/PlaybackViewModel.kt | 15 +- 6 files changed, 167 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt index e97a7b88..d6f677a9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt @@ -32,6 +32,18 @@ class Playlist( fun peek(): BaseItem? = items.getOrNull(index + 1) + fun upcomingItems(): List = items.subList(index + 1, items.size) + + fun advanceTo(id: UUID): BaseItem? { + while (hasNext()) { + val potential = getAndAdvance() + if (potential.id == id) { + return potential + } + } + return null + } + companion object { const val MAX_SIZE = 100 } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt index c75cfeef..a05b00f0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt @@ -38,6 +38,7 @@ val DefaultItemFields = ItemFields.OVERVIEW, ItemFields.TRICKPLAY, ItemFields.SORT_NAME, + ItemFields.CHAPTERS, ) val DefaultButtonPadding = diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt index 8a06a4c9..2def9731 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt @@ -110,7 +110,7 @@ fun SeasonCard( .fillMaxWidth(), ) { Text( - text = dto?.name ?: "", + text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, modifier = @@ -120,7 +120,7 @@ fun SeasonCard( .enableMarquee(focusedAfterDelay), ) Text( - text = item?.data?.productionYear?.toString() ?: "", + text = item?.subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, modifier = diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index db8b9965..e689bd72 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -32,7 +33,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale @@ -43,9 +43,12 @@ import androidx.compose.ui.unit.sp import androidx.media3.common.Player import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Chapter +import com.github.damontecres.dolphin.data.model.Playlist import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.cards.ChapterCard +import com.github.damontecres.dolphin.ui.cards.SeasonCard import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.letNotEmpty @@ -85,6 +88,8 @@ fun PlaybackOverlay( subtitle: String? = null, trickplayInfo: TrickplayInfo? = null, trickplayUrlFor: (Int) -> String? = { null }, + playlist: Playlist = Playlist(listOf(), 0), + onClickPlaylist: (BaseItem) -> Unit = {}, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() @@ -119,10 +124,17 @@ fun PlaybackOverlay( enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), ) { + val nextState = + if (chapters.isNotEmpty()) { + OverlayViewState.CHAPTERS + } else if (playlist.hasNext()) { + OverlayViewState.QUEUE + } else { + null + } Controller( title = title, subtitleStreams = subtitleStreams, - chapters = chapters, playerControls = playerControls, controllerViewState = controllerViewState, showPlay = showPlay, @@ -145,18 +157,14 @@ fun PlaybackOverlay( audioStreams = audioStreams, subtitle = subtitle, seekBarInteractionSource = seekBarInteractionSource, + nextState = nextState, + onNextStateFocus = { + nextState?.let { state = it } + }, modifier = Modifier - .onKeyEvent { e -> - if (chapters.isNotEmpty() && - e.type == KeyEventType.KeyDown && isDown(e) && - !seekBarFocused - ) { - state = OverlayViewState.CHAPTERS - true - } - false - }.onGloballyPositioned { + // Don't use key events because this control has vertical items so up/down is tough to manage + .onGloballyPositioned { controllerHeight = with(density) { it.size.height.toDp() } }, ) @@ -179,8 +187,9 @@ fun PlaybackOverlay( if (e.type == KeyEventType.KeyUp && isUp(e)) { state = OverlayViewState.CONTROLLER true + } else { + false } - false }, ) { Text( @@ -223,6 +232,89 @@ fun PlaybackOverlay( ) } } + if (playlist.hasNext()) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) state = OverlayViewState.QUEUE + }.focusable(), + ) + } + } + } + } + AnimatedVisibility( + state == OverlayViewState.QUEUE, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), + ) { + if (playlist.hasNext()) { + val items = remember { playlist.upcomingItems() } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyUp && isUp(e)) { + if (chapters.isNotEmpty()) { + state = OverlayViewState.CHAPTERS + } else { + state = OverlayViewState.CONTROLLER + } + true + } else { + false + } + }, + ) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + ) + LazyRow( + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(focusRequester) + .onFocusChanged { + if (it.hasFocus) { + controllerViewState.pulseControls() + } + }, + ) { + itemsIndexed(items) { index, item -> + val interactionSource = remember { MutableInteractionSource() } + val isFocused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(isFocused) { + if (isFocused) controllerViewState.pulseControls() + } + SeasonCard( + item = item, + onClick = { + onClickPlaylist.invoke(item) + controllerViewState.hideControls() + }, + onLongClick = {}, + imageHeight = 140.dp, + interactionSource = interactionSource, + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(focusRequester), + ), + ) + } + } } } } @@ -293,6 +385,7 @@ fun PlaybackOverlay( enum class OverlayViewState { CONTROLLER, CHAPTERS, + QUEUE, } /** @@ -302,7 +395,6 @@ enum class OverlayViewState { fun Controller( title: String?, subtitleStreams: List, - chapters: List, playerControls: Player, controllerViewState: ControllerViewState, showPlay: Boolean, @@ -320,9 +412,11 @@ fun Controller( moreButtonOptions: MoreButtonOptions, currentPlayback: CurrentPlayback?, audioStreams: List, + nextState: OverlayViewState?, modifier: Modifier = Modifier, subtitle: String? = null, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onNextStateFocus: () -> Unit = {}, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -373,14 +467,32 @@ fun Controller( seekForward = seekForward, skipBackOnResume = skipBackOnResume, ) - if (chapters.isNotEmpty()) { - Text( - text = "Chapters", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 16.dp, top = 16.dp), - ) - } else { - Spacer(Modifier.height(32.dp)) + when (nextState) { + OverlayViewState.CHAPTERS -> + Text( + text = "Chapters", + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) onNextStateFocus.invoke() + }.focusable(), + ) + + OverlayViewState.QUEUE -> + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) onNextStateFocus.invoke() + }.focusable(), + ) + + else -> Spacer(Modifier.height(32.dp)) } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt index db20299b..3487d96a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt @@ -314,6 +314,10 @@ fun PlaybackPage( trickplayInfo = trickplay, trickplayUrlFor = viewModel::getTrickplayUrl, chapters = chapters, + playlist = playlist, + onClickPlaylist = { + viewModel.playItemInPlaylist(it) + }, ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 7c2bf923..438060c4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -628,7 +628,20 @@ class PlaybackViewModel nextUp.value = null } - private fun toastUnsupported(item: BaseItemDto) { + fun playItemInPlaylist(item: BaseItem) { + playlist.value?.let { playlist -> + viewModelScope.launch(ExceptionHandler()) { + val toPlay = playlist.advanceTo(item.id) + if (toPlay != null) { + val played = play(toPlay.data, 0) + if (!played) { + playUpNextUp() + } + } else { + // TODO + } + } + } } }