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
This commit is contained in:
damontecres 2025-11-22 18:40:08 -05:00 committed by GitHub
parent 9a3a33fdd3
commit 50dc4b9a35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 467 additions and 88 deletions

View file

@ -4,9 +4,12 @@ import android.content.Context
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem 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.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.ui.DefaultItemFields 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.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
@ -18,10 +21,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi 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.BaseItemKind
import org.jellyfin.sdk.model.api.CreatePlaylistDto 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.MediaType
import org.jellyfin.sdk.model.api.PlaylistUserPermissions 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.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
@ -43,19 +50,22 @@ class PlaylistCreator
*/ */
suspend fun createFromEpisode( suspend fun createFromEpisode(
seriesId: UUID, seriesId: UUID,
episodeId: UUID, episodeId: UUID?,
seasonId: UUID? = null,
shuffled: Boolean = false,
): Playlist { ): Playlist {
val request = val request =
GetEpisodesRequest( GetEpisodesRequest(
seriesId = seriesId, seriesId = seriesId,
seasonId = seasonId,
fields = DefaultItemFields, fields = DefaultItemFields,
startItemId = episodeId, startItemId = episodeId,
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
) )
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex = val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId } episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
} }
@ -79,6 +89,128 @@ class PlaylistCreator
return Playlist(baseItems, 0) 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( suspend fun getPlaylists(
mediaType: MediaType?, mediaType: MediaType?,
scope: CoroutineScope, 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
}

View file

@ -8,8 +8,12 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -18,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.layout.ContentScale 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome 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.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager 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 org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration
@HiltViewModel @HiltViewModel
class CollectionFolderViewModel class CollectionFolderViewModel
@ -182,22 +189,11 @@ class CollectionFolderViewModel
return when (filter.override) { return when (filter.override) {
GetItemsFilterOverride.NONE -> { GetItemsFilterOverride.NONE -> {
val includeItemTypes = val includeItemTypes =
when (item?.data?.collectionType) { item
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) ?.data
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) ?.collectionType
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) ?.baseItemKinds
CollectionType.MUSIC -> .orEmpty()
listOf(
BaseItemKind.AUDIO,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.MUSIC_ALBUM,
)
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
else -> listOf()
}
val request = val request =
filter.applyTo( filter.applyTo(
GetItemsRequest( GetItemsRequest(
@ -317,6 +313,7 @@ fun CollectionFolderGrid(
recursive: Boolean, recursive: Boolean,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true, showTitle: Boolean = true,
@ -329,6 +326,7 @@ fun CollectionFolderGrid(
recursive, recursive,
onClickItem, onClickItem,
sortOptions, sortOptions,
playEnabled,
modifier, modifier,
initialSortAndDirection = initialSortAndDirection, initialSortAndDirection = initialSortAndDirection,
showTitle = showTitle, showTitle = showTitle,
@ -344,6 +342,7 @@ fun CollectionFolderGrid(
recursive: Boolean, recursive: Boolean,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel(),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
@ -398,6 +397,21 @@ fun CollectionFolderGrid(
positionCallback = positionCallback, positionCallback = positionCallback,
letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
params = params, 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, onSortChange: (SortAndDirection) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
onClickPlay: (shuffle: Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
@ -499,6 +515,16 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
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()) { if (sortOptions.isNotEmpty()) {
SortByButton( SortByButton(
sortOptions = sortOptions, sortOptions = sortOptions,
@ -507,6 +533,26 @@ fun CollectionFolderGridContent(
modifier = Modifier, 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) },
)
}
}
}
} }
} }
CardGrid( CardGrid(
@ -601,3 +647,22 @@ data class CollectionFolderGridParameters(
) )
} }
} }
val CollectionType.baseItemKinds: List<BaseItemKind>
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()
}

View file

@ -2,9 +2,11 @@ package com.github.damontecres.wholphin.ui.data
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
@Serializable
data class SortAndDirection( data class SortAndDirection(
val sort: ItemSortBy, val sort: ItemSortBy,
val direction: SortOrder, val direction: SortOrder,

View file

@ -44,5 +44,6 @@ fun CollectionFolderBoxSet(
showHeader = position < columns showHeader = position < columns
}, },
params = CollectionFolderGridParameters.POSTER, params = CollectionFolderGridParameters.POSTER,
playEnabled = true,
) )
} }

