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
}