mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Improve player error reporting
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -39,4 +39,5 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.okhttp)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<MediaItem, Long?>? = 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<MediaItem, Long?>? {
|
||||
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,18 +151,19 @@ 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<MediaItem, Long?>? {
|
||||
private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
|
||||
return try {
|
||||
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
|
||||
?.takeIf { it.state == Download.STATE_COMPLETED }
|
||||
?: return null
|
||||
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val movie = mediaRepository.movies.value[mediaId]
|
||||
@@ -152,7 +187,14 @@ class PlayerMediaRepository @Inject constructor(
|
||||
subtitle = subtitle,
|
||||
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? {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TimedMarker> = emptyList(),
|
||||
val ads: List<TimedMarker> = emptyList(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,16 +157,26 @@ 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
|
||||
@@ -178,13 +189,16 @@ class PlayerViewModel @Inject constructor(
|
||||
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
||||
}
|
||||
|
||||
if (dataErrorMessage != null) {
|
||||
dataErrorMessage = null
|
||||
if (loadError != null) {
|
||||
loadError = 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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user