Implement Jellyfin playback decision flow

This commit is contained in:
2026-03-29 16:38:44 +02:00
parent a187192013
commit 4b92af26ba
7 changed files with 480 additions and 94 deletions

View File

@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.offline.DownloadManager
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
@@ -34,8 +35,8 @@ class PlayerMediaRepository @Inject constructor(
private val downloadManager: DownloadManager
) {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
buildOnlineMediaItem(mediaId) ?: buildOfflineMediaItem(mediaId)
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
}
private fun calculateResumePosition(
@@ -72,21 +73,18 @@ class PlayerMediaRepository @Inject constructor(
if (existingIds.contains(stringId)) {
return@mapNotNull null
}
val mediaSources = jellyfinApiClient.getMediaSources(id)
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = id,
mediaSource = selectedMediaSource
) ?: return@mapNotNull null
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
val selectedMediaSource = playbackDecision.mediaSource
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
createMediaItem(
mediaId = stringId,
playbackUrl = playbackUrl,
playbackUrl = playbackDecision.url,
title = episode.name ?: selectedMediaSource.name!!,
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs
subtitleConfigurations = subtitleConfigs,
tag = playbackDecision.reportContext
)
}
}.getOrElse { error ->
@@ -95,14 +93,13 @@ class PlayerMediaRepository @Inject constructor(
}
}
private suspend fun buildOnlineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? {
return runCatching {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
val selectedMediaSource = mediaSources.firstOrNull() ?: return null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
mediaId = mediaId,
mediaSource = selectedMediaSource
forceTranscode = forceTranscode
) ?: return null
val selectedMediaSource = playbackDecision.mediaSource
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
@@ -112,11 +109,12 @@ class PlayerMediaRepository @Inject constructor(
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = playbackUrl,
playbackUrl = playbackDecision.url,
title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs
subtitleConfigurations = subtitleConfigs,
tag = playbackDecision.reportContext
)
mediaItem to resumePositionMs
@@ -247,7 +245,8 @@ class PlayerMediaRepository @Inject constructor(
title: String,
subtitle: String?,
artworkUrl: String,
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList()
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
tag: PlaybackReportContext? = null
): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
@@ -258,6 +257,7 @@ class PlayerMediaRepository @Inject constructor(
.setUri(playbackUrl.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setTag(tag)
.setSubtitleConfigurations(subtitleConfigurations)
.build()
}

View File

@@ -9,6 +9,7 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
@@ -73,14 +74,20 @@ class PlayerManager @Inject constructor(
state.copy(
isBuffering = buffering,
isEnded = ended,
error = if (playbackState == Player.STATE_IDLE) state.error else null
error = if (playbackState == Player.STATE_IDLE) state.error else null,
errorCode = if (playbackState == Player.STATE_IDLE) state.errorCode else null
)
}
if (ended) player.pause()
}
override fun onPlayerError(error: PlaybackException) {
_playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
_playbackState.update {
it.copy(
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error",
errorCode = error.errorCode
)
}
}
override fun onTracksChanged(tracks: Tracks) {
@@ -128,7 +135,27 @@ class PlayerManager @Inject constructor(
_progress.value = PlaybackProgressSnapshot()
refreshMetadata(mediaItem)
refreshQueue()
_playbackState.update { it.copy(isEnded = false, error = null) }
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
}
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
currentMediaContext = mediaContext
val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run {
play(mediaItem, mediaContext, startPositionMs)
return
}
player.replaceMediaItem(currentIndex, mediaItem)
if (startPositionMs != null) {
player.seekTo(currentIndex, startPositionMs)
} else {
player.seekToDefaultPosition(currentIndex)
}
player.prepare()
player.playWhenReady = true
refreshMetadata(mediaItem)
refreshQueue()
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
}
fun addToQueue(mediaItem: MediaItem) {
@@ -232,7 +259,7 @@ class PlayerManager @Inject constructor(
}
fun clearError() {
_playbackState.update { it.copy(error = null) }
_playbackState.update { it.copy(error = null, errorCode = null) }
}
fun snapshotProgress(): PlaybackProgressSnapshot {
@@ -341,10 +368,12 @@ class PlayerManager @Inject constructor(
}
private fun refreshMetadata(mediaItem: MediaItem?) {
val playbackReportContext = mediaItem?.localConfiguration?.tag as? PlaybackReportContext
_metadata.value = MetadataState(
mediaId = mediaItem?.mediaId,
title = mediaItem?.mediaMetadata?.title?.toString(),
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString()
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(),
playbackReportContext = playbackReportContext
)
}
@@ -357,7 +386,8 @@ data class PlaybackStateSnapshot(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val isEnded: Boolean = false,
val error: String? = null
val error: String? = null,
val errorCode: Int? = null
)
data class PlaybackProgressSnapshot(
@@ -370,7 +400,8 @@ data class PlaybackProgressSnapshot(
data class MetadataState(
val mediaId: String? = null,
val title: String? = null,
val subtitle: String? = null
val subtitle: String? = null,
val playbackReportContext: PlaybackReportContext? = null
)
data class MediaContext(

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.core.player.manager
import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -25,6 +26,7 @@ class ProgressManager @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null
private var activeItemId: UUID? = null
private var activePlaybackReportContext: PlaybackReportContext? = null
private var lastPositionMs: Long = 0L
private var lastDurationMs: Long = 0L
private var isPaused: Boolean = false
@@ -47,6 +49,7 @@ class ProgressManager @Inject constructor(
lastDurationMs = prog.durationMs
isPaused = !state.isPlaying
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
activePlaybackReportContext = meta.playbackReportContext
// Media changed or ended - stop session
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
@@ -55,19 +58,20 @@ class ProgressManager @Inject constructor(
// Start session when we have a media item and none is active
if (activeItemId == null && mediaId != null && !state.isEnded) {
startSession(mediaId, prog.positionMs)
startSession(mediaId, prog.positionMs, meta.playbackReportContext)
}
}
}
}
private fun startSession(itemId: UUID, positionMs: Long) {
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
activeItemId = itemId
report(itemId, positionMs, isStart = true)
activePlaybackReportContext = reportContext
report(itemId, positionMs, reportContext = reportContext, isStart = true)
progressJob = scope.launch {
while (isActive) {
delay(5000)
report(itemId, lastPositionMs, isPaused = isPaused)
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
}
}
}
@@ -75,7 +79,7 @@ class ProgressManager @Inject constructor(
private fun stopSession() {
progressJob?.cancel()
activeItemId?.let { itemId ->
report(itemId, lastPositionMs, isStop = true)
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
scope.launch(Dispatchers.IO) {
try {
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
@@ -85,16 +89,27 @@ class ProgressManager @Inject constructor(
}
}
activeItemId = null
activePlaybackReportContext = null
}
private fun report(itemId: UUID, positionMs: Long, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false) {
private fun report(
itemId: UUID,
positionMs: Long,
reportContext: PlaybackReportContext?,
isPaused: Boolean = false,
isStart: Boolean = false,
isStop: Boolean = false
) {
val ticks = positionMs * 10_000
scope.launch(Dispatchers.IO) {
try {
if (reportContext == null) {
return@launch
}
when {
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
}
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
} catch (e: Exception) {
@@ -111,7 +126,9 @@ class ProgressManager @Inject constructor(
val durMs = lastDurationMs
CoroutineScope(Dispatchers.IO).launch {
try {
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
activePlaybackReportContext?.let { reportContext ->
jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
}
updateWatchProgressUseCase(itemId, posMs, durMs)
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
} catch (e: Exception) {

View File

@@ -1,16 +1,20 @@
package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.client.PlaybackReportContext
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
import hu.bbara.purefin.core.player.manager.MediaContext
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
import hu.bbara.purefin.core.player.manager.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption
import org.jellyfin.sdk.model.api.PlayMethod
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -43,6 +47,8 @@ class PlayerViewModel @Inject constructor(
private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null
private var dataErrorMessage: String? = null
private var activeMediaId: String? = null
private var transcodingRetryMediaId: String? = null
init {
progressManager.bind(
@@ -57,6 +63,10 @@ class PlayerViewModel @Inject constructor(
private fun observePlayerState() {
viewModelScope.launch {
playerManager.playbackState.collect { state ->
if (state.error != null && maybeRetryWithTranscoding(state)) {
_uiState.update { it.copy(isBuffering = true, error = null) }
return@collect
}
_uiState.update {
it.copy(
isPlaying = state.isPlaying,
@@ -93,6 +103,10 @@ class PlayerViewModel @Inject constructor(
)
}
val currentMediaId = metadata.mediaId
if (currentMediaId != activeMediaId) {
activeMediaId = currentMediaId
transcodingRetryMediaId = null
}
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
lastNextUpMediaId = currentMediaId
loadNextUp(currentMediaId)
@@ -133,6 +147,15 @@ class PlayerViewModel @Inject constructor(
}
private fun loadMediaById(id: String) {
loadMediaById(id = id, forceTranscode = false, startPositionMsOverride = null, replaceCurrent = false)
}
private fun loadMediaById(
id: String,
forceTranscode: Boolean,
startPositionMsOverride: Long?,
replaceCurrent: Boolean
) {
val uuid = id.toUuidOrNull()
if (uuid == null) {
dataErrorMessage = "Invalid media id"
@@ -140,15 +163,20 @@ class PlayerViewModel @Inject constructor(
return
}
viewModelScope.launch {
val result = playerMediaRepository.getMediaItem(uuid)
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
if (result != null) {
val (mediaItem, resumePositionMs) = result
// Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
val startPositionMs = startPositionMsOverride ?: resumePositionMs
playerManager.play(mediaItem, mediaContext, resumePositionMs)
if (replaceCurrent) {
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
} else {
playerManager.play(mediaItem, mediaContext, startPositionMs)
}
if (dataErrorMessage != null) {
dataErrorMessage = null
@@ -161,6 +189,48 @@ class PlayerViewModel @Inject constructor(
}
}
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
val errorCode = state.errorCode ?: return false
if (currentMediaId == transcodingRetryMediaId) return false
if (!playbackReportContext.canRetryWithTranscoding) return false
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
transcodingRetryMediaId = currentMediaId
loadMediaById(
id = currentMediaId,
forceTranscode = true,
startPositionMsOverride = player.currentPosition.takeIf { it > 0L },
replaceCurrent = true
)
return true
}
private fun isRetryablePlaybackError(
errorCode: Int,
errorMessage: String?,
playbackReportContext: PlaybackReportContext
): Boolean {
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
return false
}
if (errorCode in setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
)
) {
return true
}
val message = errorMessage?.lowercase().orEmpty()
return "decoder" in message || "codec" in message || "unsupported" in message
}
private fun loadNextUp(currentMediaId: String) {
val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch {