Playlist UI updates & more handling of unsupported playback

This commit is contained in:
Damontecres 2025-10-13 16:23:50 -04:00
parent aab7e2eb17
commit 9148ca0719
No known key found for this signature in database
3 changed files with 337 additions and 201 deletions

View file

@ -236,6 +236,24 @@ fun DialogPopup(
}
}
@Composable
fun DialogPopup(
params: DialogParams,
onDismissRequest: () -> Unit,
dismissOnClick: Boolean = true,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp,
) = DialogPopup(
showDialog = true,
waitToLoad = params.fromLongClick,
title = params.title,
dialogItems = params.items,
onDismissRequest = onDismissRequest,
dismissOnClick = dismissOnClick,
properties = properties,
elevation = elevation,
)
/**
* A dialog that can be scrolled, typically for longer text content
*/

View file

@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -17,11 +18,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@ -36,6 +41,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
@ -49,9 +55,13 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.ItemCardImage
import com.github.damontecres.dolphin.ui.components.DialogItem
import com.github.damontecres.dolphin.ui.components.DialogParams
import com.github.damontecres.dolphin.ui.components.DialogPopup
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.components.OverviewText
import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.Destination
@ -116,6 +126,8 @@ fun PlaylistDetails(
val playlist by viewModel.item.observeAsState(null)
val items by viewModel.items.observeAsState(listOf())
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val st = loading) {
is LoadingState.Error -> ErrorMessage(st, modifier)
LoadingState.Pending, LoadingState.Loading -> LoadingPage(modifier)
@ -126,7 +138,8 @@ fun PlaylistDetails(
PlaylistDetailsContent(
playlist = it,
items = items,
onClickIndex = { index ->
focusRequester = focusRequester,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
@ -135,23 +148,64 @@ fun PlaylistDetails(
),
)
},
modifier = modifier.focusRequester(focusRequester),
onLongClickIndex = { index, item ->
longClickDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
listOf(
DialogItem(
"Go to",
Icons.Default.ArrowForward,
) {
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
itemId = item.id,
type = item.type,
item = item,
),
)
},
DialogItem(
"Play from here",
Icons.Default.PlayArrow,
) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
positionMs = 0L,
startIndex = index,
),
)
},
),
)
},
modifier = modifier,
)
}
}
longClickDialog?.let { params ->
DialogPopup(
params = params,
onDismissRequest = { longClickDialog = null },
)
}
}
@Composable
fun PlaylistDetailsContent(
playlist: BaseItem,
items: List<BaseItem?>,
onClickIndex: (Int) -> Unit,
onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
var savedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
val focusRequester = remember { FocusRequester() }
val focus = remember { FocusRequester() }
val focusedItem = items.getOrNull(focusedIndex)
Box(
@ -190,52 +244,142 @@ fun PlaylistDetailsContent(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.padding(start = 16.dp, top = 16.dp)
.padding(top = 16.dp)
.fillMaxSize(),
) {
Text(
text = playlist.name ?: "Playlist",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium,
)
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier =
Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
) {
PlaylistDetailsHeader(
focusedItem = focusedItem,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth(.66f),
.fillMaxWidth(.25f),
)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = playlist.name ?: "Playlist",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.fillMaxWidth(.8f)
.align(Alignment.CenterHorizontally)
.padding(bottom = 32.dp)
.fillMaxHeight()
// .fillMaxWidth(.8f)
.weight(1f)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
).focusGroup()
.focusRestorer(focusRequester),
).focusRequester(focusRequester)
.focusGroup()
.focusRestorer(focus),
) {
itemsIndexed(items) { index, item ->
val interactionSource = remember { MutableInteractionSource() }
ListItem(
selected = false,
PlaylistItem(
item = item,
index = index,
onClick = {
savedIndex = index
onClickIndex.invoke(index)
item?.let {
onClickIndex.invoke(index, item)
}
},
onLongClick = {
savedIndex = index
item?.let {
onLongClickIndex.invoke(index, item)
}
},
modifier =
Modifier
.height(80.dp)
.ifElse(
index == savedIndex,
Modifier.focusRequester(focus),
).onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
},
)
}
}
}
}
}
}
}
@Composable
fun PlaylistDetailsHeader(
focusedItem: BaseItem?,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = focusedItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = focusedItem?.subtitle ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
OverviewText(
overview = focusedItem?.data?.overview ?: "",
maxLines = 10,
onClick = {},
enabled = false,
)
}
}
@Composable
fun PlaylistItem(
item: BaseItem?,
index: Int,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = false,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
headlineContent = {
Text(
text = item?.title ?: "",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.enableMarquee(focused),
)
},
supportingContent = {
Text(
text = item?.subtitle ?: "",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.enableMarquee(focused),
)
},
trailingContent = {
@ -267,48 +411,6 @@ fun PlaylistDetailsContent(
)
}
},
modifier =
Modifier
.height(80.dp)
.ifElse(
index == savedIndex,
Modifier.focusRequester(focusRequester),
).onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
},
)
}
}
}
}
}
@Composable
fun PlaylistDetailsHeader(
focusedItem: BaseItem?,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = focusedItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = focusedItem?.subtitle ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
OverviewText(
overview = focusedItem?.data?.overview ?: "",
maxLines = 2,
onClick = {},
enabled = false,
)
}
}

View file

@ -23,6 +23,7 @@ import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.showToast
import com.github.damontecres.dolphin.util.EqualityMutableLiveData
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
@ -31,6 +32,7 @@ import com.github.damontecres.dolphin.util.checkForSupport
import com.github.damontecres.dolphin.util.formatDateTime
import com.github.damontecres.dolphin.util.seasonEpisodePadded
import com.github.damontecres.dolphin.util.subtitleMimeTypes
import com.github.damontecres.dolphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@ -81,7 +83,7 @@ data class StreamDecision(
class PlaybackViewModel
@Inject
constructor(
@ApplicationContext context: Context,
@param:ApplicationContext val context: Context,
val api: ApiClient,
val playlistCreator: PlaylistCreator,
val navigationManager: NavigationManager,
@ -168,8 +170,13 @@ class PlaybackViewModel
private suspend fun play(
base: BaseItemDto,
positionMs: Long,
) = withContext(Dispatchers.IO) {
): Boolean =
withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}")
if (base.type !in supportItemKinds) {
showToast(context, "Unsupported type '${base.type}', skipping...")
return@withContext false
}
dto = base
val title =
if (base.type == BaseItemKind.EPISODE) {
@ -294,6 +301,7 @@ class PlaybackViewModel
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
}
listenForSegments()
return@withContext true
}
@OptIn(UnstableApi::class)
@ -582,7 +590,11 @@ class PlaybackViewModel
if (it.hasNext()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
play(it.getAndAdvance().data, 0)
val item = it.getAndAdvance()
val played = play(item.data, 0)
if (!played) {
playUpNextUp()
}
}
}
}
@ -593,7 +605,11 @@ class PlaybackViewModel
if (it.hasPrevious()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
play(it.getPreviousAndReverse().data, 0)
val item = it.getPreviousAndReverse()
val played = play(item.data, 0)
if (!played) {
playPrevious()
}
}
}
}
@ -602,6 +618,9 @@ class PlaybackViewModel
fun cancelUpNextEpisode() {
nextUp.value = null
}
private fun toastUnsupported(item: BaseItemDto) {
}
}
data class CurrentPlayback(
@ -687,6 +706,3 @@ private fun applyTrackSelections(
}
}
}
private val singlePlays =
setOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO)