Improve player error reporting

This commit is contained in:
2026-04-06 20:20:44 +02:00
parent 60291dc78e
commit 9e8772467c
12 changed files with 476 additions and 99 deletions

View File

@@ -623,11 +623,20 @@ private fun TvPlayerStateCard(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = uiState.error ?: "Playback error", text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground, color = scheme.onBackground,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) { Text("Retry") } Button(onClick = onRetry) { Text("Retry") }
Button( Button(

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
@@ -46,10 +47,19 @@ fun PlayerLoadingErrorEndCard(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = uiState.error ?: "Playback error", text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground, color = scheme.onBackground,
fontWeight = FontWeight.Bold 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) { Button(onClick = onRetry) {
Text("Retry") Text("Retry")

View File

@@ -39,4 +39,5 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core) implementation(libs.jellyfin.core)
implementation(libs.okhttp) implementation(libs.okhttp)
testImplementation(libs.junit)
} }

View File

@@ -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
}

View File

@@ -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.client.PlaybackReportContext
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.player.model.PlayerError
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -34,9 +35,34 @@ class PlayerMediaRepository @Inject constructor(
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val downloadManager: DownloadManager private val downloadManager: DownloadManager
) { ) {
companion object {
private const val TAG = "PlayerMediaRepo"
}
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) { suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId) 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( private fun calculateResumePosition(
@@ -88,17 +114,25 @@ class PlayerMediaRepository @Inject constructor(
) )
} }
}.getOrElse { error -> }.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() emptyList()
} }
} }
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? { private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
return runCatching { return try {
val playbackDecision = jellyfinApiClient.getPlaybackDecision( val playbackDecision = jellyfinApiClient.getPlaybackDecision(
mediaId = mediaId, mediaId = mediaId,
forceTranscode = forceTranscode 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 selectedMediaSource = playbackDecision.mediaSource
val baseItem = jellyfinApiClient.getItemInfo(mediaId) val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -117,18 +151,19 @@ class PlayerMediaRepository @Inject constructor(
tag = playbackDecision.reportContext tag = playbackDecision.reportContext
) )
mediaItem to resumePositionMs PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
}.getOrElse { error -> } catch (error: Exception) {
Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error) Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
null PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
} }
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? { private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
return try {
val download = downloadManager.downloadIndex.getDownload(mediaId.toString()) val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
?.takeIf { it.state == Download.STATE_COMPLETED } ?.takeIf { it.state == Download.STATE_COMPLETED }
?: return null ?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
val serverUrl = userSessionRepository.serverUrl.first() val serverUrl = userSessionRepository.serverUrl.first()
val movie = mediaRepository.movies.value[mediaId] val movie = mediaRepository.movies.value[mediaId]
@@ -152,7 +187,14 @@ class PlayerMediaRepository @Inject constructor(
subtitle = subtitle, subtitle = subtitle,
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
) )
return mediaItem to resumePositionMs 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"
)
}
} }
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? { 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" "$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()) MediaItem.SubtitleConfiguration.Builder(url.toUri())
.setMimeType(mimeType) .setMimeType(mimeType)
@@ -235,7 +277,7 @@ class PlayerMediaRepository @Inject constructor(
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin "sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS "pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
else -> { else -> {
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec") Log.w(TAG, "Unknown subtitle codec: $codec")
null null
} }
} }
@@ -262,4 +304,9 @@ class PlayerMediaRepository @Inject constructor(
.setSubtitleConfigurations(subtitleConfigurations) .setSubtitleConfigurations(subtitleConfigurations)
.build() .build()
} }
private sealed interface OfflineLoadResult {
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
data class Unavailable(val detail: String) : OfflineLoadResult
}
} }

View File

@@ -10,6 +10,7 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.PlaybackReportContext 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.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType import hu.bbara.purefin.core.player.model.TrackType
@@ -74,8 +75,7 @@ class PlayerManager @Inject constructor(
state.copy( state.copy(
isBuffering = buffering, isBuffering = buffering,
isEnded = ended, 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() if (ended) player.pause()
@@ -84,8 +84,7 @@ class PlayerManager @Inject constructor(
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
_playbackState.update { _playbackState.update {
it.copy( it.copy(
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error", error = PlayerError.fromPlaybackException(error)
errorCode = error.errorCode
) )
} }
} }
@@ -135,7 +134,7 @@ class PlayerManager @Inject constructor(
_progress.value = PlaybackProgressSnapshot() _progress.value = PlaybackProgressSnapshot()
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() 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) { fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
@@ -155,7 +154,7 @@ class PlayerManager @Inject constructor(
player.playWhenReady = true player.playWhenReady = true
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() refreshQueue()
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) } _playbackState.update { it.copy(isEnded = false, error = null) }
} }
fun addToQueue(mediaItem: MediaItem) { fun addToQueue(mediaItem: MediaItem) {
@@ -259,7 +258,7 @@ class PlayerManager @Inject constructor(
} }
fun clearError() { fun clearError() {
_playbackState.update { it.copy(error = null, errorCode = null) } _playbackState.update { it.copy(error = null) }
} }
fun snapshotProgress(): PlaybackProgressSnapshot { fun snapshotProgress(): PlaybackProgressSnapshot {
@@ -386,8 +385,7 @@ data class PlaybackStateSnapshot(
val isPlaying: Boolean = false, val isPlaying: Boolean = false,
val isBuffering: Boolean = false, val isBuffering: Boolean = false,
val isEnded: Boolean = false, val isEnded: Boolean = false,
val error: String? = null, val error: PlayerError? = null
val errorCode: Int? = null
) )
data class PlaybackProgressSnapshot( data class PlaybackProgressSnapshot(

View File

@@ -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<String>().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<String>().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
}
}
}
}

