Implement Jellyfin playback decision flow

This commit is contained in:
2026-03-29 16:38:44 +02:00
parent a187192013
commit 4b92af26ba
7 changed files with 480 additions and 94 deletions

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.core.data.client package hu.bbara.purefin.core.data.client
import android.content.Context
import android.media.AudioManager
import android.media.MediaCodecList import android.media.MediaCodecList
import android.util.Log import android.util.Log
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
@@ -14,32 +16,59 @@ import org.jellyfin.sdk.model.api.SubtitleProfile
*/ */
object AndroidDeviceProfile { object AndroidDeviceProfile {
fun create(): DeviceProfile { private const val TAG = "AndroidDeviceProfile"
// Debug: Log all available decoders private const val DEFAULT_MAX_AUDIO_CHANNELS = 8
CodecDebugHelper.logAvailableDecoders()
val audioCodecs = getAudioCodecs() @Volatile
val videoCodecs = getVideoCodecs() private var cachedSnapshot: CapabilitySnapshot? = null
Log.d("AndroidDeviceProfile", "Supported audio codecs: ${audioCodecs.joinToString()}") data class CapabilitySnapshot(
Log.d("AndroidDeviceProfile", "Supported video codecs: ${videoCodecs.joinToString()}") val deviceProfile: DeviceProfile,
val maxAudioChannels: Int
)
// Check specifically for DTS fun getSnapshot(context: Context): CapabilitySnapshot {
val hasDTS = CodecDebugHelper.hasDecoderFor("audio/vnd.dts") cachedSnapshot?.let { return it }
val hasDTSHD = CodecDebugHelper.hasDecoderFor("audio/vnd.dts.hd")
Log.d("AndroidDeviceProfile", "Has DTS decoder: $hasDTS, Has DTS-HD decoder: $hasDTSHD")
return synchronized(this) {
cachedSnapshot?.let { return@synchronized it }
val applicationContext = context.applicationContext
// Debug logging is noisy and expensive, so keep it to debug builds/devices only when needed.
val audioCodecs = getAudioCodecs()
val videoCodecs = getVideoCodecs()
val maxAudioChannels = resolveMaxAudioChannels(applicationContext)
Log.d(TAG, "Supported audio codecs: ${audioCodecs.joinToString()}")
Log.d(TAG, "Supported video codecs: ${videoCodecs.joinToString()}")
Log.d(TAG, "Max audio channels: $maxAudioChannels")
val snapshot = CapabilitySnapshot(
deviceProfile = buildDeviceProfile(audioCodecs, videoCodecs),
maxAudioChannels = maxAudioChannels
)
cachedSnapshot = snapshot
snapshot
}
}
fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile
private fun buildDeviceProfile(
audioCodecs: List<String>,
videoCodecs: List<String>
): DeviceProfile {
return DeviceProfile( return DeviceProfile(
name = "Android", name = "Android Media3",
maxStaticBitrate = 100_000_000, maxStaticBitrate = 100_000_000,
maxStreamingBitrate = 100_000_000, maxStreamingBitrate = 100_000_000,
// Direct play profiles - what we can play natively // Direct play profiles - what we can play natively
// By specifying supported codecs, Jellyfin will transcode unsupported formats like DTS-HD
directPlayProfiles = listOf( directPlayProfiles = listOf(
DirectPlayProfile( DirectPlayProfile(
type = DlnaProfileType.VIDEO, type = DlnaProfileType.VIDEO,
container = "mp4,m4v,mkv,webm", container = "mp4,m4v,mkv,webm,ts,mov",
videoCodec = videoCodecs.joinToString(","), videoCodec = videoCodecs.joinToString(","),
audioCodec = audioCodecs.joinToString(",") audioCodec = audioCodecs.joinToString(",")
) )
@@ -77,15 +106,18 @@ object AndroidDeviceProfile {
private fun getAudioCodecs(): List<String> { private fun getAudioCodecs(): List<String> {
val supportedCodecs = mutableListOf<String>() val supportedCodecs = mutableListOf<String>()
// Common codecs supported on most Android devices
val commonCodecs = listOf( val commonCodecs = listOf(
"aac" to android.media.MediaFormat.MIMETYPE_AUDIO_AAC, "aac" to "audio/mp4a-latm",
"mp3" to android.media.MediaFormat.MIMETYPE_AUDIO_MPEG, "mp3" to "audio/mpeg",
"ac3" to android.media.MediaFormat.MIMETYPE_AUDIO_AC3, "ac3" to "audio/ac3",
"eac3" to android.media.MediaFormat.MIMETYPE_AUDIO_EAC3, "eac3" to "audio/eac3",
"flac" to android.media.MediaFormat.MIMETYPE_AUDIO_FLAC, "dts" to "audio/vnd.dts",
"vorbis" to android.media.MediaFormat.MIMETYPE_AUDIO_VORBIS, "dtshd_ma" to "audio/vnd.dts.hd",
"opus" to android.media.MediaFormat.MIMETYPE_AUDIO_OPUS "truehd" to "audio/true-hd",
"flac" to "audio/flac",
"vorbis" to "audio/vorbis",
"opus" to "audio/opus",
"alac" to "audio/alac"
) )
for ((codecName, mimeType) in commonCodecs) { for ((codecName, mimeType) in commonCodecs) {
@@ -109,12 +141,12 @@ object AndroidDeviceProfile {
val supportedCodecs = mutableListOf<String>() val supportedCodecs = mutableListOf<String>()
val commonCodecs = listOf( val commonCodecs = listOf(
"h264" to android.media.MediaFormat.MIMETYPE_VIDEO_AVC, "h264" to "video/avc",
"hevc" to android.media.MediaFormat.MIMETYPE_VIDEO_HEVC, "hevc" to "video/hevc",
"vp9" to android.media.MediaFormat.MIMETYPE_VIDEO_VP9, "vp9" to "video/x-vnd.on2.vp9",
"vp8" to android.media.MediaFormat.MIMETYPE_VIDEO_VP8, "vp8" to "video/x-vnd.on2.vp8",
"mpeg4" to android.media.MediaFormat.MIMETYPE_VIDEO_MPEG4, "mpeg4" to "video/mp4v-es",
"av1" to android.media.MediaFormat.MIMETYPE_VIDEO_AV1 "av1" to "video/av01"
) )
for ((codecName, mimeType) in commonCodecs) { for ((codecName, mimeType) in commonCodecs) {
@@ -135,15 +167,58 @@ object AndroidDeviceProfile {
* Check if a specific decoder (not encoder) is supported on this device. * Check if a specific decoder (not encoder) is supported on this device.
*/ */
private fun isCodecSupported(mimeType: String): Boolean { private fun isCodecSupported(mimeType: String): Boolean {
return try { val platformSupport = try {
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
codecList.codecInfos.any { codecInfo -> codecList.codecInfos.any { codecInfo ->
!codecInfo.isEncoder && !codecInfo.isEncoder &&
codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) } codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }
} }
} catch (_: Exception) { } catch (_: Exception) {
// If we can't determine, assume not supported
false false
} }
if (platformSupport) {
return true
}
return isCodecSupportedByFfmpeg(mimeType)
}
private fun isCodecSupportedByFfmpeg(mimeType: String): Boolean {
return runCatching {
val ffmpegLibraryClass = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary")
val supportsFormat = ffmpegLibraryClass.getMethod("supportsFormat", String::class.java)
supportsFormat.invoke(null, mimeType) as? Boolean ?: false
}.getOrElse { false }
}
private fun resolveMaxAudioChannels(context: Context): Int {
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
return runCatching {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val maxDeviceChannels = devices
.flatMap { device ->
buildList {
addAll(device.channelCounts.toList())
addAll(device.channelMasks.map(::channelMaskToCount))
addAll(device.channelIndexMasks.map { Integer.bitCount(it) })
}
}
.maxOrNull()
maxDeviceChannels
?.takeIf { it > 0 }
?: DEFAULT_MAX_AUDIO_CHANNELS
}.getOrElse {
DEFAULT_MAX_AUDIO_CHANNELS
}
}
private fun channelMaskToCount(mask: Int): Int {
return if (mask == 0) {
0
} else {
Integer.bitCount(mask)
}
} }
} }

View File

@@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackInfoResponse
import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo
@@ -44,6 +45,11 @@ class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context, @ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
) { ) {
companion object {
private const val TAG = "JellyfinApiClient"
private const val MAX_STREAMING_BITRATE = 100_000_000
}
private val jellyfin = createJellyfin { private val jellyfin = createJellyfin {
context = applicationContext context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
@@ -248,17 +254,87 @@ class JellyfinApiClient @Inject constructor(
} }
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) { suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
val result = api.mediaInfoApi requestPlaybackInfo(mediaId)?.mediaSources ?: emptyList()
.getPostedPlaybackInfo( }
mediaId,
PlaybackInfoDto( suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) {
userId = getUserId(), val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null
deviceProfile = AndroidDeviceProfile.create(), val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
maxStreamingBitrate = 100_000_000, val selectedMediaSource = selectMediaSource(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null
),
) val url = when {
Log.d("getMediaSources", result.toString()) forceTranscode && selectedMediaSource.supportsTranscoding -> {
result.content.mediaSources resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
!forceTranscode && selectedMediaSource.supportsDirectPlay -> {
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = true,
mediaSourceId = selectedMediaSource.id,
playSessionId = playbackInfo.playSessionId,
liveStreamId = selectedMediaSource.liveStreamId
)
}
!forceTranscode && selectedMediaSource.supportsDirectStream -> {
api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = selectedMediaSource.id,
playSessionId = playbackInfo.playSessionId,
liveStreamId = selectedMediaSource.liveStreamId,
container = selectedMediaSource.transcodingContainer ?: selectedMediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
selectedMediaSource.supportsTranscoding -> {
resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = selectedMediaSource,
playSessionId = playbackInfo.playSessionId,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
}
else -> null
} ?: return@withContext null
val playMethod = when {
forceTranscode -> PlayMethod.TRANSCODE
selectedMediaSource.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
selectedMediaSource.supportsDirectStream -> PlayMethod.DIRECT_STREAM
selectedMediaSource.supportsTranscoding -> PlayMethod.TRANSCODE
else -> return@withContext null
}
val reportContext = PlaybackReportContext(
playMethod = playMethod,
mediaSourceId = selectedMediaSource.id,
audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex,
subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex,
liveStreamId = selectedMediaSource.liveStreamId,
playSessionId = playbackInfo.playSessionId,
canRetryWithTranscoding = !forceTranscode &&
playMethod != PlayMethod.TRANSCODE &&
selectedMediaSource.supportsTranscoding
)
Log.d(TAG, "Playback decision for $mediaId: $playMethod using ${selectedMediaSource.id}")
PlaybackDecision(
url = url,
mediaSource = selectedMediaSource,
reportContext = reportContext
)
} }
suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) { suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) {
@@ -266,28 +342,43 @@ class JellyfinApiClient @Inject constructor(
return@withContext null return@withContext null
} }
// Check if transcoding is required based on the MediaSourceInfo from getMediaSources val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val shouldTranscode = mediaSource.supportsTranscoding == true &&
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) { val url = when {
// Use transcoding URL mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl(
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
"$baseUrl${mediaSource.transcodingUrl}"
} else {
// Use direct play URL
api.videosApi.getVideoStreamUrl(
itemId = mediaId, itemId = mediaId,
static = true, static = true,
mediaSourceId = mediaSource.id, mediaSourceId = mediaSource.id,
liveStreamId = mediaSource.liveStreamId
) )
mediaSource.supportsDirectStream -> api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container,
enableAutoStreamCopy = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
mediaSource.supportsTranscoding -> resolveTranscodeUrl(
mediaId = mediaId,
mediaSource = mediaSource,
playSessionId = null,
maxAudioChannels = capabilitySnapshot.maxAudioChannels
)
else -> null
} }
Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url") Log.d(TAG, "Resolved standalone playback URL for $mediaId -> $url")
url url
} }
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) { suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStart( api.playStateApi.reportPlaybackStart(
PlaybackStartInfo( PlaybackStartInfo(
@@ -296,14 +387,24 @@ class JellyfinApiClient @Inject constructor(
canSeek = true, canSeek = true,
isPaused = false, isPaused = false,
isMuted = false, isMuted = false,
playMethod = PlayMethod.DIRECT_PLAY, mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod,
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT playbackOrder = PlaybackOrder.DEFAULT
) )
) )
} }
suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) { suspend fun reportPlaybackProgress(
itemId: UUID,
positionTicks: Long,
isPaused: Boolean,
reportContext: PlaybackReportContext
) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackProgress( api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo( PlaybackProgressInfo(
@@ -312,21 +413,93 @@ class JellyfinApiClient @Inject constructor(
canSeek = true, canSeek = true,
isPaused = isPaused, isPaused = isPaused,
isMuted = false, isMuted = false,
playMethod = PlayMethod.DIRECT_PLAY, mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod,
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT playbackOrder = PlaybackOrder.DEFAULT
) )
) )
} }
suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = withContext(Dispatchers.IO) { suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext if (!ensureConfigured()) return@withContext
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
itemId = itemId, itemId = itemId,
positionTicks = positionTicks, positionTicks = positionTicks,
mediaSourceId = reportContext.mediaSourceId,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
failed = false failed = false
) )
) )
} }
private suspend fun requestPlaybackInfo(mediaId: UUID, forceTranscode: Boolean = false): PlaybackInfoResponse? {
if (!ensureConfigured()) {
return null
}
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext)
val response = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
deviceProfile = capabilitySnapshot.deviceProfile,
maxStreamingBitrate = MAX_STREAMING_BITRATE,
maxAudioChannels = capabilitySnapshot.maxAudioChannels,
enableDirectPlay = !forceTranscode,
enableDirectStream = !forceTranscode,
enableTranscoding = true,
allowVideoStreamCopy = !forceTranscode,
allowAudioStreamCopy = !forceTranscode,
alwaysBurnInSubtitleWhenTranscoding = false
)
)
Log.d(TAG, "PlaybackInfo for $mediaId -> sources=${response.content.mediaSources.size}, session=${response.content.playSessionId}")
return response.content
}
private fun selectMediaSource(mediaSources: List<MediaSourceInfo>, forceTranscode: Boolean): MediaSourceInfo? {
return when {
forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }
else -> mediaSources.firstOrNull { it.supportsDirectPlay }
?: mediaSources.firstOrNull { it.supportsDirectStream }
?: mediaSources.firstOrNull { it.supportsTranscoding }
?: mediaSources.firstOrNull()
}
}
private suspend fun resolveTranscodeUrl(
mediaId: UUID,
mediaSource: MediaSourceInfo,
playSessionId: String?,
maxAudioChannels: Int
): String? {
mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl ->
if (transcodingUrl.startsWith("http", ignoreCase = true)) {
return transcodingUrl
}
val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/')
return "$baseUrl$transcodingUrl"
}
return api.videosApi.getVideoStreamUrl(
itemId = mediaId,
static = false,
mediaSourceId = mediaSource.id,
playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4",
enableAutoStreamCopy = false,
allowVideoStreamCopy = false,
allowAudioStreamCopy = false,
maxAudioChannels = maxAudioChannels
)
}
} }