View file

@ -23,6 +23,7 @@ fun CollectionFolderGeneric(
itemId: UUID, itemId: UUID,
usePosters: Boolean, usePosters: Boolean,
recursive: Boolean, recursive: Boolean,
playEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filter: GetItemsFilter = GetItemsFilter(), filter: GetItemsFilter = GetItemsFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
@ -51,5 +52,6 @@ fun CollectionFolderGeneric(
showHeader = position < columns showHeader = position < columns
}, },
params = params, params = params,
playEnabled = playEnabled,
) )
} }

View file

@ -169,6 +169,7 @@ fun CollectionFolderLiveTv(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} else { } else {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $selectedTabIndex", null)

View file

@ -88,6 +88,7 @@ fun CollectionFolderMovie(
) )
} }
when (selectedTabIndex) { when (selectedTabIndex) {
// Recommended
0 -> { 0 -> {
RecommendedMovie( RecommendedMovie(
preferences = preferences, preferences = preferences,
@ -102,6 +103,7 @@ fun CollectionFolderMovie(
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
} }
// Library
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -124,8 +126,10 @@ fun CollectionFolderMovie(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = true,
) )
} }
// Collections
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -148,9 +152,10 @@ fun CollectionFolderMovie(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} }
// Genres
3 -> { 3 -> {
GenreCardGrid( GenreCardGrid(
itemId = destination.itemId, itemId = destination.itemId,

View file

@ -44,5 +44,6 @@ fun CollectionFolderPlaylist(
showHeader = position < columns showHeader = position < columns
}, },
params = CollectionFolderGridParameters.SQUARE, params = CollectionFolderGridParameters.SQUARE,
playEnabled = false,
) )
} }

View file

@ -42,5 +42,6 @@ fun CollectionFolderRecordings(
showHeader = position < columns showHeader = position < columns
}, },
params = CollectionFolderGridParameters.POSTER, params = CollectionFolderGridParameters.POSTER,
playEnabled = false,
) )
} }

View file

@ -91,6 +91,7 @@ fun CollectionFolderTv(
) )
} }
when (selectedTabIndex) { when (selectedTabIndex) {
// Recommended
0 -> { 0 -> {
RecommendedTvShow( RecommendedTvShow(
preferences = preferences, preferences = preferences,
@ -105,6 +106,7 @@ fun CollectionFolderTv(
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
} }
// Library
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -127,8 +129,10 @@ fun CollectionFolderTv(
onClickItem = { _, item -> onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination()) preferencesViewModel.navigationManager.navigateTo(item.destination())
}, },
playEnabled = false,
) )
} }
// Genres
2 -> { 2 -> {
GenreCardGrid( GenreCardGrid(
itemId = destination.itemId, itemId = destination.itemId,

View file

@ -104,7 +104,9 @@ fun FavoritesPage(
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
) )
} }
// TODO playEnabled = true for movies & episodes
when (selectedTabIndex) { when (selectedTabIndex) {
// Movies
0 -> { 0 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -126,8 +128,10 @@ fun FavoritesPage(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} }
// TV
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -149,8 +153,10 @@ fun FavoritesPage(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} }
// Episodes
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -174,8 +180,10 @@ fun FavoritesPage(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} }
// Videos
3 -> { 3 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -198,8 +206,10 @@ fun FavoritesPage(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} }
// Playlists
4 -> { 4 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -222,9 +232,10 @@ fun FavoritesPage(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} }
// People
5 -> { 5 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
@ -251,6 +262,7 @@ fun FavoritesPage(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false,
) )
} }
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null) else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)

View file

