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 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 {
const val MAX_SIZE = 100
}

View file

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

View file

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

View file

@ -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
}
},
) {
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<SubtitleStream>,
chapters: List<Chapter>,
playerControls: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
@ -320,9 +412,11 @@ fun Controller(
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
audioStreams: List<AudioStream>,
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()) {
when (nextState) {
OverlayViewState.CHAPTERS ->
Text(
text = "Chapters",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
modifier =
Modifier
.padding(start = 16.dp, top = 16.dp)
.onFocusChanged {
if (it.isFocused) onNextStateFocus.invoke()
}.focusable(),
)
} else {
Spacer(Modifier.height(32.dp))
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,
trickplayUrlFor = viewModel::getTrickplayUrl,
chapters = chapters,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
)
}

View file

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