fix(playback): bound the device profile by the audio route's channels (DR-141)

MediaCodecList answers "can this device decode 5.1", which is not the
question that decides whether the user hears anything: a phone decodes an
AC-3 5.1 track happily and still has two channels to play it out of. The
DeviceProfile carried no MaxAudioChannels, so Jellyfin was free to
direct-play the multichannel track to a two-channel sink — silence or
dialogue folded into surround channels that go nowhere, depending on the
device.

Report media3 AudioCapabilities.maxChannelCount for the current route over
JNI alongside the codec lists, and bound the direct-play and transcoding
profiles (and the HLS URL's TranscodingMaxAudioChannels, previously
hardcoded to 2) by it. No codec is ever removed, so a device with genuine
surround output keeps direct-playing it. A missing or zero reading means
"route not yet established", not "no audio", and falls back to stereo.
This commit is contained in:
2026-08-09 15:01:57 +02:00
parent db520c6551
commit a53042fe80
7 changed files with 199 additions and 15 deletions
+33 -7
View File
@@ -58,11 +58,20 @@ static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();
struct DetectedCodecs {
video_codecs: Vec<String>,
audio_codecs: Vec<String>,
/// Channels the *current audio output route* accepts, as reported by
/// media3's `AudioCapabilities`. Distinct from the codec lists: a device
/// decodes 5.1 happily and still has only two channels to play it out of.
/// `None` when the platform had no answer.
max_audio_channels: Option<u32>,
}
impl DetectedCodecs {
/// Create from comma-separated codec strings (from JNI)
fn from_jni_strings(video_codecs: &str, audio_codecs: &str) -> Self {
fn from_jni_strings(
video_codecs: &str,
audio_codecs: &str,
max_audio_channels: Option<u32>,
) -> Self {
Self {
video_codecs: video_codecs
.split(',')
@@ -74,6 +83,7 @@ impl DetectedCodecs {
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect(),
max_audio_channels,
}
}
@@ -88,11 +98,19 @@ impl DetectedCodecs {
}
}
/// Public function to get detected codecs (for use in repository layer)
pub fn get_detected_codecs() -> Option<(String, String)> {
DETECTED_CODECS
.get()
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
/// Public function to get detected codecs (for use in repository layer).
///
/// Returns `(video, audio, max_audio_channels)` — the third element is how many
/// channels the current audio output can actually voice, which bounds what the
/// server may direct-play.
pub fn get_detected_codecs() -> Option<(String, String, Option<u32>)> {
DETECTED_CODECS.get().map(|codecs| {
(
codecs.video_codecs_string(),
codecs.audio_codecs_string(),
codecs.max_audio_channels,
)
})
}
/// Trait for handling media commands from Android MediaSession.
@@ -1125,6 +1143,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
_class: JClass,
video_codecs: JString,
audio_codecs: JString,
max_audio_channels: jint,
) {
let video_str: String = env
.get_string(&video_codecs)
@@ -1136,7 +1155,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
.map(|s| s.into())
.unwrap_or_default();
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str);
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
log::info!(
"[CodecDetection] Detected {} video codecs: {}",
@@ -1148,6 +1170,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
codecs.audio_codecs.len(),
codecs.audio_codecs_string()
);
log::info!(
"[CodecDetection] Audio route max channels: {:?}",
codecs.max_audio_channels
);
// Store in global state
if DETECTED_CODECS.set(codecs).is_err() {
@@ -0,0 +1,95 @@
//! Device-profile policy: turning what a device *reports* about its audio
//! output into the constraints we send Jellyfin.
//!
//! The platform layer reports raw facts (what `MediaCodecList` enumerates, how
//! many channels the current audio route accepts); deciding what those facts
//! mean for a `DeviceProfile` is domain logic and lives here, on the Rust side
//! of the boundary, where it is testable without a device.
/// Channel count assumed when the platform cannot tell us — every audio route
/// can voice stereo, so it is the only safe floor.
const FALLBACK_AUDIO_CHANNELS: u32 = 2;
/// Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a
/// nonsense reading from a driver should not become a nonsense profile.
const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
/// Decide the `MaxAudioChannels` to advertise, given what the current audio
/// route reported.
///
/// Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to
/// a sink that only has two channels. What the user hears then is device
/// dependent and rarely correct — a failed `AudioSink` configuration (silence),
/// or centre-channel dialogue folded away to near-inaudibility. Naming the real
/// channel count makes the server downmix instead, which is always audible.
///
/// A missing or zero reading means "route not established yet", not "no audio":
/// fall back to stereo rather than claiming a capability we have not seen.
///
/// TRACES: UR-004 | DR-141 | UT-131
pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
match reported {
Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
_ => FALLBACK_AUDIO_CHANNELS,
}
}
/// The channel cap for this device, reading the platform's report where one
/// exists.
///
/// TRACES: UR-004 | DR-141 | UT-131
pub fn max_audio_channels() -> u32 {
#[cfg(target_os = "android")]
let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
// Desktop plays video through the WebKitGTK HTML5 <video> element, which we
// do not interrogate for a channel count; stereo is the safe assumption.
#[cfg(not(target_os = "android"))]
let reported: Option<u32> = None;
clamp_max_audio_channels(reported)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unknown_route_falls_back_to_stereo() {
// Codec detection has not run yet, or the platform has no answer. Never
// claim surround we have not seen — every sink can do stereo.
assert_eq!(clamp_max_audio_channels(None), 2);
}
#[test]
fn a_zero_reading_is_not_a_capability() {
// A route that has not been established reports 0; taking that literally
// would advertise a device with no audio at all.
assert_eq!(clamp_max_audio_channels(Some(0)), 2);
}
#[test]
fn a_stereo_sink_is_reported_as_stereo() {
// The phone speaker / Bluetooth headset case: the server must downmix
// 5.1 rather than direct-play it.
assert_eq!(clamp_max_audio_channels(Some(2)), 2);
}
#[test]
fn a_surround_route_keeps_its_channels() {
// HDMI to an AVR: 5.1 and 7.1 direct play stay available.
assert_eq!(clamp_max_audio_channels(Some(6)), 6);
assert_eq!(clamp_max_audio_channels(Some(8)), 8);
}
#[test]
fn an_absurd_reading_is_capped_rather_than_forwarded() {
// Some drivers report the AudioTrack maximum rather than the route's.
assert_eq!(clamp_max_audio_channels(Some(32)), 8);
}
#[test]
fn a_mono_route_is_taken_at_its_word() {
assert_eq!(clamp_max_audio_channels(Some(1)), 1);
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod device_profile;
pub mod hybrid;
pub mod offline;
pub mod online;
+21 -3
View File
@@ -404,7 +404,10 @@ impl OnlineRepository {
("MaxStreamingBitrate", "20000000".to_string()),
("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()),
("TranscodingMaxAudioChannels", "2".to_string()),
(
"TranscodingMaxAudioChannels",
super::device_profile::max_audio_channels().to_string(),
),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
@@ -1271,6 +1274,10 @@ impl MediaRepository for OnlineRepository {
name: String,
max_streaming_bitrate: i64,
max_static_bitrate: i64,
/// Channels the device's audio route can actually voice. Without it
/// the server may direct-play a 5.1 track to a two-channel sink,
/// which is silence or inaudible dialogue depending on the device.
max_audio_channels: String,
direct_play_profiles: Vec<DirectPlayProfile>,
transcoding_profiles: Vec<TranscodingProfile>,
subtitle_profiles: Vec<SubtitleProfile>,
@@ -1298,6 +1305,7 @@ impl MediaRepository for OnlineRepository {
#[serde(skip_serializing_if = "Option::is_none")]
video_codec: Option<String>,
audio_codec: String,
max_audio_channels: String,
}
#[derive(Debug, Serialize)]
@@ -1338,8 +1346,9 @@ impl MediaRepository for OnlineRepository {
// Get detected codecs from Android MediaCodecList or use platform defaults
#[cfg(target_os = "android")]
let (video_codecs, audio_codecs) =
crate::player::get_detected_codecs().unwrap_or_else(|| {
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
.map(|(video, audio, _channels)| (video, audio))
.unwrap_or_else(|| {
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
("h264,hevc".to_string(), "aac,mp3".to_string())
});
@@ -1362,11 +1371,18 @@ impl MediaRepository for OnlineRepository {
info!("[DeviceProfile] Using video codecs: {}", video_codecs);
info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
// Bound every profile by what the audio route can actually voice, so a
// multichannel track is downmixed by the server rather than direct-played
// into a sink that has nowhere to put the extra channels.
let max_audio_channels = super::device_profile::max_audio_channels().to_string();
info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
// Create device profile with detected hardware capabilities
let device_profile = DeviceProfile {
name: "JellyTau Native Player".to_string(),
max_streaming_bitrate: 999_999_999,
max_static_bitrate: 999_999_999,
max_audio_channels: max_audio_channels.clone(),
direct_play_profiles: vec![
DirectPlayProfile {
profile_type: "Video".to_string(),
@@ -1389,6 +1405,7 @@ impl MediaRepository for OnlineRepository {
container: "ts".to_string(),
video_codec: Some("h264,hevc".to_string()),
audio_codec: "aac,mp3".to_string(),
max_audio_channels: max_audio_channels.clone(),
},
TranscodingProfile {
profile_type: "Audio".to_string(),
@@ -1397,6 +1414,7 @@ impl MediaRepository for OnlineRepository {
container: "mp3".to_string(),
video_codec: None,
audio_codec: "mp3".to_string(),
max_audio_channels: max_audio_channels.clone(),
},
],
subtitle_profiles: vec![