View File

@@ -0,0 +1,20 @@
package hu.bbara.purefin.core.data.client
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
data class PlaybackDecision(
val url: String,
val mediaSource: MediaSourceInfo,
val reportContext: PlaybackReportContext
)
data class PlaybackReportContext(
val playMethod: PlayMethod,
val mediaSourceId: String?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val liveStreamId: String?,
val playSessionId: String?,
val canRetryWithTranscoding: Boolean
)

View File

@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.offline.DownloadManager
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.MediaRepository import hu.bbara.purefin.core.data.MediaRepository
import hu.bbara.purefin.core.data.client.JellyfinApiClient 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.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -34,8 +35,8 @@ class PlayerMediaRepository @Inject constructor(
private val downloadManager: DownloadManager private val downloadManager: DownloadManager
) { ) {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) { suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
buildOnlineMediaItem(mediaId) ?: buildOfflineMediaItem(mediaId) buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
} }
private fun calculateResumePosition( private fun calculateResumePosition(
@@ -72,21 +73,18 @@ class PlayerMediaRepository @Inject constructor(
if (existingIds.contains(stringId)) { if (existingIds.contains(stringId)) {
return@mapNotNull null return@mapNotNull null
} }
val mediaSources = jellyfinApiClient.getMediaSources(id) val playbackDecision = jellyfinApiClient.getPlaybackDecision(id) ?: return@mapNotNull null
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null val selectedMediaSource = playbackDecision.mediaSource
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = id,
mediaSource = selectedMediaSource
) ?: return@mapNotNull null
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource) val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
createMediaItem( createMediaItem(
mediaId = stringId, mediaId = stringId,
playbackUrl = playbackUrl, playbackUrl = playbackDecision.url,
title = episode.name ?: selectedMediaSource.name!!, title = episode.name ?: selectedMediaSource.name!!,
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}", subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}",
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs subtitleConfigurations = subtitleConfigs,
tag = playbackDecision.reportContext
) )
} }
}.getOrElse { error -> }.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 { return runCatching {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId) val playbackDecision = jellyfinApiClient.getPlaybackDecision(
val selectedMediaSource = mediaSources.firstOrNull() ?: return null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = mediaId, mediaId = mediaId,
mediaSource = selectedMediaSource forceTranscode = forceTranscode
) ?: return null ) ?: return null
val selectedMediaSource = playbackDecision.mediaSource
val baseItem = jellyfinApiClient.getItemInfo(mediaId) val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource) val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
@@ -112,11 +109,12 @@ class PlayerMediaRepository @Inject constructor(
val mediaItem = createMediaItem( val mediaItem = createMediaItem(
mediaId = mediaId.toString(), mediaId = mediaId.toString(),
playbackUrl = playbackUrl, playbackUrl = playbackDecision.url,
title = baseItem?.name ?: selectedMediaSource.name.orEmpty(), title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) }, subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs subtitleConfigurations = subtitleConfigs,
tag = playbackDecision.reportContext
) )
mediaItem to resumePositionMs mediaItem to resumePositionMs
@@ -247,7 +245,8 @@ class PlayerMediaRepository @Inject constructor(
title: String, title: String,
subtitle: String?, subtitle: String?,
artworkUrl: String, artworkUrl: String,
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList() subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
tag: PlaybackReportContext? = null
): MediaItem { ): MediaItem {
val metadata = MediaMetadata.Builder() val metadata = MediaMetadata.Builder()
.setTitle(title) .setTitle(title)
@@ -258,6 +257,7 @@ class PlayerMediaRepository @Inject constructor(
.setUri(playbackUrl.toUri()) .setUri(playbackUrl.toUri())
.setMediaId(mediaId) .setMediaId(mediaId)
.setMediaMetadata(metadata) .setMediaMetadata(metadata)
.setTag(tag)
.setSubtitleConfigurations(subtitleConfigurations) .setSubtitleConfigurations(subtitleConfigurations)
.build() .build()
} }

