feat: add FFmpeg decoder support for unsupported audio codecs

Add support for audio codecs like DTS-HD that aren't natively supported
by Android's MediaCodec by integrating Jellyfin's FFmpeg decoder extension.

Changes:
- Add media3-ffmpeg-decoder dependency for software audio decoding
- Create AndroidDeviceProfile to detect and report device codec capabilities
- Configure ExoPlayer with extension renderer mode and decoder fallback
- Update playback URL selection to use transcoding when direct play unsupported
- Add CodecDebugHelper for debugging available device codecs

This fixes playback failures when encountering premium audio formats
by falling back to FFmpeg software decoding when hardware decoding fails.
This commit is contained in:
2026-02-15 21:43:08 +01:00
parent 798e95da0d
commit 74b1c19c0c
7 changed files with 244 additions and 28 deletions

View File

@@ -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)

View File

@@ -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<String> {
val supportedCodecs = mutableListOf<String>()
// 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<String> {
val supportedCodecs = mutableListOf<String>()
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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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

View File

@@ -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" }