Files
plezy/lib/services/jellyfin_client/parts/playback.dart
T
edde746 4872adcde3 feat(player): keep the session's explicit track choices across episodes
Episode advance carried live player state, so the viewer's choice only
survived while every episode could serve it: one episode without the
picked audio or subtitle fell back, and the fallback became the carry for
the rest of the session. The screen now keeps the last explicit audio,
subtitle, and secondary-subtitle choices for its lifetime; automatic
outcomes never overwrite them, so the choice retries on every following
episode and reattaches as soon as a catalog can serve it again.

Audio catches up with the subtitle carry from #1785. The old matcher
required raw language equality (a 'sv' pick never found a 'swe' row) and
otherwise took the first same-language track, flipping a commentary or
alternate-mix pick back to the main mix on every episode. Audio now uses
the same evidence bands as subtitles: bridged language parity is
authoritative, a unique title match vouches for untagged tracks, codec
and channel-count parity only break ties, and an ambiguous catalog
declines to the server's own choice instead of guessing. The synthesized
source descriptor also prefers the row's own title over the display title
that collapses to the bare language.

Episode advance previously sent no audio hint to negotiation at all, so a
transcode baked in the server's default audio no matter what was playing.
Both backends now resolve the carried semantics against the new episode's
streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the
transcode decision, an explicit per-part stream id always wins, and a
failed match falls back to the server's pick.

close #1785
2026-08-04 16:10:02 +02:00

