mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
Implement Jellyfin playback decision flow
This commit is contained in:
@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.MediaRepository
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
@@ -34,8 +35,8 @@ class PlayerMediaRepository @Inject constructor(
|
||||
private val downloadManager: DownloadManager
|
||||
) {
|
||||
|
||||
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
||||
buildOnlineMediaItem(mediaId) ?: buildOfflineMediaItem(mediaId)
|
||||
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
||||
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
@@ -72,21 +73,18 @@ class PlayerMediaRepository @Inject constructor(
|
||||
if (existingIds.contains(stringId)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val mediaSources = jellyfinApiClient.getMediaSources(id)
|
||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
|
||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
||||
mediaId = id,
|
||||
mediaSource = selectedMediaSource
|
||||
) ?: return@mapNotNull null
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
|
||||
val selectedMediaSource = playbackDecision.mediaSource
|
||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
|
||||
createMediaItem(
|
||||
mediaId = stringId,
|
||||
playbackUrl = playbackUrl,
|
||||
playbackUrl = playbackDecision.url,
|
||||
title = episode.name ?: selectedMediaSource.name!!,
|
||||
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
|
||||
artworkUrl = artworkUrl,
|
||||
subtitleConfigurations = subtitleConfigs
|
||||
subtitleConfigurations = subtitleConfigs,
|
||||
tag = playbackDecision.reportContext
|
||||
)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
@@ -95,14 +93,13 @@ class PlayerMediaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildOnlineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
|
||||
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? {
|
||||
return runCatching {
|
||||
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
|
||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return null
|
||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
|
||||
mediaId = mediaId,
|
||||
mediaSource = selectedMediaSource
|
||||
forceTranscode = forceTranscode
|
||||
) ?: return null
|
||||
val selectedMediaSource = playbackDecision.mediaSource
|
||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
|
||||
@@ -112,11 +109,12 @@ class PlayerMediaRepository @Inject constructor(
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = playbackUrl,
|
||||
playbackUrl = playbackDecision.url,
|
||||
title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
|
||||
subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
|
||||
artworkUrl = artworkUrl,
|
||||
subtitleConfigurations = subtitleConfigs
|
||||
subtitleConfigurations = subtitleConfigs,
|
||||
tag = playbackDecision.reportContext
|
||||
)
|
||||
|
||||
mediaItem to resumePositionMs
|
||||
@@ -247,7 +245,8 @@ class PlayerMediaRepository @Inject constructor(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
|
||||
tag: PlaybackReportContext? = null
|
||||
): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
@@ -258,6 +257,7 @@ class PlayerMediaRepository @Inject constructor(
|
||||
.setUri(playbackUrl.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setTag(tag)
|
||||
.setSubtitleConfigurations(subtitleConfigurations)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.media3.common.TrackSelectionOverride
|
||||
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.QueueItemUi
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
@@ -73,14 +74,20 @@ class PlayerManager @Inject constructor(
|
||||
state.copy(
|
||||
isBuffering = buffering,
|
||||
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()
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") }
|
||||
_playbackState.update {
|
||||
it.copy(
|
||||
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error",
|
||||
errorCode = error.errorCode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
@@ -128,7 +135,27 @@ class PlayerManager @Inject constructor(
|
||||
_progress.value = PlaybackProgressSnapshot()
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
_playbackState.update { it.copy(isEnded = false, error = null) }
|
||||
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
||||
}
|
||||
|
||||
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
||||
currentMediaContext = mediaContext
|
||||
val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run {
|
||||
play(mediaItem, mediaContext, startPositionMs)
|
||||
return
|
||||
}
|
||||
|
||||
player.replaceMediaItem(currentIndex, mediaItem)
|
||||
if (startPositionMs != null) {
|
||||
player.seekTo(currentIndex, startPositionMs)
|
||||
} else {
|
||||
player.seekToDefaultPosition(currentIndex)
|
||||
}
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
||||
}
|
||||
|
||||
fun addToQueue(mediaItem: MediaItem) {
|
||||
@@ -232,7 +259,7 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_playbackState.update { it.copy(error = null) }
|
||||
_playbackState.update { it.copy(error = null, errorCode = null) }
|
||||
}
|
||||
|
||||
fun snapshotProgress(): PlaybackProgressSnapshot {
|
||||
@@ -341,10 +368,12 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
private fun refreshMetadata(mediaItem: MediaItem?) {
|
||||
val playbackReportContext = mediaItem?.localConfiguration?.tag as? PlaybackReportContext
|
||||
_metadata.value = MetadataState(
|
||||
mediaId = mediaItem?.mediaId,
|
||||
title = mediaItem?.mediaMetadata?.title?.toString(),
|
||||
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString()
|
||||
subtitle = mediaItem?.mediaMetadata?.subtitle?.toString(),
|
||||
playbackReportContext = playbackReportContext
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,7 +386,8 @@ data class PlaybackStateSnapshot(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val isEnded: Boolean = false,
|
||||
val error: String? = null
|
||||
val error: String? = null,
|
||||
val errorCode: Int? = null
|
||||
)
|
||||
|
||||
data class PlaybackProgressSnapshot(
|
||||
@@ -370,7 +400,8 @@ data class PlaybackProgressSnapshot(
|
||||
data class MetadataState(
|
||||
val mediaId: String? = null,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null
|
||||
val subtitle: String? = null,
|
||||
val playbackReportContext: PlaybackReportContext? = null
|
||||
)
|
||||
|
||||
data class MediaContext(
|
||||
|
||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.core.player.manager
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -25,6 +26,7 @@ class ProgressManager @Inject constructor(
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var progressJob: Job? = null
|
||||
private var activeItemId: UUID? = null
|
||||
private var activePlaybackReportContext: PlaybackReportContext? = null
|
||||
private var lastPositionMs: Long = 0L
|
||||
private var lastDurationMs: Long = 0L
|
||||
private var isPaused: Boolean = false
|
||||
@@ -47,6 +49,7 @@ class ProgressManager @Inject constructor(
|
||||
lastDurationMs = prog.durationMs
|
||||
isPaused = !state.isPlaying
|
||||
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
activePlaybackReportContext = meta.playbackReportContext
|
||||
|
||||
// Media changed or ended - stop session
|
||||
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
|
||||
@@ -55,19 +58,20 @@ class ProgressManager @Inject constructor(
|
||||
|
||||
// Start session when we have a media item and none is active
|
||||
if (activeItemId == null && mediaId != null && !state.isEnded) {
|
||||
startSession(mediaId, prog.positionMs)
|
||||
startSession(mediaId, prog.positionMs, meta.playbackReportContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSession(itemId: UUID, positionMs: Long) {
|
||||
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
|
||||
activeItemId = itemId
|
||||
report(itemId, positionMs, isStart = true)
|
||||
activePlaybackReportContext = reportContext
|
||||
report(itemId, positionMs, reportContext = reportContext, isStart = true)
|
||||
progressJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(5000)
|
||||
report(itemId, lastPositionMs, isPaused = isPaused)
|
||||
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +79,7 @@ class ProgressManager @Inject constructor(
|
||||
private fun stopSession() {
|
||||
progressJob?.cancel()
|
||||
activeItemId?.let { itemId ->
|
||||
report(itemId, lastPositionMs, isStop = true)
|
||||
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
|
||||
@@ -85,16 +89,27 @@ class ProgressManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
activeItemId = null
|
||||
activePlaybackReportContext = null
|
||||
}
|
||||
|
||||
private fun report(itemId: UUID, positionMs: Long, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false) {
|
||||
private fun report(
|
||||
itemId: UUID,
|
||||
positionMs: Long,
|
||||
reportContext: PlaybackReportContext?,
|
||||
isPaused: Boolean = false,
|
||||
isStart: Boolean = false,
|
||||
isStop: Boolean = false
|
||||
) {
|
||||
val ticks = positionMs * 10_000
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (reportContext == null) {
|
||||
return@launch
|
||||
}
|
||||
when {
|
||||
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks)
|
||||
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
||||
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused)
|
||||
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
|
||||
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
|
||||
}
|
||||
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
|
||||
} catch (e: Exception) {
|
||||
@@ -111,7 +126,9 @@ class ProgressManager @Inject constructor(
|
||||
val durMs = lastDurationMs
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
jellyfinApiClient.reportPlaybackStopped(itemId, ticks)
|
||||
activePlaybackReportContext?.let { reportContext ->
|
||||
jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||
}
|
||||
updateWatchProgressUseCase(itemId, posMs, durMs)
|
||||
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,16 +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.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.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
|
||||
@@ -43,6 +47,8 @@ class PlayerViewModel @Inject constructor(
|
||||
private var autoHideJob: Job? = null
|
||||
private var lastNextUpMediaId: String? = null
|
||||
private var dataErrorMessage: String? = null
|
||||
private var activeMediaId: String? = null
|
||||
private var transcodingRetryMediaId: String? = null
|
||||
|
||||
init {
|
||||
progressManager.bind(
|
||||
@@ -57,6 +63,10 @@ class PlayerViewModel @Inject constructor(
|
||||
private fun observePlayerState() {
|
||||
viewModelScope.launch {
|
||||
playerManager.playbackState.collect { state ->
|
||||
if (state.error != null && maybeRetryWithTranscoding(state)) {
|
||||
_uiState.update { it.copy(isBuffering = true, error = null) }
|
||||
return@collect
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPlaying = state.isPlaying,
|
||||
@@ -93,6 +103,10 @@ class PlayerViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
val currentMediaId = metadata.mediaId
|
||||
if (currentMediaId != activeMediaId) {
|
||||
activeMediaId = currentMediaId
|
||||
transcodingRetryMediaId = null
|
||||
}
|
||||
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
|
||||
lastNextUpMediaId = currentMediaId
|
||||
loadNextUp(currentMediaId)
|
||||
@@ -133,6 +147,15 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun loadMediaById(id: String) {
|
||||
loadMediaById(id = id, forceTranscode = false, startPositionMsOverride = null, replaceCurrent = false)
|
||||
}
|
||||
|
||||
private fun loadMediaById(
|
||||
id: String,
|
||||
forceTranscode: Boolean,
|
||||
startPositionMsOverride: Long?,
|
||||
replaceCurrent: Boolean
|
||||
) {
|
||||
val uuid = id.toUuidOrNull()
|
||||
if (uuid == null) {
|
||||
dataErrorMessage = "Invalid media id"
|
||||
@@ -140,15 +163,20 @@ class PlayerViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = playerMediaRepository.getMediaItem(uuid)
|
||||
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
|
||||
if (result != null) {
|
||||
val (mediaItem, resumePositionMs) = result
|
||||
|
||||
// Determine preference key: movies use their own ID, episodes use series ID
|
||||
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
|
||||
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
|
||||
val startPositionMs = startPositionMsOverride ?: resumePositionMs
|
||||
|
||||
playerManager.play(mediaItem, mediaContext, resumePositionMs)
|
||||
if (replaceCurrent) {
|
||||
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
|
||||
} else {
|
||||
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
||||
}
|
||||
|
||||
if (dataErrorMessage != null) {
|
||||
dataErrorMessage = null
|
||||
@@ -161,6 +189,48 @@ 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
|
||||
|
||||
if (currentMediaId == transcodingRetryMediaId) return false
|
||||
if (!playbackReportContext.canRetryWithTranscoding) return false
|
||||
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
|
||||
|
||||
transcodingRetryMediaId = currentMediaId
|
||||
loadMediaById(
|
||||
id = currentMediaId,
|
||||
forceTranscode = true,
|
||||
startPositionMsOverride = player.currentPosition.takeIf { it > 0L },
|
||||
replaceCurrent = 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) {
|
||||
val uuid = currentMediaId.toUuidOrNull() ?: return
|
||||
viewModelScope.launch {
|
||||
|
||||
Reference in New Issue
Block a user