From 9148ca07190c71221d6c288b4ff037319b8dc3f6 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 16:23:50 -0400 Subject: [PATCH] Playlist UI updates & more handling of unsupported playback --- .../dolphin/ui/components/Dialogs.kt | 18 ++ .../dolphin/ui/detail/PlaylistDetails.kt | 274 ++++++++++++------ .../dolphin/ui/playback/PlaybackViewModel.kt | 246 ++++++++-------- 3 files changed, 337 insertions(+), 201 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt index da309be2..ca2531c1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt @@ -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 */ diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt index f9807ae9..e04ecfe6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt @@ -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(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, - 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,95 +244,81 @@ 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, - ) - PlaylistDetailsHeader( - focusedItem = focusedItem, + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), modifier = Modifier - .padding(start = 16.dp) - .fillMaxWidth(.66f), - ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .fillMaxWidth(.8f) - .align(Alignment.CenterHorizontally) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(16.dp), - ).focusGroup() - .focusRestorer(focusRequester), + .padding(horizontal = 16.dp) + .fillMaxWidth(), ) { - itemsIndexed(items) { index, item -> - val interactionSource = remember { MutableInteractionSource() } - ListItem( - selected = false, - onClick = { - savedIndex = index - onClickIndex.invoke(index) - }, - interactionSource = interactionSource, - headlineContent = { - Text( - text = item?.title ?: "", - style = MaterialTheme.typography.titleLarge, - ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - style = MaterialTheme.typography.titleSmall, - ) - }, - trailingContent = { - item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { - Text( - text = it.toString(), - ) - } - }, - leadingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, - ) - ItemCardImage( - imageUrl = item?.imageUrl, - name = item?.name, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - modifier = Modifier.width(160.dp), - useFallbackText = false, - ) - } - }, + PlaylistDetailsHeader( + focusedItem = focusedItem, + modifier = + Modifier + .padding(start = 16.dp) + .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 - .height(80.dp) - .ifElse( - index == savedIndex, - Modifier.focusRequester(focusRequester), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index + .padding(bottom = 32.dp) + .fillMaxHeight() +// .fillMaxWidth(.8f) + .weight(1f) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ).focusRequester(focusRequester) + .focusGroup() + .focusRestorer(focus), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + savedIndex = 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 + } + }, + ) + } + } } } } @@ -306,9 +346,71 @@ fun PlaylistDetailsHeader( ) OverviewText( overview = focusedItem?.data?.overview ?: "", - maxLines = 2, + 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 = { + item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { + Text( + text = it.toString(), + ) + } + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, + ) + ItemCardImage( + imageUrl = item?.imageUrl, + name = item?.name, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + modifier = Modifier.width(160.dp), + useFallbackText = false, + ) + } + }, + modifier = modifier, + ) +} 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 8ce8e7ea..198c10a1 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 @@ -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,133 +170,139 @@ class PlaybackViewModel private suspend fun play( base: BaseItemDto, positionMs: Long, - ) = withContext(Dispatchers.IO) { - Timber.i("Playing ${base.id}") - dto = base - val title = - if (base.type == BaseItemKind.EPISODE) { - base.seriesName - } else { - base.name + ): Boolean = + withContext(Dispatchers.IO) { + Timber.i("Playing ${base.id}") + if (base.type !in supportItemKinds) { + showToast(context, "Unsupported type '${base.type}', skipping...") + return@withContext false } - val subtitle = - if (base.type == BaseItemKind.EPISODE) { - buildList { - add(base.seasonEpisodePadded) - add(base.name) - add(base.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") - } else { - base.productionYear?.toString() + dto = base + val title = + if (base.type == BaseItemKind.EPISODE) { + base.seriesName + } else { + base.name + } + val subtitle = + if (base.type == BaseItemKind.EPISODE) { + buildList { + add(base.seasonEpisodePadded) + add(base.name) + add(base.premiereDate?.let { formatDateTime(it) }) + }.filterNotNull().joinToString(" - ") + } else { + base.productionYear?.toString() + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.title.value = title + this@PlaybackViewModel.subtitle.value = subtitle } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.title.value = title - this@PlaybackViewModel.subtitle.value = subtitle - } - base.mediaStreams - ?.filter { it.type == MediaStreamType.VIDEO } - ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } - val subtitleStreams = base.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - ) - }.orEmpty() - val audioStreams = - base.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - ?.map { - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) - .orEmpty() + ?.filter { it.type == MediaStreamType.VIDEO } + ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } + val subtitleStreams = + base.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } + ?.map { + SubtitleStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.isExternal, + it.isForced, + it.isDefault, + ) + }.orEmpty() + val audioStreams = + base.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map { + AudioStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.channels, + it.channelLayout, + ) + }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + .orEmpty() - // TODO audio selection based on channel layout or preferences or default - val audioLanguage = preferences.userConfig.audioLanguagePreference - val audioIndex = - if (audioLanguage != null) { - audioStreams.firstOrNull { it.language == audioLanguage }?.index - ?: audioStreams.firstOrNull()?.index - } else { - audioStreams.firstOrNull()?.index - } - val subtitleMode = preferences.userConfig.subtitleMode - val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference - val subtitleIndex = - when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - subtitleStreams.firstOrNull()?.index - } + // TODO audio selection based on channel layout or preferences or default + val audioLanguage = preferences.userConfig.audioLanguagePreference + val audioIndex = + if (audioLanguage != null) { + audioStreams.firstOrNull { it.language == audioLanguage }?.index + ?: audioStreams.firstOrNull()?.index + } else { + audioStreams.firstOrNull()?.index } - - SubtitlePlaybackMode.ONLY_FORCED -> - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index - } else { - subtitleStreams.firstOrNull { it.forced }?.index + val subtitleMode = preferences.userConfig.subtitleMode + val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference + val subtitleIndex = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + subtitleStreams.firstOrNull()?.index + } } - SubtitlePlaybackMode.SMART -> { - if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - null + SubtitlePlaybackMode.ONLY_FORCED -> + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index + } else { + subtitleStreams.firstOrNull { it.forced }?.index + } + + SubtitlePlaybackMode.SMART -> { + if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + null + } } - } - SubtitlePlaybackMode.DEFAULT -> { - // TODO check for language? - ( - subtitleStreams.firstOrNull { it.default && it.forced } - ?: subtitleStreams.firstOrNull { it.default } - ?: subtitleStreams.firstOrNull { it.forced } - )?.index - } + SubtitlePlaybackMode.DEFAULT -> { + // TODO check for language? + ( + subtitleStreams.firstOrNull { it.default && it.forced } + ?: subtitleStreams.firstOrNull { it.default } + ?: subtitleStreams.firstOrNull { it.forced } + )?.index + } - SubtitlePlaybackMode.NONE -> null - } + SubtitlePlaybackMode.NONE -> null + } // Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("audioStreams=$audioStreams") - Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") - withContext(Dispatchers.Main) { - this@PlaybackViewModel.audioStreams.value = audioStreams - this@PlaybackViewModel.subtitleStreams.value = subtitleStreams + withContext(Dispatchers.Main) { + this@PlaybackViewModel.audioStreams.value = audioStreams + this@PlaybackViewModel.subtitleStreams.value = subtitleStreams - changeStreams( - base, - audioIndex, - subtitleIndex, - if (positionMs > 0) positionMs else C.TIME_UNSET, - ) - player.prepare() + changeStreams( + base, + audioIndex, + subtitleIndex, + if (positionMs > 0) positionMs else C.TIME_UNSET, + ) + player.prepare() - this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api) - Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}") + this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api) + Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}") + } + listenForSegments() + return@withContext true } - listenForSegments() - } @OptIn(UnstableApi::class) private suspend fun changeStreams( @@ -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)