mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
fix(playback): fall back to transcoding on direct play errors
This commit is contained in:
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user