diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea3e3e2..50b8898 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(libs.medi3.ui) implementation(libs.medi3.exoplayer) implementation(libs.medi3.ui.compose) + implementation(libs.medi3.ffmpeg.decoder) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.room.ktx) diff --git a/app/src/main/java/hu/bbara/purefin/client/AndroidDeviceProfile.kt b/app/src/main/java/hu/bbara/purefin/client/AndroidDeviceProfile.kt new file mode 100644 index 0000000..9001798 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/client/AndroidDeviceProfile.kt @@ -0,0 +1,141 @@ +package hu.bbara.purefin.client + +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.SubtitleDeliveryMethod +import org.jellyfin.sdk.model.api.SubtitleProfile + +/** + * 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 { + + fun create(): DeviceProfile { + // Debug: Log all available decoders + CodecDebugHelper.logAvailableDecoders() + + val audioCodecs = getAudioCodecs() + val videoCodecs = getVideoCodecs() + + Log.d("AndroidDeviceProfile", "Supported audio codecs: ${audioCodecs.joinToString()}") + Log.d("AndroidDeviceProfile", "Supported video codecs: ${videoCodecs.joinToString()}") + + // 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") + + return DeviceProfile( + name = "Android", + 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", + videoCodec = videoCodecs.joinToString(","), + audioCodec = audioCodecs.joinToString(",") + ) + ), + + // Empty transcoding profiles - Jellyfin will use its defaults + transcodingProfiles = emptyList(), + + codecProfiles = emptyList(), + + subtitleProfiles = listOf( + 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() + + // 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 + ) + + 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 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 + ) + + 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 { + return 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 + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/client/CodecDebugHelper.kt b/app/src/main/java/hu/bbara/purefin/client/CodecDebugHelper.kt new file mode 100644 index 0000000..f81adfa --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/client/CodecDebugHelper.kt @@ -0,0 +1,66 @@ +package hu.bbara.purefin.client + +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.util.Log + +/** + * Helper to debug available audio/video codecs on the device. + */ +object CodecDebugHelper { + + private const val TAG = "CodecDebug" + + /** + * Logs all available decoders on this device. + * Call this to understand what your device can actually decode. + */ + fun logAvailableDecoders() { + try { + val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) + Log.d(TAG, "=== Available Audio Decoders ===") + + codecList.codecInfos + .filter { !it.isEncoder } + .forEach { codecInfo -> + codecInfo.supportedTypes.forEach { mimeType -> + if (mimeType.startsWith("audio/")) { + Log.d(TAG, "${codecInfo.name}: $mimeType") + if (mimeType.contains("dts", ignoreCase = true) || + mimeType.contains("truehd", ignoreCase = true)) { + Log.w(TAG, " ^^^ DTS/TrueHD decoder found! ^^^") + } + } + } + } + + Log.d(TAG, "=== Available Video Decoders ===") + codecList.codecInfos + .filter { !it.isEncoder } + .forEach { codecInfo -> + codecInfo.supportedTypes.forEach { mimeType -> + if (mimeType.startsWith("video/")) { + Log.d(TAG, "${codecInfo.name}: $mimeType") + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to list codecs", e) + } + } + + /** + * Check if a specific MIME type has a decoder available. + */ + fun hasDecoderFor(mimeType: String): Boolean { + return try { + val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) + codecList.codecInfos.any { codecInfo -> + !codecInfo.isEncoder && + codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) } + } + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index 29d4a1d..d4b55fa 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -256,40 +256,38 @@ class JellyfinApiClient @Inject constructor( mediaId, PlaybackInfoDto( userId = getUserId(), - deviceProfile = - //TODO check this - DeviceProfile( - name = "Direct play all", - maxStaticBitrate = 1_000_000_000, - maxStreamingBitrate = 1_000_000_000, - codecProfiles = emptyList(), - containerProfiles = emptyList(), - directPlayProfiles = emptyList(), - transcodingProfiles = emptyList(), - subtitleProfiles = - listOf( - SubtitleProfile("srt", SubtitleDeliveryMethod.EXTERNAL), - SubtitleProfile("ass", SubtitleDeliveryMethod.EXTERNAL), - ), - ), - maxStreamingBitrate = 1_000_000_000, + deviceProfile = AndroidDeviceProfile.create(), + maxStreamingBitrate = 100_000_000, ), ) Log.d("getMediaSources", result.toString()) result.content.mediaSources } - suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSourceId: String? = null): String? = withContext(Dispatchers.IO) { + suspend fun getMediaPlaybackUrl(mediaId: UUID, mediaSource: MediaSourceInfo): String? = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext null } - val response = api.videosApi.getVideoStreamUrl( - itemId = mediaId, - static = true, - mediaSourceId = mediaSourceId, - ) - Log.d("getMediaPlaybackUrl", response) - response + + // Check if transcoding is required based on the MediaSourceInfo from getMediaSources + val shouldTranscode = mediaSource.supportsTranscoding == true && + (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) + + 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("getMediaPlaybackUrl", "Direct play: ${!shouldTranscode}, URL: $url") + url } suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt index 87ccd94..80decf5 100644 --- a/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/player/data/PlayerMediaRepository.kt @@ -27,7 +27,7 @@ class PlayerMediaRepository @Inject constructor( val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( mediaId = mediaId, - mediaSourceId = selectedMediaSource.id + mediaSource = selectedMediaSource ) ?: return@withContext null val baseItem = jellyfinApiClient.getItemInfo(mediaId) @@ -85,7 +85,7 @@ class PlayerMediaRepository @Inject constructor( val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( mediaId = id, - mediaSourceId = selectedMediaSource.id + mediaSource = selectedMediaSource ) ?: return@mapNotNull null val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) createMediaItem( diff --git a/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt index 594b726..4a342ac 100644 --- a/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt +++ b/app/src/main/java/hu/bbara/purefin/player/module/VideoPlayerModule.kt @@ -7,6 +7,7 @@ import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.SeekParameters import androidx.media3.exoplayer.trackselection.DefaultTrackSelector @@ -50,11 +51,18 @@ object VideoPlayerModule { 5_000 ) .build() - return ExoPlayer.Builder(application) + + // Configure RenderersFactory to use all available decoders and enable passthrough + val renderersFactory = DefaultRenderersFactory(application) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + .setEnableDecoderFallback(true) + + return ExoPlayer.Builder(application, renderersFactory) .setTrackSelector(trackSelector) .setPauseAtEndOfMediaItems(true) .setLoadControl(loadControl) .setSeekParameters(SeekParameters.CLOSEST_SYNC) + .setAudioAttributes(audioAttributes, true) .build() .apply { playWhenReady = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c59cb6..e637c7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ okhttp = "4.12.0" foundation = "1.10.1" coil = "3.3.0" media3 = "1.9.0" +media3FfmpegDecoder = "1.9.0+1" nav3Core = "1.0.0" room = "2.6.1" @@ -53,6 +54,7 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp" medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"} medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"} medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"} +medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"} androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }