From 38f1f94956696577e8731d04b0502b6936a2b8e4 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 1 Jun 2026 21:25:21 +0200 Subject: [PATCH] fix(playback): fall back to transcoding on direct play errors --- .../purefin/core/data/PlaybackMediaItemTag.kt | 13 +++ .../core/player/manager/PlayerManager.kt | 106 +++++++++++++++++- .../DefaultPlayableMediaRepository.kt | 29 +++-- .../playback/JellyfinPlaybackResolver.kt | 26 ++++- 4 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 core/src/main/java/hu/bbara/purefin/core/data/PlaybackMediaItemTag.kt diff --git a/core/src/main/java/hu/bbara/purefin/core/data/PlaybackMediaItemTag.kt b/core/src/main/java/hu/bbara/purefin/core/data/PlaybackMediaItemTag.kt new file mode 100644 index 00000000..cd4f8954 --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/core/data/PlaybackMediaItemTag.kt @@ -0,0 +1,13 @@ +package hu.bbara.purefin.core.data + +/** + * Playback-only data stored in [androidx.media3.common.MediaItem.LocalConfiguration.tag]. + * + * The player uses [playbackReportContext] for Jellyfin progress reporting and consumes the + * transcoding fallback fields once when direct playback fails. + */ +data class PlaybackMediaItemTag( + val playbackReportContext: PlaybackReportContext?, + val transcodingFallbackUrl: String?, + val transcodingFallbackReportContext: PlaybackReportContext?, +) diff --git a/core/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt b/core/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt index 60c25ca6..9463fda7 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt @@ -10,6 +10,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.core.data.PlayableMediaRepository +import hu.bbara.purefin.core.data.PlaybackMediaItemTag import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.player.model.MetadataState import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot @@ -76,6 +77,7 @@ class PlayerManager @Inject constructor( } private var pendingSeekPositionMs: Long? = null + private var pendingFallbackSeek: PendingFallbackSeek? = null private val _playbackState = MutableStateFlow(PlaybackStateSnapshot()) val playbackState: StateFlow = _playbackState.asStateFlow() @@ -107,6 +109,9 @@ class PlayerManager @Inject constructor( } override fun onPlayerError(error: PlaybackException) { + if (switchToTranscodingFallback()) { + return + } _playbackState.update { it.copy(error = mapPlayerError(error)) } @@ -122,10 +127,15 @@ class PlayerManager @Inject constructor( _playbackState.update { it.copy(error = "Media not found in playlist") } return@launch } - seekTo(currentMedia.resumePositionMs) + val fallbackSeek = pendingFallbackSeek + pendingFallbackSeek = null + val fallbackSeekPositionMs = fallbackSeek + ?.takeIf { it.mediaId == mediaItem?.mediaId } + ?.positionMs + seekTo(fallbackSeekPositionMs ?: currentMedia.resumePositionMs) installMediaSegments(currentMedia.mediaSegments) // Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time. - if (currentMedia is PlayableMedia.Episode) { + if (fallbackSeekPositionMs == null && currentMedia is PlayableMedia.Episode) { updatePlaylist() } } @@ -424,7 +434,7 @@ class PlayerManager @Inject constructor( private fun updateFromPlayer(player: Player) { val currentMediaItem = player.currentMediaItem val playbackState = player.playbackState - val playbackReportContext = currentMediaItem?.localConfiguration?.tag as? PlaybackReportContext + val playbackReportContext = currentMediaItem?.playbackReportContext() val currentMetadata = player.mediaMetadata val playerError = player.playerError @@ -487,6 +497,96 @@ class PlayerManager @Inject constructor( return error?.errorCodeName ?: error?.localizedMessage ?: error?.message } + private fun switchToTranscodingFallback(): Boolean { + val currentMediaItem = player.currentMediaItem ?: return false + val playbackTag = currentMediaItem.localConfiguration?.tag as? PlaybackMediaItemTag + ?: return false + val fallbackUrl = playbackTag.transcodingFallbackUrl?.takeIf { it.isNotBlank() } ?: return false + val currentMediaItemIndex = player.currentMediaItemIndex + if (currentMediaItemIndex == C.INDEX_UNSET) return false + + val fallbackMediaItem = currentMediaItem.buildUpon() + .setUri(fallbackUrl) + .setTag( + playbackTag.copy( + playbackReportContext = playbackTag.transcodingFallbackReportContext + ?: playbackTag.playbackReportContext, + transcodingFallbackUrl = null, + transcodingFallbackReportContext = null, + ) + ) + .build() + val fallbackPositionMs = fallbackPositionMs() + val playWhenReady = player.playWhenReady + + pendingFallbackSeek = PendingFallbackSeek( + mediaId = currentMediaItem.mediaId, + positionMs = fallbackPositionMs, + ) + clearStalePendingFallbackSeek(currentMediaItem.mediaId) + player.replaceMediaItem(currentMediaItemIndex, fallbackMediaItem) + replacePlaylistMediaItem(fallbackMediaItem) + clearActiveSkippableSegment() + player.seekTo(currentMediaItemIndex, fallbackPositionMs) + player.prepare() + player.playWhenReady = playWhenReady + _playbackState.update { it.copy(error = null) } + + return true + } + + private fun fallbackPositionMs(): Long { + val positionMs = pendingSeekPositionMs ?: player.currentPosition + return if (positionMs > 0L) { + positionMs + } else { + _progress.value.positionMs.coerceAtLeast(0L) + } + } + + private fun replacePlaylistMediaItem(mediaItem: MediaItem) { + val mediaId = runCatching { UUID.fromString(mediaItem.mediaId) }.getOrNull() ?: return + _playlist.update { playlist -> + playlist.map { playableMedia -> + if (playableMedia.id == mediaId) { + playableMedia.withMediaItem(mediaItem) + } else { + playableMedia + } + } + } + } + + private fun PlayableMedia.withMediaItem(mediaItem: MediaItem): PlayableMedia { + return when (this) { + is PlayableMedia.Episode -> copy(mediaItem = mediaItem) + is PlayableMedia.Movie -> copy(mediaItem = mediaItem) + is PlayableMedia.Series -> copy(mediaItem = mediaItem) + } + } + + private fun MediaItem.playbackReportContext(): PlaybackReportContext? { + return when (val tag = localConfiguration?.tag) { + is PlaybackMediaItemTag -> tag.playbackReportContext + is PlaybackReportContext -> tag + else -> null + } + } + + private fun clearStalePendingFallbackSeek(mediaId: String) { + scope.launch { + delay(1_000) + if (pendingFallbackSeek?.mediaId == mediaId) { + pendingFallbackSeek = null + } + } + } + + private data class PendingFallbackSeek( + val mediaId: String, + val positionMs: Long, + ) + private fun syncPendingSeek(positionMs: Long) { val pendingPosition = pendingSeekPositionMs ?: return if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) { diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index 3d196a83..9e32434b 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -9,6 +9,8 @@ import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.PlayableMediaRepository +import hu.bbara.purefin.core.data.PlaybackMediaItemTag +import hu.bbara.purefin.core.data.PlaybackMethod import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.download.MediaDownloadController @@ -17,6 +19,7 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver +import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.PlayableMedia @@ -87,7 +90,7 @@ class DefaultPlayableMediaRepository @Inject constructor( val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(mediaId) ?: return null val mediaItem = if (downloadedMediaItem == null) { - getMediaItem(baseItem, playbackSource.directPlayUrl) + getMediaItem(baseItem, playbackSource) } else { getDownloadedMediaItem(baseItem, downloadedMediaItem) } @@ -170,7 +173,10 @@ class DefaultPlayableMediaRepository @Inject constructor( return null } - private suspend fun getMediaItem(baseItem: BaseItemDto, url: String): MediaItem = withContext(Dispatchers.IO) { + private suspend fun getMediaItem( + baseItem: BaseItemDto, + playbackSource: PlaybackSource, + ): MediaItem = withContext(Dispatchers.IO) { val mediaId = baseItem.id val baseItem = jellyfinApiClient.getItemInfo(mediaId) @@ -179,12 +185,11 @@ class DefaultPlayableMediaRepository @Inject constructor( val mediaItem = createMediaItem( mediaId = mediaId.toString(), - url = url, + url = playbackSource.directPlayUrl, title = baseItem?.name ?: "Unknown", subtitle = seasonEpisodeLabel(baseItem), artworkUrl = artworkUrl, - // TODO - playbackReportContext = null, + playbackTag = playbackSource.toPlaybackMediaItemTag(), ) return@withContext mediaItem @@ -243,7 +248,7 @@ class DefaultPlayableMediaRepository @Inject constructor( title: String, subtitle: String?, artworkUrl: String, - playbackReportContext: PlaybackReportContext?, + playbackTag: Any?, ): MediaItem { val metadata = MediaMetadata.Builder() .setTitle(title) @@ -254,10 +259,20 @@ class DefaultPlayableMediaRepository @Inject constructor( .setUri(url.toUri()) .setMediaId(mediaId) .setMediaMetadata(metadata) - .setTag(playbackReportContext) + .setTag(playbackTag) return builder.build() } + private fun PlaybackSource.toPlaybackMediaItemTag(): PlaybackMediaItemTag { + return PlaybackMediaItemTag( + playbackReportContext = playbackReportContext, + transcodingFallbackUrl = transcodingUrl, + transcodingFallbackReportContext = transcodingUrl?.let { + playbackReportContext.copy(playMethod = PlaybackMethod.TRANSCODE) + }, + ) + } + private fun calculateResumePosition( baseItem: BaseItemDto?, mediaSource: MediaSourceInfo, diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt index b6000c23..e1390d8e 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt @@ -48,20 +48,36 @@ class JellyfinPlaybackResolver @Inject constructor( return@withContext null } + val mediaSource = playbackInfo.mediaSources.first() + val transcodingUrl = mediaSource.transcodingUrl + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { url -> resolveUrl(serverUrl, url) } + PlaybackSource( - mediaSource = playbackInfo.mediaSources.first(), + mediaSource = mediaSource, directPlayUrl = directPlayUrl, - transcodingUrl = playbackInfo.mediaSources.firstOrNull()?.transcodingUrl, + transcodingUrl = transcodingUrl, playbackReportContext = PlaybackReportContext( playMethod = PlaybackMethod.DIRECT_PLAY, - mediaSourceId = playbackInfo.mediaSources.firstOrNull()?.id, - audioStreamIndex = playbackInfo.mediaSources.firstOrNull()?.defaultAudioStreamIndex, - subtitleStreamIndex = playbackInfo.mediaSources.firstOrNull()?.defaultSubtitleStreamIndex, + mediaSourceId = mediaSource.id, + audioStreamIndex = mediaSource.defaultAudioStreamIndex, + subtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex, playSessionId = playbackInfo.playSessionId, ), ) } + private fun resolveUrl(serverUrl: String, url: String): String { + if ( + url.startsWith("http://", ignoreCase = true) || + url.startsWith("https://", ignoreCase = true) + ) { + return url + } + return "${serverUrl.trimEnd('/')}/${url.trimStart('/')}" + } + private companion object { private const val TAG = "PlaybackResolver" }