857 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
part of '../../jellyfin_client.dart';
bool _canUseJellyfinStaticStreamFallback(Object error) {
if (error is MediaServerAuthException) return false;
if (error is MediaServerHttpException) {
final status = error.statusCode;
return !error.isCancellation && status != 401 && status != 403;
}
return true;
}
mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
/// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters
/// at the item level (`raw['Chapters']`) and native skip segments through a
/// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort
/// so older servers still use chapter title fallback.
@override
Future<PlaybackExtras> fetchPlaybackExtras(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
bool forceRefresh = false,
}) async {
final item = await fetchItem(itemId);
final markers = item == null ? const <MediaMarker>[] : await _fetchMediaSegmentMarkers(itemId);
return jellyfinPlaybackExtrasFromRaw(
item?.raw,
itemId,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
markers: markers,
);
}
@override
Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
}) async {
final item = await cache.getMetadata(ServerId(cacheServerId), itemId);
if (item == null) return null;
final markers = await _fetchCachedMediaSegmentMarkers(itemId);
return jellyfinPlaybackExtrasFromRaw(
item.raw,
itemId,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
markers: markers,
);
}
@override
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId) async {
final item = await cache.getMetadata(ServerId(cacheServerId), itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
final sources = raw['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final first = sources.first;
if (first is! Map<String, dynamic>) return null;
return jellyfinMediaSourceToMediaSourceInfo(first, chapters: raw['Chapters'], trickplay: raw['Trickplay']);
}
@override
Future<ScrubPreviewSource?> createScrubPreviewSource({
required MediaItem item,
required MediaSourceInfo mediaSource,
}) async {
if (!capabilities.scrubThumbnails) return null;
final manifest = mediaSource.trickplayByWidth;
if (manifest == null || manifest.isEmpty) return null;
return JellyfinTrickplayService.create(
client: this as JellyfinClient,
itemId: item.id,
mediaSourceId: mediaSource.mediaSourceId,
manifest: manifest,
);
}
Future<List<MediaMarker>> _fetchMediaSegmentMarkers(String itemId) async {
final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId);
try {
return await fetchWithCacheFallback<List<MediaMarker>>(
cacheKey: endpoint,
networkCall: () async {
final response = await _http.get(endpoint);
if (response.statusCode == 404) {
return MediaServerResponse(statusCode: 200, headers: response.headers, requestUri: response.requestUri);
}
throwIfHttpError(response);
return response;
},
parseCache: jellyfinMediaSegmentsToMarkers,
parseResponse: (response) => jellyfinMediaSegmentsToMarkers(response.data),
) ??
const [];
} on MediaServerHttpException catch (e) {
if (e.statusCode != 404) {
appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
}
return const [];
} catch (e) {
appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
return const [];
}
}
Future<List<MediaMarker>> _fetchCachedMediaSegmentMarkers(String itemId) async {
try {
final data = await cache.get(ServerId(cacheServerId), JellyfinApiCache.mediaSegmentsEndpoint(itemId));
return jellyfinMediaSegmentsToMarkers(data);
} catch (e) {
appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e);
return const [];
}
}
String _withApiKey(String urlOrPath) {
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath);
final params = Map<String, String>.from(uri.queryParameters)..['api_key'] = connection.accessToken;
return uri.replace(queryParameters: params).toString();
}
/// Jellyfin playback URL resolution.
///
/// Always POSTs `/Items/{id}/PlaybackInfo` so Jellyfin can resolve external
/// audio/subtitle streams server-side. Uses the returned `TranscodingUrl`
/// when the caller asked for a capped quality; otherwise — and on any
/// DirectPlay decision — builds the static direct stream URL
/// (`/Videos/{id}/stream?Static=true&api_key=...`) itself, because Jellyfin
/// never returns a direct-play URL of its own.
///
/// The returned `MediaSourceInfo` is what the player uses for track-picker
/// labels and auto-track selection by language.
///
/// Throws [PlaybackException] when the item is missing or has no
/// `MediaSources`.
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
final metadata = options.metadata;
final bundle = await fetchPlaybackBundle(
metadata.id,
sourceIndex: options.selectedMediaIndex,
sourceId: options.selectedMediaSourceId,
preferredSignature: options.preferredVersionSignature,
);
if (bundle == null) {
throw PlaybackException('Item ${metadata.id} returned no MediaSources');
}
var mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
bundle.selectedSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
var effectiveSourceId = bundle.selectedSourceId;
var effectiveContainer = bundle.container;
var includeExternalSubtitleDelivery = false;
String? videoUrl;
String? playSessionId;
var playMethod = 'DirectPlay';
var isTranscoding = false;
TranscodeFallbackReason? fallbackReason;
// Tracks negotiate with the audio device profile and ignore the
// (video-shaped) [PlaybackInitializationOptions.qualityPreset]; capping
// comes from [PlaybackInitializationOptions.audioQualityPreset] instead.
// Original / null keeps the unlimited default so high-bitrate lossless
// files direct-play uncapped.
final isTrack = metadata.kind == MediaKind.track;
final preset = options.qualityPreset;
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal;
final requestedAudioStreamId = options.selectedAudioStreamId == null
? options.preferredAudioTrack == null
? null
: findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id
: _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
final int? maxStreamingBitrate = wantsOriginal
? null
: isTrack
// Non-original audio presets always carry a bitrate by construction.
? audioPreset.bitrateKbps! * 1000
: (preset.videoBitrateKbps ?? 100_000) * 1000;
final resumeOffsetMs = metadata.viewOffsetMs;
final int? transcodeStartTimeTicks = !wantsOriginal && resumeOffsetMs != null && resumeOffsetMs > 0
? msToJellyfinTicks(resumeOffsetMs)
: null;
Map<String, dynamic>? negotiation;
Map<String, dynamic>? chosenSource;
try {
negotiation = await getPlaybackInfo(
metadata.id,
maxStreamingBitrate: maxStreamingBitrate,
mediaSourceId: bundle.selectedSourceId,
startTimeTicks: transcodeStartTimeTicks,
audioStreamIndex: requestedAudioStreamId,
subtitleStreamIndex: requestedSubtitleStreamId,
audioProfile: isTrack,
);
chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
} catch (error, stackTrace) {
if (!_canUseJellyfinStaticStreamFallback(error)) {
Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace);
}
appLogger.w(
'Jellyfin playback negotiation unavailable; using the static stream',
error: error,
stackTrace: stackTrace,
);
}
if (chosenSource == null) {
fallbackReason = TranscodeFallbackReason.decisionFailed;
appLogger.w('Jellyfin playback negotiation returned no usable source; using the static stream');
} else {
final negotiatedSourceId = chosenSource['Id'];
final negotiatedContainer = chosenSource['Container'];
if (negotiatedSourceId is String) effectiveSourceId = negotiatedSourceId;
if (negotiatedContainer is String) effectiveContainer = negotiatedContainer;
if (chosenSource['MediaStreams'] is List) {
mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
chosenSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
}
final transcodingUrl = chosenSource['TranscodingUrl'];
if (!wantsOriginal && 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.
final urlSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId'];
final negotiatedSessionId = negotiation!['PlaySessionId'];
playSessionId = urlSessionId != null && urlSessionId.isNotEmpty
? urlSessionId
: (negotiatedSessionId is String ? negotiatedSessionId : null);
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
includeExternalSubtitleDelivery = true;
} else if (!wantsOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo);
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
// present, but lyrics flow through fetchLyrics, not the subtitle path).
final subtitleSidecars = isTrack
? const <PlaybackSubtitleSidecar>[]
: _buildExternalSubtitles(
metadata.id,
effectiveSourceId,
mediaInfo,
includeExternalDelivery: includeExternalSubtitleDelivery,
);
mediaInfo = _withSidecarBackedSubtitleIdentity(mediaInfo, subtitleSidecars);
// Jellyfin's streaming endpoint resolves a blank MediaSourceId to its own
// first sorted source, which for an item with alternate versions is a
// different file. Pin the source the negotiation actually settled on, as
// every official client does.
final pinnedSourceId = _normalizedSourceId(effectiveSourceId);
videoUrl ??= isTrack
? buildAudioDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId)
: buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId);
return PlaybackInitializationResult(
availableVersions: bundle.availableVersions,
videoUrl: videoUrl,
mediaInfo: mediaInfo,
subtitleSidecars: subtitleSidecars,
isOffline: false,
isTranscoding: isTranscoding,
fallbackReason: fallbackReason,
activeAudioStreamId: requestedAudioStreamId,
playSessionId: playSessionId,
playMethod: playMethod,
selectedMediaIndex: bundle.selectedSourceIndex,
);
}
/// Source ids ride into `MediaSourceId=`, where Jellyfin compares them
/// ordinally and, on a miss, parses them as a GUID. Only ever forward a
/// non-empty id the server itself gave us; a blank one must stay absent.
static String? _normalizedSourceId(String? sourceId) {
final id = sourceId?.trim();
return id == null || id.isEmpty ? null : id;
}
int? _validJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) {
if (explicit == null) return null;
return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null;
}
int? _validJellyfinSubtitleStreamId(SubtitlePreference? preferred, MediaSourceInfo mediaInfo) {
switch (preferred) {
case null:
return null;
case SubtitleOffPreference():
return -1;
case SubtitleIntentPreference(:final intent):
return findSourceTrackForIntent(intent, mediaInfo.subtitleTracks)?.id;
case SubtitleTrackPreference(:final track):
const sourcePrefix = 'source:';
final intent = SubtitleIntent.fromTrack(track);
if (track.id.startsWith(sourcePrefix)) {
final explicit = int.tryParse(track.id.substring(sourcePrefix.length));
if (explicit != null && mediaInfo.subtitleTracks.any((row) => row.id == explicit)) {
// A source id is authoritative only within one item. When semantic
// metadata is available, re-derive the row through the hard-gated
// intent match so a reused stream index cannot cross language or
// forced-ness classes (#1716).
final hasLanguage = intent?.language?.isNotEmpty ?? false;
if (!hasLanguage) return explicit;
return findSourceTrackForIntent(intent!, mediaInfo.subtitleTracks)?.id;
}
}
return intent == null ? null : findSourceTrackForIntent(intent, mediaInfo.subtitleTracks)?.id;
}
}
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
if (sources is! List || sources.isEmpty) return null;
final requestedSourceId = selectedSourceId?.trim();
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
for (final source in sources) {
if (source is! Map<String, dynamic>) {
throw const FormatException('Malformed Jellyfin PlaybackInfo media source');
}
final sourceId = source['Id'];
if (sourceId is String && sourceId.toLowerCase() == requestedSourceId.toLowerCase()) {
return source;
}
}
return null;
}
final first = sources.first;
if (first is! Map<String, dynamic>) {
throw const FormatException('Malformed Jellyfin PlaybackInfo media source');
}
final firstId = first['Id'];
if (firstId != null && firstId is! String) {
throw const FormatException('Malformed Jellyfin PlaybackInfo media source id');
}
return first;
}
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 mediaInfo.copyWith(
audioTracks: [for (final track in mediaInfo.audioTracks) track.withSelected(track.id == selectedStreamId)],
);
}
/// Restrict sidecar identity to the subtitle rows this open actually fetched
/// as sidecars.
///
/// Plezy's device profile declares every subtitle format with
/// `Method: External`, so Jellyfin returns `DeliveryMethod: External` and a
/// `DeliveryUrl` even for streams embedded in a direct-played container.
/// [_buildExternalSubtitles] correctly skips those, and the native player
/// reads them out of the container instead — but the leftover delivery URL
/// makes the shared track matchers demand a sidecar that will never load,
/// which leaves automatic subtitle selection permanently unresolved.
///
/// `IsExternal` rows are left alone: a stream that lives in a separate file
/// is absent from the container whether or not this open managed to build a
/// sidecar URL for it, so it must never fuzzy-match a native track.
MediaSourceInfo _withSidecarBackedSubtitleIdentity(
MediaSourceInfo mediaInfo,
List<PlaybackSubtitleSidecar> sidecars,
) {
if (mediaInfo.subtitleTracks.isEmpty) return mediaInfo;
final sidecarSourceIds = {for (final sidecar in sidecars) ?sidecar.sourceStreamId};
return mediaInfo.copyWith(
subtitleTracks: [
for (final track in mediaInfo.subtitleTracks)
track.isExternalFile || sidecarSourceIds.contains(track.id) ? track : track.withoutSidecarIdentity(),
],
);
}
String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) {
final sourceId = mediaSourceId;
final streamIndex = track.index ?? track.id;
final codec = track.codec;
if (sourceId == null || codec == null || codec.isEmpty) return null;
final path = Uri(
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'],
).path;
return path.startsWith('/') ? path : '/$path';
}
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(
String itemId,
String? mediaSourceId,
MediaSourceInfo mediaInfo, {
bool includeExternalDelivery = false,
}) {
final externalSubtitles = <PlaybackSubtitleSidecar>[];
for (final track in mediaInfo.subtitleTracks) {
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) {
continue;
}
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
// absolute URL with the api_key query param.
final url = _withApiKey(path);
externalSubtitles.add(
PlaybackSubtitleSidecar(
sourceStreamId: track.id,
track: SubtitleTrack.uri(
url,
title:
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
cleanTrackMetadataValue(track.language),
language: cleanTrackMetadataValue(track.languageCode),
codec: track.codec,
isDefault: track.selected,
isForced: track.forced,
),
),
);
}
return externalSubtitles;
}
/// Internal accessor for [PlaybackInitializationService]. Returns the
/// chosen `MediaSource` JSON, every available source's [MediaVersion],
/// and the item's `Chapters` array. One round-trip vs. fetchItem + raw
/// extraction at the call site.
///
/// Returns `null` when the item doesn't exist or has no `MediaSources`.
/// [sourceId] wins when present because Jellyfin plugins may reorder merged
/// `MediaSources` between requests. [sourceIndex] is clamped to the valid
/// range as a fallback to mirror Plex's `parseVideoPlaybackDataFromJson`.
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
String itemId, {
int sourceIndex = 0,
String? sourceId,
String? preferredSignature,
}) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
final sources = raw['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final availableVersions = jellyfinSourcesToVersions(sources);
var index = sourceIndex;
final requestedSourceId = sourceId?.trim();
var resolvedBySourceId = false;
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
final byId = sources.indexWhere((source) => source is Map<String, dynamic> && source['Id'] == requestedSourceId);
if (byId >= 0) {
index = byId;
resolvedBySourceId = true;
}
}
// Saved-preference signature: only meaningful when the id didn't pin a
// source (Resume rows omit MediaSources, so launch passes a signature and
// a stored index that may not fit this item's source ordering).
if (!resolvedBySourceId && preferredSignature != null && preferredSignature.isNotEmpty) {
final bySignature = MediaVersion.findMatchingIndex(availableVersions, {preferredSignature});
if (bySignature != null) index = bySignature;
}
if (index < 0 || index >= sources.length) index = 0;
final source = sources[index];
if (source is! Map<String, dynamic>) return null;
final chapters = raw['Chapters'];
return JellyfinPlaybackBundle(
availableVersions: availableVersions,
selectedSource: source,
chapters: chapters is List ? chapters : const [],
container: source['Container'] as String?,
selectedSourceId: source['Id'] as String?,
selectedSourceIndex: index,
trickplay: raw['Trickplay'],
);
}
/// Direct-stream URL for [itemId]. Best for files the device can play
/// natively. Adds `?Static=true` to skip the transcoder and
/// `&api_key=...` so the request authenticates without a header.
///
/// Pass [mediaSourceId] to stream a non-default alternate version. When the
/// item only has a single MediaSource, [mediaSourceId] equals [itemId] and
/// can be omitted; for items with multiple versions Jellyfin uses the
/// param to pick which file to serve.
String buildDirectStreamUrl(
String itemId, {
String? container,
String? mediaSourceId,
String? playSessionId,
String? liveStreamId,
int? audioStreamIndex,
}) {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
container: container,
mediaSourceId: mediaSourceId,
playSessionId: playSessionId,
liveStreamId: liveStreamId,
audioStreamIndex: audioStreamIndex,
);
}
/// Audio sibling of [buildDirectStreamUrl]: `/Audio/{id}/stream` with the
/// same `Static=true` + `api_key` + `DeviceId` self-authentication. Used
/// for track direct-play fallback, downloads, and external players.
String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
mediaSegment: 'Audio',
container: container,
mediaSourceId: mediaSourceId,
);
}
/// Trickplay sprite-sheet URL. [width] picks one of the resolutions
/// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based
/// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails).
/// Pass [mediaSourceId] when the item has more than one source so the
/// server returns the matching version's tiles.
String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) {
return buildJellyfinTrickplayTileUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
width: width,
sheetIndex: sheetIndex,
mediaSourceId: mediaSourceId,
);
}
/// Negotiate playback and return a structurally valid successful response.
/// Typed request/decode/cancellation failures propagate unchanged. A
/// successful response must be a map with a list-valued `MediaSources`;
/// the list may be empty for consumer-specific unavailable-stream policy.
///
/// 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.
/// [startTimeTicks] is forwarded to Jellyfin's playback negotiation for
/// resume-aware stream metadata. Our video transcode profile is HLS, and
/// Jellyfin omits `StartTimeTicks` from the returned HLS URL, so the player
/// still performs the initial seek.
/// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
/// to pick for the transcode profile (Jellyfin's negotiation factors them in
/// when picking codec compatibility).
/// [audioProfile] extends the DeviceProfile with music direct-play and
/// audio→mp3 transcode entries for track playback; the video profiles (and
/// the request body when false) are untouched either way.
Future<Map<String, dynamic>> getPlaybackInfo(
String itemId, {
int? maxStreamingBitrate = 100_000_000,
String? mediaSourceId,
String? liveStreamId,
int? startTimeTicks,
int? audioStreamIndex,
int? subtitleStreamIndex,
bool? autoOpenLiveStream,
bool? enableDirectPlay,
bool? enableDirectStream,
bool? enableTranscoding,
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
bool audioProfile = false,
}) async {
final query = <String, String>{
'userId': connection.userId,
'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(),
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'StartTimeTicks': ?startTimeTicks?.toString(),
'AudioStreamIndex': ?audioStreamIndex?.toString(),
'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(),
'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(),
'EnableDirectPlay': ?enableDirectPlay?.toString(),
'EnableDirectStream': ?enableDirectStream?.toString(),
'EnableTranscoding': ?enableTranscoding?.toString(),
'AllowVideoStreamCopy': ?allowVideoStreamCopy?.toString(),
'AllowAudioStreamCopy': ?allowAudioStreamCopy?.toString(),
};
final response = await _http.post(
'/Items/${_segment(itemId)}/PlaybackInfo',
queryParameters: query,
body: {
'UserId': connection.userId,
'MaxStreamingBitrate': ?maxStreamingBitrate,
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'StartTimeTicks': ?startTimeTicks,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'AutoOpenLiveStream': ?autoOpenLiveStream,
'EnableDirectPlay': ?enableDirectPlay,
'EnableDirectStream': ?enableDirectStream,
'EnableTranscoding': ?enableTranscoding,
'AllowVideoStreamCopy': ?allowVideoStreamCopy,
'AllowAudioStreamCopy': ?allowAudioStreamCopy,
'DeviceProfile': <String, Object?>{
'Name': 'Plezy',
'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
// ahead of H.264 so a server that has "Allow encoding in HEVC
// format" enabled will actually emit HEVC instead of falling
// back to H.264.
'TranscodingProfiles': <Map<String, Object?>>[
const {
'Type': 'Video',
'Container': 'ts',
'Protocol': 'hls',
'VideoCodec': 'hevc,h264',
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus',
},
// Track playback transcode target: stereo mp3 over plain http.
// Appended after the video profile so the first-entry-wins
// ordering for video output codecs is untouched.
if (audioProfile)
const {
'Type': 'Audio',
'Container': 'mp3',
'AudioCodec': 'mp3',
'Protocol': 'http',
'Context': 'Streaming',
'MaxAudioChannels': '2',
},
],
// Declaring HEVC in DirectPlayProfile.VideoCodec stops the server
// from forcing a transcode for HEVC sources whose container we
// already accept — mpv decodes HEVC natively on every platform
// we ship.
'DirectPlayProfiles': <Map<String, Object?>>[
const {
'Type': 'Video',
'Container': 'mp4,mkv,m4v,webm,mov,ts',
'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video',
'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts',
},
// Music containers/codecs mpv plays natively everywhere.
if (audioProfile)
const {
'Type': 'Audio',
'Container': 'flac,mp3,ogg,oga,opus,m4a,m4b,aac,alac,wav,aiff,wma,webma',
'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
},
],
// Embed is listed first so a direct-played container reports its
// subtitle streams as `DeliveryMethod: Embed`, matching what the
// native player actually reads. External stays declared for every
// format because a remux or transcode drops those streams from the
// rendition and the server must hand us sidecar URLs instead; the
// server picks per play method, so both entries are required.
'SubtitleProfiles': const <Map<String, Object?>>[
{'Format': 'srt', 'Method': 'Embed'},
{'Format': 'ass', 'Method': 'Embed'},
{'Format': 'ssa', 'Method': 'Embed'},
{'Format': 'vtt', 'Method': 'Embed'},
{'Format': 'pgssub', 'Method': 'Embed'},
{'Format': 'dvdsub', 'Method': 'Embed'},
{'Format': 'dvbsub', 'Method': 'Embed'},
{'Format': 'srt', 'Method': 'External'},
{'Format': 'ass', 'Method': 'External'},
{'Format': 'ssa', 'Method': 'External'},
{'Format': 'vtt', 'Method': 'External'},
{'Format': 'pgssub', 'Method': 'External'},
{'Format': 'dvdsub', 'Method': 'External'},
{'Format': 'dvbsub', 'Method': 'External'},
],
},
},
);
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic> || data['MediaSources'] is! List) {
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: response.statusCode,
message: 'Malformed Jellyfin PlaybackInfo response',
);
}
return data;
}
@override
Future<ExternalIds> fetchExternalIds(String itemId) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
final providerIds = raw is Map<String, dynamic> ? raw['ProviderIds'] : null;
if (providerIds is Map<String, dynamic>) {
return ExternalIds.fromJellyfinProviderIds(providerIds);
}
return const ExternalIds();
}
/// Jellyfin embeds the access token in the URL query string (`api_key=...`)
/// rather than relying on headers, so the player needs no extra headers
/// for direct streams.
@override
Map<String, String> get streamHeaders => const {};
/// Shared body for the `/Sessions/Playing[/Progress]` pair — only [path] and
/// [isPaused] differ between start and progress. Shape mirrors the Jellyfin
/// SDK's `PlaybackStartInfo`/`PlaybackProgressInfo`: Findroid sends the same
/// fields, and Jellyfin's session tracker drops events that omit `PlayMethod`
/// because it has no way to associate progress with an active session row.
Future<void> _postPlayingState(
String path, {
required String itemId,
required Duration position,
required bool isPaused,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
final response = await _http.post(
path,
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'CanSeek': true,
'IsPaused': isPaused,
'IsMuted': false,
'PlayMethod': playMethod ?? 'DirectPlay',
'RepeatMode': 'RepeatNone',
'PlaybackOrder': 'Default',
'PlaySessionId': ?playSessionId,
'LiveStreamId': ?liveStreamId,
},
);
throwIfHttpError(response);
}
/// Tell the server the user has started playing [itemId].
///
/// [duration] is accepted for interface symmetry with Plex but ignored —
/// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes
/// are still sent so the active session reflects the chosen tracks.
@override
Future<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) => _postPlayingState(
'/Sessions/Playing',
itemId: itemId,
position: position,
isPaused: false,
playSessionId: playSessionId,
playMethod: playMethod,
liveStreamId: liveStreamId,
mediaSourceId: mediaSourceId,
audioStreamIndex: audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex,
);
/// Periodic progress ping (510s cadence is typical). Server uses this to
/// drive the resume position, detect idle sessions, and save remembered
/// audio/subtitle stream indexes when enabled in Jellyfin user settings.
@override
Future<void> reportPlaybackProgress({
required String itemId,
required Duration position,
required Duration duration,
bool isPaused = false,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) => _postPlayingState(
'/Sessions/Playing/Progress',
itemId: itemId,
position: position,
isPaused: isPaused,
playSessionId: playSessionId,
playMethod: playMethod,
liveStreamId: liveStreamId,
mediaSourceId: mediaSourceId,
audioStreamIndex: audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex,
);
/// End-of-playback signal. Final position becomes the resume bookmark.
/// [duration] is accepted for interface symmetry with Plex but ignored.
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? liveStreamId,
String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
final response = await _http.post(
'/Sessions/Playing/Stopped',
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'Failed': false,
'PlaySessionId': ?playSessionId,
'LiveStreamId': ?liveStreamId,
},
);
throwIfHttpError(response);
}
}