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:
@@ -2187,6 +2187,25 @@ pub struct PlaybackCapabilities {
|
||||
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||
/// compositing), so it stays on the HTML5 element.
|
||||
pub supports_native_video: bool,
|
||||
/// True when the user may send video to the webview element instead of the
|
||||
/// native renderer — the frontend offers the switch only then, and honours
|
||||
/// the stored preference only then. See [`webview_video_fallback`].
|
||||
pub webview_video_fallback: bool,
|
||||
}
|
||||
|
||||
/// Whether the user may send video to the webview `<video>` element instead of
|
||||
/// the native renderer.
|
||||
///
|
||||
/// Never on Android: ExoPlayer is its only video renderer. Downloads there are
|
||||
/// the untouched source file (DR-293), and the webview decodes none of the
|
||||
/// AC-3/E-AC-3/DTS/TrueHD that ExoPlayer plays through the FFmpeg extension, so
|
||||
/// the fallback would be a silent film. Beside mpv's native video on Linux the
|
||||
/// webview is still the tested fallback; everywhere else it is the only
|
||||
/// renderer and there is nothing to switch.
|
||||
///
|
||||
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
|
||||
pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
|
||||
!is_android && native_video_enabled
|
||||
}
|
||||
|
||||
/// Report this platform's playback capabilities to the frontend.
|
||||
@@ -2203,6 +2222,11 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
|
||||
// TRACES: UR-080 | DR-235
|
||||
supports_native_video: cfg!(target_os = "android")
|
||||
|| crate::player::native_video::enabled(),
|
||||
// TRACES: UR-003, UR-071 | DR-293
|
||||
webview_video_fallback: webview_video_fallback(
|
||||
cfg!(target_os = "android"),
|
||||
crate::player::native_video::enabled(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3058,6 +3082,37 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
mod tests {
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// Android has one video renderer, ExoPlayer. The webview element could only
|
||||
/// be reached by the user switching native video off, and a file downloaded
|
||||
/// as the untouched original — AC-3 audio included — plays silent there,
|
||||
/// so the switch is gone on Android (DR-293). Where mpv draws video on Linux
|
||||
/// the webview is still the tested fallback, so the switch stays there;
|
||||
/// everywhere else the webview is the only renderer and there is nothing to
|
||||
/// switch.
|
||||
///
|
||||
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
|
||||
#[test]
|
||||
fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
|
||||
use super::webview_video_fallback;
|
||||
|
||||
assert!(
|
||||
!webview_video_fallback(true, false),
|
||||
"Android: ExoPlayer is the only video renderer"
|
||||
);
|
||||
assert!(
|
||||
!webview_video_fallback(true, true),
|
||||
"Android never falls back, whatever else is switched on"
|
||||
);
|
||||
assert!(
|
||||
webview_video_fallback(false, true),
|
||||
"Linux with mpv native video: the webview is the fallback"
|
||||
);
|
||||
assert!(
|
||||
!webview_video_fallback(false, false),
|
||||
"the webview is the only renderer; nothing to fall back from"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-206 — the volume the command hands on is always a real number in
|
||||
/// 0.0..=1.0.
|
||||
///
|
||||
|
||||
@@ -195,17 +195,15 @@ pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
|
||||
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||
/// the user gets picture with no sound.
|
||||
///
|
||||
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
||||
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
||||
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
||||
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
||||
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
||||
/// the alternative is silence for everyone the switch lands the other way, which
|
||||
/// is the bug this exists to prevent.
|
||||
///
|
||||
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
||||
/// direct play where a leaner device is transcoded to AAC and plays fine.
|
||||
/// Which renderer gets it depends on the platform. Linux draws video in the
|
||||
/// element (unless mpv native video is switched on), so it gets the narrow list.
|
||||
/// Android draws video only in ExoPlayer: it used to follow the
|
||||
/// `experimentalNativeVideo` setting, which could send video to the webview, and
|
||||
/// while that switch existed the narrow list was the only one true on both sides
|
||||
/// of it. DR-293 removed the webview video path on Android, so there the
|
||||
/// platform list is the whole answer — it includes the FFmpeg extension's
|
||||
/// AC-3/E-AC-3/DTS/TrueHD, which `CodecDetector` reports alongside the
|
||||
/// `MediaCodecList` decoders.
|
||||
///
|
||||
/// This applies to the *video* direct-play profile only. Audio-only playback
|
||||
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
||||
@@ -323,6 +321,9 @@ pub fn renderer_can_decode_audio(codec: &str) -> bool {
|
||||
/// Whether the webview `<video>` element can decode this audio codec.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
// Unreachable on Android since DR-293: video renders only in ExoPlayer there,
|
||||
// so every caller goes through `renderer_can_decode_audio`'s device-list arm.
|
||||
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||
WEBVIEW_AUDIO_CODECS
|
||||
.iter()
|
||||
|
||||
@@ -2530,28 +2530,32 @@ impl MediaRepository for OnlineRepository {
|
||||
params.push("allowVideoStreamCopy=false".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy —
|
||||
// unless the audio in that copy is undecodable where the file will
|
||||
// be played back. A download is watched with no server in reach, so
|
||||
// it has to satisfy the same constraint DR-149 applies to streams:
|
||||
// the webview `<video>` element renders video on both platforms and
|
||||
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
|
||||
// disk is what made a downloaded film play offline as picture with
|
||||
// no sound while the same film had sound when streamed.
|
||||
// unless the audio in that copy is undecodable by the renderer that
|
||||
// will play the file. A download is watched with no server in reach,
|
||||
// so there is nothing to fall back to: copying a track the renderer
|
||||
// cannot decode is what made a downloaded film play offline as
|
||||
// picture with no sound while the same film had sound when streamed
|
||||
// (DR-171).
|
||||
//
|
||||
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
|
||||
// h264 source's picture byte-for-byte, so "original" still means
|
||||
// original quality, and no bitrate or resolution cap is added. A
|
||||
// source the webview could not have rendered anyway (HEVC) is
|
||||
// re-encoded to h264 as a side effect, which is the only form of it
|
||||
// that would have played.
|
||||
// "The renderer" is DR-234's per-platform answer, not the webview's
|
||||
// list. On Android that is ExoPlayer — the only video renderer there
|
||||
// since DR-293 removed the webview path — which decodes the device's
|
||||
// own codecs plus AC-3/E-AC-3/DTS/TrueHD through the FFmpeg
|
||||
// extension. So on Android every `original` download is a
|
||||
// `Static=true` copy: fast, resumable (HTTP 206), and the real file.
|
||||
// Judging against the webview's list instead turned most films into a
|
||||
// server transcode — generated as it is sent, no `Content-Length`,
|
||||
// `Range` ignored — measured at ~1 MB/s against 14.5 MB/s for the
|
||||
// copy, and restarting from zero on every network blip.
|
||||
//
|
||||
// The cost of the transcode is that the response is no longer
|
||||
// range-resumable, which is exactly why this is decided per item
|
||||
// rather than applied to every `original` download.
|
||||
// On Linux the webview still draws video, so the renderer's list *is*
|
||||
// the webview's and the transcode below still applies there. Only the
|
||||
// *audio* is re-encoded: `allowVideoStreamCopy` keeps an h264 source's
|
||||
// picture byte-for-byte, so "original" still means original quality.
|
||||
//
|
||||
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
|
||||
None => match source_audio_codec {
|
||||
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
||||
Some(codec) if !super::device_profile::renderer_can_decode_audio(codec) => {
|
||||
params.push("videoCodec=h264".to_string());
|
||||
params.push("allowVideoStreamCopy=true".to_string());
|
||||
params.push("audioCodec=aac".to_string());
|
||||
@@ -3731,13 +3735,17 @@ mod tests {
|
||||
/// holds audio this device cannot decode.
|
||||
///
|
||||
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
||||
/// track included, and video plays through the webview `<video>` element on
|
||||
/// both platforms — which decodes none of them. Streaming already knows this
|
||||
/// (DR-149 forces a transcode over the server's own direct-play offer); the
|
||||
/// download path did not, so a downloaded film played offline as picture with
|
||||
/// no sound while the very same film had sound when streamed.
|
||||
/// track included. Where the webview `<video>` element renders video —
|
||||
/// Linux, which is where this test runs — none of them decode. Streaming
|
||||
/// already knew this (DR-149); the download path did not, so a downloaded
|
||||
/// film played offline as picture with no sound.
|
||||
///
|
||||
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
/// On Android the renderer is ExoPlayer with the FFmpeg extension, which
|
||||
/// decodes all of these, so the same call there yields a `Static=true` copy
|
||||
/// (DR-293). The policy is `renderer_can_decode_audio`; this test pins its
|
||||
/// webview half.
|
||||
///
|
||||
/// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
Reference in New Issue
Block a user