Add multi-part videos to playback queue (#520)

## Description
When playing media with multiple parts, the additional parts will be
added to the playback queue. This means a multi-part movie or episode
will be play all of the parts in order before the next item in the
queue.

This only affects the playback queue. There's no UI changes and no
indication that a movie or episode has multiple parts. I'll work on that
in a separate PR.

### Related issues
Related to #455
This commit is contained in:
Ray 2025-12-21 21:30:00 -05:00 committed by GitHub
parent 6df89fe8c9
commit 925a4b9d82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 68 additions and 20 deletions

View file

@ -10,6 +10,7 @@ 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.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
@ -22,6 +23,7 @@ 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.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto 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
@ -64,10 +66,14 @@ class PlaylistCreator
sortBy = if (shuffled) ItemSortBy.RANDOM else null, 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
.convertAndAddParts(false)
val startIndex = val startIndex =
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) return Playlist(episodes, startIndex)
} }
suspend fun createFromPlaylistId( suspend fun createFromPlaylistId(
@ -82,12 +88,11 @@ class PlaylistCreator
startIndex = startIndex, startIndex = startIndex,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
) )
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.from(it, api) }
if (shuffled) { if (shuffled) {
baseItems = baseItems.shuffled() items = items.shuffled()
} }
return Playlist(baseItems, 0) return Playlist(items.convertAndAddParts(), 0)
} }
private suspend fun createFromCollection( private suspend fun createFromCollection(
@ -120,19 +125,20 @@ class PlaylistCreator
), ),
) )
val items = val items =
GetItemsRequestHandler.execute(api, request).content.items.map { GetItemsRequestHandler
BaseItem.from(it, api) .execute(api, request)
} .content.items
.convertAndAddParts()
return Playlist(items, 0) return Playlist(items, 0)
} }
suspend fun createFrom( suspend fun createFrom(
item: BaseItemDto, item: BaseItemDto,
startIndex: Int = 0, startIndex: Int = 0,
sortAndDirection: SortAndDirection?, sortAndDirection: SortAndDirection? = SortAndDirection.DEFAULT,
shuffled: Boolean, shuffled: Boolean = false,
recursive: Boolean, recursive: Boolean = false,
filter: GetItemsFilter, filter: GetItemsFilter = GetItemsFilter(),
): PlaylistCreationResult = ): PlaylistCreationResult =
when (item.type) { when (item.type) {
BaseItemKind.BOX_SET, BaseItemKind.BOX_SET,
@ -166,7 +172,7 @@ class PlaylistCreator
), ),
) )
} else { } else {
PlaylistCreationResult.Error(null, "Episode has not seriesId") PlaylistCreationResult.Error(null, "Episode has no seriesId")
} }
} }
@ -182,7 +188,7 @@ class PlaylistCreator
), ),
) )
} else { } else {
PlaylistCreationResult.Error(null, "Episode has not seriesId") PlaylistCreationResult.Error(null, "Episode has no seriesId")
} }
} }
@ -207,6 +213,26 @@ class PlaylistCreator
) )
} }
BaseItemKind.MOVIE,
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
-> {
val list =
buildList {
add(BaseItem(item, false))
if (item.partCount.gt(1)) {
api.videosApi
.getAdditionalPart(item.id)
.content.items
.map {
BaseItem(it, false)
}.let(::addAll)
}
}
PlaylistCreationResult.Success(Playlist(list, 0))
}
// Not support yet // Not support yet
// BaseItemKind.AGGREGATE_FOLDER -> TODO() // BaseItemKind.AGGREGATE_FOLDER -> TODO()
// BaseItemKind.FOLDER -> TODO() // BaseItemKind.FOLDER -> TODO()
@ -220,6 +246,20 @@ class PlaylistCreator
} }
} }
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<BaseItem> =
buildList {
this@convertAndAddParts.forEach { ep ->
add(BaseItem(ep, useSeriesForPrimary))
if (ep.partCount.gt(1)) {
val parts =
api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
BaseItem(part, useSeriesForPrimary)
}
addAll(parts)
}
}
}
suspend fun getServerPlaylists( suspend fun getServerPlaylists(
mediaType: MediaType?, mediaType: MediaType?,
scope: CoroutineScope, scope: CoroutineScope,

View file

@ -418,3 +418,10 @@ fun rememberBackDropImage(item: BaseItem): String? {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
} }
/**
* Check if this, coalescing nulls to zero, is greater than that
*/
fun Int?.gt(that: Int) = (this ?: 0) > that
fun Int?.lt(that: Int) = (this ?: 0) < that

View file

@ -286,11 +286,12 @@ class PlaybackViewModel
playNextUp() playNextUp()
} }
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { if (!isPlaylist) {
val playlist = val result = playlistCreator.createFrom(queriedItem)
playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id) if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist this@PlaybackViewModel.playlist.value = result.playlist
}
} }
} }
} }