mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Show queue during playback
This commit is contained in:
parent
fc56a2c631
commit
8dba561376
6 changed files with 167 additions and 25 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue