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()