From 50dc4b9a35440b69eee674e329fb4f4e7e473843 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:40:08 -0500 Subject: [PATCH] Play all or shuffle libraries, shows, collections, etc (#278) Adds ability to play all (or shuffle play) most libraries, collections, folders, TV series, TV seasons, etc This adds buttons to play all or shuffle play at the top of grid pages that support. Additionally, a shuffle button is added to TV Series details. And play all or shuffle play is added when long clicking TV seasons. Note: Until #42 is completed, only the first 100 items will play back-to-back Closes #197 --- .../wholphin/services/PlaylistCreator.kt | 149 +++++++++++++++++- .../ui/components/CollectionFolderGrid.kt | 111 ++++++++++--- .../wholphin/ui/data/SortAndDirection.kt | 2 + .../ui/detail/CollectionFolderBoxSet.kt | 1 + .../ui/detail/CollectionFolderGeneric.kt | 2 + .../ui/detail/CollectionFolderLiveTv.kt | 1 + .../ui/detail/CollectionFolderMovie.kt | 7 +- .../ui/detail/CollectionFolderPlaylist.kt | 1 + .../ui/detail/CollectionFolderRecordings.kt | 1 + .../wholphin/ui/detail/CollectionFolderTv.kt | 4 + .../wholphin/ui/detail/FavoritesPage.kt | 14 +- .../wholphin/ui/detail/PlaylistDetails.kt | 11 +- .../ui/detail/series/SeriesDetails.kt | 74 ++++++--- .../wholphin/ui/nav/Destination.kt | 17 +- .../wholphin/ui/nav/DestinationContent.kt | 8 +- .../wholphin/ui/playback/PlaybackConstants.kt | 51 ++++++ .../wholphin/ui/playback/PlaybackPage.kt | 6 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 95 +++++++---- 18 files changed, 467 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 9b97799b..eaab4e7c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -4,9 +4,12 @@ import android.content.Context import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ApiRequestPager @@ -18,10 +21,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CreatePlaylistDto +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PlaylistUserPermissions +import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest @@ -43,19 +50,22 @@ class PlaylistCreator */ suspend fun createFromEpisode( seriesId: UUID, - episodeId: UUID, + episodeId: UUID?, + seasonId: UUID? = null, + shuffled: Boolean = false, ): Playlist { val request = GetEpisodesRequest( seriesId = seriesId, + seasonId = seasonId, fields = DefaultItemFields, startItemId = episodeId, + sortBy = if (shuffled) ItemSortBy.RANDOM else null, limit = Playlist.MAX_SIZE, ) val episodes = GetEpisodesRequestHandler.execute(api, request).content.items val startIndex = - episodes.indexOfFirstOrNull { it.id == episodeId } - ?: throw IllegalStateException("Episode $episodeId was not returned") + episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) } @@ -79,6 +89,128 @@ class PlaylistCreator return Playlist(baseItems, 0) } + private suspend fun createFromCollection( + item: BaseItemDto, + startIndex: Int = 0, + sortAndDirection: SortAndDirection?, + recursive: Boolean, + filter: GetItemsFilter, + ): Playlist { + val includeItemTypes = + item.collectionType?.baseItemKinds?.takeIf { it.isNotEmpty() } + ?: listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO) + val request = + filter.applyTo( + GetItemsRequest( + parentId = item.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + includeItemTypes = includeItemTypes, + recursive = true, + excludeItemIds = listOf(item.id), + sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) }, + sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) }, + fields = DefaultItemFields, + startIndex = startIndex, + limit = Playlist.MAX_SIZE, + ), + ) + val items = + GetItemsRequestHandler.execute(api, request).content.items.map { + BaseItem.from(it, api) + } + return Playlist(items, 0) + } + + suspend fun createFrom( + item: BaseItemDto, + startIndex: Int = 0, + sortAndDirection: SortAndDirection?, + shuffled: Boolean, + recursive: Boolean, + filter: GetItemsFilter, + ): PlaylistCreationResult = + when (item.type) { + BaseItemKind.BOX_SET, + BaseItemKind.COLLECTION_FOLDER, + BaseItemKind.USER_VIEW, + -> + PlaylistCreationResult.Success( + createFromCollection( + item = item, + startIndex = startIndex, + sortAndDirection = + if (shuffled) { + SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING) + } else { + sortAndDirection + }, + recursive = recursive, + filter = filter, + ), + ) + + BaseItemKind.EPISODE -> { + val seriesId = item.seriesId + if (seriesId != null) { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = seriesId, + seasonId = null, + episodeId = item.id, + shuffled = shuffled, + ), + ) + } else { + PlaylistCreationResult.Error(null, "Episode has not seriesId") + } + } + + BaseItemKind.SEASON -> { + val seriesId = item.seriesId + if (seriesId != null) { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = seriesId, + seasonId = item.id, + episodeId = null, + shuffled = shuffled, + ), + ) + } else { + PlaylistCreationResult.Error(null, "Episode has not seriesId") + } + } + + BaseItemKind.SERIES -> + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = item.id, + seasonId = null, + episodeId = null, + shuffled = shuffled, + ), + ) + + BaseItemKind.PLAYLIST -> + PlaylistCreationResult.Success( + createFromPlaylistId( + item.id, + startIndex, + shuffled, + ), + ) + + // Not support yet +// BaseItemKind.AGGREGATE_FOLDER -> TODO() +// BaseItemKind.FOLDER -> TODO() +// BaseItemKind.GENRE -> TODO() +// BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO() +// BaseItemKind.MUSIC_ALBUM -> TODO() +// BaseItemKind.MUSIC_ARTIST -> TODO() + + else -> PlaylistCreationResult.Error(null, "Unsupported type: ${item.type}") + } + suspend fun getPlaylists( mediaType: MediaType?, scope: CoroutineScope, @@ -136,3 +268,14 @@ class PlaylistCreator ) } } + +sealed interface PlaylistCreationResult { + data class Success( + val playlist: Playlist, + ) : PlaylistCreationResult + + data class Error( + val ex: Exception?, + val message: String?, + ) : PlaylistCreationResult +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index f1542f10..3f0b3ce1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -8,8 +8,12 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -18,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.layout.ContentScale @@ -54,6 +59,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager @@ -80,6 +86,7 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration @HiltViewModel class CollectionFolderViewModel @@ -182,22 +189,11 @@ class CollectionFolderViewModel return when (filter.override) { GetItemsFilterOverride.NONE -> { val includeItemTypes = - when (item?.data?.collectionType) { - CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) - CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) - CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) - CollectionType.MUSIC -> - listOf( - BaseItemKind.AUDIO, - BaseItemKind.MUSIC_ARTIST, - BaseItemKind.MUSIC_ALBUM, - ) - - CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET) - CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST) - - else -> listOf() - } + item + ?.data + ?.collectionType + ?.baseItemKinds + .orEmpty() val request = filter.applyTo( GetItemsRequest( @@ -317,6 +313,7 @@ fun CollectionFolderGrid( recursive: Boolean, onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, + playEnabled: Boolean, modifier: Modifier = Modifier, initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, @@ -329,6 +326,7 @@ fun CollectionFolderGrid( recursive, onClickItem, sortOptions, + playEnabled, modifier, initialSortAndDirection = initialSortAndDirection, showTitle = showTitle, @@ -344,6 +342,7 @@ fun CollectionFolderGrid( recursive: Boolean, onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, + playEnabled: Boolean, modifier: Modifier = Modifier, viewModel: CollectionFolderViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), @@ -398,6 +397,21 @@ fun CollectionFolderGrid( positionCallback = positionCallback, letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, params = params, + playEnabled = playEnabled, + onClickPlay = { shuffle -> + itemId.toUUIDOrNull()?.let { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ), + ) + } + }, ) } } @@ -464,6 +478,8 @@ fun CollectionFolderGridContent( onSortChange: (SortAndDirection) -> Unit, letterPosition: suspend (Char) -> Int, sortOptions: List, + playEnabled: Boolean, + onClickPlay: (shuffle: Boolean) -> Unit, modifier: Modifier = Modifier, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, @@ -499,13 +515,43 @@ fun CollectionFolderGridContent( modifier = Modifier.fillMaxWidth(), ) } - if (sortOptions.isNotEmpty()) { - SortByButton( - sortOptions = sortOptions, - current = sortAndDirection, - onSortChange = onSortChange, - modifier = Modifier, - ) + val endPadding = + 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = endPadding) + .fillMaxWidth(), + ) { + if (sortOptions.isNotEmpty()) { + SortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + if (playEnabled) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlay.invoke(false) }, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlay.invoke(true) }, + ) + } + } } } } @@ -601,3 +647,22 @@ data class CollectionFolderGridParameters( ) } } + +val CollectionType.baseItemKinds: List + get() = + when (this) { + CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) + CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) + CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) + CollectionType.MUSIC -> + listOf( + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + ) + + CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET) + CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST) + + else -> listOf() + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index 3acbec62..e86acbad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -2,9 +2,11 @@ package com.github.damontecres.wholphin.ui.data import androidx.annotation.StringRes import com.github.damontecres.wholphin.R +import kotlinx.serialization.Serializable import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +@Serializable data class SortAndDirection( val sort: ItemSortBy, val direction: SortOrder, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index fefaa541..86578340 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -44,5 +44,6 @@ fun CollectionFolderBoxSet( showHeader = position < columns }, params = CollectionFolderGridParameters.POSTER, + playEnabled = true, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 7bf16c30..f4246e27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -23,6 +23,7 @@ fun CollectionFolderGeneric( itemId: UUID, usePosters: Boolean, recursive: Boolean, + playEnabled: Boolean, modifier: Modifier = Modifier, filter: GetItemsFilter = GetItemsFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), @@ -51,5 +52,6 @@ fun CollectionFolderGeneric( showHeader = position < columns }, params = params, + playEnabled = playEnabled, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 62c4ff4c..e6185e45 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -169,6 +169,7 @@ fun CollectionFolderLiveTv( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } else { ErrorMessage("Invalid tab index $selectedTabIndex", null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index f6cd23a9..cfbd6806 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -88,6 +88,7 @@ fun CollectionFolderMovie( ) } when (selectedTabIndex) { + // Recommended 0 -> { RecommendedMovie( preferences = preferences, @@ -102,6 +103,7 @@ fun CollectionFolderMovie( .focusRequester(focusRequester), ) } + // Library 1 -> { CollectionFolderGrid( preferences = preferences, @@ -124,8 +126,10 @@ fun CollectionFolderMovie( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = true, ) } + // Collections 2 -> { CollectionFolderGrid( preferences = preferences, @@ -148,9 +152,10 @@ fun CollectionFolderMovie( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } - + // Genres 3 -> { GenreCardGrid( itemId = destination.itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 68abc55a..c630a85b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -44,5 +44,6 @@ fun CollectionFolderPlaylist( showHeader = position < columns }, params = CollectionFolderGridParameters.SQUARE, + playEnabled = false, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index b8cff836..c86088e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -42,5 +42,6 @@ fun CollectionFolderRecordings( showHeader = position < columns }, params = CollectionFolderGridParameters.POSTER, + playEnabled = false, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index cddfc04c..75033bde 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -91,6 +91,7 @@ fun CollectionFolderTv( ) } when (selectedTabIndex) { + // Recommended 0 -> { RecommendedTvShow( preferences = preferences, @@ -105,6 +106,7 @@ fun CollectionFolderTv( .focusRequester(focusRequester), ) } + // Library 1 -> { CollectionFolderGrid( preferences = preferences, @@ -127,8 +129,10 @@ fun CollectionFolderTv( onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, + playEnabled = false, ) } + // Genres 2 -> { GenreCardGrid( itemId = destination.itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 8c4302a9..7167fab8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -104,7 +104,9 @@ fun FavoritesPage( onClick = { selectedTabIndex = it }, ) } + // TODO playEnabled = true for movies & episodes when (selectedTabIndex) { + // Movies 0 -> { CollectionFolderGrid( preferences = preferences, @@ -126,8 +128,10 @@ fun FavoritesPage( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } + // TV 1 -> { CollectionFolderGrid( preferences = preferences, @@ -149,8 +153,10 @@ fun FavoritesPage( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } + // Episodes 2 -> { CollectionFolderGrid( preferences = preferences, @@ -174,8 +180,10 @@ fun FavoritesPage( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } + // Videos 3 -> { CollectionFolderGrid( preferences = preferences, @@ -198,8 +206,10 @@ fun FavoritesPage( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } + // Playlists 4 -> { CollectionFolderGrid( preferences = preferences, @@ -222,9 +232,10 @@ fun FavoritesPage( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } - + // People 5 -> { CollectionFolderGrid( preferences = preferences, @@ -251,6 +262,7 @@ fun FavoritesPage( positionCallback = { columns, position -> showHeader = position < columns }, + playEnabled = false, ) } else -> ErrorMessage("Invalid tab index $selectedTabIndex", null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 9d3b6eb5..09df1d8c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -148,18 +148,17 @@ fun PlaylistDetails( focusRequester = focusRequester, onClickIndex = { index, _ -> viewModel.navigationManager.navigateTo( - Destination.Playback( + Destination.PlaybackList( itemId = it.id, - positionMs = 0L, startIndex = index, + shuffle = false, ), ) }, onClickPlay = { shuffle -> viewModel.navigationManager.navigateTo( - Destination.Playback( + Destination.PlaybackList( itemId = it.id, - positionMs = 0L, startIndex = 0, shuffle = shuffle, ), @@ -183,10 +182,10 @@ fun PlaylistDetails( Icons.Default.PlayArrow, ) { viewModel.navigationManager.navigateTo( - Destination.Playback( + Destination.PlaybackList( itemId = it.id, - positionMs = it.resumeMs ?: 0L, startIndex = index, + shuffle = false, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 9cb0598f..844d593a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -167,6 +167,14 @@ fun SeriesDetails( markPlayed = { played -> viewModel.setSeasonWatched(season.id, played) }, + onClickPlay = { shuffle -> + viewModel.navigateTo( + Destination.PlaybackList( + itemId = season.id, + shuffle = shuffle, + ), + ) + }, ) }, overviewOnClick = { @@ -177,7 +185,18 @@ fun SeriesDetails( files = listOf(), ) }, - playOnClick = { viewModel.playNextUp() }, + playOnClick = { shuffle -> + if (shuffle) { + viewModel.navigateTo( + Destination.PlaybackList( + itemId = item.id, + shuffle = true, + ), + ) + } else { + viewModel.playNextUp() + } + }, watchOnClick = { showWatchConfirmation = true }, favoriteOnClick = { val favorite = item.data.userData?.isFavorite ?: false @@ -278,7 +297,7 @@ fun SeriesDetailsContent( onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, overviewOnClick: () -> Unit, - playOnClick: () -> Unit, + playOnClick: (Boolean) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, @@ -344,16 +363,7 @@ fun SeriesDetailsContent( item { SeriesDetailsHeader( series = series, - played = played, - favorite = favorite, overviewOnClick = overviewOnClick, - playOnClick = { - position = HEADER_ROW - playOnClick.invoke() - }, - watchOnClick = watchOnClick, - favoriteOnClick = favoriteOnClick, - bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier .fillMaxWidth(.7f) @@ -374,7 +384,23 @@ fun SeriesDetailsContent( icon = Icons.Default.PlayArrow, onClick = { position = HEADER_ROW - playOnClick.invoke() + playOnClick.invoke(false) + }, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { + position = HEADER_ROW + playOnClick.invoke(true) }, modifier = Modifier.onFocusChanged { @@ -568,13 +594,7 @@ fun SeriesDetailsContent( @Composable fun SeriesDetailsHeader( series: BaseItem, - played: Boolean, - favorite: Boolean, - bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, - playOnClick: () -> Unit, - watchOnClick: () -> Unit, - favoriteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -632,6 +652,7 @@ fun buildDialogForSeason( s: BaseItem, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, + onClickPlay: (Boolean) -> Unit, ): DialogParams { val items = buildList { @@ -653,6 +674,23 @@ fun buildDialogForSeason( }, ) } + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onClickPlay.invoke(false) + }, + ) + add( + DialogItem( + context.getString(R.string.shuffle), + R.string.fa_shuffle, + ) { + onClickPlay.invoke(true) + }, + ) } return DialogParams( title = s.name ?: context.getString(R.string.tv_season), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 89f42723..9310d0fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption @@ -79,14 +80,24 @@ sealed class Destination( val itemId: UUID, val positionMs: Long, @Transient val item: BaseItem? = null, - val startIndex: Int? = null, - val shuffle: Boolean = false, val itemPlayback: ItemPlayback? = null, val forceTranscoding: Boolean = false, ) : Destination(true) { override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" - constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item) + constructor(item: BaseItem) : this(item.id, item.resumeMs, item) + } + + @Serializable + data class PlaybackList( + val itemId: UUID, + val filter: GetItemsFilter = GetItemsFilter(), + val startIndex: Int? = null, + val shuffle: Boolean = false, + val recursive: Boolean = false, + val sortAndDirection: SortAndDirection? = null, + ) : Destination(true) { + override fun toString(): String = "PlaybackList(itemId=$itemId)" } @Serializable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index f02a6c94..f416851c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -48,8 +48,9 @@ fun DestinationContent( preferences = preferences, modifier = modifier, ) - - is Destination.Playback -> + is Destination.PlaybackList, + is Destination.Playback, + -> PlaybackPage( preferences = preferences, deviceProfile = deviceProfile, @@ -164,6 +165,7 @@ fun DestinationContent( filter = destination.filter, recursive = destination.recursive, usePosters = true, + playEnabled = true, // TODO only genres use this currently, so might need to change in future modifier = modifier, ) @@ -261,6 +263,7 @@ fun CollectionFolder( destination.itemId, usePosters = usePostersOverride ?: false, recursive = recursiveOverride ?: false, + playEnabled = true, modifier = modifier, ) @@ -274,6 +277,7 @@ fun CollectionFolder( destination.itemId, usePosters = usePostersOverride ?: false, recursive = recursiveOverride ?: false, + playEnabled = false, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 322827c1..e579117e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale import com.github.damontecres.wholphin.preferences.PrefContentScale +import org.jellyfin.sdk.model.api.BaseItemKind val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0") @@ -27,3 +28,53 @@ val PrefContentScale.scale: ContentScale PrefContentScale.FILL_HEIGHT -> ContentScale.FillHeight PrefContentScale.UNRECOGNIZED -> ContentScale.Fit } + +/** + * Whether the type can be played as-is + * + * For example, a video file is playable as-is, but a playlist requires fetching the items first + */ +val BaseItemKind.playable: Boolean + get() = + when (this) { + BaseItemKind.AUDIO_BOOK, + BaseItemKind.AUDIO, + BaseItemKind.CHANNEL, + BaseItemKind.EPISODE, + BaseItemKind.MOVIE, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PROGRAM, + BaseItemKind.RECORDING, + BaseItemKind.TRAILER, + BaseItemKind.TV_CHANNEL, + BaseItemKind.TV_PROGRAM, + BaseItemKind.VIDEO, + -> true + + BaseItemKind.AGGREGATE_FOLDER, + BaseItemKind.BASE_PLUGIN_FOLDER, + BaseItemKind.BOOK, + BaseItemKind.BOX_SET, + BaseItemKind.CHANNEL_FOLDER_ITEM, + BaseItemKind.COLLECTION_FOLDER, + BaseItemKind.FOLDER, + BaseItemKind.GENRE, + BaseItemKind.MANUAL_PLAYLISTS_FOLDER, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_GENRE, + BaseItemKind.PERSON, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + BaseItemKind.PLAYLIST, + BaseItemKind.PLAYLISTS_FOLDER, + BaseItemKind.SEASON, + BaseItemKind.SERIES, + BaseItemKind.STUDIO, + BaseItemKind.USER_ROOT_FOLDER, + BaseItemKind.USER_VIEW, + BaseItemKind.YEAR, + -> false + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 3e89f629..b2d192b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -94,16 +94,16 @@ import kotlin.time.Duration.Companion.seconds fun PlaybackPage( preferences: UserPreferences, deviceProfile: DeviceProfile, - destination: Destination.Playback, + destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel = hiltViewModel(), ) { - LifecycleStartEffect(destination.itemId) { + LifecycleStartEffect(destination) { onStopOrDispose { viewModel.release() } } - LaunchedEffect(destination.itemId) { + LaunchedEffect(destination) { viewModel.init(destination, deviceProfile, preferences) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 17d5f4c2..6c9e93ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlayerFactory +import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -171,55 +172,93 @@ class PlaybackViewModel * Initialize from the UI to start playback */ fun init( - destination: Destination.Playback, + destination: Destination, deviceProfile: DeviceProfile, preferences: UserPreferences, ) { nextUp.value = null this.preferences = preferences this.deviceProfile = deviceProfile - this.forceTranscoding = destination.forceTranscoding - val itemId = destination.itemId + this.forceTranscoding = + (destination as? Destination.Playback)?.forceTranscoding ?: false + val positionMs: Long + val itemPlayback: ItemPlayback? + val forceTranscoding: Boolean + + val itemId = + when (val d = destination) { + is Destination.Playback -> { + positionMs = d.positionMs + itemPlayback = d.itemPlayback + forceTranscoding = d.forceTranscoding + d.itemId + } + + is Destination.PlaybackList -> { + positionMs = 0 + itemPlayback = null + forceTranscoding = false + d.itemId + } + + else -> throw IllegalArgumentException("Destination not supported: $destination") + } this.itemId = itemId - val item = destination.item viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( loading, - "Error preparing for playback for ${destination.itemId}", + "Error preparing for playback for $itemId", ), ) { - val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content + val queriedItem = + (destination as? Destination.Playback)?.item?.data + ?: api.userLibraryApi.getItem(itemId).content val base = - if (queriedItem.type == BaseItemKind.PLAYLIST) { - isPlaylist = true - val playlist = - playlistCreator.createFromPlaylistId( - queriedItem.id, - destination.startIndex, - destination.shuffle, - ) - if (playlist.items.isEmpty()) { - showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) - navigationManager.goBack() - return@launch - } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = playlist - } - // TODO start index - playlist.items.first().data - } else { + if (queriedItem.type.playable) { queriedItem + } else if (destination is Destination.PlaybackList) { + isPlaylist = true + val playlistResult = + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + when (val r = playlistResult) { + is PlaylistCreationResult.Error -> { + loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) + return@launch + } + + is PlaylistCreationResult.Success -> { + if (r.playlist.items.isEmpty()) { + showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) + navigationManager.goBack() + return@launch + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = r.playlist + } + r.playlist.items + .first() + .data + } + } + } else { + throw IllegalArgumentException("Item is not playable and not PlaybackList") } val item = BaseItem.from(base, api) val played = play( item, - destination.positionMs, - destination.itemPlayback, - destination.forceTranscoding, + positionMs, + itemPlayback, + forceTranscoding, ) if (!played) { playNextUp()