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.components.baseItemKinds
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.playback.playable
import com.github.damontecres.wholphin.ui.toServerString
@ -22,6 +23,7 @@ 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.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CreatePlaylistDto
@ -64,10 +66,14 @@ class PlaylistCreator
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
limit = Playlist.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val episodes =
GetEpisodesRequestHandler
.execute(api, request)
.content.items
.convertAndAddParts(false)
val startIndex =
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
return Playlist(episodes, startIndex)
}
suspend fun createFromPlaylistId(
@ -82,12 +88,11 @@ class PlaylistCreator
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.from(it, api) }
var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
if (shuffled) {
baseItems = baseItems.shuffled()
items = items.shuffled()
}
return Playlist(baseItems, 0)
return Playlist(items.convertAndAddParts(), 0)
}
private suspend fun createFromCollection(
@ -120,19 +125,20 @@ class PlaylistCreator
),
)
val items =
GetItemsRequestHandler.execute(api, request).content.items.map {
BaseItem.from(it, api)
}
GetItemsRequestHandler
.execute(api, request)
.content.items
.convertAndAddParts()
return Playlist(items, 0)
}
suspend fun createFrom(
item: BaseItemDto,
startIndex: Int = 0,
sortAndDirection: SortAndDirection?,
shuffled: Boolean,
recursive: Boolean,
filter: GetItemsFilter,
sortAndDirection: SortAndDirection? = SortAndDirection.DEFAULT,
shuffled: Boolean = false,
recursive: Boolean = false,
filter: GetItemsFilter = GetItemsFilter(),
): PlaylistCreationResult =
when (item.type) {
BaseItemKind.BOX_SET,
@ -166,7 +172,7 @@ class PlaylistCreator
),
)
} else {
PlaylistCreationResult.Error(null, "Episode has not seriesId")
PlaylistCreationResult.Error(null, "Episode has no seriesId")
}
}
@ -182,7 +188,7 @@ class PlaylistCreator
),
)
} 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
// BaseItemKind.AGGREGATE_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(
mediaType: MediaType?,
scope: CoroutineScope,

View file

@ -418,3 +418,10 @@ fun rememberBackDropImage(item: BaseItem): String? {
val imageUrlService = LocalImageUrlService.current
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()
}
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
val playlist =
playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
if (!isPlaylist) {
val result = playlistCreator.createFrom(queriedItem)
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = result.playlist
}
}
}
}