View File

@@ -10,7 +10,7 @@ data class PlayerUiState(
val durationMs: Long = 0L, val durationMs: Long = 0L,
val positionMs: Long = 0L, val positionMs: Long = 0L,
val bufferedMs: Long = 0L, val bufferedMs: Long = 0L,
val error: String? = null, val error: PlayerError? = null,
val playbackSpeed: Float = 1f, val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(), val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(), val ads: List<TimedMarker> = emptyList(),

View File

@@ -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
}
}

View File

@@ -1,20 +1,20 @@
package hu.bbara.purefin.core.player.viewmodel package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository 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.data.PlayerMediaRepository
import hu.bbara.purefin.core.player.manager.MediaContext import hu.bbara.purefin.core.player.manager.MediaContext
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
import hu.bbara.purefin.core.player.manager.PlayerManager import hu.bbara.purefin.core.player.manager.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager 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.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import org.jellyfin.sdk.model.api.PlayMethod
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,9 +46,10 @@ class PlayerViewModel @Inject constructor(
private var autoHideJob: Job? = null private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null private var lastNextUpMediaId: String? = null
private var dataErrorMessage: String? = null private var loadError: PlayerError? = null
private var activeMediaId: String? = null private var activeMediaId: String? = null
private var transcodingRetryMediaId: String? = null private var transcodingRetryMediaId: String? = null
private var lastLoadRequest: PendingLoadRequest? = null
init { init {
progressManager.bind( progressManager.bind(
@@ -72,7 +73,7 @@ class PlayerViewModel @Inject constructor(
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
isBuffering = state.isBuffering, isBuffering = state.isBuffering,
isEnded = state.isEnded, isEnded = state.isEnded,
error = state.error ?: dataErrorMessage error = state.error ?: loadError
) )
} }
if (state.isEnded) { if (state.isEnded) {
@@ -156,16 +157,26 @@ class PlayerViewModel @Inject constructor(
startPositionMsOverride: Long?, startPositionMsOverride: Long?,
replaceCurrent: Boolean replaceCurrent: Boolean
) { ) {
lastLoadRequest = PendingLoadRequest(
id = id,
forceTranscode = forceTranscode,
startPositionMsOverride = startPositionMsOverride,
replaceCurrent = replaceCurrent
)
val uuid = id.toUuidOrNull() val uuid = id.toUuidOrNull()
if (uuid == null) { if (uuid == null) {
dataErrorMessage = "Invalid media id" loadError = PlayerError.invalidMediaId(id)
_uiState.update { it.copy(error = dataErrorMessage) } _uiState.update { it.copy(isBuffering = false, error = loadError) }
return return
} }
loadError = null
_uiState.update { it.copy(isBuffering = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode) val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
if (result != null) { when (result) {
val (mediaItem, resumePositionMs) = result is PlayerMediaLoadResult.Success -> {
val mediaItem = result.mediaItem
val resumePositionMs = result.resumePositionMs
// Determine preference key: movies use their own ID, episodes use series ID // Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
@@ -178,13 +189,16 @@ class PlayerViewModel @Inject constructor(
playerManager.play(mediaItem, mediaContext, startPositionMs) playerManager.play(mediaItem, mediaContext, startPositionMs)
} }
if (dataErrorMessage != null) { if (loadError != null) {
dataErrorMessage = null loadError = null
_uiState.update { it.copy(error = null) } _uiState.update { it.copy(error = null) }
} }
} else { }
dataErrorMessage = "Unable to load media"
_uiState.update { it.copy(error = dataErrorMessage) } is PlayerMediaLoadResult.Failure -> {
loadError = result.error
_uiState.update { it.copy(isBuffering = false, error = loadError) }
}
} }
} }
} }
@@ -192,11 +206,10 @@ class PlayerViewModel @Inject constructor(
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean { private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
val currentMediaId = playerManager.metadata.value.mediaId ?: return false val currentMediaId = playerManager.metadata.value.mediaId ?: return false
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: 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 (currentMediaId == transcodingRetryMediaId) return false
if (!playbackReportContext.canRetryWithTranscoding) return false if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
transcodingRetryMediaId = currentMediaId transcodingRetryMediaId = currentMediaId
loadMediaById( loadMediaById(
@@ -208,29 +221,6 @@ class PlayerViewModel @Inject constructor(
return 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) { private fun loadNextUp(currentMediaId: String) {
val uuid = currentMediaId.toUuidOrNull() ?: return val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -304,6 +294,11 @@ class PlayerViewModel @Inject constructor(
} }
fun retry() { fun retry() {
val error = uiState.value.error
if (error?.source == PlayerErrorSource.LOAD) {
retryLoad()
return
}
playerManager.retry() playerManager.retry()
} }
@@ -313,7 +308,7 @@ class PlayerViewModel @Inject constructor(
} }
fun clearError() { fun clearError() {
dataErrorMessage = null loadError = null
playerManager.clearError() playerManager.clearError()
_uiState.update { it.copy(error = null) } _uiState.update { it.copy(error = null) }
} }
@@ -326,5 +321,22 @@ class PlayerViewModel @Inject constructor(
playerManager.release() playerManager.release()
} }
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() 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
)
} }

View File

@@ -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"))
}
}

View File

@@ -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
)
}
}