fix(playback): fall back to transcoding on direct play errors

This commit is contained in:
2026-06-01 21:25:21 +02:00
parent 72cb574753
commit 38f1f94956
4 changed files with 159 additions and 15 deletions

View File

@@ -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?,
)

View File

@@ -10,6 +10,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.PlayableMediaRepository 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.data.PlaybackReportContext
import hu.bbara.purefin.core.player.model.MetadataState import hu.bbara.purefin.core.player.model.MetadataState
import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot
@@ -76,6 +77,7 @@ class PlayerManager @Inject constructor(
} }
private var pendingSeekPositionMs: Long? = null private var pendingSeekPositionMs: Long? = null
private var pendingFallbackSeek: PendingFallbackSeek? = null
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot()) private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow() val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
@@ -107,6 +109,9 @@ class PlayerManager @Inject constructor(
} }
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
if (switchToTranscodingFallback()) {
return
}
_playbackState.update { _playbackState.update {
it.copy(error = mapPlayerError(error)) it.copy(error = mapPlayerError(error))
} }
@@ -122,10 +127,15 @@ class PlayerManager @Inject constructor(
_playbackState.update { it.copy(error = "Media not found in playlist") } _playbackState.update { it.copy(error = "Media not found in playlist") }
return@launch 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) installMediaSegments(currentMedia.mediaSegments)
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time. // 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() updatePlaylist()
} }
} }
@@ -424,7 +434,7 @@ class PlayerManager @Inject constructor(
private fun updateFromPlayer(player: Player) { private fun updateFromPlayer(player: Player) {
val currentMediaItem = player.currentMediaItem val currentMediaItem = player.currentMediaItem
val playbackState = player.playbackState val playbackState = player.playbackState
val playbackReportContext = currentMediaItem?.localConfiguration?.tag as? PlaybackReportContext val playbackReportContext = currentMediaItem?.playbackReportContext()
val currentMetadata = player.mediaMetadata val currentMetadata = player.mediaMetadata
val playerError = player.playerError val playerError = player.playerError
@@ -487,6 +497,96 @@ class PlayerManager @Inject constructor(
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message 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) { private fun syncPendingSeek(positionMs: Long) {
val pendingPosition = pendingSeekPositionMs ?: return val pendingPosition = pendingSeekPositionMs ?: return
if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) { if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) {

View File

@@ -9,6 +9,8 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.PlayableMediaRepository 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.PlaybackReportContext
import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.download.MediaDownloadController 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.core.player.preference.TrackPreferencesRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver 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.Episode
import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.model.PlayableMedia
@@ -87,7 +90,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(mediaId) ?: return null val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(mediaId) ?: return null
val mediaItem = if (downloadedMediaItem == null) { val mediaItem = if (downloadedMediaItem == null) {
getMediaItem(baseItem, playbackSource.directPlayUrl) getMediaItem(baseItem, playbackSource)
} else { } else {
getDownloadedMediaItem(baseItem, downloadedMediaItem) getDownloadedMediaItem(baseItem, downloadedMediaItem)
} }
@@ -170,7 +173,10 @@ class DefaultPlayableMediaRepository @Inject constructor(
return null 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 mediaId = baseItem.id
val baseItem = jellyfinApiClient.getItemInfo(mediaId) val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -179,12 +185,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
val mediaItem = createMediaItem( val mediaItem = createMediaItem(
mediaId = mediaId.toString(), mediaId = mediaId.toString(),
url = url, url = playbackSource.directPlayUrl,
title = baseItem?.name ?: "Unknown", title = baseItem?.name ?: "Unknown",
subtitle = seasonEpisodeLabel(baseItem), subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
// TODO playbackTag = playbackSource.toPlaybackMediaItemTag(),
playbackReportContext = null,
) )
return@withContext mediaItem return@withContext mediaItem
@@ -243,7 +248,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
title: String, title: String,
subtitle: String?, subtitle: String?,
artworkUrl: String, artworkUrl: String,
playbackReportContext: PlaybackReportContext?, playbackTag: Any?,
): MediaItem { ): MediaItem {
val metadata = MediaMetadata.Builder() val metadata = MediaMetadata.Builder()
.setTitle(title) .setTitle(title)
@@ -254,10 +259,20 @@ class DefaultPlayableMediaRepository @Inject constructor(
.setUri(url.toUri()) .setUri(url.toUri())
.setMediaId(mediaId) .setMediaId(mediaId)
.setMediaMetadata(metadata) .setMediaMetadata(metadata)
.setTag(playbackReportContext) .setTag(playbackTag)
return builder.build() 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( private fun calculateResumePosition(
baseItem: BaseItemDto?, baseItem: BaseItemDto?,
mediaSource: MediaSourceInfo, mediaSource: MediaSourceInfo,

View File

@@ -48,20 +48,36 @@ class JellyfinPlaybackResolver @Inject constructor(
return@withContext null return@withContext null
} }
val mediaSource = playbackInfo.mediaSources.first()
val transcodingUrl = mediaSource.transcodingUrl
?.trim()
?.takeIf { it.isNotBlank() }
?.let { url -> resolveUrl(serverUrl, url) }
PlaybackSource( PlaybackSource(
mediaSource = playbackInfo.mediaSources.first(), mediaSource = mediaSource,
directPlayUrl = directPlayUrl, directPlayUrl = directPlayUrl,
transcodingUrl = playbackInfo.mediaSources.firstOrNull()?.transcodingUrl, transcodingUrl = transcodingUrl,
playbackReportContext = PlaybackReportContext( playbackReportContext = PlaybackReportContext(
playMethod = PlaybackMethod.DIRECT_PLAY, playMethod = PlaybackMethod.DIRECT_PLAY,
mediaSourceId = playbackInfo.mediaSources.firstOrNull()?.id, mediaSourceId = mediaSource.id,
audioStreamIndex = playbackInfo.mediaSources.firstOrNull()?.defaultAudioStreamIndex, audioStreamIndex = mediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = playbackInfo.mediaSources.firstOrNull()?.defaultSubtitleStreamIndex, subtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex,
playSessionId = playbackInfo.playSessionId, 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 companion object {
private const val TAG = "PlaybackResolver" private const val TAG = "PlaybackResolver"
} }