From 1a17c5232655d54794dafa6262edc0fec26bd168 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 8 Apr 2026 18:03:23 +0200 Subject: [PATCH] refactor: Throw out overcomplicated shit code for Playback --- .../bbara/purefin/tv/player/TvPlayerScreen.kt | 6 +- .../components/TvPlayerLoadingErrorEndCard.kt | 12 +- .../components/PlayerLoadingErrorEndCard.kt | 12 +- .../core/data/client/AndroidDeviceProfile.kt | 303 ---------------- .../core/data/client/HlsPlaybackStability.kt | 17 - .../core/data/client/JellyfinApiClient.kt | 290 ++------------- .../core/data/client/PlaybackDecision.kt | 20 -- .../core/data/client/PlaybackSourcePlanner.kt | 78 ---- core/player/build.gradle.kts | 2 - .../core/player/data/PlayerMediaLoadResult.kt | 15 - .../core/player/data/PlayerMediaRepository.kt | 332 ++++-------------- .../core/player/manager/PendingSeekTracker.kt | 43 --- .../core/player/manager/PlayerManager.kt | 118 +------ .../core/player/manager/ProgressManager.kt | 34 +- .../purefin/core/player/model/PlayerError.kt | 110 ------ .../core/player/model/PlayerUiModels.kt | 2 +- .../core/player/module/VideoPlayerModule.kt | 19 +- .../player/viewmodel/PlaybackRetryPolicy.kt | 58 --- .../core/player/viewmodel/PlayerViewModel.kt | 127 ++----- .../player/manager/PendingSeekTrackerTest.kt | 55 --- .../core/player/model/PlayerErrorTest.kt | 48 --- .../viewmodel/PlaybackRetryPolicyTest.kt | 93 ----- 22 files changed, 162 insertions(+), 1632 deletions(-) delete mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt delete mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/client/HlsPlaybackStability.kt delete mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt delete mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlanner.kt delete mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt delete mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt delete mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerError.kt delete mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt delete mode 100644 core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt delete mode 100644 core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt delete mode 100644 core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt index 28e0abb9..9c6d756f 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt @@ -77,12 +77,12 @@ fun TvPlayerScreen( } BackHandler(enabled = controlsVisible) { - viewModel.hideControls() + viewModel.toggleControlsVisibility() } LaunchedEffect(uiState.isPlaying) { - if (uiState.isPlaying) { - viewModel.hideControls() + if (uiState.isPlaying && controlsVisible) { + viewModel.toggleControlsVisibility() } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerLoadingErrorEndCard.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerLoadingErrorEndCard.kt index 83f09f7e..decd89d2 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerLoadingErrorEndCard.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerLoadingErrorEndCard.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import hu.bbara.purefin.core.player.model.PlayerUiState @@ -48,20 +47,11 @@ internal fun TvPlayerLoadingErrorEndCard( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = uiState.error?.summary ?: "Playback error", + text = uiState.error ?: "Playback error", color = scheme.onBackground, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium ) - uiState.error?.detailText?.let { detail -> - Text( - text = detail, - color = scheme.onBackground.copy(alpha = 0.82f), - style = MaterialTheme.typography.bodySmall, - maxLines = 4, - overflow = TextOverflow.Ellipsis - ) - } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(onClick = onRetry) { Text("Retry") diff --git a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt index ed46ead9..5382810a 100644 --- a/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt +++ b/app/src/main/java/hu/bbara/purefin/player/ui/components/PlayerLoadingErrorEndCard.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import hu.bbara.purefin.core.player.model.PlayerUiState @@ -47,19 +46,10 @@ fun PlayerLoadingErrorEndCard( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = uiState.error?.summary ?: "Playback error", + text = uiState.error ?: "Playback error", color = scheme.onBackground, fontWeight = FontWeight.Bold ) - uiState.error?.detailText?.let { detail -> - Text( - text = detail, - color = scheme.onBackground.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - maxLines = 4, - overflow = TextOverflow.Ellipsis - ) - } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(onClick = onRetry) { Text("Retry") 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 deleted file mode 100644 index 1c43e3e6..00000000 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt +++ /dev/null @@ -1,303 +0,0 @@ -package hu.bbara.purefin.core.data.client - -import android.content.Context -import android.media.AudioAttributes as PlatformAudioAttributes -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import android.media.MediaCodecList -import android.util.Log -import org.jellyfin.sdk.model.api.DeviceProfile -import org.jellyfin.sdk.model.api.DirectPlayProfile -import org.jellyfin.sdk.model.api.DlnaProfileType -import org.jellyfin.sdk.model.api.EncodingContext -import org.jellyfin.sdk.model.api.MediaStreamProtocol -import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import org.jellyfin.sdk.model.api.SubtitleProfile -import org.jellyfin.sdk.model.api.TranscodingProfile - -/** - * Creates a DeviceProfile for Android devices with proper codec support detection. - * This prevents playback failures by requesting transcoding for unsupported formats like DTS-HD. - */ -object AndroidDeviceProfile { - - private const val TAG = "AndroidDeviceProfile" - private const val DEFAULT_MAX_AUDIO_CHANNELS = 2 - private const val PROBE_SAMPLE_RATE = 48_000 - private const val PROBE_ENCODING = AudioFormat.ENCODING_PCM_16BIT - - @Volatile - private var cachedSnapshot: CapabilitySnapshot? = null - - data class CapabilitySnapshot( - val deviceProfile: DeviceProfile, - val maxAudioChannels: Int - ) - - 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 = createDeviceProfile(audioCodecs, videoCodecs, maxAudioChannels), - maxAudioChannels = maxAudioChannels - ) - cachedSnapshot = snapshot - snapshot - } - } - - fun create(context: Context): DeviceProfile = getSnapshot(context).deviceProfile - - internal fun createDeviceProfile( - audioCodecs: List, - videoCodecs: List, - maxAudioChannels: Int - ): DeviceProfile { - val hlsOptions = HlsPlaybackStability.conservativeHlsOptions - - return DeviceProfile( - name = "Android Media3", - maxStaticBitrate = 100_000_000, - maxStreamingBitrate = 100_000_000, - - // Direct play profiles - what we can play natively - directPlayProfiles = listOf( - DirectPlayProfile( - type = DlnaProfileType.VIDEO, - container = "mp4,m4v,mkv,webm,ts,mov", - videoCodec = videoCodecs.joinToString(","), - audioCodec = audioCodecs.joinToString(",") - ) - ), - - // Explicit video transcoding support keeps Jellyfin 10.10/10.11 from - // returning sources that exist but are not marked usable for playback. - transcodingProfiles = listOf( - TranscodingProfile( - container = "ts", - type = DlnaProfileType.VIDEO, - videoCodec = "h264", - audioCodec = "aac", - protocol = MediaStreamProtocol.HLS, - context = EncodingContext.STREAMING, - maxAudioChannels = maxAudioChannels.toString(), - minSegments = hlsOptions.minSegments, - segmentLength = hlsOptions.segmentLengthSeconds, - breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames, - copyTimestamps = hlsOptions.copyTimestamps, - conditions = emptyList() - ) - ), - - codecProfiles = emptyList(), - - subtitleProfiles = listOf( - // Prefer EMBED so subtitles stay in the container — this gives - // correct cues after seeking (Media3 parses them at extraction time). - SubtitleProfile("srt", SubtitleDeliveryMethod.EMBED), - SubtitleProfile("ass", SubtitleDeliveryMethod.EMBED), - SubtitleProfile("ssa", SubtitleDeliveryMethod.EMBED), - SubtitleProfile("subrip", SubtitleDeliveryMethod.EMBED), - SubtitleProfile("sub", SubtitleDeliveryMethod.EMBED), - // EXTERNAL fallback for when embedding isn't possible - SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL), - SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL), - SubtitleProfile("ssa", SubtitleDeliveryMethod.EXTERNAL), - SubtitleProfile("vtt", SubtitleDeliveryMethod.EXTERNAL), - SubtitleProfile("sub", SubtitleDeliveryMethod.EXTERNAL) - ), - - containerProfiles = emptyList() - ) - } - - /** - * Get list of supported audio codecs on this device. - * Excludes unsupported formats like DTS, DTS-HD, TrueHD which commonly cause playback failures. - */ - private fun getAudioCodecs(): List { - val supportedCodecs = mutableListOf() - - val commonCodecs = listOf( - "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) { - if (isCodecSupported(mimeType)) { - supportedCodecs.add(codecName) - } - } - - // AAC is mandatory on Android - ensure it's always included - if (!supportedCodecs.contains("aac")) { - supportedCodecs.add("aac") - } - - return supportedCodecs - } - - /** - * Get list of supported video codecs on this device. - */ - private fun getVideoCodecs(): List { - val supportedCodecs = mutableListOf() - - val commonCodecs = listOf( - "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) { - if (isCodecSupported(mimeType)) { - supportedCodecs.add(codecName) - } - } - - // H.264 is mandatory on Android - ensure it's always included - if (!supportedCodecs.contains("h264")) { - supportedCodecs.add("h264") - } - - return supportedCodecs - } - - /** - * Check if a specific decoder (not encoder) is supported on this device. - */ - private fun isCodecSupported(mimeType: String): Boolean { - 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) { - 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 reportedMaxChannels = devices - .flatMap { device -> - buildList { - addAll(device.channelCounts.toList()) - addAll(device.channelMasks.map(::channelMaskToCount)) - addAll(device.channelIndexMasks.map { Integer.bitCount(it) }) - } - } - .maxOrNull() - ?.takeIf { it > 0 } - ?: DEFAULT_MAX_AUDIO_CHANNELS - - val verifiedMaxChannels = verifyOutputChannelSupport(reportedMaxChannels) - Log.d(TAG, "Reported max audio channels: $reportedMaxChannels, verified: $verifiedMaxChannels") - verifiedMaxChannels - }.getOrElse { - DEFAULT_MAX_AUDIO_CHANNELS - } - } - - private fun verifyOutputChannelSupport(reportedMaxChannels: Int): Int { - val candidates = buildList { - if (reportedMaxChannels >= 8) add(8) - if (reportedMaxChannels >= 6) add(6) - add(2) - }.distinct() - - return candidates.firstOrNull(::canCreateAudioTrackForChannelCount) ?: DEFAULT_MAX_AUDIO_CHANNELS - } - - private fun canCreateAudioTrackForChannelCount(channelCount: Int): Boolean { - val channelMask = when (channelCount) { - 8 -> AudioFormat.CHANNEL_OUT_7POINT1_SURROUND - 6 -> AudioFormat.CHANNEL_OUT_5POINT1 - 2 -> AudioFormat.CHANNEL_OUT_STEREO - else -> return false - } - - val minBufferSize = AudioTrack.getMinBufferSize(PROBE_SAMPLE_RATE, channelMask, PROBE_ENCODING) - if (minBufferSize <= 0) { - return false - } - - return runCatching { - val audioTrack = AudioTrack.Builder() - .setAudioAttributes( - PlatformAudioAttributes.Builder() - .setUsage(PlatformAudioAttributes.USAGE_MEDIA) - .setContentType(PlatformAudioAttributes.CONTENT_TYPE_MOVIE) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(PROBE_SAMPLE_RATE) - .setEncoding(PROBE_ENCODING) - .setChannelMask(channelMask) - .build() - ) - .setTransferMode(AudioTrack.MODE_STREAM) - .setBufferSizeInBytes(minBufferSize * 2) - .build() - - try { - audioTrack.state == AudioTrack.STATE_INITIALIZED - } finally { - audioTrack.release() - } - }.getOrElse { false } - } - - 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/HlsPlaybackStability.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/HlsPlaybackStability.kt deleted file mode 100644 index 2557ef8e..00000000 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/HlsPlaybackStability.kt +++ /dev/null @@ -1,17 +0,0 @@ -package hu.bbara.purefin.core.data.client - -internal data class HlsStabilityOptions( - val segmentLengthSeconds: Int, - val minSegments: Int, - val breakOnNonKeyFrames: Boolean, - val copyTimestamps: Boolean -) - -internal object HlsPlaybackStability { - val conservativeHlsOptions = HlsStabilityOptions( - segmentLengthSeconds = 6, - minSegments = 3, - breakOnNonKeyFrames = false, - copyTimestamps = true - ) -} 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 cdfdc8f1..4867ee2a 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 @@ -29,7 +29,6 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType 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 @@ -47,19 +46,6 @@ 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 - - internal fun streamingHlsOptionsFor( - urlStrategy: PlaybackSourcePlanner.UrlStrategy - ): HlsStabilityOptions? = when (urlStrategy) { - PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> null - PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM, - PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> HlsPlaybackStability.conservativeHlsOptions - } - } - private val jellyfin = createJellyfin { context = applicationContext clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") @@ -283,43 +269,18 @@ class JellyfinApiClient @Inject constructor( } suspend fun getMediaSources(mediaId: UUID): List = withContext(Dispatchers.IO) { - 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 playbackPlan = PlaybackSourcePlanner.plan(playbackInfo.mediaSources, forceTranscode) ?: return@withContext null - val selectedMediaSource = playbackPlan.mediaSource - - val url = resolvePlaybackUrl( - mediaId = mediaId, - mediaSource = selectedMediaSource, - urlStrategy = playbackPlan.urlStrategy, - playSessionId = playbackInfo.playSessionId, - maxAudioChannels = capabilitySnapshot.maxAudioChannels - ) ?: return@withContext null - - val reportContext = PlaybackReportContext( - playMethod = playbackPlan.playMethod, - mediaSourceId = selectedMediaSource.id, - audioStreamIndex = selectedMediaSource.defaultAudioStreamIndex, - subtitleStreamIndex = selectedMediaSource.defaultSubtitleStreamIndex, - liveStreamId = selectedMediaSource.liveStreamId, - playSessionId = playbackInfo.playSessionId, - canRetryWithTranscoding = playbackPlan.canRetryWithTranscoding - ) - - Log.d( - TAG, - "Playback decision for $mediaId: ${playbackPlan.playMethod} using ${selectedMediaSource.id}" + - if (playbackPlan.usedFallback) " (conservative fallback)" else "" - ) - PlaybackDecision( - url = url, - mediaSource = selectedMediaSource, - reportContext = reportContext - ) + val result = api.mediaInfoApi + .getPostedPlaybackInfo( + mediaId, + PlaybackInfoDto( + userId = getUserId(), + // TODO add supportedContainers + deviceProfile = null, + maxStreamingBitrate = 100_000_000, + ), + ) + Log.d("getMediaSources", result.toString()) + result.content.mediaSources } suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) { @@ -327,26 +288,28 @@ class JellyfinApiClient @Inject constructor( return@withContext null } - val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) + // Check if transcoding is required based on the MediaSourceInfo from getMediaSources + val shouldTranscode = mediaSource.supportsTranscoding == true && + (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) - val playbackPlan = PlaybackSourcePlanner.plan(mediaSource) ?: return@withContext null - val url = resolvePlaybackUrl( - mediaId = mediaId, - mediaSource = playbackPlan.mediaSource, - urlStrategy = playbackPlan.urlStrategy, - playSessionId = null, - maxAudioChannels = capabilitySnapshot.maxAudioChannels - ) + 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( + itemId = mediaId, + static = true, + mediaSourceId = mediaSource.id, + ) + } - Log.d( - TAG, - "Resolved standalone playback URL for $mediaId -> $url via ${playbackPlan.playMethod}" + - if (playbackPlan.usedFallback) " (conservative fallback)" else "" - ) + Log.d("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url") url } - suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) { + suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) { if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackStart( PlaybackStartInfo( @@ -355,24 +318,15 @@ class JellyfinApiClient @Inject constructor( canSeek = true, isPaused = false, isMuted = false, - mediaSourceId = reportContext.mediaSourceId, - audioStreamIndex = reportContext.audioStreamIndex, - subtitleStreamIndex = reportContext.subtitleStreamIndex, - liveStreamId = reportContext.liveStreamId, - playSessionId = reportContext.playSessionId, - playMethod = reportContext.playMethod, + // TODO Send correct play method + playMethod = PlayMethod.DIRECT_PLAY, repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT ) ) } - suspend fun reportPlaybackProgress( - itemId: UUID, - positionTicks: Long, - isPaused: Boolean, - reportContext: PlaybackReportContext - ) = withContext(Dispatchers.IO) { + suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) = withContext(Dispatchers.IO) { if (!ensureConfigured()) return@withContext api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( @@ -381,197 +335,21 @@ class JellyfinApiClient @Inject constructor( canSeek = true, isPaused = isPaused, isMuted = false, - mediaSourceId = reportContext.mediaSourceId, - audioStreamIndex = reportContext.audioStreamIndex, - subtitleStreamIndex = reportContext.subtitleStreamIndex, - liveStreamId = reportContext.liveStreamId, - playSessionId = reportContext.playSessionId, - playMethod = reportContext.playMethod, + playMethod = PlayMethod.DIRECT_PLAY, repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT ) ) } - suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long, reportContext: PlaybackReportContext) = withContext(Dispatchers.IO) { + suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) = 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) - Log.d( - TAG, - "Playback request for $mediaId (forceTranscode=$forceTranscode) maxAudioChannels=${capabilitySnapshot.maxAudioChannels}, " + - "hlsOptions=${HlsPlaybackStability.conservativeHlsOptions}" - ) - 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 - ) - ) - - logPlaybackInfo( - mediaId = mediaId, - playbackInfo = response.content, - forceTranscode = forceTranscode - ) - return response.content - } - - private suspend fun resolvePlaybackUrl( - mediaId: UUID, - mediaSource: MediaSourceInfo, - urlStrategy: PlaybackSourcePlanner.UrlStrategy, - playSessionId: String?, - maxAudioChannels: Int - ): String? { - return when (urlStrategy) { - PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> api.videosApi.getVideoStreamUrl( - itemId = mediaId, - static = true, - mediaSourceId = mediaSource.id, - playSessionId = playSessionId, - liveStreamId = mediaSource.liveStreamId - ) - - PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM -> resolveDirectStreamUrl( - mediaId = mediaId, - mediaSource = mediaSource, - playSessionId = playSessionId, - maxAudioChannels = maxAudioChannels - ) - - PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> resolveTranscodeUrl( - mediaId = mediaId, - mediaSource = mediaSource, - playSessionId = playSessionId, - maxAudioChannels = maxAudioChannels - ) - } - } - - private suspend fun resolveDirectStreamUrl( - mediaId: UUID, - mediaSource: MediaSourceInfo, - playSessionId: String?, - maxAudioChannels: Int - ): String { - val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM) - ?: error("Direct stream requests must use HLS stability options.") - - Log.d( - TAG, - "Stream URL request for $mediaId strategy=DIRECT_STREAM container=${mediaSource.transcodingContainer ?: mediaSource.container}, " + - "hlsOptions=$hlsOptions" - ) - return api.videosApi.getVideoStreamUrl( - itemId = mediaId, - static = false, - mediaSourceId = mediaSource.id, - playSessionId = playSessionId, - liveStreamId = mediaSource.liveStreamId, - container = mediaSource.transcodingContainer ?: mediaSource.container, - segmentLength = hlsOptions.segmentLengthSeconds, - minSegments = hlsOptions.minSegments, - enableAutoStreamCopy = true, - allowVideoStreamCopy = true, - allowAudioStreamCopy = true, - breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames, - copyTimestamps = hlsOptions.copyTimestamps, - maxAudioChannels = maxAudioChannels - ) - } - - private suspend fun resolveTranscodeUrl( - mediaId: UUID, - mediaSource: MediaSourceInfo, - playSessionId: String?, - maxAudioChannels: Int - ): String? { - val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE) - ?: error("Transcode requests must use HLS stability options.") - - mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl -> - Log.d( - TAG, - "Using server transcodingUrl for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container}, " + - "hlsOptions=$hlsOptions" - ) - if (transcodingUrl.startsWith("http", ignoreCase = true)) { - return transcodingUrl - } - val baseUrl = userSessionRepository.serverUrl.first().trim().trimEnd('/') - return "$baseUrl$transcodingUrl" - } - - Log.d( - TAG, - "Stream URL request for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4"}, " + - "hlsOptions=$hlsOptions" - ) - return api.videosApi.getVideoStreamUrl( - itemId = mediaId, - static = false, - mediaSourceId = mediaSource.id, - playSessionId = playSessionId, - liveStreamId = mediaSource.liveStreamId, - container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4", - segmentLength = hlsOptions.segmentLengthSeconds, - minSegments = hlsOptions.minSegments, - enableAutoStreamCopy = false, - allowVideoStreamCopy = false, - allowAudioStreamCopy = false, - breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames, - copyTimestamps = hlsOptions.copyTimestamps, - maxAudioChannels = maxAudioChannels - ) - } - - private fun logPlaybackInfo( - mediaId: UUID, - playbackInfo: PlaybackInfoResponse, - forceTranscode: Boolean - ) { - Log.d( - TAG, - "PlaybackInfo for $mediaId (forceTranscode=$forceTranscode) -> " + - "sources=${playbackInfo.mediaSources.size}, session=${playbackInfo.playSessionId}, error=${playbackInfo.errorCode}" - ) - - playbackInfo.mediaSources.forEachIndexed { index, mediaSource -> - Log.d( - TAG, - "PlaybackInfo[$index] id=${mediaSource.id}, container=${mediaSource.container}, " + - "transcodingContainer=${mediaSource.transcodingContainer}, protocol=${mediaSource.protocol}, " + - "supportsDirectPlay=${mediaSource.supportsDirectPlay}, " + - "supportsDirectStream=${mediaSource.supportsDirectStream}, " + - "supportsTranscoding=${mediaSource.supportsTranscoding}, " + - "hasTranscodingUrl=${!mediaSource.transcodingUrl.isNullOrBlank()}" - ) - } - } } 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 deleted file mode 100644 index bf2d3c8a..00000000 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackDecision.kt +++ /dev/null @@ -1,20 +0,0 @@ -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/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlanner.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlanner.kt deleted file mode 100644 index 96361a56..00000000 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackSourcePlanner.kt +++ /dev/null @@ -1,78 +0,0 @@ -package hu.bbara.purefin.core.data.client - -import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.api.PlayMethod - -internal object PlaybackSourcePlanner { - data class Plan( - val mediaSource: MediaSourceInfo, - val urlStrategy: UrlStrategy, - val playMethod: PlayMethod, - val canRetryWithTranscoding: Boolean, - val usedFallback: Boolean - ) - - enum class UrlStrategy { - DIRECT_PLAY, - DIRECT_STREAM, - TRANSCODE - } - - fun plan(mediaSources: List, forceTranscode: Boolean): Plan? { - val selected = when { - forceTranscode -> mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource -> - PlannedSelection( - mediaSource = mediaSource, - urlStrategy = UrlStrategy.TRANSCODE, - playMethod = PlayMethod.TRANSCODE - ) - } - - else -> mediaSources.firstOrNull { it.supportsDirectPlay }?.let { mediaSource -> - PlannedSelection( - mediaSource = mediaSource, - urlStrategy = UrlStrategy.DIRECT_PLAY, - playMethod = PlayMethod.DIRECT_PLAY - ) - } ?: mediaSources.firstOrNull { it.supportsDirectStream }?.let { mediaSource -> - PlannedSelection( - mediaSource = mediaSource, - urlStrategy = UrlStrategy.DIRECT_STREAM, - playMethod = PlayMethod.DIRECT_STREAM - ) - } ?: mediaSources.firstOrNull { it.supportsTranscoding }?.let { mediaSource -> - PlannedSelection( - mediaSource = mediaSource, - urlStrategy = UrlStrategy.TRANSCODE, - playMethod = PlayMethod.TRANSCODE - ) - } ?: mediaSources.firstOrNull()?.let { mediaSource -> - PlannedSelection( - mediaSource = mediaSource, - urlStrategy = UrlStrategy.DIRECT_STREAM, - playMethod = PlayMethod.DIRECT_STREAM, - usedFallback = true - ) - } - } ?: return null - - return Plan( - mediaSource = selected.mediaSource, - urlStrategy = selected.urlStrategy, - playMethod = selected.playMethod, - canRetryWithTranscoding = !forceTranscode && - selected.playMethod != PlayMethod.TRANSCODE && - mediaSources.any { it.supportsTranscoding }, - usedFallback = selected.usedFallback - ) - } - - fun plan(mediaSource: MediaSourceInfo): Plan? = plan(listOf(mediaSource), forceTranscode = false) - - private data class PlannedSelection( - val mediaSource: MediaSourceInfo, - val urlStrategy: UrlStrategy, - val playMethod: PlayMethod, - val usedFallback: Boolean = false - ) -} diff --git a/core/player/build.gradle.kts b/core/player/build.gradle.kts index 97550e92..d176a9f0 100644 --- a/core/player/build.gradle.kts +++ b/core/player/build.gradle.kts @@ -34,11 +34,9 @@ dependencies { implementation(libs.hilt) ksp(libs.hilt.compiler) implementation(libs.medi3.exoplayer) - implementation(libs.medi3.exoplayer.hls) implementation(libs.media3.datasource.okhttp) implementation(libs.datastore) implementation(libs.kotlinx.serialization.json) implementation(libs.jellyfin.core) implementation(libs.okhttp) - testImplementation(libs.junit) } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt deleted file mode 100644 index ab313efd..00000000 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaLoadResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -package hu.bbara.purefin.core.player.data - -import androidx.media3.common.MediaItem -import hu.bbara.purefin.core.player.model.PlayerError - -sealed interface PlayerMediaLoadResult { - data class Success( - val mediaItem: MediaItem, - val resumePositionMs: Long? - ) : PlayerMediaLoadResult - - data class Failure( - val error: PlayerError - ) : PlayerMediaLoadResult -} 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 e173bf23..ab41283f 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 @@ -1,71 +1,104 @@ package hu.bbara.purefin.core.player.data import android.util.Log -import androidx.annotation.OptIn import androidx.core.net.toUri -import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.offline.Download -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 hu.bbara.purefin.core.player.model.PlayerError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import java.util.UUID import javax.inject.Inject @ViewModelScoped -class PlayerMediaRepository @OptIn(UnstableApi::class) -@Inject constructor( +class PlayerMediaRepository @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, - private val userSessionRepository: UserSessionRepository, - private val mediaRepository: MediaRepository, - private val downloadManager: DownloadManager + private val userSessionRepository: UserSessionRepository ) { - companion object { - private const val TAG = "PlayerMediaRepo" + + suspend fun getMediaItem(mediaId: UUID): Pair? = withContext(Dispatchers.IO) { + val mediaSources = jellyfinApiClient.getMediaSources(mediaId) + val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null + val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( + mediaId = mediaId, + mediaSource = selectedMediaSource + ) ?: return@withContext null + val baseItem = jellyfinApiClient.getItemInfo(mediaId) + + // Calculate resume position + val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource) + + val serverUrl = userSessionRepository.serverUrl.first() + val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) + + val mediaItem = createMediaItem( + mediaId = mediaId.toString(), + playbackUrl = playbackUrl, + title = baseItem?.name ?: selectedMediaSource.name!!, + subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}", + artworkUrl = artworkUrl, + ) + + Pair(mediaItem, resumePositionMs) } - suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) { - when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) { - is PlayerMediaLoadResult.Success -> onlineResult - is PlayerMediaLoadResult.Failure -> { - when (val offlineResult = buildOfflineMediaItem(mediaId)) { - is OfflineLoadResult.Success -> { - Log.w( - TAG, - "Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}" - ) - offlineResult.result - } - - is OfflineLoadResult.Unavailable -> { - val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail) - Log.w( - TAG, - "Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}" - ) - PlayerMediaLoadResult.Failure(finalError) - } + suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set, count: Int = 5): List = withContext(Dispatchers.IO) { + runCatching { + val serverUrl = userSessionRepository.serverUrl.first() + val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) + episodes.mapNotNull { episode -> + val id = episode.id ?: return@mapNotNull null + val stringId = id.toString() + 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 artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) + createMediaItem( + mediaId = stringId, + playbackUrl = playbackUrl, + title = episode.name ?: selectedMediaSource.name!!, + subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}", + artworkUrl = artworkUrl, + ) } + }.getOrElse { error -> + Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error) + emptyList() } } + private fun createMediaItem( + mediaId: String, + playbackUrl: String, + title: String, + subtitle: String?, + artworkUrl: String, + ): MediaItem { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setArtworkUri(artworkUrl.toUri()) + .build() + return MediaItem.Builder() + .setUri(playbackUrl.toUri()) + .setMediaId(mediaId) + .setMediaMetadata(metadata) + .build() + } + private fun calculateResumePosition( baseItem: BaseItemDto?, mediaSource: MediaSourceInfo @@ -89,225 +122,4 @@ class PlayerMediaRepository @OptIn(UnstableApi::class) // Apply thresholds: resume only if 5% ≤ progress ≤ 95% return if (percentage in 5.0..95.0) positionMs else null } - - suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set, count: Int = 5): List = withContext(Dispatchers.IO) { - runCatching { - val serverUrl = userSessionRepository.serverUrl.first() - val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) - episodes.mapNotNull { episode -> - val id = episode.id ?: return@mapNotNull null - val stringId = id.toString() - if (existingIds.contains(stringId)) { - 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 = playbackDecision.url, - title = episode.name ?: selectedMediaSource.name!!, - subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}", - artworkUrl = artworkUrl, - subtitleConfigurations = subtitleConfigs, - tag = playbackDecision.reportContext - ) - } - }.getOrElse { error -> - Log.w(TAG, "Unable to load next-up items for $episodeId", error) - emptyList() - } - } - - private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult { - return try { - val playbackDecision = jellyfinApiClient.getPlaybackDecision( - mediaId = mediaId, - forceTranscode = forceTranscode - ) ?: run { - val detail = if (forceTranscode) { - "Jellyfin did not return a transcoded playback source." - } else { - "Jellyfin did not return a playable media source." - } - Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)") - return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail)) - } - val selectedMediaSource = playbackDecision.mediaSource - val baseItem = jellyfinApiClient.getItemInfo(mediaId) - - val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource) - val serverUrl = userSessionRepository.serverUrl.first() - val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) - val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource) - - val mediaItem = createMediaItem( - mediaId = mediaId.toString(), - playbackUrl = playbackDecision.url, - title = baseItem?.name ?: selectedMediaSource.name.orEmpty(), - subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) }, - artworkUrl = artworkUrl, - subtitleConfigurations = subtitleConfigs, - tag = playbackDecision.reportContext - ) - - PlayerMediaLoadResult.Success(mediaItem, resumePositionMs) - } catch (error: Exception) { - Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error) - PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error)) - } - } - - @OptIn(UnstableApi::class) - private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult { - return try { - val download = downloadManager.downloadIndex.getDownload(mediaId.toString()) - ?.takeIf { it.state == Download.STATE_COMPLETED } - ?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.") - - val serverUrl = userSessionRepository.serverUrl.first() - val movie = mediaRepository.movies.value[mediaId] - val episode = mediaRepository.episodes.value[mediaId] - - val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank { - "Offline media" - } - val subtitle = episode?.let { episodeSubtitle(null, it.index) } - val artworkUrl = when { - movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY) - episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY) - else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) - } - val resumePositionMs = resumePositionFor(movie, episode) - - val mediaItem = createMediaItem( - mediaId = mediaId.toString(), - playbackUrl = download.request.uri.toString(), - title = title, - subtitle = subtitle, - artworkUrl = artworkUrl, - ) - OfflineLoadResult.Success(PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)) - } catch (error: Exception) { - Log.w(TAG, "Offline fallback failed for $mediaId", error) - val technicalDetail = PlayerError.fromThrowable(error).detailText ?: error.javaClass.simpleName - OfflineLoadResult.Unavailable( - "Offline fallback failed: $technicalDetail" - ) - } - } - - private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? { - val progress = movie?.progress ?: episode?.progress ?: return null - val runtime = movie?.runtime ?: episode?.runtime ?: return null - val durationMs = parseRuntimeToMs(runtime) ?: return null - if (durationMs <= 0L) return null - return (durationMs * (progress / 100.0)).toLong().takeIf { it > 0L } - } - - private fun parseRuntimeToMs(runtime: String): Long? { - val trimmed = runtime.trim() - if (trimmed.isBlank() || trimmed == "—") return null - - val hourMatch = Regex("(\\d+)h").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L - val minuteMatch = Regex("(\\d+)m").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L - val totalMinutes = hourMatch * 60L + minuteMatch - return if (totalMinutes > 0L) totalMinutes * 60_000L else null - } - - private fun episodeSubtitle(seasonNumber: Int?, episodeNumber: Int?): String? { - if (seasonNumber == null && episodeNumber == null) return null - return buildString { - if (seasonNumber != null) append("S").append(seasonNumber) - if (episodeNumber != null) { - if (isNotEmpty()) append(":") - append("E").append(episodeNumber) - } - } - } - - @OptIn(UnstableApi::class) - private fun buildExternalSubtitleConfigs( - serverUrl: String, - mediaId: UUID, - mediaSource: MediaSourceInfo - ): List { - val streams = mediaSource.mediaStreams ?: return emptyList() - val mediaSourceId = mediaSource.id ?: return emptyList() - val baseUrl = serverUrl.trimEnd('/') - - return streams - .filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL } - .mapNotNull { stream -> - val codec = stream.codec ?: return@mapNotNull null - val mimeType = subtitleCodecToMimeType(codec) ?: return@mapNotNull null - // Use deliveryUrl from server if available, otherwise construct it - val url = if (!stream.deliveryUrl.isNullOrBlank()) { - if (stream.deliveryUrl!!.startsWith("http")) { - stream.deliveryUrl!! - } else { - "$baseUrl${stream.deliveryUrl}" - } - } else { - val format = if (codec == "subrip") "srt" else codec - "$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format" - } - - Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url") - - MediaItem.SubtitleConfiguration.Builder(url.toUri()) - .setMimeType(mimeType) - .setLanguage(stream.language) - .setLabel(stream.displayTitle ?: stream.language ?: "Track ${stream.index}") - .setSelectionFlags( - if (stream.isForced) C.SELECTION_FLAG_FORCED - else if (stream.isDefault) C.SELECTION_FLAG_DEFAULT - else 0 - ) - .build() - } - } - - @OptIn(UnstableApi::class) - private fun subtitleCodecToMimeType(codec: String): String? = when (codec.lowercase()) { - "srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP - "ass", "ssa" -> MimeTypes.TEXT_SSA - "vtt", "webvtt" -> MimeTypes.TEXT_VTT - "ttml", "dfxp" -> MimeTypes.APPLICATION_TTML - "sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin - "pgs", "pgssub" -> MimeTypes.APPLICATION_PGS - else -> { - Log.w(TAG, "Unknown subtitle codec: $codec") - null - } - } - - private fun createMediaItem( - mediaId: String, - playbackUrl: String, - title: String, - subtitle: String?, - artworkUrl: String, - subtitleConfigurations: List = emptyList(), - tag: PlaybackReportContext? = null - ): MediaItem { - val metadata = MediaMetadata.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setArtworkUri(artworkUrl.toUri()) - .build() - return MediaItem.Builder() - .setUri(playbackUrl.toUri()) - .setMediaId(mediaId) - .setMediaMetadata(metadata) - .setTag(tag) - .setSubtitleConfigurations(subtitleConfigurations) - .build() - } - - private sealed interface OfflineLoadResult { - data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult - data class Unavailable(val detail: String) : OfflineLoadResult - } } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt deleted file mode 100644 index d3c3ebd6..00000000 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt +++ /dev/null @@ -1,43 +0,0 @@ -package hu.bbara.purefin.core.player.manager - -import kotlin.math.abs - -internal class PendingSeekTracker( - private val settleToleranceMs: Long = 500L -) { - private var pendingSeek: PendingSeek? = null - - fun currentPosition(playerPositionMs: Long): Long { - val pending = pendingSeek ?: return playerPositionMs - if (pending.isSatisfiedBy(playerPositionMs, settleToleranceMs)) { - pendingSeek = null - return playerPositionMs - } - return pending.targetPositionMs - } - - fun recordSeek(basePositionMs: Long, targetPositionMs: Long): Long { - val target = targetPositionMs.coerceAtLeast(0L) - pendingSeek = PendingSeek( - targetPositionMs = target, - direction = target.compareTo(basePositionMs) - ) - return target - } - - fun clear() { - pendingSeek = null - } - - private data class PendingSeek( - val targetPositionMs: Long, - val direction: Int - ) { - fun isSatisfiedBy(playerPositionMs: Long, settleToleranceMs: Long): Boolean = - when { - direction > 0 -> playerPositionMs >= targetPositionMs - settleToleranceMs - direction < 0 -> playerPositionMs <= targetPositionMs + settleToleranceMs - else -> abs(playerPositionMs - targetPositionMs) <= settleToleranceMs - } - } -} 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 9fb051f6..6ff4ea9b 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,8 +9,6 @@ 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.PlayerError import hu.bbara.purefin.core.player.model.QueueItemUi import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackType @@ -45,7 +43,6 @@ class PlayerManager @Inject constructor( ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val pendingSeekTracker = PendingSeekTracker() private var currentMediaContext: MediaContext? = null @@ -83,11 +80,7 @@ class PlayerManager @Inject constructor( } override fun onPlayerError(error: PlaybackException) { - _playbackState.update { - it.copy( - error = PlayerError.fromPlaybackException(error) - ) - } + _playbackState.update { it.copy(error = error.errorCodeName ?: error.localizedMessage ?: "Playback error") } } override fun onTracksChanged(tracks: Tracks) { @@ -98,22 +91,9 @@ class PlayerManager @Inject constructor( } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - pendingSeekTracker.clear() refreshMetadata(mediaItem) refreshQueue() } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - if (reason == Player.DISCONTINUITY_REASON_SEEK && - newPosition.positionMs < oldPosition.positionMs - ) { - refreshSubtitleRendererOnBackwardSeek() - } - } } init { @@ -124,36 +104,9 @@ class PlayerManager @Inject constructor( startProgressLoop() } - fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { + fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) { currentMediaContext = mediaContext - pendingSeekTracker.clear() - if (startPositionMs != null) { - player.setMediaItem(mediaItem, startPositionMs) - } else { - player.setMediaItem(mediaItem) - } - player.prepare() - player.playWhenReady = true - _progress.value = PlaybackProgressSnapshot() - refreshMetadata(mediaItem) - refreshQueue() - _playbackState.update { it.copy(isEnded = false, error = null) } - } - - fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { - currentMediaContext = mediaContext - pendingSeekTracker.clear() - 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.setMediaItem(mediaItem) player.prepare() player.playWhenReady = true refreshMetadata(mediaItem) @@ -171,17 +124,16 @@ class PlayerManager @Inject constructor( } fun seekTo(positionMs: Long) { - requestSeek(positionMs) + player.seekTo(positionMs) } fun seekBy(deltaMs: Long) { - val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition) - requestSeek(basePositionMs + deltaMs, basePositionMs) + val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) + seekTo(target) } fun seekToLiveEdge() { if (player.isCurrentMediaItemLive) { - pendingSeekTracker.clear() player.seekToDefaultPosition() player.play() } @@ -189,14 +141,12 @@ class PlayerManager @Inject constructor( fun next() { if (player.hasNextMediaItem()) { - pendingSeekTracker.clear() player.seekToNextMediaItem() } } fun previous() { if (player.hasPreviousMediaItem()) { - pendingSeekTracker.clear() player.seekToPreviousMediaItem() } } @@ -258,7 +208,6 @@ class PlayerManager @Inject constructor( val items = _queue.value val targetIndex = items.indexOfFirst { it.id == id } if (targetIndex >= 0) { - pendingSeekTracker.clear() player.seekToDefaultPosition(targetIndex) player.playWhenReady = true refreshQueue() @@ -269,35 +218,6 @@ class PlayerManager @Inject constructor( _playbackState.update { it.copy(error = null) } } - fun snapshotProgress(): PlaybackProgressSnapshot { - val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs - return PlaybackProgressSnapshot( - durationMs = duration, - positionMs = pendingSeekTracker.currentPosition(player.currentPosition), - bufferedMs = player.bufferedPosition, - isLive = player.isCurrentMediaItemLive - ) - } - - private fun requestSeek(positionMs: Long, basePositionMs: Long = pendingSeekTracker.currentPosition(player.currentPosition)) { - val targetPositionMs = clampSeekPosition(positionMs) - pendingSeekTracker.recordSeek(basePositionMs = basePositionMs, targetPositionMs = targetPositionMs) - _progress.update { - it.copy( - durationMs = player.duration.takeIf { value -> value > 0L } ?: it.durationMs, - positionMs = targetPositionMs, - bufferedMs = player.bufferedPosition, - isLive = player.isCurrentMediaItemLive - ) - } - player.seekTo(targetPositionMs) - } - - private fun clampSeekPosition(positionMs: Long): Long { - val durationMs = player.duration.takeIf { it > 0L } ?: _progress.value.durationMs.takeIf { it > 0L } - return durationMs?.let { positionMs.coerceIn(0L, it) } ?: positionMs.coerceAtLeast(0L) - } - private suspend fun applyTrackPreferences() { val context = currentMediaContext ?: return val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return @@ -350,17 +270,6 @@ class PlayerManager @Inject constructor( } } - private fun refreshSubtitleRendererOnBackwardSeek() { - val currentParams = player.trackSelectionParameters - if (C.TRACK_TYPE_TEXT in currentParams.disabledTrackTypes) return - scope.launch { - player.trackSelectionParameters = currentParams.buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - .build() - player.trackSelectionParameters = currentParams - } - } - fun release() { scope.cancel() player.removeListener(listener) @@ -370,7 +279,15 @@ class PlayerManager @Inject constructor( private fun startProgressLoop() { scope.launch { while (isActive) { - _progress.value = snapshotProgress() + val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs + val position = player.currentPosition + val buffered = player.bufferedPosition + _progress.value = PlaybackProgressSnapshot( + durationMs = duration, + positionMs = position, + bufferedMs = buffered, + isLive = player.isCurrentMediaItemLive + ) delay(500) } } @@ -394,12 +311,10 @@ 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(), - playbackReportContext = playbackReportContext ) } @@ -412,7 +327,7 @@ data class PlaybackStateSnapshot( val isPlaying: Boolean = false, val isBuffering: Boolean = false, val isEnded: Boolean = false, - val error: PlayerError? = null + val error: String? = null ) data class PlaybackProgressSnapshot( @@ -426,7 +341,6 @@ data class MetadataState( val mediaId: String? = null, val title: 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 f3a633eb..4ef203d1 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,7 +3,6 @@ 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 @@ -26,16 +25,10 @@ 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 - fun syncProgress(snapshot: PlaybackProgressSnapshot) { - lastPositionMs = snapshot.positionMs - lastDurationMs = snapshot.durationMs - } - fun bind( playbackState: StateFlow, progress: StateFlow, @@ -49,7 +42,6 @@ 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)) { @@ -58,20 +50,19 @@ 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, meta.playbackReportContext) + startSession(mediaId, prog.positionMs) } } } } - private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) { + private fun startSession(itemId: UUID, positionMs: Long) { activeItemId = itemId - activePlaybackReportContext = reportContext - report(itemId, positionMs, reportContext = reportContext, isStart = true) + report(itemId, positionMs, isStart = true) progressJob = scope.launch { while (isActive) { delay(5000) - report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused) + report(itemId, lastPositionMs, isPaused = isPaused) } } } @@ -79,7 +70,7 @@ class ProgressManager @Inject constructor( private fun stopSession() { progressJob?.cancel() activeItemId?.let { itemId -> - report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true) + report(itemId, lastPositionMs, isStop = true) scope.launch(Dispatchers.IO) { try { updateWatchProgressUseCase(itemId, lastPositionMs, lastDurationMs) @@ -89,13 +80,11 @@ class ProgressManager @Inject constructor( } } activeItemId = null - activePlaybackReportContext = null } private fun report( itemId: UUID, positionMs: Long, - reportContext: PlaybackReportContext?, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false @@ -103,13 +92,10 @@ class ProgressManager @Inject constructor( val ticks = positionMs * 10_000 scope.launch(Dispatchers.IO) { try { - if (reportContext == null) { - return@launch - } when { - isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks, reportContext) - isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext) - else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused, reportContext) + isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks) + isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks) + else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused) } Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused") } catch (e: Exception) { @@ -126,9 +112,7 @@ class ProgressManager @Inject constructor( val durMs = lastDurationMs CoroutineScope(Dispatchers.IO).launch { try { - activePlaybackReportContext?.let { reportContext -> - jellyfinApiClient.reportPlaybackStopped(itemId, ticks, reportContext) - } + jellyfinApiClient.reportPlaybackStopped(itemId, ticks) 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/model/PlayerError.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerError.kt deleted file mode 100644 index 666ed94a..00000000 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerError.kt +++ /dev/null @@ -1,110 +0,0 @@ -package hu.bbara.purefin.core.player.model - -import androidx.media3.common.PlaybackException - -enum class PlayerErrorSource { - LOAD, - PLAYBACK -} - -data class PlayerError( - val summary: String, - val technicalDetail: String? = null, - val source: PlayerErrorSource, - val errorCode: Int? = null, - val errorCodeName: String? = null, - val retryable: Boolean = false -) { - val detailText: String? - get() = technicalDetail?.takeIf { detail -> - detail.isNotBlank() && !detail.equals(summary, ignoreCase = true) - } - - fun withAdditionalTechnicalDetail(additionalDetail: String?): PlayerError { - val normalizedAdditionalDetail = additionalDetail?.trim()?.takeIf { it.isNotEmpty() } ?: return this - val mergedDetail = linkedSetOf().apply { - technicalDetail?.trim()?.takeIf { it.isNotEmpty() }?.let(::add) - add(normalizedAdditionalDetail) - }.joinToString(" | ") - - return copy(technicalDetail = mergedDetail) - } - - companion object { - fun loadFailure( - summary: String = "Unable to load media", - technicalDetail: String? = null, - retryable: Boolean = true - ): PlayerError { - return PlayerError( - summary = summary, - technicalDetail = technicalDetail, - source = PlayerErrorSource.LOAD, - retryable = retryable - ) - } - - fun invalidMediaId(mediaId: String): PlayerError { - return PlayerError( - summary = "Invalid media id", - technicalDetail = "The requested media id is not a valid UUID: $mediaId", - source = PlayerErrorSource.LOAD, - retryable = false - ) - } - - fun fromPlaybackException(error: PlaybackException): PlayerError { - return playbackFailure( - errorCode = error.errorCode, - errorCodeName = error.errorCodeName, - technicalDetail = error.localizedMessage, - cause = error.cause - ) - } - - fun playbackFailure( - errorCode: Int? = null, - errorCodeName: String? = null, - technicalDetail: String? = null, - cause: Throwable? = null, - retryable: Boolean = true - ): PlayerError { - val mergedTechnicalDetail = linkedSetOf().apply { - errorCodeName?.takeIf { it.isNotBlank() }?.let(::add) - technicalDetail?.takeIf { it.isNotBlank() }?.let(::add) - cause?.toTechnicalDetail()?.let(::add) - }.joinToString(" | ").ifBlank { null } - - return PlayerError( - summary = "Playback error", - technicalDetail = mergedTechnicalDetail, - source = PlayerErrorSource.PLAYBACK, - errorCode = errorCode, - errorCodeName = errorCodeName, - retryable = retryable - ) - } - - fun fromThrowable( - throwable: Throwable, - summary: String = "Unable to load media", - retryable: Boolean = true - ): PlayerError { - return loadFailure( - summary = summary, - technicalDetail = throwable.toTechnicalDetail(), - retryable = retryable - ) - } - - internal fun Throwable.toTechnicalDetail(): String { - val typeName = this::class.simpleName ?: javaClass.simpleName.ifBlank { javaClass.name } - val message = message?.trim().takeIf { !it.isNullOrEmpty() } - return if (message != null) { - "$typeName: $message" - } else { - typeName - } - } - } -} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt index d28c7fcd..a35868e9 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/model/PlayerUiModels.kt @@ -10,7 +10,7 @@ data class PlayerUiState( val durationMs: Long = 0L, val positionMs: Long = 0L, val bufferedMs: Long = 0L, - val error: PlayerError? = null, + val error: String? = null, val playbackSpeed: Float = 1f, val chapters: List = emptyList(), val ads: List = emptyList(), diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt index 81ba30c7..e9000e0e 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt @@ -18,7 +18,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped -import hu.bbara.purefin.core.data.client.AndroidDeviceProfile @Module @InstallIn(ViewModelComponent::class) @@ -28,7 +27,6 @@ object VideoPlayerModule { @Provides @ViewModelScoped fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player { - val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(application) val trackSelector = DefaultTrackSelector(application) val audioAttributes = AudioAttributes.Builder() @@ -39,24 +37,15 @@ object VideoPlayerModule { trackSelector.setParameters( trackSelector .buildUponParameters() - .setMaxAudioChannelCount(capabilitySnapshot.maxAudioChannels) - .setExceedAudioConstraintsIfNecessary(false) .setTunnelingEnabled(false) -// .setPreferredAudioLanguage( -// appPreferences.getValue(appPreferences.preferredAudioLanguage) -// ) -// .setPreferredTextLanguage( -// appPreferences.getValue(appPreferences.preferredSubtitleLanguage) -// ) ) val loadControl = DefaultLoadControl.Builder() .setBufferDurationsMs( - 15_000, // minBufferMs - 50_000, // maxBufferMs - 2_500, // bufferForPlaybackMs - 5_000 // bufferForPlaybackAfterRebufferMs + 10_000, + 30_000, + 5_000, + 5_000 ) - .setPrioritizeTimeOverSizeThresholds(true) .build() // Configure RenderersFactory to use all available decoders and enable passthrough diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt deleted file mode 100644 index 11bb23aa..00000000 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt +++ /dev/null @@ -1,58 +0,0 @@ -package hu.bbara.purefin.core.player.viewmodel - -import androidx.media3.common.PlaybackException -import hu.bbara.purefin.core.data.client.PlaybackReportContext -import hu.bbara.purefin.core.player.model.PlayerError -import hu.bbara.purefin.core.player.model.PlayerErrorSource -import org.jellyfin.sdk.model.api.PlayMethod - -internal object PlaybackRetryPolicy { - private val retryableErrorCodes = setOf( - PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, - PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED, - PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED, - PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED, - PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, - PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED, - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, - PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED - ) - - fun shouldRetryWithTranscoding( - error: PlayerError, - playbackReportContext: PlaybackReportContext - ): Boolean { - if (error.source != PlayerErrorSource.PLAYBACK) { - return false - } - if (!playbackReportContext.canRetryWithTranscoding) { - return false - } - if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) { - return false - } - - val errorCode = error.errorCode - if (errorCode != null && errorCode in retryableErrorCodes) { - return true - } - - val detail = buildString { - append(error.summary) - error.errorCodeName?.let { - append(' ') - append(it) - } - error.detailText?.let { - append(' ') - append(it) - } - }.lowercase() - - return "decoder" in detail || - "codec" in detail || - "unsupported" in detail || - "audiotrack" in detail || - "audio sink" in detail - } -} 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 86227ae1..4e3cf253 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 @@ -5,14 +5,10 @@ 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.player.data.PlayerMediaLoadResult import hu.bbara.purefin.core.player.data.PlayerMediaRepository import hu.bbara.purefin.core.player.manager.MediaContext -import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot import hu.bbara.purefin.core.player.manager.PlayerManager import hu.bbara.purefin.core.player.manager.ProgressManager -import hu.bbara.purefin.core.player.model.PlayerError -import hu.bbara.purefin.core.player.model.PlayerErrorSource import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.TrackOption import kotlinx.coroutines.Job @@ -46,10 +42,7 @@ class PlayerViewModel @Inject constructor( private var autoHideJob: Job? = null private var lastNextUpMediaId: String? = null - private var loadError: PlayerError? = null - private var activeMediaId: String? = null - private var transcodingRetryMediaId: String? = null - private var lastLoadRequest: PendingLoadRequest? = null + private var dataErrorMessage: String? = null init { progressManager.bind( @@ -64,16 +57,12 @@ 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, isBuffering = state.isBuffering, isEnded = state.isEnded, - error = state.error ?: loadError + error = state.error ?: dataErrorMessage ) } if (state.isEnded) { @@ -104,10 +93,6 @@ class PlayerViewModel @Inject constructor( ) } val currentMediaId = metadata.mediaId - if (currentMediaId != activeMediaId) { - activeMediaId = currentMediaId - transcodingRetryMediaId = null - } if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) { lastNextUpMediaId = currentMediaId loadNextUp(currentMediaId) @@ -148,79 +133,37 @@ 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 - ) { - lastLoadRequest = PendingLoadRequest( - id = id, - forceTranscode = forceTranscode, - startPositionMsOverride = startPositionMsOverride, - replaceCurrent = replaceCurrent - ) val uuid = id.toUuidOrNull() if (uuid == null) { - loadError = PlayerError.invalidMediaId(id) - _uiState.update { it.copy(isBuffering = false, error = loadError) } + dataErrorMessage = "Invalid media id" + _uiState.update { it.copy(error = dataErrorMessage) } return } - loadError = null - _uiState.update { it.copy(isBuffering = true, error = null) } viewModelScope.launch { - val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode) - when (result) { - is PlayerMediaLoadResult.Success -> { - val mediaItem = result.mediaItem - val resumePositionMs = result.resumePositionMs + val result = playerMediaRepository.getMediaItem(uuid) + 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 + // 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) - if (replaceCurrent) { - playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs) - } else { - playerManager.play(mediaItem, mediaContext, startPositionMs) - } + playerManager.play(mediaItem, mediaContext) - if (loadError != null) { - loadError = null - _uiState.update { it.copy(error = null) } - } - } - - is PlayerMediaLoadResult.Failure -> { - loadError = result.error - _uiState.update { it.copy(isBuffering = false, error = loadError) } + // Seek to resume position after play() is called + resumePositionMs?.let { playerManager.seekTo(it) } + + if (dataErrorMessage != null) { + dataErrorMessage = null + _uiState.update { it.copy(error = null) } } + } else { + dataErrorMessage = "Unable to load media" + _uiState.update { it.copy(error = dataErrorMessage) } } } } - private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean { - val currentMediaId = playerManager.metadata.value.mediaId ?: return false - val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false - val error = state.error ?: return false - - if (currentMediaId == transcodingRetryMediaId) return false - if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false - - transcodingRetryMediaId = currentMediaId - loadMediaById( - id = currentMediaId, - forceTranscode = true, - startPositionMsOverride = player.currentPosition.takeIf { it > 0L }, - replaceCurrent = true - ) - return true - } - private fun loadNextUp(currentMediaId: String) { val uuid = currentMediaId.toUuidOrNull() ?: return viewModelScope.launch { @@ -260,11 +203,6 @@ class PlayerViewModel @Inject constructor( if (_controlsVisible.value) scheduleAutoHide() } - fun hideControls() { - _controlsVisible.value = false - autoHideJob?.cancel() - } - private fun scheduleAutoHide() { autoHideJob?.cancel() if (!player.isPlaying) return @@ -294,11 +232,6 @@ class PlayerViewModel @Inject constructor( } fun retry() { - val error = uiState.value.error - if (error?.source == PlayerErrorSource.LOAD) { - retryLoad() - return - } playerManager.retry() } @@ -308,7 +241,7 @@ class PlayerViewModel @Inject constructor( } fun clearError() { - loadError = null + dataErrorMessage = null playerManager.clearError() _uiState.update { it.copy(error = null) } } @@ -316,27 +249,9 @@ class PlayerViewModel @Inject constructor( override fun onCleared() { super.onCleared() autoHideJob?.cancel() - progressManager.syncProgress(playerManager.snapshotProgress()) progressManager.release() playerManager.release() } - private fun retryLoad() { - val request = lastLoadRequest ?: return - loadMediaById( - id = request.id, - forceTranscode = request.forceTranscode, - startPositionMsOverride = request.startPositionMsOverride, - replaceCurrent = request.replaceCurrent - ) - } - private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() - - private data class PendingLoadRequest( - val id: String, - val forceTranscode: Boolean, - val startPositionMsOverride: Long?, - val replaceCurrent: Boolean - ) } diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt deleted file mode 100644 index f452cae8..00000000 --- a/core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package hu.bbara.purefin.core.player.manager - -import org.junit.Assert.assertEquals -import org.junit.Test - -class PendingSeekTrackerTest { - - @Test - fun repeatedForwardSeeksAccumulateWhilePlayerPositionIsStale() { - val tracker = PendingSeekTracker() - - val firstBase = tracker.currentPosition(playerPositionMs = 30_000L) - tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 40_000L) - - val secondBase = tracker.currentPosition(playerPositionMs = 30_000L) - tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase + 10_000L) - - assertEquals(50_000L, tracker.currentPosition(playerPositionMs = 30_000L)) - } - - @Test - fun repeatedBackwardSeeksAccumulateWhilePlayerPositionIsStale() { - val tracker = PendingSeekTracker() - - val firstBase = tracker.currentPosition(playerPositionMs = 30_000L) - tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 20_000L) - - val secondBase = tracker.currentPosition(playerPositionMs = 30_000L) - tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase - 10_000L) - - assertEquals(10_000L, tracker.currentPosition(playerPositionMs = 30_000L)) - } - - @Test - fun pendingForwardSeekClearsOncePlayerCatchesUp() { - val tracker = PendingSeekTracker() - - tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 40_000L) - - assertEquals(40_000L, tracker.currentPosition(playerPositionMs = 30_000L)) - assertEquals(39_700L, tracker.currentPosition(playerPositionMs = 39_700L)) - assertEquals(41_000L, tracker.currentPosition(playerPositionMs = 41_000L)) - } - - @Test - fun pendingBackwardSeekClearsOncePlayerCatchesUp() { - val tracker = PendingSeekTracker() - - tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 20_000L) - - assertEquals(20_000L, tracker.currentPosition(playerPositionMs = 30_000L)) - assertEquals(20_300L, tracker.currentPosition(playerPositionMs = 20_300L)) - assertEquals(19_000L, tracker.currentPosition(playerPositionMs = 19_000L)) - } -} diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt deleted file mode 100644 index a7dcaba9..00000000 --- a/core/player/src/test/java/hu/bbara/purefin/core/player/model/PlayerErrorTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package hu.bbara.purefin.core.player.model - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class PlayerErrorTest { - @Test - fun `playbackFailure preserves code and detail`() { - val playerError = PlayerError.playbackFailure( - errorCode = 4001, - errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED", - technicalDetail = "Decoder init failed on test device" - ) - - assertEquals("Playback error", playerError.summary) - assertEquals(PlayerErrorSource.PLAYBACK, playerError.source) - assertEquals(4001, playerError.errorCode) - assertEquals("ERROR_CODE_DECODER_INIT_FAILED", playerError.errorCodeName) - assertNotNull(playerError.detailText) - assertTrue(playerError.detailText!!.contains("ERROR_CODE_DECODER_INIT_FAILED")) - assertTrue(playerError.detailText!!.contains("Decoder init failed on test device")) - } - - @Test - fun `withAdditionalTechnicalDetail appends distinct detail`() { - val playerError = PlayerError.loadFailure(technicalDetail = "IllegalStateException: boom") - - val mergedError = playerError.withAdditionalTechnicalDetail( - "Offline fallback unavailable: no completed download." - ) - - assertTrue(mergedError.detailText!!.contains("IllegalStateException: boom")) - assertTrue(mergedError.detailText!!.contains("Offline fallback unavailable: no completed download.")) - } - - @Test - fun `invalidMediaId is a non retryable load error`() { - val playerError = PlayerError.invalidMediaId("abc") - - assertEquals("Invalid media id", playerError.summary) - assertEquals(PlayerErrorSource.LOAD, playerError.source) - assertFalse(playerError.retryable) - assertTrue(playerError.detailText!!.contains("abc")) - } -} diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt deleted file mode 100644 index ef271bd3..00000000 --- a/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package hu.bbara.purefin.core.player.viewmodel - -import androidx.media3.common.PlaybackException -import hu.bbara.purefin.core.data.client.PlaybackReportContext -import hu.bbara.purefin.core.player.model.PlayerError -import hu.bbara.purefin.core.player.model.PlayerErrorSource -import org.jellyfin.sdk.model.api.PlayMethod -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class PlaybackRetryPolicyTest { - @Test - fun `decoder failures are retried when transcoding is available`() { - val error = PlayerError( - summary = "Playback error", - technicalDetail = "Decoder init failed", - source = PlayerErrorSource.PLAYBACK, - errorCode = PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, - errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED", - retryable = true - ) - - val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY) - - assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) - } - - @Test - fun `transcoded playback is not retried again`() { - val error = PlayerError( - summary = "Playback error", - technicalDetail = "Unsupported container", - source = PlayerErrorSource.PLAYBACK, - errorCodeName = "ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED", - retryable = true - ) - - val playbackReportContext = playbackReportContext(playMethod = PlayMethod.TRANSCODE) - - assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) - } - - @Test - fun `load errors are never retried as transcoding fallbacks`() { - val error = PlayerError.loadFailure(technicalDetail = "No playable source was returned.") - - val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY) - - assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) - } - - @Test - fun `unsupported codec detail is retryable without an error code`() { - val error = PlayerError( - summary = "Playback error", - technicalDetail = "Codec unsupported by this device", - source = PlayerErrorSource.PLAYBACK - ) - - val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_STREAM) - - assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) - } - - @Test - fun `audio track init failures are retried when transcoding is available`() { - val error = PlayerError( - summary = "Playback error", - technicalDetail = "AudioTrack init failed", - source = PlayerErrorSource.PLAYBACK, - errorCode = PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED, - errorCodeName = "ERROR_CODE_AUDIO_TRACK_INIT_FAILED", - retryable = true - ) - - val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY) - - assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) - } - - private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext { - return PlaybackReportContext( - playMethod = playMethod, - mediaSourceId = "source-id", - audioStreamIndex = 0, - subtitleStreamIndex = null, - liveStreamId = null, - playSessionId = "session-id", - canRetryWithTranscoding = true - ) - } -}