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.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
}

View file

@ -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<ItemSortBy>,
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<ItemSortBy>,
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<ItemSortBy>,
playEnabled: Boolean,
onClickPlay: (shuffle: Boolean) -> Unit,
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
@ -499,6 +515,16 @@ fun CollectionFolderGridContent(
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()) {
SortByButton(
sortOptions = sortOptions,
@ -507,6 +533,26 @@ fun CollectionFolderGridContent(
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(
@ -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 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,

View file

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

View file

@ -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,
)
}

View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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,
),
)
},

View file

@ -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),

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.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

View file

@ -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,
)
}

View file

@ -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
}

View file

@ -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)
}

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.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) {
if (queriedItem.type.playable) {
queriedItem
} else if (destination is Destination.PlaybackList) {
isPlaylist = true
val playlist =
playlistCreator.createFromPlaylistId(
queriedItem.id,
destination.startIndex,
destination.shuffle,
val playlistResult =
playlistCreator.createFrom(
item = queriedItem,
startIndex = destination.startIndex ?: 0,
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)
navigationManager.goBack()
return@launch
}
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 {
queriedItem
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()