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() {