feat: implement auto-play next episode and up-next queue

- Implement `getNextEpisodes` in `JellyfinApiClient` to fetch a list of upcoming episodes for a given series.
- In `PlayerViewModel`, automatically load the next few episodes into the media queue when a new item starts playing.
- Attach `MediaMetadata` (e.g., title) to `MediaItem` objects to ensure the correct title is displayed for queued items.
- Trigger loading of the next episodes in the `onMediaItemTransition` player callback.
- Refactor video playback and queueing methods (`playVideo`, `addVideoUri`) to accept and utilize `MediaMetadata`.
This commit is contained in:
2026-01-28 20:24:25 +01:00
parent abf66c75e1
commit eae843312c
2 changed files with 75 additions and 9 deletions

View File

@@ -227,6 +227,26 @@ class JellyfinApiClient @Inject constructor(
return result.content.mediaSources
}
suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List<BaseItemDto> {
if (!ensureConfigured()) {
return emptyList()
}
// TODO pass complete Episode object not only an id
val episodeInfo = getItemInfo(episodeId) ?: return emptyList()
val seriesId = episodeInfo.seriesId ?: return emptyList()
val nextUpEpisodesResult = api.tvShowsApi.getEpisodes(
userId = getUserId(),
seriesId = seriesId,
enableUserData = true,
startItemId = episodeId,
limit = count + 1
)
//Remove first element as we need only the next episodes
val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1)
Log.d("getNextEpisodeMediaSources response: {}", nextUpEpisodes.toString())
return nextUpEpisodes
}
suspend fun getMediaPlaybackInfo(mediaId: UUID, mediaSourceId: String? = null): String? {
if (!ensureConfigured()) {
return null
@@ -240,5 +260,4 @@ class JellyfinApiClient @Inject constructor(
return response
}
}

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.player.viewmodel
import android.net.Uri
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
@@ -8,10 +9,12 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.player.model.PlayerUiState
@@ -81,8 +84,11 @@ class PlayerViewModel @Inject constructor(
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val mediaId = mediaItem?.mediaId
if (!mediaId.isNullOrEmpty()) {
updateMetadata(mediaItem)
updateQueue()
loadNextUpMedias(mediaId)
}
}
}
@@ -100,25 +106,65 @@ class PlayerViewModel @Inject constructor(
viewModelScope.launch {
val mediaSources: List<MediaSourceInfo> =
jellyfinApiClient.getMediaSources(UUID.fromString(mediaId!!))
val selectedMediaSource = mediaSources.first()
val contentUriString =
jellyfinApiClient.getMediaPlaybackInfo(
mediaId = UUID.fromString(mediaId),
mediaSourceId = mediaSources.first().id
mediaSourceId = selectedMediaSource.id
)
val mediaMetadata = MediaMetadata.Builder()
.setTitle(selectedMediaSource.name)
.build()
contentUriString?.toUri()?.let {
playVideo(it)
playVideo(
uri = it,
metadata = mediaMetadata
)
}
}
}
fun addVideoUri(contentUri: Uri) {
private fun loadNextUpMedias(mediaId: String) {
viewModelScope.launch {
val episodes = jellyfinApiClient.getNextEpisodes(
episodeId = UUID.fromString(mediaId),
count = 2
)
for (episode in episodes) {
if (_uiState.value.queue.any { it.id == episode.id.toString() }) {
continue
}
val mediaSources = jellyfinApiClient.getMediaSources(episode.id)
val selectedMediaSource = mediaSources.first()
val contentUriString = jellyfinApiClient.getMediaPlaybackInfo(
mediaId = episode.id,
mediaSourceId = selectedMediaSource.id
)
val mediaMetadata = MediaMetadata.Builder()
.setTitle(selectedMediaSource.name)
.build()
contentUriString?.toUri()?.let {
addVideoUri(it, mediaMetadata, episode.id.toString())
}
}
updateQueue()
}
}
fun addVideoUri(contentUri: Uri, metadata: MediaMetadata, mediaId: String? = null) {
savedStateHandle["videoUris"] = videoUris.value + contentUri
player.addMediaItem(MediaItem.fromUri(contentUri))
val mediaItem = MediaItem.Builder()
.setUri(contentUri)
.setMediaMetadata(metadata)
.setMediaId(mediaId ?: contentUri.toString())
.build()
player.addMediaItem(mediaItem)
}
fun playVideo(uri: Uri) {
fun playVideo(uri: Uri, metadata: MediaMetadata) {
val mediaItem = MediaItem.Builder()
.setUri(uri)
.setMediaMetadata(metadata)
.setMediaId(mediaId ?: uri.toString())
.build()
player.setMediaItem(mediaItem)
@@ -264,6 +310,7 @@ class PlayerViewModel @Inject constructor(
}
}
@OptIn(UnstableApi::class)
private fun updateTracks(tracks: Tracks = player.currentTracks) {
val audio = mutableListOf<TrackOption>()
val text = mutableListOf<TrackOption>()