Files
plezy/lib/media/media_source_info.dart
T

369 lines
12 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,
});
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;
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);
}
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 isIntro => type == 'intro';
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);
}
}