View File

@@ -9,6 +9,7 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks 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.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
@@ -73,14 +74,20 @@ 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()
} }
override fun onPlayerError(error: PlaybackException) { 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) { override fun onTracksChanged(tracks: Tracks) {
@@ -128,7 +135,27 @@ class PlayerManager @Inject constructor(
_progress.value = PlaybackProgressSnapshot() _progress.value = PlaybackProgressSnapshot()
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() 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) { fun addToQueue(mediaItem: MediaItem) {
@@ -232,7 +259,7 @@ class PlayerManager @Inject constructor(
} }
fun clearError() { fun clearError() {
_playbackState.update { it.copy(error = null) } _playbackState.update { it.copy(error = null, errorCode = null) }
} }
fun snapshotProgress(): PlaybackProgressSnapshot { fun snapshotProgress(): PlaybackProgressSnapshot {
@@ -341,10 +368,12 @@ class PlayerManager @Inject constructor(
} }
private fun refreshMetadata(mediaItem: MediaItem?) { private fun refreshMetadata(mediaItem: MediaItem?) {
val playbackReportContext = mediaItem?.localConfiguration?.tag as? PlaybackReportContext
_metadata.value = MetadataState( _metadata.value = MetadataState(
mediaId = mediaItem?.mediaId, mediaId = mediaItem?.mediaId,
title = mediaItem?.mediaMetadata?.title?.toString(), 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 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: String? = null,
val errorCode: Int? = null
) )
data class PlaybackProgressSnapshot( data class PlaybackProgressSnapshot(
@@ -370,7 +400,8 @@ data class PlaybackProgressSnapshot(
data class MetadataState( data class MetadataState(
val mediaId: String? = null, val mediaId: String? = null,
val title: String? = null, val title: String? = null,
val subtitle: String? = null val subtitle: String? = null,
val playbackReportContext: PlaybackReportContext? = null
) )
data class MediaContext( data class MediaContext(

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.core.player.manager
import android.util.Log import android.util.Log
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.JellyfinApiClient 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 hu.bbara.purefin.core.data.domain.usecase.UpdateWatchProgressUseCase
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -25,6 +26,7 @@ class ProgressManager @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null private var progressJob: Job? = null
private var activeItemId: UUID? = null private var activeItemId: UUID? = null
private var activePlaybackReportContext: PlaybackReportContext? = null
private var lastPositionMs: Long = 0L private var lastPositionMs: Long = 0L
private var lastDurationMs: Long = 0L private var lastDurationMs: Long = 0L
private var isPaused: Boolean = false private var isPaused: Boolean = false
@@ -47,6 +49,7 @@ class ProgressManager @Inject constructor(
lastDurationMs = prog.durationMs lastDurationMs = prog.durationMs
isPaused = !state.isPlaying isPaused = !state.isPlaying
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() } val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
activePlaybackReportContext = meta.playbackReportContext
// Media changed or ended - stop session // Media changed or ended - stop session
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) { 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 // Start session when we have a media item and none is active
if (activeItemId == null && mediaId != null && !state.isEnded) { 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 activeItemId = itemId
report(itemId, positionMs, isStart = true) activePlaybackReportContext = reportContext
report(itemId, positionMs, reportContext = reportContext, isStart = true)
progressJob = scope.launch { progressJob = scope.launch {
while (isActive) { while (isActive) {
delay(5000) 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() { private fun stopSession() {
progressJob?.cancel() progressJob?.cancel()
activeItemId?.let { itemId -> activeItemId?.let { itemId ->
report(itemId, lastPositionMs, isStop = true) report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs) updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs)
@@ -85,16 +89,27 @@ class ProgressManager @Inject constructor(
} }
} }
activeItemId = null 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 val ticks = positionMs * 10_000
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
if (reportContext == null) {
return@launch
}
when { when {
isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks) isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext)
isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks) isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused) 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") Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
} catch (e: Exception) { } catch (e: Exception) {
@@ -111,7 +126,9 @@ class ProgressManager @Inject constructor(
val durMs = lastDurationMs val durMs = lastDurationMs
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
jellyfinApiClient.reportPlaybackStopped(itemId, ticks) activePlaybackReportContext?.let { reportContext ->
jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext)
}
updateWatchProgressUseCase(itemId, posMs, durMs) updateWatchProgressUseCase(itemId, posMs, durMs)
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms") Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -1,16 +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.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.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.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
@@ -43,6 +47,8 @@ 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 dataErrorMessage: String? = null
private var activeMediaId: String? = null
private var transcodingRetryMediaId: String? = null
init { init {
progressManager.bind( progressManager.bind(
@@ -57,6 +63,10 @@ class PlayerViewModel @Inject constructor(
private fun observePlayerState() { private fun observePlayerState() {
viewModelScope.launch { viewModelScope.launch {
playerManager.playbackState.collect { state -> playerManager.playbackState.collect { state ->
if (state.error != null && maybeRetryWithTranscoding(state)) {
_uiState.update { it.copy(isBuffering = true, error = null) }
return@collect
}
_uiState.update { _uiState.update {
it.copy( it.copy(
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
@@ -93,6 +103,10 @@ class PlayerViewModel @Inject constructor(
) )
} }
val currentMediaId = metadata.mediaId val currentMediaId = metadata.mediaId
if (currentMediaId != activeMediaId) {
activeMediaId = currentMediaId
transcodingRetryMediaId = null
}
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) { if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
lastNextUpMediaId = currentMediaId lastNextUpMediaId = currentMediaId
loadNextUp(currentMediaId) loadNextUp(currentMediaId)
@@ -133,6 +147,15 @@ class PlayerViewModel @Inject constructor(
} }
private fun loadMediaById(id: String) { 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() val uuid = id.toUuidOrNull()
if (uuid == null) { if (uuid == null) {
dataErrorMessage = "Invalid media id" dataErrorMessage = "Invalid media id"
@@ -140,15 +163,20 @@ class PlayerViewModel @Inject constructor(
return return
} }
viewModelScope.launch { viewModelScope.launch {
val result = playerMediaRepository.getMediaItem(uuid) val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
if (result != null) { if (result != null) {
val (mediaItem, resumePositionMs) = result val (mediaItem, resumePositionMs) = result
// 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
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey) 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) { if (dataErrorMessage != null) {
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) { private fun loadNextUp(currentMediaId: String) {
val uuid = currentMediaId.toUuidOrNull() ?: return val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch { viewModelScope.launch {