From eae843312ca0ff0b2890bd42d0a7274459b1391a Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 28 Jan 2026 20:24:25 +0100 Subject: [PATCH] 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`. --- .../bbara/purefin/client/JellyfinApiClient.kt | 21 ++++++- .../player/viewmodel/PlayerViewModel.kt | 63 ++++++++++++++++--- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index 6607b9e..4d2481b 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -227,6 +227,26 @@ class JellyfinApiClient @Inject constructor( return result.content.mediaSources } + suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List { + 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 } - } diff --git a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt index 41a0118..936d48e 100644 --- a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt @@ -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) { - updateMetadata(mediaItem) - updateQueue() + val mediaId = mediaItem?.mediaId + if (!mediaId.isNullOrEmpty()) { + updateMetadata(mediaItem) + loadNextUpMedias(mediaId) + } } } @@ -100,25 +106,65 @@ class PlayerViewModel @Inject constructor( viewModelScope.launch { val mediaSources: List = 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) { - savedStateHandle["videoUris"] = videoUris.value + contentUri - player.addMediaItem(MediaItem.fromUri(contentUri)) + 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 playVideo(uri: Uri) { + fun addVideoUri(contentUri: Uri, metadata: MediaMetadata, mediaId: String? = null) { + savedStateHandle["videoUris"] = videoUris.value + contentUri + val mediaItem = MediaItem.Builder() + .setUri(contentUri) + .setMediaMetadata(metadata) + .setMediaId(mediaId ?: contentUri.toString()) + .build() + player.addMediaItem(mediaItem) + } + + 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() val text = mutableListOf()