mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -71,6 +71,7 @@ dependencies {
|
|||||||
implementation(libs.medi3.ui)
|
implementation(libs.medi3.ui)
|
||||||
implementation(libs.medi3.exoplayer)
|
implementation(libs.medi3.exoplayer)
|
||||||
implementation(libs.medi3.ui.compose)
|
implementation(libs.medi3.ui.compose)
|
||||||
|
implementation(libs.medi3.ffmpeg.decoder)
|
||||||
implementation(libs.androidx.navigation3.runtime)
|
implementation(libs.androidx.navigation3.runtime)
|
||||||
implementation(libs.androidx.navigation3.ui)
|
implementation(libs.androidx.navigation3.ui)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,40 +256,38 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
mediaId,
|
mediaId,
|
||||||
PlaybackInfoDto(
|
PlaybackInfoDto(
|
||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
deviceProfile =
|
deviceProfile = AndroidDeviceProfile.create(),
|
||||||
//TODO check this
|
maxStreamingBitrate = 100_000_000,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Log.d("getMediaSources", result.toString())
|
Log.d("getMediaSources", result.toString())
|
||||||
result.content.mediaSources
|
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()) {
|
if (!ensureConfigured()) {
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
val response = api.videosApi.getVideoStreamUrl(
|
|
||||||
itemId = mediaId,
|
// Check if transcoding is required based on the MediaSourceInfo from getMediaSources
|
||||||
static = true,
|
val shouldTranscode = mediaSource.supportsTranscoding == true &&
|
||||||
mediaSourceId = mediaSourceId,
|
(mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null)
|
||||||
)
|
|
||||||
Log.d("getMediaPlaybackUrl", response)
|
val url = if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) {
|
||||||
response
|
// 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) {
|
suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null
|
val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null
|
||||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
||||||
mediaId = mediaId,
|
mediaId = mediaId,
|
||||||
mediaSourceId = selectedMediaSource.id
|
mediaSource = selectedMediaSource
|
||||||
) ?: return@withContext null
|
) ?: return@withContext null
|
||||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
|
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null
|
||||||
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
|
||||||
mediaId = id,
|
mediaId = id,
|
||||||
mediaSourceId = selectedMediaSource.id
|
mediaSource = selectedMediaSource
|
||||||
) ?: return@mapNotNull null
|
) ?: return@mapNotNull null
|
||||||
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY)
|
||||||
createMediaItem(
|
createMediaItem(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.media3.common.C
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.SeekParameters
|
import androidx.media3.exoplayer.SeekParameters
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
@@ -50,11 +51,18 @@ object VideoPlayerModule {
|
|||||||
5_000
|
5_000
|
||||||
)
|
)
|
||||||
.build()
|
.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)
|
.setTrackSelector(trackSelector)
|
||||||
.setPauseAtEndOfMediaItems(true)
|
.setPauseAtEndOfMediaItems(true)
|
||||||
.setLoadControl(loadControl)
|
.setLoadControl(loadControl)
|
||||||
.setSeekParameters(SeekParameters.CLOSEST_SYNC)
|
.setSeekParameters(SeekParameters.CLOSEST_SYNC)
|
||||||
|
.setAudioAttributes(audioAttributes, true)
|
||||||
.build()
|
.build()
|
||||||
.apply {
|
.apply {
|
||||||
playWhenReady = true
|
playWhenReady = true
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ okhttp = "4.12.0"
|
|||||||
foundation = "1.10.1"
|
foundation = "1.10.1"
|
||||||
coil = "3.3.0"
|
coil = "3.3.0"
|
||||||
media3 = "1.9.0"
|
media3 = "1.9.0"
|
||||||
|
media3FfmpegDecoder = "1.9.0+1"
|
||||||
nav3Core = "1.0.0"
|
nav3Core = "1.0.0"
|
||||||
room = "2.6.1"
|
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-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
|
||||||
medi3-ui = { group = "androidx.media3", name = "media3-ui", 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-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-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", 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" }
|
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
|
|||||||
Reference in New Issue
Block a user