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