@ -148,18 +148,17 @@ fun PlaylistDetails(
focusRequester = focusRequester, focusRequester = focusRequester,
onClickIndex = { index, _ -> onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.PlaybackList(
itemId = it.id, itemId = it.id,
positionMs = 0L,
startIndex = index, startIndex = index,
shuffle = false,
), ),
) )
}, },
onClickPlay = { shuffle -> onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.PlaybackList(
itemId = it.id, itemId = it.id,
positionMs = 0L,
startIndex = 0, startIndex = 0,
shuffle = shuffle, shuffle = shuffle,
), ),
@ -183,10 +182,10 @@ fun PlaylistDetails(
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
) { ) {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.PlaybackList(
itemId = it.id, itemId = it.id,
positionMs = it.resumeMs ?: 0L,
startIndex = index, startIndex = index,
shuffle = false,
), ),
) )
}, },

View file

@ -167,6 +167,14 @@ fun SeriesDetails(
markPlayed = { played -> markPlayed = { played ->
viewModel.setSeasonWatched(season.id, played) viewModel.setSeasonWatched(season.id, played)
}, },
onClickPlay = { shuffle ->
viewModel.navigateTo(
Destination.PlaybackList(
itemId = season.id,
shuffle = shuffle,
),
)
},
) )
}, },
overviewOnClick = { overviewOnClick = {
@ -177,7 +185,18 @@ fun SeriesDetails(
files = listOf(), files = listOf(),
) )
}, },
playOnClick = { viewModel.playNextUp() }, playOnClick = { shuffle ->
if (shuffle) {
viewModel.navigateTo(
Destination.PlaybackList(
itemId = item.id,
shuffle = true,
),
)
} else {
viewModel.playNextUp()
}
},
watchOnClick = { showWatchConfirmation = true }, watchOnClick = { showWatchConfirmation = true },
favoriteOnClick = { favoriteOnClick = {
val favorite = item.data.userData?.isFavorite ?: false val favorite = item.data.userData?.isFavorite ?: false
@ -278,7 +297,7 @@ fun SeriesDetailsContent(
onClickPerson: (Person) -> Unit, onClickPerson: (Person) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
playOnClick: () -> Unit, playOnClick: (Boolean) -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
@ -344,16 +363,7 @@ fun SeriesDetailsContent(
item { item {
SeriesDetailsHeader( SeriesDetailsHeader(
series = series, series = series,
played = played,
favorite = favorite,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
playOnClick = {
position = HEADER_ROW
playOnClick.invoke()
},
watchOnClick = watchOnClick,
favoriteOnClick = favoriteOnClick,
bringIntoViewRequester = bringIntoViewRequester,
modifier = modifier =
Modifier Modifier
.fillMaxWidth(.7f) .fillMaxWidth(.7f)
@ -374,7 +384,23 @@ fun SeriesDetailsContent(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
onClick = { onClick = {
position = HEADER_ROW 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 =
Modifier.onFocusChanged { Modifier.onFocusChanged {
@ -568,13 +594,7 @@ fun SeriesDetailsContent(
@Composable @Composable
fun SeriesDetailsHeader( fun SeriesDetailsHeader(
series: BaseItem, series: BaseItem,
played: Boolean,
favorite: Boolean,
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -632,6 +652,7 @@ fun buildDialogForSeason(
s: BaseItem, s: BaseItem,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit, markPlayed: (Boolean) -> Unit,
onClickPlay: (Boolean) -> Unit,
): DialogParams { ): DialogParams {
val items = val items =
buildList { 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( return DialogParams(
title = s.name ?: context.getString(R.string.tv_season), title = s.name ?: context.getString(R.string.tv_season),

View file

@ -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.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinServer 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.SeasonEpisode
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -79,14 +80,24 @@ sealed class Destination(
val itemId: UUID, val itemId: UUID,
val positionMs: Long, val positionMs: Long,
@Transient val item: BaseItem? = null, @Transient val item: BaseItem? = null,
val startIndex: Int? = null,
val shuffle: Boolean = false,
val itemPlayback: ItemPlayback? = null, val itemPlayback: ItemPlayback? = null,
val forceTranscoding: Boolean = false, val forceTranscoding: Boolean = false,
) : Destination(true) { ) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" 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 @Serializable

View file

@ -48,8 +48,9 @@ fun DestinationContent(
preferences = preferences, preferences = preferences,
modifier = modifier, modifier = modifier,
) )
is Destination.PlaybackList,
is Destination.Playback -> is Destination.Playback,
->
PlaybackPage( PlaybackPage(
preferences = preferences, preferences = preferences,
deviceProfile = deviceProfile, deviceProfile = deviceProfile,
@ -164,6 +165,7 @@ fun DestinationContent(
filter = destination.filter, filter = destination.filter,
recursive = destination.recursive, recursive = destination.recursive,
usePosters = true, usePosters = true,
playEnabled = true, // TODO only genres use this currently, so might need to change in future
modifier = modifier, modifier = modifier,
) )
@ -261,6 +263,7 @@ fun CollectionFolder(
destination.itemId, destination.itemId,
usePosters = usePostersOverride ?: false, usePosters = usePostersOverride ?: false,
recursive = recursiveOverride ?: false, recursive = recursiveOverride ?: false,
playEnabled = true,
modifier = modifier, modifier = modifier,
) )
@ -274,6 +277,7 @@ fun CollectionFolder(
destination.itemId, destination.itemId,
usePosters = usePostersOverride ?: false, usePosters = usePostersOverride ?: false,
recursive = recursiveOverride ?: false, recursive = recursiveOverride ?: false,
playEnabled = false,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.github.damontecres.wholphin.preferences.PrefContentScale 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") 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.FILL_HEIGHT -> ContentScale.FillHeight
PrefContentScale.UNRECOGNIZED -> ContentScale.Fit 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
}

View file

@ -94,16 +94,16 @@ import kotlin.time.Duration.Companion.seconds
fun PlaybackPage( fun PlaybackPage(
preferences: UserPreferences, preferences: UserPreferences,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
destination: Destination.Playback, destination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(), viewModel: PlaybackViewModel = hiltViewModel(),
) { ) {
LifecycleStartEffect(destination.itemId) { LifecycleStartEffect(destination) {
onStopOrDispose { onStopOrDispose {
viewModel.release() viewModel.release()
} }
} }
LaunchedEffect(destination.itemId) { LaunchedEffect(destination) {
viewModel.init(destination, deviceProfile, preferences) viewModel.init(destination, deviceProfile, preferences)
} }

View file

@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlayerFactory 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.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -171,55 +172,93 @@ class PlaybackViewModel
* Initialize from the UI to start playback * Initialize from the UI to start playback
*/ */
fun init( fun init(
destination: Destination.Playback, destination: Destination,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
preferences: UserPreferences, preferences: UserPreferences,
) { ) {
nextUp.value = null nextUp.value = null
this.preferences = preferences this.preferences = preferences
this.deviceProfile = deviceProfile this.deviceProfile = deviceProfile
this.forceTranscoding = destination.forceTranscoding this.forceTranscoding =
val itemId = destination.itemId (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 this.itemId = itemId
val item = destination.item
viewModelScope.launch( viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
loading, 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 = val base =
if (queriedItem.type == BaseItemKind.PLAYLIST) { if (queriedItem.type.playable) {
queriedItem
} else if (destination is Destination.PlaybackList) {
isPlaylist = true isPlaylist = true
val playlist = val playlistResult =
playlistCreator.createFromPlaylistId( playlistCreator.createFrom(
queriedItem.id, item = queriedItem,
destination.startIndex, startIndex = destination.startIndex ?: 0,
destination.shuffle, sortAndDirection = destination.sortAndDirection,
shuffled = destination.shuffle,
recursive = destination.recursive,
filter = destination.filter,
) )
if (playlist.items.isEmpty()) { 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) showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
navigationManager.goBack() navigationManager.goBack()
return@launch return@launch
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist this@PlaybackViewModel.playlist.value = r.playlist
}
r.playlist.items
.first()
.data
}
} }
// TODO start index
playlist.items.first().data
} else { } else {
queriedItem throw IllegalArgumentException("Item is not playable and not PlaybackList")
} }
val item = BaseItem.from(base, api) val item = BaseItem.from(base, api)
val played = val played =
play( play(
item, item,
destination.positionMs, positionMs,
destination.itemPlayback, itemPlayback,
destination.forceTranscoding, forceTranscoding,
) )
if (!played) { if (!played) {
playNextUp() playNextUp()