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 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? { suspend fun getMediaPlaybackInfo(mediaId: UUID, mediaSourceId: String? = null): String? {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return null return null
@@ -240,5 +260,4 @@ class JellyfinApiClient @Inject constructor(
return response return response
} }
} }

View File

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