Plezy's device profile declares every subtitle format with `Method: External`, so Jellyfin answers PlaybackInfo with `DeliveryMethod: External` and a `DeliveryUrl` even for streams embedded in a direct-played container. Direct play never fetches those URLs, but the rows kept the delivery URL as `MediaSubtitleTrack.key`, and keyed rows only match a native track loaded from the same URL. No embedded track could satisfy that, so `selectSubtitleTrack` reported "still pending" forever: playback started with subtitles off and logged the five- and thirty-second waits, and the server's default subtitle had to be picked by hand on every item. Restrict sidecar identity to the rows an open actually fetched as sidecars. A row that stays in the container loses `key` and `usesExternalDelivery` and matches on metadata again; genuine `IsExternal` files keep theirs, and remuxed or transcoded renditions still resolve their sidecars by URL. Also declare every subtitle format Embed-first so a direct-played container reports embedded delivery in the first place, and make the pending contract match its purpose on every backend. The complete-catalog escape is no longer Plex-only, so a Jellyfin row the native player has not produced keeps the pass pending instead of committing an unrelated default and retiring the listener that was waiting for the real track. A source id absent from the catalog no longer defers a decision that can never change, and the thirty-second deadline resolves from what has arrived instead of re-deriving the same deferral and applying nothing. close #1696
433 lines
14 KiB
Dart
433 lines
14 KiB
Dart
import '../i18n/strings.g.dart';
|
|
import '../utils/track_label_builder.dart' show TrackLabel, TrackLabelBuilder;
|
|
import 'media_display_criteria.dart';
|
|
|
|
class MediaSourceInfo {
|
|
final String videoUrl;
|
|
final List<MediaAudioTrack> audioTracks;
|
|
final List<MediaSubtitleTrack> subtitleTracks;
|
|
final List<MediaChapter> chapters;
|
|
final int? partId;
|
|
final MediaDisplayCriteria? displayCriteria;
|
|
|
|
/// Backend-opaque source id for the selected version. Plex uses the
|
|
/// authoritative `MediaVersion.id`; Jellyfin uses the selected
|
|
/// `MediaSources` id.
|
|
final String? mediaSourceId;
|
|
|
|
/// Jellyfin default stream indexes for this source. A subtitle index of -1
|
|
/// is an explicit server/user decision to start with subtitles off.
|
|
final int? defaultAudioStreamIndex;
|
|
final int? defaultSubtitleStreamIndex;
|
|
|
|
/// Jellyfin trickplay manifest for the selected source, keyed by tile
|
|
/// width. Null when the server didn't run trickplay extraction. Plex stays
|
|
/// null here and uses [partId] + the BIF service instead.
|
|
final Map<int, TrickplayInfo>? trickplayByWidth;
|
|
|
|
/// Display aspect ratio of the video stream (width / height).
|
|
final double? videoAspectRatio;
|
|
|
|
MediaSourceInfo({
|
|
required this.videoUrl,
|
|
required this.audioTracks,
|
|
required this.subtitleTracks,
|
|
required this.chapters,
|
|
this.partId,
|
|
this.displayCriteria,
|
|
this.mediaSourceId,
|
|
this.defaultAudioStreamIndex,
|
|
this.defaultSubtitleStreamIndex,
|
|
this.trickplayByWidth,
|
|
this.videoAspectRatio,
|
|
});
|
|
|
|
/// Field-preserving rebuild. Track lists are the only members that the
|
|
/// playback pipeline rewrites after construction; every other field must
|
|
/// survive those rewrites untouched.
|
|
MediaSourceInfo copyWith({List<MediaAudioTrack>? audioTracks, List<MediaSubtitleTrack>? subtitleTracks}) {
|
|
return MediaSourceInfo(
|
|
videoUrl: videoUrl,
|
|
audioTracks: audioTracks ?? this.audioTracks,
|
|
subtitleTracks: subtitleTracks ?? this.subtitleTracks,
|
|
chapters: chapters,
|
|
partId: partId,
|
|
displayCriteria: displayCriteria,
|
|
mediaSourceId: mediaSourceId,
|
|
defaultAudioStreamIndex: defaultAudioStreamIndex,
|
|
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
|
|
trickplayByWidth: trickplayByWidth,
|
|
videoAspectRatio: videoAspectRatio,
|
|
);
|
|
}
|
|
|
|
int? getPartId() => partId;
|
|
}
|
|
|
|
/// Per-resolution Jellyfin trickplay manifest. Mirrors `TrickplayInfoDto`
|
|
/// from the Jellyfin OpenAPI spec.
|
|
class TrickplayInfo {
|
|
final int width;
|
|
final int height;
|
|
final int tileWidth;
|
|
final int tileHeight;
|
|
final int thumbnailCount;
|
|
final int interval;
|
|
final int bandwidth;
|
|
|
|
const TrickplayInfo({
|
|
required this.width,
|
|
required this.height,
|
|
required this.tileWidth,
|
|
required this.tileHeight,
|
|
required this.thumbnailCount,
|
|
required this.interval,
|
|
required this.bandwidth,
|
|
});
|
|
}
|
|
|
|
/// Shared fallback-index math for [MediaAudioTrack] and [MediaSubtitleTrack]
|
|
/// labels; the label content itself is built by [TrackLabelBuilder].
|
|
mixin _TrackLabelMixin {
|
|
int get id;
|
|
int? get index;
|
|
|
|
int get _fallbackLabelIndex {
|
|
final streamIndex = index ?? id;
|
|
return streamIndex > 0 ? streamIndex - 1 : 0;
|
|
}
|
|
}
|
|
|
|
class MediaAudioTrack with _TrackLabelMixin {
|
|
@override
|
|
final int id;
|
|
@override
|
|
final int? index;
|
|
final String? codec;
|
|
final String? language;
|
|
final String? languageCode;
|
|
final String? title;
|
|
final String? displayTitle;
|
|
final int? channels;
|
|
final bool selected;
|
|
final bool external;
|
|
|
|
MediaAudioTrack({
|
|
required this.id,
|
|
this.index,
|
|
this.codec,
|
|
this.language,
|
|
this.languageCode,
|
|
this.title,
|
|
this.displayTitle,
|
|
this.channels,
|
|
required this.selected,
|
|
this.external = false,
|
|
});
|
|
|
|
bool get isExternal => external;
|
|
|
|
/// Rebuild with a different server-selected flag.
|
|
MediaAudioTrack withSelected(bool selected) {
|
|
return MediaAudioTrack(
|
|
id: id,
|
|
index: index,
|
|
codec: codec,
|
|
language: language,
|
|
languageCode: languageCode,
|
|
title: title,
|
|
displayTitle: displayTitle,
|
|
channels: channels,
|
|
selected: selected,
|
|
external: external,
|
|
);
|
|
}
|
|
|
|
TrackLabel get label {
|
|
return TrackLabelBuilder.audioLabel(
|
|
title: title,
|
|
language: language,
|
|
languageCode: languageCode,
|
|
codec: codec,
|
|
channels: channels,
|
|
displayTitle: displayTitle,
|
|
index: _fallbackLabelIndex,
|
|
);
|
|
}
|
|
}
|
|
|
|
class MediaSubtitleTrack with _TrackLabelMixin {
|
|
@override
|
|
final int id;
|
|
@override
|
|
final int? index;
|
|
final String? codec;
|
|
final String? language;
|
|
final String? languageCode;
|
|
final String? title;
|
|
final String? displayTitle;
|
|
final bool selected;
|
|
final bool forced;
|
|
final String? key;
|
|
final bool external;
|
|
final bool usesExternalDelivery;
|
|
|
|
MediaSubtitleTrack({
|
|
required this.id,
|
|
this.index,
|
|
this.codec,
|
|
this.language,
|
|
this.languageCode,
|
|
this.title,
|
|
this.displayTitle,
|
|
required this.selected,
|
|
required this.forced,
|
|
this.key,
|
|
this.external = false,
|
|
this.usesExternalDelivery = false,
|
|
});
|
|
|
|
TrackLabel get label {
|
|
return labelForIndex(_fallbackLabelIndex);
|
|
}
|
|
|
|
TrackLabel labelForIndex(int visibleIndex) {
|
|
return TrackLabelBuilder.subtitleLabel(
|
|
title: title,
|
|
language: language,
|
|
languageCode: languageCode,
|
|
codec: codec,
|
|
forced: forced,
|
|
displayTitle: displayTitle,
|
|
index: visibleIndex,
|
|
);
|
|
}
|
|
|
|
/// Returns true if this subtitle track is an external file (sidecar subtitle).
|
|
/// Some backends provide a direct key/URL, others require constructing one
|
|
/// from stream metadata.
|
|
bool get isExternalFile => external;
|
|
|
|
bool get isExternal => external || usesExternalDelivery || (key != null && key!.isNotEmpty);
|
|
|
|
/// Rebuild with a different server-selected flag.
|
|
MediaSubtitleTrack withSelected(bool selected) => _rebuild(selected: selected);
|
|
|
|
/// Rebuild without the sidecar identity fields ([key] and
|
|
/// [usesExternalDelivery]).
|
|
///
|
|
/// Whether a row has sidecar identity is a per-playback fact that only the
|
|
/// backend service layer can establish; this just applies that decision.
|
|
/// [external] is left untouched for the caller to interpret.
|
|
MediaSubtitleTrack withoutSidecarIdentity() =>
|
|
key == null && !usesExternalDelivery ? this : _rebuild(dropSidecarIdentity: true);
|
|
|
|
MediaSubtitleTrack _rebuild({bool? selected, bool dropSidecarIdentity = false}) {
|
|
return MediaSubtitleTrack(
|
|
id: id,
|
|
index: index,
|
|
codec: codec,
|
|
language: language,
|
|
languageCode: languageCode,
|
|
title: title,
|
|
displayTitle: displayTitle,
|
|
selected: selected ?? this.selected,
|
|
forced: forced,
|
|
key: dropSidecarIdentity ? null : key,
|
|
external: external,
|
|
usesExternalDelivery: dropSidecarIdentity ? false : usesExternalDelivery,
|
|
);
|
|
}
|
|
}
|
|
|
|
class MediaChapter {
|
|
final int id;
|
|
final int? index;
|
|
final int? startTimeOffset;
|
|
final int? endTimeOffset;
|
|
final String? title;
|
|
final String? thumb;
|
|
|
|
MediaChapter({required this.id, this.index, this.startTimeOffset, this.endTimeOffset, this.title, this.thumb});
|
|
|
|
/// Backfill missing `endTimeOffset` on each chapter from the next chapter's
|
|
/// `startTimeOffset`. Jellyfin sends only starts; the seek-bar tick UI needs
|
|
/// duration ranges. Mutates [chapters] in place and returns it.
|
|
static List<MediaChapter> backfillEndOffsets(List<MediaChapter> chapters, {int? runtimeMs}) {
|
|
for (var i = 0; i < chapters.length - 1; i++) {
|
|
final c = chapters[i];
|
|
if (c.endTimeOffset != null) continue;
|
|
chapters[i] = MediaChapter(
|
|
id: c.id,
|
|
index: c.index,
|
|
startTimeOffset: c.startTimeOffset,
|
|
endTimeOffset: chapters[i + 1].startTimeOffset,
|
|
title: c.title,
|
|
thumb: c.thumb,
|
|
);
|
|
}
|
|
if (runtimeMs != null && chapters.isNotEmpty) {
|
|
final last = chapters.last;
|
|
final start = last.startTimeOffset;
|
|
if (last.endTimeOffset == null && start != null && runtimeMs > start) {
|
|
chapters[chapters.length - 1] = MediaChapter(
|
|
id: last.id,
|
|
index: last.index,
|
|
startTimeOffset: start,
|
|
endTimeOffset: runtimeMs,
|
|
title: last.title,
|
|
thumb: last.thumb,
|
|
);
|
|
}
|
|
}
|
|
return chapters;
|
|
}
|
|
|
|
String get label => title ?? t.common.chapterNumber(number: (index ?? 0) + 1);
|
|
|
|
Duration get startTime => Duration(milliseconds: startTimeOffset ?? 0);
|
|
Duration? get endTime => endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
|
|
|
|
/// Find the chapter index containing [position]. Returns null if none match.
|
|
/// A chapter's end defaults to the next chapter's start when [endTimeOffset]
|
|
/// is missing; the final chapter without an end extends to infinity.
|
|
static int? indexAtPosition(Duration position, List<MediaChapter> chapters) {
|
|
final positionMs = position.inMilliseconds;
|
|
for (int i = 0; i < chapters.length; i++) {
|
|
final chapter = chapters[i];
|
|
final startMs = chapter.startTimeOffset ?? 0;
|
|
final endMs =
|
|
chapter.endTimeOffset ??
|
|
(i < chapters.length - 1 ? chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt());
|
|
if (positionMs >= startMs && positionMs < endMs) return i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Find the chapter targeted by next/previous traversal. Previous traversal
|
|
/// restarts the current chapter only after [previousRestartThreshold]; before
|
|
/// that it selects an earlier chapter.
|
|
static int? seekTargetIndex(
|
|
Duration position,
|
|
List<MediaChapter> chapters, {
|
|
required bool forward,
|
|
Duration previousRestartThreshold = const Duration(seconds: 3),
|
|
}) {
|
|
final positionMs = position.inMilliseconds;
|
|
if (forward) {
|
|
for (int i = 0; i < chapters.length; i++) {
|
|
if ((chapters[i].startTimeOffset ?? 0) > positionMs) return i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
final thresholdMs = previousRestartThreshold.inMilliseconds;
|
|
for (int i = chapters.length - 1; i >= 0; i--) {
|
|
if (positionMs > (chapters[i].startTimeOffset ?? 0) + thresholdMs) return i;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class MediaMarker {
|
|
final int id;
|
|
final String type;
|
|
final int startTimeOffset;
|
|
final int endTimeOffset;
|
|
|
|
MediaMarker({required this.id, required this.type, required this.startTimeOffset, required this.endTimeOffset});
|
|
|
|
Duration get startTime => Duration(milliseconds: startTimeOffset);
|
|
Duration get endTime => Duration(milliseconds: endTimeOffset);
|
|
|
|
bool get isCredits => type == 'credits';
|
|
|
|
bool containsPosition(Duration position) {
|
|
final posMs = position.inMilliseconds;
|
|
return posMs >= startTimeOffset && posMs < endTimeOffset;
|
|
}
|
|
}
|
|
|
|
/// Combined chapters and markers fetched in a single API call
|
|
class PlaybackExtras {
|
|
final List<MediaChapter> chapters;
|
|
final List<MediaMarker> markers;
|
|
|
|
PlaybackExtras({required this.chapters, required this.markers});
|
|
|
|
static String? _classifyChapterTitle(String title, RegExp introPattern, RegExp creditsPattern) {
|
|
if (introPattern.hasMatch(title)) return 'intro';
|
|
if (creditsPattern.hasMatch(title)) return 'credits';
|
|
return null;
|
|
}
|
|
|
|
/// Returns [PlaybackExtras] using real markers when available, filling any
|
|
/// missing marker types from chapter titles matching intro/credits patterns.
|
|
/// [forceChapterFallback] prefers chapter-derived markers for any type they
|
|
/// provide. When real markers exist, reclassifies markers with unknown types
|
|
/// against the patterns so non-standard type strings get recognized.
|
|
factory PlaybackExtras.withChapterFallback({
|
|
required List<MediaChapter> chapters,
|
|
required List<MediaMarker> markers,
|
|
String? introPatternStr,
|
|
String? creditsPatternStr,
|
|
bool forceChapterFallback = false,
|
|
}) {
|
|
final introPattern = RegExp(
|
|
introPatternStr ?? r'(?:^|\b)(?:intro(?:duction)?|opening)(?:\b|$)|^op(?:\s?\d+)?$',
|
|
caseSensitive: false,
|
|
);
|
|
final creditsPattern = RegExp(
|
|
creditsPatternStr ?? r'(?:^|\b)(?:outro|closing|credits?|ending)(?:\b|$)|^ed(?:\s?\d+)?$',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
final synthetic = <MediaMarker>[];
|
|
for (var i = 0; i < chapters.length; i++) {
|
|
final ch = chapters[i];
|
|
final title = ch.title;
|
|
if (title == null || title.isEmpty) continue;
|
|
|
|
final type = _classifyChapterTitle(title, introPattern, creditsPattern);
|
|
if (type == null) continue;
|
|
|
|
final start = ch.startTimeOffset;
|
|
if (start == null) continue;
|
|
|
|
final end = ch.endTimeOffset ?? (i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null);
|
|
if (end == null) continue;
|
|
|
|
synthetic.add(MediaMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end));
|
|
}
|
|
|
|
if (markers.isNotEmpty) {
|
|
// Reclassify markers with non-standard types against the patterns.
|
|
final reclassified = markers.map((m) {
|
|
if (m.type == 'intro' || m.type == 'credits') return m;
|
|
final newType = _classifyChapterTitle(m.type, introPattern, creditsPattern);
|
|
if (newType != null) {
|
|
return MediaMarker(
|
|
id: m.id,
|
|
type: newType,
|
|
startTimeOffset: m.startTimeOffset,
|
|
endTimeOffset: m.endTimeOffset,
|
|
);
|
|
}
|
|
return m;
|
|
}).toList();
|
|
|
|
if (synthetic.isEmpty) return PlaybackExtras(chapters: chapters, markers: reclassified);
|
|
|
|
final syntheticTypes = synthetic.map((m) => m.type).toSet();
|
|
final nativeTypes = reclassified.map((m) => m.type).toSet();
|
|
final merged = forceChapterFallback
|
|
? <MediaMarker>[...reclassified.where((m) => !syntheticTypes.contains(m.type)), ...synthetic]
|
|
: <MediaMarker>[...reclassified, ...synthetic.where((m) => !nativeTypes.contains(m.type))];
|
|
merged.sort((a, b) => a.startTimeOffset.compareTo(b.startTimeOffset));
|
|
return PlaybackExtras(chapters: chapters, markers: merged);
|
|
}
|
|
|
|
return PlaybackExtras(chapters: chapters, markers: synthetic);
|
|
}
|
|
}
|