feat(android): play the original file — decode Dolby/DTS audio with FFmpeg
Android ships no AC-3, E-AC-3, DTS or TrueHD decoders; they are licensed codecs, present only where a vendor paid for them. The ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all. So every film with Dolby audio was re-encoded by the server, for streaming and for download alike, and a transcoded download has no Content-Length and ignores Range: ~1 MB/s, restarting from byte zero on every network blip. ExoPlayer now carries Jellyfin's media3 FFmpeg audio decoder in extension mode ON (platform decoders first, FFmpeg for what they lack), and CodecDetector reports its codecs so the device profile and the download policy agree with what actually decodes. The download policy judges audio against the renderer that will play the file (renderer_can_decode_audio) instead of the webview's list, so Android downloads are always the direct copy — a 910 MB E-AC-3 5.1 episode downloaded in 94 s and played offline. The webview video path is removed on Android: it decodes none of these codecs, so a stored "native video off" would play every original-file download silent. Rust reports webview_video_fallback (false on Android, true only beside mpv native video on Linux); Settings offers the switch and the player honours it only then. Linux keeps the fallback and, with it, the server transcode for undecodable audio. The decoder is GPL-3.0; the distributed APK carries its terms and the source stays MIT (THIRD_PARTY_NOTICES.md). The on-device remux spec this replaces is folded into 05-platform-backends.md and deleted. DR-293, UT-259, UT-262.
This commit is contained in:
@@ -172,6 +172,16 @@ dependencies {
|
||||
// itself: without a view to hand them to, a selected subtitle track renders
|
||||
// nowhere. See JellyTauPlayer.onCues. (DR-260)
|
||||
implementation("androidx.media3:media3-ui:1.5.0")
|
||||
// Software audio decoders for what Android does not ship: AC-3, E-AC-3,
|
||||
// DTS and TrueHD are licensed codecs, present only where a vendor paid for
|
||||
// them (the ROD2-W09 tablet has DTS but no AC-3/E-AC-3 at all). With this,
|
||||
// ExoPlayer plays the source file as-is, so neither a download nor a stream
|
||||
// needs the server to re-encode its audio (DR-293). Jellyfin's own build of
|
||||
// the media3 FFmpeg extension, versioned to match media3 above — keep the
|
||||
// two in step. Licence: GPL-3.0 — the distributed APK carries its terms,
|
||||
// the source stays MIT; see THIRD_PARTY_NOTICES.md and
|
||||
// docs/architecture/05-platform-backends.md.
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
|
||||
implementation("com.google.guava:guava:33.0.0-android")
|
||||
|
||||
// Media library for VolumeProviderCompat (remote volume control)
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.dtourolle.jellytau.player
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecList
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
|
||||
/**
|
||||
@@ -13,9 +16,24 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
* This class queries the device's media codec capabilities and reports
|
||||
* them to the Rust backend via JNI for accurate DeviceProfile generation.
|
||||
*/
|
||||
@OptIn(UnstableApi::class) // FfmpegLibrary and the licensed-codec MimeTypes
|
||||
object CodecDetector {
|
||||
private const val TAG = "CodecDetector"
|
||||
|
||||
/**
|
||||
* Formats the FFmpeg extension can decode, as Jellyfin codec names. The
|
||||
* platform decodes the rest itself; these are the licensed codecs a device
|
||||
* often lacks.
|
||||
*/
|
||||
private val FFMPEG_AUDIO_FORMATS = listOf(
|
||||
MimeTypes.AUDIO_AC3 to "ac3",
|
||||
MimeTypes.AUDIO_E_AC3 to "eac3",
|
||||
MimeTypes.AUDIO_E_AC3_JOC to "eac3",
|
||||
MimeTypes.AUDIO_DTS to "dts",
|
||||
MimeTypes.AUDIO_DTS_HD to "dts",
|
||||
MimeTypes.AUDIO_TRUEHD to "truehd",
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class to hold detected codec capabilities.
|
||||
*/
|
||||
@@ -67,6 +85,25 @@ object CodecDetector {
|
||||
}
|
||||
}
|
||||
|
||||
// The FFmpeg extension decodes in software what the platform lacks.
|
||||
// ExoPlayer uses it for playback (JellyTauPlayer's renderers factory),
|
||||
// so it belongs in the same list: Rust judges both the streaming
|
||||
// profile and the download policy against this set, and a codec
|
||||
// missing here is re-encoded by the server for nothing. Asked per
|
||||
// format rather than assumed, so a build whose native library failed
|
||||
// to load reports only what the platform itself decodes.
|
||||
// TRACES: UR-004, UR-071 | DR-293
|
||||
if (FfmpegLibrary.isAvailable()) {
|
||||
for ((mime, codec) in FFMPEG_AUDIO_FORMATS) {
|
||||
if (FfmpegLibrary.supportsFormat(mime)) {
|
||||
audioCodecs.add(codec)
|
||||
Log.d(TAG, "Audio codec: $codec (MIME: $mime, FFmpeg extension)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "FFmpeg extension unavailable; reporting platform decoders only")
|
||||
}
|
||||
|
||||
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
|
||||
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
|
||||
} catch (e: Exception) {
|
||||
@@ -148,7 +185,12 @@ object CodecDetector {
|
||||
"audio/eac3" -> "eac3"
|
||||
"audio/eac3-joc" -> "eac3"
|
||||
"audio/dts" -> "dts"
|
||||
// The platform's own spelling — what MediaCodecList reports on the
|
||||
// ROD2-W09. Only the `.hd` variant was listed, so plain DTS was
|
||||
// detected by luck, through the HD decoder advertising both.
|
||||
"audio/vnd.dts" -> "dts"
|
||||
"audio/vnd.dts.hd" -> "dts"
|
||||
"audio/true-hd" -> "truehd"
|
||||
"audio/x-ms-wma" -> "wma"
|
||||
"audio/amr-nb" -> "amrnb"
|
||||
"audio/amr-wb" -> "amrwb"
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||
@@ -332,6 +333,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
// Extension renderers ON: the device's own decoders are tried first
|
||||
// (a vendor DTS decoder stays in charge where there is one), and the
|
||||
// FFmpeg audio renderer takes any format they cannot decode — AC-3,
|
||||
// E-AC-3, TrueHD on a device without Dolby licensing. This is what
|
||||
// lets the untouched source file play, instead of a server transcode.
|
||||
// CodecDetector reports the same codecs to Rust, so the device
|
||||
// profile and the download policy agree with what actually decodes.
|
||||
// TRACES: UR-004, UR-071 | DR-293
|
||||
.setRenderersFactory(
|
||||
DefaultRenderersFactory(appContext)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
)
|
||||
// Decline the player's own load-error retry for a stream it could
|
||||
// only restart (DR-203). Every other source keeps the default
|
||||
// behaviour, which resumes the failed load where it stopped.
|
||||
|
||||
Reference in New Issue
Block a user