fix(jellyfin): prefer direct playback by default
This commit is contained in:
@@ -505,7 +505,8 @@ abstract class MediaServerClient {
|
||||
/// Resolve the video URL, media info, and external subtitle list for
|
||||
/// playback. Backends own the per-backend particulars: Plex runs the
|
||||
/// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset]
|
||||
/// is non-original; Jellyfin always direct-streams. Throws
|
||||
/// is non-original; Jellyfin asks PlaybackInfo for a matching stream when a
|
||||
/// non-original preset is explicitly selected. Throws
|
||||
/// [PlaybackException] when the item can't be resolved (no MediaSources,
|
||||
/// no playable URL, transcode decision unavailable).
|
||||
///
|
||||
|
||||
@@ -216,17 +216,19 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
await currentPlayer.setDisplayCriteria(
|
||||
!result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
|
||||
);
|
||||
final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero;
|
||||
final transcodeTimelineDuration = result.isTranscoding && episodeMetadata.durationMs != null
|
||||
? Duration(milliseconds: episodeMetadata.durationMs!)
|
||||
: null;
|
||||
final openTiming = _playbackOpenTiming(
|
||||
backend: episodeMetadata.backend,
|
||||
isTranscoding: result.isTranscoding,
|
||||
resumePosition: resumePosition,
|
||||
durationMs: episodeMetadata.durationMs,
|
||||
);
|
||||
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
|
||||
await currentPlayer.open(
|
||||
Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders),
|
||||
Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders),
|
||||
play: isExoPlayer || !hasExternalSubs,
|
||||
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
|
||||
timelineOffset: transcodeTimelineOffset,
|
||||
timelineDuration: transcodeTimelineDuration,
|
||||
timelineOffset: openTiming.timelineOffset,
|
||||
timelineDuration: openTiming.timelineDuration,
|
||||
);
|
||||
|
||||
_completionTriggered = false;
|
||||
|
||||
@@ -307,21 +307,22 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
// ExoPlayer: attach external subs at open time so it discovers
|
||||
// them in a single prepare() — no media reload needed for selection.
|
||||
// MPV (all platforms including Android): external subs added after open via sub-add.
|
||||
final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero;
|
||||
final transcodeTimelineDuration = result.isTranscoding && _currentMetadata.durationMs != null
|
||||
? Duration(milliseconds: _currentMetadata.durationMs!)
|
||||
: null;
|
||||
// Plex's chunked HTTP/MKV transcode can be seekable even when MPV
|
||||
// cannot prove it from response headers. Force only for that path and
|
||||
// reset everywhere else so live/direct/offline streams keep native
|
||||
// seekability detection.
|
||||
final openTiming = _playbackOpenTiming(
|
||||
backend: _currentMetadata.backend,
|
||||
isTranscoding: result.isTranscoding,
|
||||
resumePosition: resumePosition,
|
||||
durationMs: _currentMetadata.durationMs,
|
||||
);
|
||||
// Transcode streams can be seekable even when MPV cannot prove it
|
||||
// from response headers. Reset non-transcodes so live/direct/offline
|
||||
// streams keep native seekability detection.
|
||||
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
|
||||
await currentPlayer.open(
|
||||
Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders),
|
||||
Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders),
|
||||
play: shouldAutoPlay,
|
||||
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
|
||||
timelineOffset: transcodeTimelineOffset,
|
||||
timelineDuration: transcodeTimelineDuration,
|
||||
timelineOffset: openTiming.timelineOffset,
|
||||
timelineDuration: openTiming.timelineDuration,
|
||||
);
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
|
||||
|
||||
@@ -114,6 +114,28 @@ Future<void> _setWakelock(bool enabled) async {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaybackOpenTiming {
|
||||
final Duration? mediaStart;
|
||||
final Duration timelineOffset;
|
||||
final Duration? timelineDuration;
|
||||
|
||||
const _PlaybackOpenTiming({this.mediaStart, required this.timelineOffset, this.timelineDuration});
|
||||
}
|
||||
|
||||
_PlaybackOpenTiming _playbackOpenTiming({
|
||||
required MediaBackend backend,
|
||||
required bool isTranscoding,
|
||||
required Duration? resumePosition,
|
||||
required int? durationMs,
|
||||
}) {
|
||||
final usesSourceOffsetTranscode = isTranscoding && backend == MediaBackend.plex;
|
||||
return _PlaybackOpenTiming(
|
||||
mediaStart: usesSourceOffsetTranscode ? null : resumePosition,
|
||||
timelineOffset: usesSourceOffsetTranscode ? resumePosition ?? Duration.zero : Duration.zero,
|
||||
timelineDuration: isTranscoding && durationMs != null ? Duration(milliseconds: durationMs) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a [TrackPreferencePersister] that fans the language-preference +
|
||||
/// stream-selection writes out to a [PlexClient] resolved lazily on each
|
||||
/// call. Returns a no-op-on-null persister so the [TrackManager] doesn't
|
||||
@@ -587,15 +609,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
throw StateError('No client registered for ${_currentMetadata.serverId}');
|
||||
}
|
||||
_streamHeaders = genericClient.streamHeaders;
|
||||
// Single source of truth — `capabilities.videoTranscoding` reflects
|
||||
// the per-Plex-server probe (false on Plex installs without a working
|
||||
// transcoder) and is hard-false on Jellyfin. The long-press context
|
||||
// menu's quality picker reads the same flag. Alternate-version
|
||||
// selection still works regardless because it's gated on
|
||||
// `availableVersions.length`, not transcoding capability.
|
||||
// Single source of truth for showing quality controls. Plex uses a
|
||||
// per-server probe; Jellyfin supports explicit quality selection but
|
||||
// should not inherit the global Plex-style default on ordinary play.
|
||||
_serverSupportsTranscoding = genericClient.capabilities.videoTranscoding;
|
||||
if (widget.selectedQualityPreset == null) {
|
||||
_selectedQualityPreset = settingsService.read(SettingsService.defaultQualityPreset);
|
||||
_selectedQualityPreset = genericClient.backend == MediaBackend.plex && _serverSupportsTranscoding
|
||||
? settingsService.read(SettingsService.defaultQualityPreset)
|
||||
: TranscodeQualityPreset.original;
|
||||
} else {
|
||||
_selectedQualityPreset = widget.selectedQualityPreset!;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
int? maxStreamingBitrate = 100000000,
|
||||
String? mediaSourceId,
|
||||
String? liveStreamId,
|
||||
int? startTimeTicks,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
bool? autoOpenLiveStream,
|
||||
|
||||
@@ -161,10 +161,15 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
final preset = options.qualityPreset;
|
||||
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
|
||||
final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100000) * 1000;
|
||||
final resumeOffsetMs = metadata.viewOffsetMs;
|
||||
final int? transcodeStartTimeTicks = !preset.isOriginal && resumeOffsetMs != null && resumeOffsetMs > 0
|
||||
? msToJellyfinTicks(resumeOffsetMs)
|
||||
: null;
|
||||
final negotiation = await getPlaybackInfo(
|
||||
metadata.id,
|
||||
maxStreamingBitrate: maxStreamingBitrate,
|
||||
mediaSourceId: bundle.selectedSourceId,
|
||||
startTimeTicks: transcodeStartTimeTicks,
|
||||
audioStreamIndex: requestedAudioStreamId,
|
||||
);
|
||||
if (negotiation == null) {
|
||||
@@ -194,7 +199,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
}
|
||||
|
||||
final transcodingUrl = chosenSource['TranscodingUrl'];
|
||||
if (transcodingUrl is String && transcodingUrl.isNotEmpty) {
|
||||
final directStreamUrl = chosenSource['DirectStreamUrl'];
|
||||
if (!preset.isOriginal && 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.
|
||||
@@ -202,13 +208,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
videoUrl = _withApiKey(transcodingUrl);
|
||||
playMethod = 'Transcode';
|
||||
isTranscoding = true;
|
||||
} else if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
|
||||
capturePlaySessionId(directStreamUrl);
|
||||
videoUrl = _withApiKey(directStreamUrl);
|
||||
playMethod = 'DirectStream';
|
||||
} else {
|
||||
final directStreamUrl = chosenSource['DirectStreamUrl'];
|
||||
if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
|
||||
capturePlaySessionId(directStreamUrl);
|
||||
videoUrl = _withApiKey(directStreamUrl);
|
||||
playMethod = 'DirectStream';
|
||||
} else if (!preset.isOriginal) {
|
||||
if (!preset.isOriginal) {
|
||||
fallbackReason = TranscodeFallbackReason.directPlayOnly;
|
||||
}
|
||||
}
|
||||
@@ -424,6 +429,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
/// 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.
|
||||
/// [startTimeTicks] asks Jellyfin to start transcoding at the requested
|
||||
/// source offset so server-generated streams line up with the app timeline.
|
||||
/// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
|
||||
/// to pick for the transcode profile (Jellyfin's negotiation factors them in
|
||||
/// when picking codec compatibility).
|
||||
@@ -432,6 +439,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
int? maxStreamingBitrate = 100000000,
|
||||
String? mediaSourceId,
|
||||
String? liveStreamId,
|
||||
int? startTimeTicks,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
bool? autoOpenLiveStream,
|
||||
@@ -447,6 +455,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(),
|
||||
'MediaSourceId': ?mediaSourceId,
|
||||
'LiveStreamId': ?liveStreamId,
|
||||
'StartTimeTicks': ?startTimeTicks?.toString(),
|
||||
'AudioStreamIndex': ?audioStreamIndex?.toString(),
|
||||
'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(),
|
||||
'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(),
|
||||
@@ -464,6 +473,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
'MaxStreamingBitrate': ?maxStreamingBitrate,
|
||||
'MediaSourceId': ?mediaSourceId,
|
||||
'LiveStreamId': ?liveStreamId,
|
||||
'StartTimeTicks': ?startTimeTicks,
|
||||
'AudioStreamIndex': ?audioStreamIndex,
|
||||
'SubtitleStreamIndex': ?subtitleStreamIndex,
|
||||
'AutoOpenLiveStream': ?autoOpenLiveStream,
|
||||
|
||||
@@ -60,7 +60,7 @@ class PlaybackInitializationResult {
|
||||
final List<SubtitleTrack> externalSubtitles;
|
||||
final bool isOffline;
|
||||
|
||||
/// `true` when [videoUrl] is a Plex transcode start URL.
|
||||
/// `true` when [videoUrl] points at a backend transcoding stream.
|
||||
final bool isTranscoding;
|
||||
|
||||
/// Non-null when a non-original preset was requested but fallback kicked in.
|
||||
|
||||
@@ -251,7 +251,9 @@ void main() {
|
||||
expect(uri.queryParameters['Container'], 'mp4');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization preserves PlaySessionId from TranscodingUrl', () async {
|
||||
test('getPlaybackInitialization sends resume ticks without rewriting TranscodingUrl', () async {
|
||||
final playbackInfoUris = <Uri>[];
|
||||
final playbackInfoBodies = <String>[];
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
@@ -270,6 +272,8 @@ void main() {
|
||||
);
|
||||
}
|
||||
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||
playbackInfoUris.add(request.url);
|
||||
playbackInfoBodies.add(request.body);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaSources': [
|
||||
@@ -302,7 +306,13 @@ void main() {
|
||||
|
||||
final result = await scoped.getPlaybackInitialization(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
|
||||
metadata: MediaItem(
|
||||
id: 'item-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.movie,
|
||||
serverId: 'srv-1',
|
||||
viewOffsetMs: 143894,
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
qualityPreset: TranscodeQualityPreset.p720_2mbps,
|
||||
),
|
||||
@@ -311,9 +321,16 @@ void main() {
|
||||
expect(result.isTranscoding, isTrue);
|
||||
expect(result.playMethod, 'Transcode');
|
||||
expect(result.playSessionId, 'play-session-1');
|
||||
expect(playbackInfoUris, hasLength(1));
|
||||
expect(playbackInfoUris.single.queryParameters['StartTimeTicks'], '1438940000');
|
||||
final body = jsonDecode(playbackInfoBodies.single) as Map<String, dynamic>;
|
||||
expect(body['StartTimeTicks'], 1438940000);
|
||||
final uri = Uri.parse(result.videoUrl!);
|
||||
expect(uri.path, '/Videos/item-1/master.m3u8');
|
||||
expect(uri.queryParameters['MediaSourceId'], 'src-1');
|
||||
expect(uri.queryParameters['PlaySessionId'], 'play-session-1');
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
|
||||
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
|
||||
expect(result.externalSubtitles, hasLength(1));
|
||||
expect(result.externalSubtitles.single.title, 'English');
|
||||
@@ -379,7 +396,7 @@ void main() {
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization negotiates original playback and uses returned source media streams', () async {
|
||||
test('getPlaybackInitialization prefers DirectStreamUrl over TranscodingUrl for original playback', () async {
|
||||
final requests = <Uri>[];
|
||||
String? playbackInfoBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
@@ -417,6 +434,8 @@ void main() {
|
||||
'Container': 'mp4',
|
||||
'DefaultAudioStreamIndex': 1,
|
||||
'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
|
||||
'TranscodingUrl':
|
||||
'/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode',
|
||||
'MediaStreams': [
|
||||
{'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'},
|
||||
{
|
||||
@@ -450,9 +469,11 @@ void main() {
|
||||
|
||||
final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo');
|
||||
expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse);
|
||||
expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse);
|
||||
expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-1');
|
||||
final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
|
||||
expect(body.containsKey('MaxStreamingBitrate'), isFalse);
|
||||
expect(body.containsKey('StartTimeTicks'), isFalse);
|
||||
final profile = body['DeviceProfile'] as Map<String, dynamic>;
|
||||
expect(profile.containsKey('MaxStreamingBitrate'), isFalse);
|
||||
|
||||
@@ -464,6 +485,7 @@ void main() {
|
||||
final uri = Uri.parse(result.videoUrl!);
|
||||
expect(uri.path, '/Videos/item-1/stream');
|
||||
expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
|
||||
expect(uri.queryParameters['PlaySessionId'], isNot('play-session-transcode'));
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
|
||||
expect(result.externalSubtitles, hasLength(1));
|
||||
@@ -473,6 +495,78 @@ void main() {
|
||||
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
|
||||
final requests = <Uri>[];
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request.url);
|
||||
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Id': 'item-1',
|
||||
'Type': 'Movie',
|
||||
'Name': 'Movie',
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': 'src-1',
|
||||
'Container': 'mkv',
|
||||
'MediaStreams': [
|
||||
{'Index': 0, 'Type': 'Video'},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': 'src-1',
|
||||
'TranscodingUrl':
|
||||
'/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode',
|
||||
'MediaStreams': [
|
||||
{'Index': 0, 'Type': 'Video'},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 404);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final result = await scoped.getPlaybackInitialization(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
);
|
||||
|
||||
final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo');
|
||||
expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse);
|
||||
expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse);
|
||||
expect(result.isTranscoding, isFalse);
|
||||
expect(result.playMethod, 'DirectPlay');
|
||||
expect(result.playSessionId, isNull);
|
||||
final uri = Uri.parse(result.videoUrl!);
|
||||
expect(uri.path, '/Videos/item-1/stream');
|
||||
expect(uri.queryParameters['Static'], 'true');
|
||||
expect(uri.queryParameters['MediaSourceId'], 'src-1');
|
||||
expect(uri.queryParameters['Container'], 'mkv');
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse);
|
||||
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
|
||||
});
|
||||
|
||||
test('selected external audio is sent to PlaybackInfo but omitted from static fallback URL', () async {
|
||||
Uri? playbackInfoUri;
|
||||
String? playbackInfoBody;
|
||||
|
||||
Reference in New Issue
Block a user