diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt index bfd37cb..a9eb331 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt @@ -1,5 +1,7 @@ package hu.bbara.purefin.core.data.client +import android.content.Context +import android.media.AudioManager import android.media.MediaCodecList import android.util.Log import org.jellyfin.sdk.model.api.DeviceProfile @@ -14,32 +16,59 @@ import org.jellyfin.sdk.model.api.SubtitleProfile */ object AndroidDeviceProfile { - fun create(): DeviceProfile { - // Debug: Log all available decoders - CodecDebugHelper.logAvailableDecoders() + private const val TAG = "AndroidDeviceProfile" + private const val DEFAULT_MAX_AUDIO_CHANNELS = 8 - val audioCodecs = getAudioCodecs() - val videoCodecs = getVideoCodecs() + @Volatile + private var cachedSnapshot: CapabilitySnapshot? = null - Log.d("AndroidDeviceProfile", "Supported audio codecs: ${audioCodecs.joinToString()}") - Log.d("AndroidDeviceProfile", "Supported video codecs: ${videoCodecs.joinToString()}") + data class CapabilitySnapshot( + val deviceProfile: DeviceProfile, + val maxAudioChannels: Int + ) - // Check specifically for DTS - val hasDTS = CodecDebugHelper.hasDecoderFor("audio/vnd.dts") - val hasDTSHD = CodecDebugHelper.hasDecoderFor("audio/vnd.dts.hd") - Log.d("AndroidDeviceProfile", "Has DTS decoder: $hasDTS, Has DTS-HD decoder: $hasDTSHD") + fun getSnapshot(context: Context): CapabilitySnapshot { + cachedSnapshot?.let { return it } + 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, + videoCodecs: List + ): DeviceProfile { return DeviceProfile( - name = "Android", + name = "Android Media3", maxStaticBitrate = 100_000_000, maxStreamingBitrate = 100_000_000, // Direct play profiles - what we can play natively - // By specifying supported codecs, Jellyfin will transcode unsupported formats like DTS-HD directPlayProfiles = listOf( DirectPlayProfile( type = DlnaProfileType.VIDEO, - container = "mp4,m4v,mkv,webm", + container = "mp4,m4v,mkv,webm,ts,mov", videoCodec = videoCodecs.joinToString(","), audioCodec = audioCodecs.joinToString(",") ) @@ -77,15 +106,18 @@ object AndroidDeviceProfile { private fun getAudioCodecs(): List { val supportedCodecs = mutableListOf() - // Common codecs supported on most Android devices val commonCodecs = listOf( - "aac" to android.media.MediaFormat.MIMETYPE_AUDIO_AAC, - "mp3" to android.media.MediaFormat.MIMETYPE_AUDIO_MPEG, - "ac3" to android.media.MediaFormat.MIMETYPE_AUDIO_AC3, - "eac3" to android.media.MediaFormat.MIMETYPE_AUDIO_EAC3, - "flac" to android.media.MediaFormat.MIMETYPE_AUDIO_FLAC, - "vorbis" to android.media.MediaFormat.MIMETYPE_AUDIO_VORBIS, - "opus" to android.media.MediaFormat.MIMETYPE_AUDIO_OPUS + "aac" to "audio/mp4a-latm", + "mp3" to "audio/mpeg", + "ac3" to "audio/ac3", + "eac3" to "audio/eac3", + "dts" to "audio/vnd.dts", + "dtshd_ma" to "audio/vnd.dts.hd", + "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) { @@ -109,12 +141,12 @@ object AndroidDeviceProfile { val supportedCodecs = mutableListOf() val commonCodecs = listOf( - "h264" to android.media.MediaFormat.MIMETYPE_VIDEO_AVC, - "hevc" to android.media.MediaFormat.MIMETYPE_VIDEO_HEVC, - "vp9" to android.media.MediaFormat.MIMETYPE_VIDEO_VP9, - "vp8" to android.media.MediaFormat.MIMETYPE_VIDEO_VP8, - "mpeg4" to android.media.MediaFormat.MIMETYPE_VIDEO_MPEG4, - "av1" to android.media.MediaFormat.MIMETYPE_VIDEO_AV1 + "h264" to "video/avc", + "hevc" to "video/hevc", + "vp9" to "video/x-vnd.on2.vp9", + "vp8" to "video/x-vnd.on2.vp8", + "mpeg4" to "video/mp4v-es", + "av1" to "video/av01" ) for ((codecName, mimeType) in commonCodecs) { @@ -135,15 +167,58 @@ object AndroidDeviceProfile { * Check if a specific decoder (not encoder) is supported on this device. */ private fun isCodecSupported(mimeType: String): Boolean { - return try { + val platformSupport = try { val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) codecList.codecInfos.any { codecInfo -> !codecInfo.isEncoder && codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) } } } catch (_: Exception) { - // If we can't determine, assume not supported 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) + } } } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt index 4a36c0e..5c0438f 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt @@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlayMethod 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.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo @@ -44,6 +45,11 @@ class JellyfinApiClient @Inject constructor( @ApplicationContext private val applicationContext: Context, private val userSessionRepository: UserSessionRepository, ) { + companion object { + private const val TAG = "JellyfinApiClient" + private const val MAX_STREAMING_BITRATE = 100_000_000 + } + private val jellyfin = createJellyfin { context = applicationContext clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") @@ -248,17 +254,87 @@ class JellyfinApiClient @Inject constructor( } suspend fun getMediaSources(mediaId: UUID): List = withContext(Dispatchers.IO) { - val result = api.mediaInfoApi - .getPostedPlaybackInfo( - mediaId, - PlaybackInfoDto( - userId = getUserId(), - deviceProfile = AndroidDeviceProfile.create(), - maxStreamingBitrate = 100_000_000, - ), - ) - Log.d("getMediaSources", result.toString()) - result.content.mediaSources + requestPlaybackInfo(mediaId)?.mediaSources ?: emptyList() + } + + suspend fun getPlaybackDecision(mediaId: UUID, forceTranscode: Boolean = false): PlaybackDecision? = withContext(Dispatchers.IO) { + val playbackInfo = requestPlaybackInfo(mediaId, forceTranscode) ?: return@withContext null + val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) + val selectedMediaSource = selectMediaSource(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null + + val url = when { + forceTranscode && selectedMediaSource.supportsTranscoding -> { + 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) { @@ -266,28 +342,43 @@ class JellyfinApiClient @Inject constructor( return@withContext null } - // Check if transcoding is required based on the MediaSourceInfo from getMediaSources - val shouldTranscode = mediaSource.supportsTranscoding == true && - (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) + val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) - val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) { - // Use transcoding URL - val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/') - "$baseUrl${mediaSource.transcodingUrl}" - } else { - // Use direct play URL - api.videosApi.getVideoStreamUrl( + val url = when { + mediaSource.supportsDirectPlay -> api.videosApi.getVideoStreamUrl( itemId = mediaId, static = true, 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 } - 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 api.playStateApi.reportPlaybackStart( PlaybackStartInfo( @@ -296,14 +387,24 @@ class JellyfinApiClient @Inject constructor( canSeek = true, isPaused = 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, 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 api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( @@ -312,21 +413,93 @@ class JellyfinApiClient @Inject constructor( canSeek = true, isPaused = isPaused, 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, 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 api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemId, positionTicks = positionTicks, + mediaSourceId = reportContext.mediaSourceId, + liveStreamId = reportContext.liveStreamId, + playSessionId = reportContext.playSessionId, 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, 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 + ) + } } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt new file mode 100644 index 0000000..bf2d3c8 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt @@ -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 +) diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt index 48d112e..184488a 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt @@ -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? = withContext(Dispatchers.IO) { - buildOnlineMediaItem(mediaId) ?: buildOfflineMediaItem(mediaId) + suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair? = 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? { + private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair? { 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 = emptyList() + subtitleConfigurations: List = 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() } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt index e006dc2..00ad840 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt @@ -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( diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt index ec71152..f3a633e 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt @@ -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) { diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index f693265..1d1a547 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -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 {