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:
@@ -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,3 +1,4 @@
|
||||
pub mod device_profile;
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
|
||||
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user