From 9e8772467c479efdb143296f2a1321eac3a3694c Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 6 Apr 2026 20:20:44 +0200 Subject: [PATCH] Improve player error reporting --- .../bbara/purefin/tv/player/TvPlayerScreen.kt | 11 +- .../components/PlayerLoadingErrorEndCard.kt | 12 +- core/player/build.gradle.kts | 1 + .../core/player/data/PlayerMediaLoadResult.kt | 15 +++ .../core/player/data/PlayerMediaRepository.kt | 121 ++++++++++++------ .../core/player/manager/PlayerManager.kt | 16 +-- .../purefin/core/player/model/PlayerError.kt | 110 ++++++++++++++++ .../core/player/model/PlayerUiModels.kt | 2 +- .../player/viewmodel/PlaybackRetryPolicy.kt | 50 ++++++++ .../core/player/viewmodel/PlayerViewModel.kt | 112 ++++++++-------- .../core/player/model/PlayerErrorTest.kt | 48 +++++++ .../viewmodel/PlaybackRetryPolicyTest.kt | 77 +++++++++++ 12 files changed, 476 insertions(+), 99 deletions(-) create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerError.kt create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt create mode 100644 core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt create mode 100644 core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt index 16466c8c..0f12e9e7 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt @@ -623,11 +623,20 @@ private fun TvPlayerStateCard( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = uiState.error ?: "Playback error", + text = uiState.error?.summary ?: "Playback error", color = scheme.onBackground, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium ) + uiState.error?.detailText?.let { detail -> + Text( + text = detail, + color = scheme.onBackground.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(onClick = onRetry) { Text("Retry") } Button( diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt index 5382810a..ed46ead9 100644 --- a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import hu.bbara.purefin.core.player.model.PlayerUiState @@ -46,10 +47,19 @@ fun PlayerLoadingErrorEndCard( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = uiState.error ?: "Playback error", + text = uiState.error?.summary ?: "Playback error", color = scheme.onBackground, fontWeight = FontWeight.Bold ) + uiState.error?.detailText?.let { detail -> + Text( + text = detail, + color = scheme.onBackground.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(onClick = onRetry) { Text("Retry") diff --git a/core/player/build.gradle.kts b/core/player/build.gradle.kts index d176a9f0..f17d64b9 100644 --- a/core/player/build.gradle.kts +++ b/core/player/build.gradle.kts @@ -39,4 +39,5 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.jellyfin.core) implementation(libs.okhttp) + testImplementation(libs.junit) } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt new file mode 100644 index 00000000..ab313efd --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt @@ -0,0 +1,15 @@ +package hu.bbara.purefin.core.player.data + +import androidx.media3.common.MediaItem +import hu.bbara.purefin.core.player.model.PlayerError + +sealed interface PlayerMediaLoadResult { + data class Success( + val mediaItem: MediaItem, + val resumePositionMs: Long? + ) : PlayerMediaLoadResult + + data class Failure( + val error: PlayerError + ) : PlayerMediaLoadResult +} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt index 146bb801..68add4b1 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt @@ -16,6 +16,7 @@ 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 hu.bbara.purefin.core.player.model.PlayerError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -34,9 +35,34 @@ class PlayerMediaRepository @Inject constructor( private val mediaRepository: MediaRepository, private val downloadManager: DownloadManager ) { + companion object { + private const val TAG = "PlayerMediaRepo" + } - suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair? = withContext(Dispatchers.IO) { - buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId) + suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) { + when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) { + is PlayerMediaLoadResult.Success -> onlineResult + is PlayerMediaLoadResult.Failure -> { + when (val offlineResult = buildOfflineMediaItem(mediaId)) { + is OfflineLoadResult.Success -> { + Log.w( + TAG, + "Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}" + ) + offlineResult.result + } + + is OfflineLoadResult.Unavailable -> { + val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail) + Log.w( + TAG, + "Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}" + ) + PlayerMediaLoadResult.Failure(finalError) + } + } + } + } } private fun calculateResumePosition( @@ -88,17 +114,25 @@ class PlayerMediaRepository @Inject constructor( ) } }.getOrElse { error -> - Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error) + Log.w(TAG, "Unable to load next-up items for $episodeId", error) emptyList() } } - private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair? { - return runCatching { + private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult { + return try { val playbackDecision = jellyfinApiClient.getPlaybackDecision( mediaId = mediaId, forceTranscode = forceTranscode - ) ?: return null + ) ?: run { + val detail = if (forceTranscode) { + "Jellyfin did not return a transcoded playback source." + } else { + "Jellyfin did not return a playable media source." + } + Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)") + return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail)) + } val selectedMediaSource = playbackDecision.mediaSource val baseItem = jellyfinApiClient.getItemInfo(mediaId) @@ -117,42 +151,50 @@ class PlayerMediaRepository @Inject constructor( tag = playbackDecision.reportContext ) - mediaItem to resumePositionMs - }.getOrElse { error -> - Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error) - null + PlayerMediaLoadResult.Success(mediaItem, resumePositionMs) + } catch (error: Exception) { + Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error) + PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error)) } } @OptIn(UnstableApi::class) - private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair? { - val download = downloadManager.downloadIndex.getDownload(mediaId.toString()) - ?.takeIf { it.state == Download.STATE_COMPLETED } - ?: return null + private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult { + return try { + val download = downloadManager.downloadIndex.getDownload(mediaId.toString()) + ?.takeIf { it.state == Download.STATE_COMPLETED } + ?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.") - val serverUrl = userSessionRepository.serverUrl.first() - val movie = mediaRepository.movies.value[mediaId] - val episode = mediaRepository.episodes.value[mediaId] + val serverUrl = userSessionRepository.serverUrl.first() + val movie = mediaRepository.movies.value[mediaId] + val episode = mediaRepository.episodes.value[mediaId] - val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank { - "Offline media" + val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank { + "Offline media" + } + val subtitle = episode?.let { episodeSubtitle(null, it.index) } + val artworkUrl = when { + movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY) + episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY) + else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) + } + val resumePositionMs = resumePositionFor(movie, episode) + + val mediaItem = createMediaItem( + mediaId = mediaId.toString(), + playbackUrl = download.request.uri.toString(), + title = title, + subtitle = subtitle, + artworkUrl = artworkUrl, + ) + OfflineLoadResult.Success(PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)) + } catch (error: Exception) { + Log.w(TAG, "Offline fallback failed for $mediaId", error) + val technicalDetail = PlayerError.fromThrowable(error).detailText ?: error.javaClass.simpleName + OfflineLoadResult.Unavailable( + "Offline fallback failed: $technicalDetail" + ) } - val subtitle = episode?.let { episodeSubtitle(null, it.index) } - val artworkUrl = when { - movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY) - episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY) - else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) - } - val resumePositionMs = resumePositionFor(movie, episode) - - val mediaItem = createMediaItem( - mediaId = mediaId.toString(), - playbackUrl = download.request.uri.toString(), - title = title, - subtitle = subtitle, - artworkUrl = artworkUrl, - ) - return mediaItem to resumePositionMs } private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? { @@ -211,7 +253,7 @@ class PlayerMediaRepository @Inject constructor( "$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format" } - Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url") + Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url") MediaItem.SubtitleConfiguration.Builder(url.toUri()) .setMimeType(mimeType) @@ -235,7 +277,7 @@ class PlayerMediaRepository @Inject constructor( "sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin "pgs", "pgssub" -> MimeTypes.APPLICATION_PGS else -> { - Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec") + Log.w(TAG, "Unknown subtitle codec: $codec") null } } @@ -262,4 +304,9 @@ class PlayerMediaRepository @Inject constructor( .setSubtitleConfigurations(subtitleConfigurations) .build() } + + private sealed interface OfflineLoadResult { + data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult + data class Unavailable(val detail: String) : OfflineLoadResult + } } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt index 00ad840f..8bfeb5c7 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt @@ -10,6 +10,7 @@ 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.PlayerError import hu.bbara.purefin.core.player.model.QueueItemUi import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackType @@ -74,8 +75,7 @@ class PlayerManager @Inject constructor( state.copy( isBuffering = buffering, isEnded = ended, - error = if (playbackState == Player.STATE_IDLE) state.error else null, - errorCode = if (playbackState == Player.STATE_IDLE) state.errorCode else null + error = if (playbackState == Player.STATE_IDLE) state.error else null ) } if (ended) player.pause() @@ -84,8 +84,7 @@ class PlayerManager @Inject constructor( override fun onPlayerError(error: PlaybackException) { _playbackState.update { it.copy( - error = error.errorCodeName ?: error.localizedMessage ?: "Playback error", - errorCode = error.errorCode + error = PlayerError.fromPlaybackException(error) ) } } @@ -135,7 +134,7 @@ class PlayerManager @Inject constructor( _progress.value = PlaybackProgressSnapshot() refreshMetadata(mediaItem) refreshQueue() - _playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) } + _playbackState.update { it.copy(isEnded = false, error = null) } } fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { @@ -155,7 +154,7 @@ class PlayerManager @Inject constructor( player.playWhenReady = true refreshMetadata(mediaItem) refreshQueue() - _playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) } + _playbackState.update { it.copy(isEnded = false, error = null) } } fun addToQueue(mediaItem: MediaItem) { @@ -259,7 +258,7 @@ class PlayerManager @Inject constructor( } fun clearError() { - _playbackState.update { it.copy(error = null, errorCode = null) } + _playbackState.update { it.copy(error = null) } } fun snapshotProgress(): PlaybackProgressSnapshot { @@ -386,8 +385,7 @@ data class PlaybackStateSnapshot( val isPlaying: Boolean = false, val isBuffering: Boolean = false, val isEnded: Boolean = false, - val error: String? = null, - val errorCode: Int? = null + val error: PlayerError? = null ) data class PlaybackProgressSnapshot( diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerError.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerError.kt new file mode 100644 index 00000000..666ed94a --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerError.kt @@ -0,0 +1,110 @@ +package hu.bbara.purefin.core.player.model + +import androidx.media3.common.PlaybackException + +enum class PlayerErrorSource { + LOAD, + PLAYBACK +} + +data class PlayerError( + val summary: String, + val technicalDetail: String? = null, + val source: PlayerErrorSource, + val errorCode: Int? = null, + val errorCodeName: String? = null, + val retryable: Boolean = false +) { + val detailText: String? + get() = technicalDetail?.takeIf { detail -> + detail.isNotBlank() && !detail.equals(summary, ignoreCase = true) + } + + fun withAdditionalTechnicalDetail(additionalDetail: String?): PlayerError { + val normalizedAdditionalDetail = additionalDetail?.trim()?.takeIf { it.isNotEmpty() } ?: return this + val mergedDetail = linkedSetOf().apply { + technicalDetail?.trim()?.takeIf { it.isNotEmpty() }?.let(::add) + add(normalizedAdditionalDetail) + }.joinToString(" | ") + + return copy(technicalDetail = mergedDetail) + } + + companion object { + fun loadFailure( + summary: String = "Unable to load media", + technicalDetail: String? = null, + retryable: Boolean = true + ): PlayerError { + return PlayerError( + summary = summary, + technicalDetail = technicalDetail, + source = PlayerErrorSource.LOAD, + retryable = retryable + ) + } + + fun invalidMediaId(mediaId: String): PlayerError { + return PlayerError( + summary = "Invalid media id", + technicalDetail = "The requested media id is not a valid UUID: $mediaId", + source = PlayerErrorSource.LOAD, + retryable = false + ) + } + + fun fromPlaybackException(error: PlaybackException): PlayerError { + return playbackFailure( + errorCode = error.errorCode, + errorCodeName = error.errorCodeName, + technicalDetail = error.localizedMessage, + cause = error.cause + ) + } + + fun playbackFailure( + errorCode: Int? = null, + errorCodeName: String? = null, + technicalDetail: String? = null, + cause: Throwable? = null, + retryable: Boolean = true + ): PlayerError { + val mergedTechnicalDetail = linkedSetOf().apply { + errorCodeName?.takeIf { it.isNotBlank() }?.let(::add) + technicalDetail?.takeIf { it.isNotBlank() }?.let(::add) + cause?.toTechnicalDetail()?.let(::add) + }.joinToString(" | ").ifBlank { null } + + return PlayerError( + summary = "Playback error", + technicalDetail = mergedTechnicalDetail, + source = PlayerErrorSource.PLAYBACK, + errorCode = errorCode, + errorCodeName = errorCodeName, + retryable = retryable + ) + } + + fun fromThrowable( + throwable: Throwable, + summary: String = "Unable to load media", + retryable: Boolean = true + ): PlayerError { + return loadFailure( + summary = summary, + technicalDetail = throwable.toTechnicalDetail(), + retryable = retryable + ) + } + + internal fun Throwable.toTechnicalDetail(): String { + val typeName = this::class.simpleName ?: javaClass.simpleName.ifBlank { javaClass.name } + val message = message?.trim().takeIf { !it.isNullOrEmpty() } + return if (message != null) { + "$typeName: $message" + } else { + typeName + } + } + } +} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt index a35868e9..d28c7fcd 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt @@ -10,7 +10,7 @@ data class PlayerUiState( val durationMs: Long = 0L, val positionMs: Long = 0L, val bufferedMs: Long = 0L, - val error: String? = null, + val error: PlayerError? = null, val playbackSpeed: Float = 1f, val chapters: List = emptyList(), val ads: List = emptyList(), diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt new file mode 100644 index 00000000..38126290 --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt @@ -0,0 +1,50 @@ +package hu.bbara.purefin.core.player.viewmodel + +import androidx.media3.common.PlaybackException +import hu.bbara.purefin.core.data.client.PlaybackReportContext +import hu.bbara.purefin.core.player.model.PlayerError +import hu.bbara.purefin.core.player.model.PlayerErrorSource +import org.jellyfin.sdk.model.api.PlayMethod + +internal object PlaybackRetryPolicy { + private val retryableErrorCodes = 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 + ) + + fun shouldRetryWithTranscoding( + error: PlayerError, + playbackReportContext: PlaybackReportContext + ): Boolean { + if (error.source != PlayerErrorSource.PLAYBACK) { + return false + } + if (!playbackReportContext.canRetryWithTranscoding) { + return false + } + if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) { + return false + } + + val errorCode = error.errorCode + if (errorCode != null && errorCode in retryableErrorCodes) { + return true + } + + val detail = buildString { + append(error.summary) + error.errorCodeName?.let { + append(' ') + append(it) + } + error.detailText?.let { + append(' ') + append(it) + } + }.lowercase() + + return "decoder" in detail || "codec" in detail || "unsupported" in detail + } +} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index 1d1a547c..86227ae1 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -1,20 +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.PlayerMediaLoadResult 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.PlayerError +import hu.bbara.purefin.core.player.model.PlayerErrorSource 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 @@ -46,9 +46,10 @@ class PlayerViewModel @Inject constructor( private var autoHideJob: Job? = null private var lastNextUpMediaId: String? = null - private var dataErrorMessage: String? = null + private var loadError: PlayerError? = null private var activeMediaId: String? = null private var transcodingRetryMediaId: String? = null + private var lastLoadRequest: PendingLoadRequest? = null init { progressManager.bind( @@ -72,7 +73,7 @@ class PlayerViewModel @Inject constructor( isPlaying = state.isPlaying, isBuffering = state.isBuffering, isEnded = state.isEnded, - error = state.error ?: dataErrorMessage + error = state.error ?: loadError ) } if (state.isEnded) { @@ -156,35 +157,48 @@ class PlayerViewModel @Inject constructor( startPositionMsOverride: Long?, replaceCurrent: Boolean ) { + lastLoadRequest = PendingLoadRequest( + id = id, + forceTranscode = forceTranscode, + startPositionMsOverride = startPositionMsOverride, + replaceCurrent = replaceCurrent + ) val uuid = id.toUuidOrNull() if (uuid == null) { - dataErrorMessage = "Invalid media id" - _uiState.update { it.copy(error = dataErrorMessage) } + loadError = PlayerError.invalidMediaId(id) + _uiState.update { it.copy(isBuffering = false, error = loadError) } return } + loadError = null + _uiState.update { it.copy(isBuffering = true, error = null) } viewModelScope.launch { val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode) - if (result != null) { - val (mediaItem, resumePositionMs) = result + when (result) { + is PlayerMediaLoadResult.Success -> { + val mediaItem = result.mediaItem + val resumePositionMs = result.resumePositionMs - // 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 + // 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 - if (replaceCurrent) { - playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs) - } else { - playerManager.play(mediaItem, mediaContext, startPositionMs) + if (replaceCurrent) { + playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs) + } else { + playerManager.play(mediaItem, mediaContext, startPositionMs) + } + + if (loadError != null) { + loadError = null + _uiState.update { it.copy(error = null) } + } } - if (dataErrorMessage != null) { - dataErrorMessage = null - _uiState.update { it.copy(error = null) } + is PlayerMediaLoadResult.Failure -> { + loadError = result.error + _uiState.update { it.copy(isBuffering = false, error = loadError) } } - } else { - dataErrorMessage = "Unable to load media" - _uiState.update { it.copy(error = dataErrorMessage) } } } } @@ -192,11 +206,10 @@ 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 + val error = state.error ?: return false if (currentMediaId == transcodingRetryMediaId) return false - if (!playbackReportContext.canRetryWithTranscoding) return false - if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false + if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false transcodingRetryMediaId = currentMediaId loadMediaById( @@ -208,29 +221,6 @@ class PlayerViewModel @Inject constructor( 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 { @@ -304,6 +294,11 @@ class PlayerViewModel @Inject constructor( } fun retry() { + val error = uiState.value.error + if (error?.source == PlayerErrorSource.LOAD) { + retryLoad() + return + } playerManager.retry() } @@ -313,7 +308,7 @@ class PlayerViewModel @Inject constructor( } fun clearError() { - dataErrorMessage = null + loadError = null playerManager.clearError() _uiState.update { it.copy(error = null) } } @@ -326,5 +321,22 @@ class PlayerViewModel @Inject constructor( playerManager.release() } + private fun retryLoad() { + val request = lastLoadRequest ?: return + loadMediaById( + id = request.id, + forceTranscode = request.forceTranscode, + startPositionMsOverride = request.startPositionMsOverride, + replaceCurrent = request.replaceCurrent + ) + } + private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() + + private data class PendingLoadRequest( + val id: String, + val forceTranscode: Boolean, + val startPositionMsOverride: Long?, + val replaceCurrent: Boolean + ) } diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt new file mode 100644 index 00000000..a7dcaba9 --- /dev/null +++ b/core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt @@ -0,0 +1,48 @@ +package hu.bbara.purefin.core.player.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerErrorTest { + @Test + fun `playbackFailure preserves code and detail`() { + val playerError = PlayerError.playbackFailure( + errorCode = 4001, + errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED", + technicalDetail = "Decoder init failed on test device" + ) + + assertEquals("Playback error", playerError.summary) + assertEquals(PlayerErrorSource.PLAYBACK, playerError.source) + assertEquals(4001, playerError.errorCode) + assertEquals("ERROR_CODE_DECODER_INIT_FAILED", playerError.errorCodeName) + assertNotNull(playerError.detailText) + assertTrue(playerError.detailText!!.contains("ERROR_CODE_DECODER_INIT_FAILED")) + assertTrue(playerError.detailText!!.contains("Decoder init failed on test device")) + } + + @Test + fun `withAdditionalTechnicalDetail appends distinct detail`() { + val playerError = PlayerError.loadFailure(technicalDetail = "IllegalStateException: boom") + + val mergedError = playerError.withAdditionalTechnicalDetail( + "Offline fallback unavailable: no completed download." + ) + + assertTrue(mergedError.detailText!!.contains("IllegalStateException: boom")) + assertTrue(mergedError.detailText!!.contains("Offline fallback unavailable: no completed download.")) + } + + @Test + fun `invalidMediaId is a non retryable load error`() { + val playerError = PlayerError.invalidMediaId("abc") + + assertEquals("Invalid media id", playerError.summary) + assertEquals(PlayerErrorSource.LOAD, playerError.source) + assertFalse(playerError.retryable) + assertTrue(playerError.detailText!!.contains("abc")) + } +} diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt new file mode 100644 index 00000000..15253547 --- /dev/null +++ b/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt @@ -0,0 +1,77 @@ +package hu.bbara.purefin.core.player.viewmodel + +import androidx.media3.common.PlaybackException +import hu.bbara.purefin.core.data.client.PlaybackReportContext +import hu.bbara.purefin.core.player.model.PlayerError +import hu.bbara.purefin.core.player.model.PlayerErrorSource +import org.jellyfin.sdk.model.api.PlayMethod +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaybackRetryPolicyTest { + @Test + fun `decoder failures are retried when transcoding is available`() { + val error = PlayerError( + summary = "Playback error", + technicalDetail = "Decoder init failed", + source = PlayerErrorSource.PLAYBACK, + errorCode = PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED", + retryable = true + ) + + val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY) + + assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) + } + + @Test + fun `transcoded playback is not retried again`() { + val error = PlayerError( + summary = "Playback error", + technicalDetail = "Unsupported container", + source = PlayerErrorSource.PLAYBACK, + errorCodeName = "ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED", + retryable = true + ) + + val playbackReportContext = playbackReportContext(playMethod = PlayMethod.TRANSCODE) + + assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) + } + + @Test + fun `load errors are never retried as transcoding fallbacks`() { + val error = PlayerError.loadFailure(technicalDetail = "No playable source was returned.") + + val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY) + + assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) + } + + @Test + fun `unsupported codec detail is retryable without an error code`() { + val error = PlayerError( + summary = "Playback error", + technicalDetail = "Codec unsupported by this device", + source = PlayerErrorSource.PLAYBACK + ) + + val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_STREAM) + + assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) + } + + private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext { + return PlaybackReportContext( + playMethod = playMethod, + mediaSourceId = "source-id", + audioStreamIndex = 0, + subtitleStreamIndex = null, + liveStreamId = null, + playSessionId = "session-id", + canRetryWithTranscoding = true + ) + } +}