fix(jellyfin): support external audio playback

This commit is contained in:
edde746
2026-05-18 13:43:58 +02:00
parent d004342c98
commit 324d145292
14 changed files with 466 additions and 86 deletions
+4
View File
@@ -94,6 +94,7 @@ class MediaAudioTrack with _TrackLabelMixin {
final String? displayTitle;
final int? channels;
final bool selected;
final bool external;
MediaAudioTrack({
required this.id,
@@ -105,8 +106,11 @@ class MediaAudioTrack with _TrackLabelMixin {
this.displayTitle,
this.channels,
required this.selected,
this.external = false,
});
bool get isExternal => external;
String get label {
final additionalParts = <String>[];
if (codec != null) additionalParts.add(CodecUtils.formatAudioCodec(codec!));
@@ -192,9 +192,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_effectiveIsOffline = result.isOffline;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
if (result.activeAudioStreamId != null) {
_selectedAudioStreamId = result.activeAudioStreamId;
}
_selectedAudioStreamId = result.activeAudioStreamId;
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
@@ -163,9 +163,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
_effectiveIsOffline = result.isOffline;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
if (result.activeAudioStreamId != null) {
_selectedAudioStreamId = result.activeAudioStreamId;
}
_selectedAudioStreamId = result.activeAudioStreamId;
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
+1
View File
@@ -213,6 +213,7 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
displayTitle: f.displayTitle,
channels: f.channels,
selected: f.isDefault,
external: f.isExternal,
);
}
@@ -9,10 +9,11 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
String? mediaSourceId,
String? playSessionId,
String? liveStreamId,
int? audioStreamIndex,
});
Future<Map<String, dynamic>?> getPlaybackInfo(
String itemId, {
int maxStreamingBitrate = 100000000,
int? maxStreamingBitrate = 100000000,
String? mediaSourceId,
String? liveStreamId,
int? audioStreamIndex,
+127 -70
View File
@@ -122,14 +122,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
/// Jellyfin playback URL resolution.
///
/// Two paths:
/// * `qualityPreset.isOriginal` → direct stream
/// (`/Videos/{id}/stream?Static=true&api_key=...`).
/// * non-original preset → POST `/Items/{id}/PlaybackInfo` with the
/// preset's bitrate and use the server-computed `TranscodingUrl`
/// from the returned `MediaSources` entry. Falls back to direct stream
/// when the server didn't provide a transcode URL (e.g. direct play
/// fits the cap) or the negotiation request failed.
/// Always POSTs `/Items/{id}/PlaybackInfo` so Jellyfin can resolve external
/// audio/subtitle streams server-side. Uses the returned `TranscodingUrl` or
/// `DirectStreamUrl` when present, otherwise falls back to a static direct
/// stream URL (`/Videos/{id}/stream?Static=true&api_key=...`).
///
/// The returned `MediaSourceInfo` is what the player uses for track-picker
/// labels and auto-track selection by language.
@@ -148,14 +144,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo);
// Only forward MediaSourceId when there's actually more than one source —
// single-source items have `MediaSourceId == itemId` so the param is a
// no-op there but adds clutter to logs.
final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != metadata.id
? bundle.selectedSourceId
: null;
var effectiveSourceId = bundle.selectedSourceId;
var effectiveContainer = bundle.container;
var externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo);
String? videoUrl;
String? playSessionId;
@@ -164,72 +155,76 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
TranscodeFallbackReason? fallbackReason;
final preset = options.qualityPreset;
if (!preset.isOriginal && preset.videoBitrateKbps != null) {
final maxBps = preset.videoBitrateKbps! * 1000;
final negotiation = await getPlaybackInfo(
metadata.id,
maxStreamingBitrate: maxBps,
mediaSourceId: bundle.selectedSourceId,
audioStreamIndex: options.selectedAudioStreamId,
);
if (negotiation == null) {
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100000) * 1000;
final negotiation = await getPlaybackInfo(
metadata.id,
maxStreamingBitrate: maxStreamingBitrate,
mediaSourceId: bundle.selectedSourceId,
audioStreamIndex: requestedAudioStreamId,
);
if (negotiation == null) {
if (!preset.isOriginal) {
fallbackReason = TranscodeFallbackReason.decisionFailed;
} else {
final sources = negotiation['MediaSources'];
Map<String, dynamic>? chosenSource;
if (sources is List && sources.isNotEmpty) {
for (final src in sources) {
if (src is Map<String, dynamic> && src['Id'] == bundle.selectedSourceId) {
chosenSource = src;
break;
}
}
chosenSource ??= sources.first is Map<String, dynamic> ? sources.first as Map<String, dynamic> : null;
}
final chosenStreams = chosenSource?['MediaStreams'];
if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) {
}
} else {
final chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
if (chosenSource != null) {
effectiveSourceId = chosenSource['Id'] as String? ?? effectiveSourceId;
effectiveContainer = chosenSource['Container'] as String? ?? effectiveContainer;
if (chosenSource['MediaStreams'] is List) {
mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
chosenSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
externalSubtitles = _buildExternalSubtitles(
metadata.id,
chosenSource['Id'] as String? ?? bundle.selectedSourceId,
mediaInfo,
);
externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo);
}
final transcodingUrl = chosenSource?['TranscodingUrl'];
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
void capturePlaySessionId(String urlOrPath) {
playSessionId = Uri.tryParse(urlOrPath)?.queryParameters['PlaySessionId'];
if ((playSessionId == null || playSessionId!.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
}
final transcodingUrl = chosenSource['TranscodingUrl'];
if (transcodingUrl is String && transcodingUrl.isNotEmpty) {
// TranscodingUrl is server-relative and already encodes container,
// codecs, MediaSourceId, and PlaySessionId; we just append the
// api_key for auth.
playSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId'];
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
capturePlaySessionId(transcodingUrl);
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
} else {
final directStreamUrl = chosenSource?['DirectStreamUrl'];
final directStreamUrl = chosenSource['DirectStreamUrl'];
if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
playSessionId = Uri.tryParse(directStreamUrl)?.queryParameters['PlaySessionId'];
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
capturePlaySessionId(directStreamUrl);
videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream';
} else {
} else if (!preset.isOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
} else if (!preset.isOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
videoUrl ??= buildDirectStreamUrl(metadata.id, container: bundle.container, mediaSourceId: pinnedSourceId);
final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo);
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
// Only forward MediaSourceId when there's actually more than one source —
// single-source items have `MediaSourceId == itemId` so the param is a
// no-op there but adds clutter to logs.
final pinnedSourceId = effectiveSourceId != null && effectiveSourceId != metadata.id ? effectiveSourceId : null;
videoUrl ??= buildDirectStreamUrl(
metadata.id,
container: effectiveContainer,
mediaSourceId: pinnedSourceId,
audioStreamIndex: requestedAudioStreamId,
);
return PlaybackInitializationResult(
availableVersions: bundle.availableVersions,
@@ -239,12 +234,71 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
isOffline: false,
isTranscoding: isTranscoding,
fallbackReason: fallbackReason,
activeAudioStreamId: isTranscoding ? options.selectedAudioStreamId : null,
activeAudioStreamId: requestedAudioStreamId,
playSessionId: playSessionId,
playMethod: playMethod,
);
}
int? _validJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) {
if (explicit == null) return null;
return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null;
}
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
if (sources is! List || sources.isEmpty) return null;
for (final source in sources) {
if (source is Map<String, dynamic> && source['Id'] == selectedSourceId) {
return source;
}
}
final first = sources.first;
return first is Map<String, dynamic> ? first : null;
}
int? _resolveJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) {
final validExplicit = _validJellyfinAudioStreamId(explicit, mediaInfo);
if (validExplicit != null) return validExplicit;
final defaultStreamIndex = mediaInfo.defaultAudioStreamIndex;
if (defaultStreamIndex != null) return defaultStreamIndex;
for (final track in mediaInfo.audioTracks) {
if (track.selected) return track.id;
}
return null;
}
MediaSourceInfo _withSelectedJellyfinAudioStream(MediaSourceInfo mediaInfo, int? selectedStreamId) {
if (selectedStreamId == null || !mediaInfo.audioTracks.any((track) => track.id == selectedStreamId)) {
return mediaInfo;
}
return MediaSourceInfo(
videoUrl: mediaInfo.videoUrl,
audioTracks: [
for (final track in mediaInfo.audioTracks)
MediaAudioTrack(
id: track.id,
index: track.index,
codec: track.codec,
language: track.language,
languageCode: track.languageCode,
title: track.title,
displayTitle: track.displayTitle,
channels: track.channels,
selected: track.id == selectedStreamId,
external: track.external,
),
],
subtitleTracks: mediaInfo.subtitleTracks,
chapters: mediaInfo.chapters,
partId: mediaInfo.partId,
displayCriteria: mediaInfo.displayCriteria,
mediaSourceId: mediaInfo.mediaSourceId,
defaultAudioStreamIndex: mediaInfo.defaultAudioStreamIndex,
defaultSubtitleStreamIndex: mediaInfo.defaultSubtitleStreamIndex,
trickplayByWidth: mediaInfo.trickplayByWidth,
);
}
String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) {
final sourceId = mediaSourceId;
final streamIndex = track.index ?? track.id;
@@ -322,6 +376,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
String? mediaSourceId,
String? playSessionId,
String? liveStreamId,
int? audioStreamIndex,
}) {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
@@ -332,6 +387,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
mediaSourceId: mediaSourceId,
playSessionId: playSessionId,
liveStreamId: liveStreamId,
audioStreamIndex: audioStreamIndex,
);
}
@@ -356,16 +412,17 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
/// server's recommended `PlaySessionId`. Caller decides which media source
/// to use and feeds the returned `TranscodingUrl` into the player.
///
/// [maxStreamingBitrate] is forwarded as both the top-level field and inside
/// the `DeviceProfile` so the server caps direct-stream and transcode bitrate
/// against the same ceiling. [mediaSourceId] pins the negotiation to a
/// specific version when the item has multiple sources. [audioStreamIndex]
/// / [subtitleStreamIndex] tell the server which streams to pick for the
/// transcode profile (Jellyfin's negotiation factors them in when picking
/// codec compatibility).
/// When non-null, [maxStreamingBitrate] is forwarded as both the top-level
/// field and inside the `DeviceProfile` so the server caps direct-stream and
/// transcode bitrate against the same ceiling. Original playback passes null
/// to avoid capping high-bitrate files. [mediaSourceId] pins the negotiation
/// to a specific version when the item has multiple sources.
/// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
/// to pick for the transcode profile (Jellyfin's negotiation factors them in
/// when picking codec compatibility).
Future<Map<String, dynamic>?> getPlaybackInfo(
String itemId, {
int maxStreamingBitrate = 100000000,
int? maxStreamingBitrate = 100000000,
String? mediaSourceId,
String? liveStreamId,
int? audioStreamIndex,
@@ -380,7 +437,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
try {
final query = <String, String>{
'userId': connection.userId,
'MaxStreamingBitrate': maxStreamingBitrate.toString(),
'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(),
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'AudioStreamIndex': ?audioStreamIndex?.toString(),
@@ -397,7 +454,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
queryParameters: query,
body: {
'UserId': connection.userId,
'MaxStreamingBitrate': maxStreamingBitrate,
'MaxStreamingBitrate': ?maxStreamingBitrate,
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'AudioStreamIndex': ?audioStreamIndex,
@@ -410,7 +467,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
'AllowAudioStreamCopy': ?allowAudioStreamCopy,
'DeviceProfile': <String, Object?>{
'Name': 'Plezy',
'MaxStreamingBitrate': maxStreamingBitrate,
'MaxStreamingBitrate': ?maxStreamingBitrate,
'CodecProfiles': const <Map<String, Object?>>[],
// Comma-separated codec lists are order-sensitive — first entry
// wins when the server picks an output codec. HEVC is listed
@@ -149,6 +149,7 @@ List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, i
displayTitle: track.displayTitle,
channels: track.channels,
selected: track.index == defaultStreamIndex,
external: track.external,
),
];
}
+2
View File
@@ -7,6 +7,7 @@ String buildJellyfinDirectStreamUrl({
String? mediaSourceId,
String? playSessionId,
String? liveStreamId,
int? audioStreamIndex,
}) {
final params = <String, String>{
'Static': 'true',
@@ -16,6 +17,7 @@ String buildJellyfinDirectStreamUrl({
'MediaSourceId': ?mediaSourceId,
'PlaySessionId': ?playSessionId,
'LiveStreamId': ?liveStreamId,
'AudioStreamIndex': ?audioStreamIndex?.toString(),
};
final encodedItem = Uri.encodeComponent(itemId);
return '$baseUrl/Videos/$encodedItem/stream?${_encodeQuery(params)}';
@@ -61,8 +61,7 @@ class PlaybackInitializationResult {
/// Non-null when a non-original preset was requested but fallback kicked in.
final TranscodeFallbackReason? fallbackReason;
/// The Plex audio stream ID actually passed to the transcoder (`null` when
/// not transcoding or when no audio stream was selectable).
/// Source audio stream ID selected by the backend (`null` when unknown).
final int? activeAudioStreamId;
/// Server playback session ID that must be echoed in progress/stop reports.
+21 -1
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import '../mpv/mpv.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../media/media_source_info.dart';
@@ -322,10 +323,17 @@ class PlaybackProgressTracker {
}
int? _currentAudioStreamIndex(MediaSourceInfo info) {
final playerAudioTracks = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList();
if (metadata.backend == MediaBackend.jellyfin &&
(info.audioTracks.any((track) => track.isExternal) || playerAudioTracks.length <= 1)) {
final selectedSourceTrack = _selectedSourceAudioTrack(info);
if (selectedSourceTrack != null) return selectedSourceTrack.id;
}
final track = player.state.track.audio;
if (track == null) return null;
final ordinal = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList().indexOf(track);
final ordinal = playerAudioTracks.indexOf(track);
if (ordinal >= 0 && ordinal < info.audioTracks.length) return info.audioTracks[ordinal].id;
final matched = findPlexTrackForMpvAudio(track, info.audioTracks, allMpvTracks: player.state.tracks.audio);
@@ -337,6 +345,18 @@ class PlaybackProgressTracker {
return null;
}
MediaAudioTrack? _selectedSourceAudioTrack(MediaSourceInfo info) {
for (final track in info.audioTracks) {
if (track.selected) return track;
}
final defaultIndex = info.defaultAudioStreamIndex;
if (defaultIndex == null) return null;
for (final track in info.audioTracks) {
if (track.id == defaultIndex) return track;
}
return null;
}
int? _currentSubtitleStreamIndex(MediaSourceInfo info) {
final track = player.state.track.subtitle;
if (track == null || track.id == 'no') return -1;
@@ -26,10 +26,9 @@ class TrackSheet extends StatelessWidget {
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
/// When true, the audio column renders the Plex [sourceAudioTracks] list
/// and taps are routed to [onSwitchAudioStreamId] instead of using the
/// player's in-stream audio selection (the transcoded stream only has one
/// audio track).
/// When true, or when a Jellyfin source has external audio, the audio column
/// renders [sourceAudioTracks] and taps are routed to [onSwitchAudioStreamId]
/// instead of using the player's in-stream audio selection.
final bool isTranscoding;
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
@@ -70,7 +69,9 @@ class TrackSheet extends StatelessWidget {
(t) => t?.subtitle ?? [],
);
final useSourceAudio = isTranscoding && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null;
final hasExternalSourceAudio = sourceAudioTracks.any((track) => track.isExternal);
final useSourceAudio =
(isTranscoding || hasExternalSourceAudio) && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null;
final showAudio = useSourceAudio || playerAudioTracks.length > 1;
final showSubtitles = subtitleTracks.isNotEmpty;
@@ -198,7 +199,7 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> {
@override
Widget build(BuildContext context) {
final selectedId = widget.selectedStreamId;
final selectedId = _effectiveSelectedStreamId();
final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId);
_initialScroll.maybeScrollTo(selectedIndex);
@@ -228,6 +229,15 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> {
],
);
}
int? _effectiveSelectedStreamId() {
final explicit = widget.selectedStreamId;
if (explicit != null && widget.tracks.any((track) => track.id == explicit)) return explicit;
for (final track in widget.tracks) {
if (track.selected) return track.id;
}
return null;
}
}
class _AudioColumn extends StatefulWidget {