Show queue during playback

This commit is contained in:
Damontecres 2025-10-14 14:15:07 -04:00
parent fc56a2c631
commit 8dba561376
No known key found for this signature in database
6 changed files with 167 additions and 25 deletions

View file

@ -32,6 +32,18 @@ class Playlist(
fun peek(): BaseItem? = items.getOrNull(index + 1) fun peek(): BaseItem? = items.getOrNull(index + 1)
fun upcomingItems(): List<BaseItem> = 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 { companion object {
const val MAX_SIZE = 100 const val MAX_SIZE = 100
} }

View file

@ -38,6 +38,7 @@ val DefaultItemFields =
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.TRICKPLAY, ItemFields.TRICKPLAY,
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.CHAPTERS,
) )
val DefaultButtonPadding = val DefaultButtonPadding =

View file

@ -110,7 +110,7 @@ fun SeasonCard(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Text( Text(
text = dto?.name ?: "", text = item?.title ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =
@ -120,7 +120,7 @@ fun SeasonCard(
.enableMarquee(focusedAfterDelay), .enableMarquee(focusedAfterDelay),
) )
Text( Text(
text = item?.data?.productionYear?.toString() ?: "", text = item?.subtitle ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement 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.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.KeyEventType 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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -43,9 +43,12 @@ import androidx.compose.ui.unit.sp
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.Chapter
import com.github.damontecres.dolphin.data.model.Playlist
import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.cards.ChapterCard 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.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty import com.github.damontecres.dolphin.ui.letNotEmpty
@ -85,6 +88,8 @@ fun PlaybackOverlay(
subtitle: String? = null, subtitle: String? = null,
trickplayInfo: TrickplayInfo? = null, trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null }, trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
onClickPlaylist: (BaseItem) -> Unit = {},
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
@ -119,10 +124,17 @@ fun PlaybackOverlay(
enter = slideInVertically() + fadeIn(), enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(), exit = slideOutVertically() + fadeOut(),
) { ) {
val nextState =
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else if (playlist.hasNext()) {
OverlayViewState.QUEUE
} else {
null
}
Controller( Controller(
title = title, title = title,
subtitleStreams = subtitleStreams, subtitleStreams = subtitleStreams,
chapters = chapters,
playerControls = playerControls, playerControls = playerControls,
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showPlay = showPlay, showPlay = showPlay,
@ -145,18 +157,14 @@ fun PlaybackOverlay(
audioStreams = audioStreams, audioStreams = audioStreams,
subtitle = subtitle, subtitle = subtitle,
seekBarInteractionSource = seekBarInteractionSource, seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState,
onNextStateFocus = {
nextState?.let { state = it }
},
modifier = modifier =
Modifier Modifier
.onKeyEvent { e -> // Don't use key events because this control has vertical items so up/down is tough to manage
if (chapters.isNotEmpty() && .onGloballyPositioned {
e.type == KeyEventType.KeyDown && isDown(e) &&
!seekBarFocused
) {
state = OverlayViewState.CHAPTERS
true
}
false
}.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() } controllerHeight = with(density) { it.size.height.toDp() }
}, },
) )
@ -179,8 +187,9 @@ fun PlaybackOverlay(
if (e.type == KeyEventType.KeyUp && isUp(e)) { if (e.type == KeyEventType.KeyUp && isUp(e)) {
state = OverlayViewState.CONTROLLER state = OverlayViewState.CONTROLLER
true true
} else {
false
} }
false
}, },
) { ) {
Text( 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 { enum class OverlayViewState {
CONTROLLER, CONTROLLER,
CHAPTERS, CHAPTERS,
QUEUE,
} }
/** /**
@ -302,7 +395,6 @@ enum class OverlayViewState {
fun Controller( fun Controller(
title: String?, title: String?,
subtitleStreams: List<SubtitleStream>, subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>,
playerControls: Player, playerControls: Player,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
showPlay: Boolean, showPlay: Boolean,
@ -320,9 +412,11 @@ fun Controller(
moreButtonOptions: MoreButtonOptions, moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?, currentPlayback: CurrentPlayback?,
audioStreams: List<AudioStream>, audioStreams: List<AudioStream>,
nextState: OverlayViewState?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -373,14 +467,32 @@ fun Controller(
seekForward = seekForward, seekForward = seekForward,
skipBackOnResume = skipBackOnResume, skipBackOnResume = skipBackOnResume,
) )
if (chapters.isNotEmpty()) { when (nextState) {
Text( OverlayViewState.CHAPTERS ->
text = "Chapters", Text(
style = MaterialTheme.typography.titleLarge, text = "Chapters",
modifier = Modifier.padding(start = 16.dp, top = 16.dp), style = MaterialTheme.typography.titleLarge,
) modifier =
} else { Modifier
Spacer(Modifier.height(32.dp)) .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))
} }
} }
} }

View file

@ -314,6 +314,10 @@ fun PlaybackPage(
trickplayInfo = trickplay, trickplayInfo = trickplay,
trickplayUrlFor = viewModel::getTrickplayUrl, trickplayUrlFor = viewModel::getTrickplayUrl,
chapters = chapters, chapters = chapters,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
) )
} }

View file

@ -628,7 +628,20 @@ class PlaybackViewModel
nextUp.value = null 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
}
}
}
} }
} }