refactor: share media stream mapping
This commit is contained in:
@@ -10,6 +10,43 @@ import 'plex_constants.dart';
|
||||
/// the four-tuple result.
|
||||
enum FileInfoStreamType { video, audio, subtitle }
|
||||
|
||||
/// Normalised projection of a single entry in Jellyfin's `MediaStreams` array.
|
||||
/// Callers build their own typed output from this shared extraction so the
|
||||
/// field-name parsing only lives in one place.
|
||||
typedef JellyfinStreamFields = ({
|
||||
String? type,
|
||||
int index,
|
||||
String? codec,
|
||||
String? language,
|
||||
String? languageCode,
|
||||
String? title,
|
||||
String? displayTitle,
|
||||
bool isDefault,
|
||||
bool isForced,
|
||||
bool isExternal,
|
||||
String? deliveryUrl,
|
||||
int? channels,
|
||||
double? frameRate,
|
||||
});
|
||||
|
||||
JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fallbackIndex = 0}) {
|
||||
return (
|
||||
type: (s['Type'] as String?)?.toLowerCase(),
|
||||
index: flexibleInt(s['Index']) ?? fallbackIndex,
|
||||
codec: s['Codec'] as String?,
|
||||
language: s['DisplayLanguage'] as String? ?? s['Language'] as String?,
|
||||
languageCode: s['Language'] as String?,
|
||||
title: s['Title'] as String?,
|
||||
displayTitle: s['DisplayTitle'] as String?,
|
||||
isDefault: s['IsDefault'] as bool? ?? false,
|
||||
isForced: s['IsForced'] as bool? ?? false,
|
||||
isExternal: s['IsExternal'] as bool? ?? false,
|
||||
deliveryUrl: s['DeliveryUrl'] as String?,
|
||||
channels: flexibleInt(s['Channels']),
|
||||
frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']),
|
||||
);
|
||||
}
|
||||
|
||||
/// Single-pass result of walking a streams array. Keeps both the raw
|
||||
/// `videoStream` / `audioStream` map pointers (for callers that need to dig
|
||||
/// out keys the parsed track classes don't carry — e.g. `colorSpace`,
|
||||
@@ -58,11 +95,17 @@ abstract class FileInfoStreamReader {
|
||||
double? frameRateOf(Map<String, dynamic> videoStream);
|
||||
}
|
||||
|
||||
typedef MalformedStreamHandler = void Function(Object error, StackTrace stackTrace, Map<String, dynamic> stream);
|
||||
|
||||
/// Walk [streams] in a single pass. Captures the first video / audio entries
|
||||
/// (later ones are ignored — both backends serve a single primary track per
|
||||
/// type), accumulates *all* audio / subtitle tracks for selection UIs, and
|
||||
/// extracts the frame rate from the video entry.
|
||||
FileInfoStreams walkStreams(List<dynamic>? streams, FileInfoStreamReader reader) {
|
||||
FileInfoStreams walkStreams(
|
||||
List<dynamic>? streams,
|
||||
FileInfoStreamReader reader, {
|
||||
MalformedStreamHandler? onMalformed,
|
||||
}) {
|
||||
if (streams == null || streams.isEmpty) return FileInfoStreams.empty;
|
||||
final audioTracks = <MediaAudioTrack>[];
|
||||
final subtitleTracks = <MediaSubtitleTrack>[];
|
||||
@@ -73,19 +116,24 @@ FileInfoStreams walkStreams(List<dynamic>? streams, FileInfoStreamReader reader)
|
||||
var subtitleIndex = 0;
|
||||
for (final raw in streams) {
|
||||
if (raw is! Map<String, dynamic>) continue;
|
||||
final type = reader.typeOf(raw);
|
||||
if (type == null) continue;
|
||||
switch (type) {
|
||||
case FileInfoStreamType.video:
|
||||
videoStream ??= raw;
|
||||
frameRate ??= reader.frameRateOf(raw);
|
||||
case FileInfoStreamType.audio:
|
||||
audioStream ??= raw;
|
||||
audioIndex++;
|
||||
audioTracks.add(reader.toAudioTrack(raw, audioIndex));
|
||||
case FileInfoStreamType.subtitle:
|
||||
subtitleIndex++;
|
||||
subtitleTracks.add(reader.toSubtitleTrack(raw, subtitleIndex));
|
||||
try {
|
||||
final type = reader.typeOf(raw);
|
||||
if (type == null) continue;
|
||||
switch (type) {
|
||||
case FileInfoStreamType.video:
|
||||
videoStream ??= raw;
|
||||
frameRate ??= reader.frameRateOf(raw);
|
||||
case FileInfoStreamType.audio:
|
||||
audioStream ??= raw;
|
||||
audioIndex++;
|
||||
audioTracks.add(reader.toAudioTrack(raw, audioIndex));
|
||||
case FileInfoStreamType.subtitle:
|
||||
subtitleIndex++;
|
||||
subtitleTracks.add(reader.toSubtitleTrack(raw, subtitleIndex));
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
if (onMalformed == null) rethrow;
|
||||
onMalformed(error, stackTrace, raw);
|
||||
}
|
||||
}
|
||||
return FileInfoStreams(
|
||||
@@ -174,32 +222,35 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
|
||||
|
||||
@override
|
||||
MediaAudioTrack toAudioTrack(Map<String, dynamic> s, int autoIndex) {
|
||||
final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex);
|
||||
return MediaAudioTrack(
|
||||
id: (s['Index'] as int?) ?? autoIndex,
|
||||
index: s['Index'] as int?,
|
||||
codec: s['Codec'] as String?,
|
||||
language: s['Language'] as String?,
|
||||
languageCode: s['Language'] as String?,
|
||||
title: s['Title'] as String?,
|
||||
displayTitle: s['DisplayTitle'] as String?,
|
||||
channels: s['Channels'] as int?,
|
||||
selected: s['IsDefault'] == true,
|
||||
id: f.index,
|
||||
index: f.index,
|
||||
codec: f.codec,
|
||||
language: f.language,
|
||||
languageCode: f.languageCode,
|
||||
title: f.title,
|
||||
displayTitle: f.displayTitle,
|
||||
channels: f.channels,
|
||||
selected: f.isDefault,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MediaSubtitleTrack toSubtitleTrack(Map<String, dynamic> s, int autoIndex) {
|
||||
final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex);
|
||||
return MediaSubtitleTrack(
|
||||
id: (s['Index'] as int?) ?? autoIndex,
|
||||
index: s['Index'] as int?,
|
||||
codec: s['Codec'] as String?,
|
||||
language: s['Language'] as String?,
|
||||
languageCode: s['Language'] as String?,
|
||||
title: s['Title'] as String?,
|
||||
displayTitle: s['DisplayTitle'] as String?,
|
||||
selected: s['IsDefault'] == true,
|
||||
forced: s['IsForced'] == true,
|
||||
key: null,
|
||||
id: f.index,
|
||||
index: f.index,
|
||||
codec: f.codec,
|
||||
language: f.language,
|
||||
languageCode: f.languageCode,
|
||||
title: f.title,
|
||||
displayTitle: f.displayTitle,
|
||||
selected: f.isDefault,
|
||||
forced: f.isForced,
|
||||
key: f.isExternal ? f.deliveryUrl : null,
|
||||
external: f.isExternal,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,50 +10,12 @@ import '../media/media_version.dart';
|
||||
import '../utils/jellyfin_time.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import '../utils/resolution_label.dart';
|
||||
import 'file_info_parser.dart';
|
||||
|
||||
// Re-export so existing callers that pulled `resolutionLabelFromHeight`
|
||||
// from this file keep compiling without a bulk import rewrite.
|
||||
export '../utils/resolution_label.dart' show resolutionLabelFromHeight;
|
||||
|
||||
/// Normalised projection of a single entry in Jellyfin's `MediaStreams` array.
|
||||
/// Both [JellyfinMappers._mediaStreams] and `jellyfinMediaSourceToMediaSourceInfo`
|
||||
/// build their own typed output (neutral [MediaStream] vs Plex-shaped
|
||||
/// `MediaAudioTrack`/`MediaSubtitleTrack`) from this shared extraction so the
|
||||
/// field-name parsing only lives in one place.
|
||||
typedef JellyfinStreamFields = ({
|
||||
String? type,
|
||||
int index,
|
||||
String? codec,
|
||||
String? language,
|
||||
String? languageCode,
|
||||
String? title,
|
||||
String? displayTitle,
|
||||
bool isDefault,
|
||||
bool isForced,
|
||||
bool isExternal,
|
||||
String? deliveryUrl,
|
||||
int? channels,
|
||||
double? frameRate,
|
||||
});
|
||||
|
||||
JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fallbackIndex = 0}) {
|
||||
return (
|
||||
type: (s['Type'] as String?)?.toLowerCase(),
|
||||
index: flexibleInt(s['Index']) ?? fallbackIndex,
|
||||
codec: s['Codec'] as String?,
|
||||
language: s['DisplayLanguage'] as String? ?? s['Language'] as String?,
|
||||
languageCode: s['Language'] as String?,
|
||||
title: s['Title'] as String?,
|
||||
displayTitle: s['DisplayTitle'] as String?,
|
||||
isDefault: s['IsDefault'] as bool? ?? false,
|
||||
isForced: s['IsForced'] as bool? ?? false,
|
||||
isExternal: s['IsExternal'] as bool? ?? false,
|
||||
deliveryUrl: s['DeliveryUrl'] as String?,
|
||||
channels: flexibleInt(s['Channels']),
|
||||
frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? jellyfinFirstVideoStream(Object? streams) {
|
||||
if (streams is! List) return null;
|
||||
for (final stream in streams) {
|
||||
@@ -165,6 +127,12 @@ class JellyfinMappers {
|
||||
|
||||
static String _query(String value) => Uri.encodeComponent(value);
|
||||
|
||||
static String _itemImagePath(String id, String type, {String? tag, int? imageIndex}) {
|
||||
final indexPart = imageIndex != null ? '/$imageIndex' : '';
|
||||
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
|
||||
return '/Items/${_segment(id)}/Images/$type$indexPart$tagPart';
|
||||
}
|
||||
|
||||
/// Map a Jellyfin `BaseItemDto` (the `Items[]` shape returned by most
|
||||
/// browse endpoints) into a [MediaItem]. Returns `null` when the server
|
||||
/// payload is missing `Id` — the mapped item would otherwise carry an
|
||||
@@ -397,8 +365,7 @@ class JellyfinMappers {
|
||||
final id = person['Id'] as String?;
|
||||
final tag = person['PrimaryImageTag'] as String?;
|
||||
if (id == null) return null;
|
||||
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
|
||||
return '/Items/${_segment(id)}/Images/Primary$tagPart';
|
||||
return _itemImagePath(id, 'Primary', tag: tag);
|
||||
}
|
||||
|
||||
static List<MediaVersion>? _mediaVersions(Object? sources) {
|
||||
@@ -464,28 +431,27 @@ class JellyfinMappers {
|
||||
String? tag;
|
||||
if (type == 'Backdrop' && backdropTags is List && backdropTags.isNotEmpty) {
|
||||
tag = backdropTags.first as String?;
|
||||
return tag != null ? '/Items/${_segment(id)}/Images/Backdrop/0?tag=${_query(tag)}' : null;
|
||||
return tag != null ? _itemImagePath(id, 'Backdrop', tag: tag, imageIndex: 0) : null;
|
||||
}
|
||||
if (tags is Map<String, dynamic>) {
|
||||
final value = tags[type];
|
||||
if (value is String) tag = value;
|
||||
}
|
||||
if (tag == null) return null;
|
||||
return '/Items/${_segment(id)}/Images/$type?tag=${_query(tag)}';
|
||||
return _itemImagePath(id, type, tag: tag);
|
||||
}
|
||||
|
||||
static String? _seriesPrimaryImage(Map<String, dynamic> item) {
|
||||
final seriesId = item['SeriesId'] as String?;
|
||||
if (seriesId == null) return null;
|
||||
final tag = item['SeriesPrimaryImageTag'] as String?;
|
||||
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
|
||||
return '/Items/${_segment(seriesId)}/Images/Primary$tagPart';
|
||||
return _itemImagePath(seriesId, 'Primary', tag: tag);
|
||||
}
|
||||
|
||||
static String? _seriesBackdropImage(Map<String, dynamic> item) {
|
||||
final seriesId = item['SeriesId'] as String?;
|
||||
if (seriesId == null) return null;
|
||||
return '/Items/${_segment(seriesId)}/Images/Backdrop/0';
|
||||
return _itemImagePath(seriesId, 'Backdrop', imageIndex: 0);
|
||||
}
|
||||
|
||||
/// Parent backdrop helper — works for episodes (parent = series) and
|
||||
@@ -499,9 +465,9 @@ class JellyfinMappers {
|
||||
final tags = item['ParentBackdropImageTags'];
|
||||
if (tags is List && tags.isNotEmpty) {
|
||||
final tag = tags.first as String?;
|
||||
if (tag != null) return '/Items/${_segment(parentId)}/Images/Backdrop/0?tag=${_query(tag)}';
|
||||
if (tag != null) return _itemImagePath(parentId, 'Backdrop', tag: tag, imageIndex: 0);
|
||||
}
|
||||
return '/Items/${_segment(parentId)}/Images/Backdrop/0';
|
||||
return _itemImagePath(parentId, 'Backdrop', imageIndex: 0);
|
||||
}
|
||||
|
||||
/// Parent logo helper — episodes/seasons inherit the series' logo via
|
||||
@@ -511,15 +477,13 @@ class JellyfinMappers {
|
||||
final parentId = item['ParentLogoItemId'] as String?;
|
||||
if (parentId == null) return null;
|
||||
final tag = item['ParentLogoImageTag'] as String?;
|
||||
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
|
||||
return '/Items/${_segment(parentId)}/Images/Logo$tagPart';
|
||||
return _itemImagePath(parentId, 'Logo', tag: tag);
|
||||
}
|
||||
|
||||
static String? _imagePath(Map<String, dynamic> item, String idField, String tagField, String type) {
|
||||
final id = item[idField] as String?;
|
||||
if (id == null) return null;
|
||||
final tag = item[tagField] as String?;
|
||||
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
|
||||
return '/Items/${_segment(id)}/Images/$type$tagPart';
|
||||
return _itemImagePath(id, type, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import '../media/media_version.dart';
|
||||
import '../media/media_source_info.dart';
|
||||
import '../utils/jellyfin_time.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'file_info_parser.dart';
|
||||
import 'jellyfin_mappers.dart';
|
||||
|
||||
/// Translate a Jellyfin `MediaSource` JSON object into [MediaSourceInfo] so the
|
||||
@@ -24,62 +25,16 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
|
||||
Object? chapters,
|
||||
Object? trickplay,
|
||||
}) {
|
||||
final streams = source['MediaStreams'];
|
||||
final audioTracks = <MediaAudioTrack>[];
|
||||
final subtitleTracks = <MediaSubtitleTrack>[];
|
||||
final rawStreams = source['MediaStreams'];
|
||||
final parsedStreams = walkStreams(rawStreams is List ? rawStreams : null, const JellyfinFileInfoStreamReader());
|
||||
// partId stays null for Jellyfin because Plex's `/library/parts/{id}`
|
||||
// select-stream endpoint has no Jellyfin equivalent. Jellyfin track
|
||||
// persistence is driven by `/Sessions/Playing/Progress` stream indexes.
|
||||
const int? partId = null;
|
||||
final defaultAudioStreamIndex = flexibleInt(source['DefaultAudioStreamIndex']);
|
||||
final defaultSubtitleStreamIndex = flexibleInt(source['DefaultSubtitleStreamIndex']);
|
||||
double? frameRate;
|
||||
|
||||
if (streams is List) {
|
||||
for (final s in streams) {
|
||||
if (s is! Map<String, dynamic>) continue;
|
||||
final f = parseJellyfinStreamFields(s);
|
||||
switch (f.type) {
|
||||
case 'video':
|
||||
frameRate ??= f.frameRate;
|
||||
break;
|
||||
case 'audio':
|
||||
audioTracks.add(
|
||||
MediaAudioTrack(
|
||||
id: f.index,
|
||||
index: f.index,
|
||||
codec: f.codec,
|
||||
language: f.language,
|
||||
languageCode: f.languageCode,
|
||||
title: f.title,
|
||||
displayTitle: f.displayTitle,
|
||||
channels: f.channels,
|
||||
selected: defaultAudioStreamIndex != null ? f.index == defaultAudioStreamIndex : f.isDefault,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'subtitle':
|
||||
subtitleTracks.add(
|
||||
MediaSubtitleTrack(
|
||||
id: f.index,
|
||||
index: f.index,
|
||||
codec: f.codec,
|
||||
language: f.language,
|
||||
languageCode: f.languageCode,
|
||||
title: f.title,
|
||||
displayTitle: f.displayTitle,
|
||||
selected: defaultSubtitleStreamIndex != null
|
||||
? f.index == defaultSubtitleStreamIndex
|
||||
: f.isDefault || f.isForced,
|
||||
forced: f.isForced,
|
||||
key: f.isExternal ? f.deliveryUrl : null,
|
||||
external: f.isExternal,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
final audioTracks = _withDefaultAudioSelection(parsedStreams.audioTracks, defaultAudioStreamIndex);
|
||||
final subtitleTracks = _withDefaultSubtitleSelection(parsedStreams.subtitleTracks, defaultSubtitleStreamIndex);
|
||||
|
||||
final mappedChapters = <MediaChapter>[];
|
||||
if (chapters is List) {
|
||||
@@ -101,7 +56,7 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
|
||||
subtitleTracks: subtitleTracks,
|
||||
chapters: mappedChapters,
|
||||
partId: partId,
|
||||
frameRate: frameRate,
|
||||
frameRate: parsedStreams.frameRate,
|
||||
mediaSourceId: mediaSourceId,
|
||||
defaultAudioStreamIndex: defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
|
||||
@@ -109,6 +64,43 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
|
||||
);
|
||||
}
|
||||
|
||||
List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, int? defaultStreamIndex) {
|
||||
if (defaultStreamIndex == null) return tracks;
|
||||
return [
|
||||
for (final track in tracks)
|
||||
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.index == defaultStreamIndex,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
|
||||
return [
|
||||
for (final track in tracks)
|
||||
MediaSubtitleTrack(
|
||||
id: track.id,
|
||||
index: track.index,
|
||||
codec: track.codec,
|
||||
language: track.language,
|
||||
languageCode: track.languageCode,
|
||||
title: track.title,
|
||||
displayTitle: track.displayTitle,
|
||||
selected: defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
|
||||
forced: track.forced,
|
||||
key: track.key,
|
||||
external: track.external,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse Jellyfin chapters from the raw `BaseItemDto` payload into neutral
|
||||
/// playback extras. Markers are not exposed by Jellyfin, so the list is empty.
|
||||
PlaybackExtras jellyfinPlaybackExtrasFromRaw(dynamic raw, String itemId) {
|
||||
|
||||
@@ -26,8 +26,8 @@ import '../media/media_version.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'plex_constants.dart';
|
||||
import '../utils/obfuscation_utils.dart';
|
||||
import 'file_info_parser.dart';
|
||||
|
||||
/// Shared suffix of both unmatched-agent URL schemes: legacy
|
||||
/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`.
|
||||
@@ -1004,60 +1004,18 @@ MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map<String, dynamic> metadata,
|
||||
final selectedMedia = mediaIndex >= 0 && mediaIndex < media.length ? media[mediaIndex] : media.first;
|
||||
final parts = flexibleList(selectedMedia['Part']);
|
||||
if (parts == null || parts.isEmpty) return null;
|
||||
final streams = flexibleList(parts.first['Stream']);
|
||||
|
||||
final audioTracks = <MediaAudioTrack>[];
|
||||
final subtitleTracks = <MediaSubtitleTrack>[];
|
||||
double? frameRate;
|
||||
|
||||
if (streams != null) {
|
||||
for (final s in streams) {
|
||||
try {
|
||||
final streamType = s['streamType'] as int?;
|
||||
if (streamType == PlexStreamType.video) {
|
||||
frameRate ??= (s['frameRate'] as num?)?.toDouble();
|
||||
} else if (streamType == PlexStreamType.audio) {
|
||||
audioTracks.add(
|
||||
MediaAudioTrack(
|
||||
id: s['id'] as int,
|
||||
index: s['index'] as int?,
|
||||
codec: s['codec'] as String?,
|
||||
language: s['language'] as String?,
|
||||
languageCode: s['languageCode'] as String?,
|
||||
title: s['title'] as String?,
|
||||
displayTitle: s['displayTitle'] as String?,
|
||||
channels: s['channels'] as int?,
|
||||
selected: flexibleBool(s['selected']),
|
||||
),
|
||||
);
|
||||
} else if (streamType == PlexStreamType.subtitle) {
|
||||
subtitleTracks.add(
|
||||
MediaSubtitleTrack(
|
||||
id: s['id'] as int,
|
||||
index: s['index'] as int?,
|
||||
codec: s['codec'] as String?,
|
||||
language: s['language'] as String?,
|
||||
languageCode: s['languageCode'] as String?,
|
||||
title: s['title'] as String?,
|
||||
displayTitle: s['displayTitle'] as String?,
|
||||
selected: flexibleBool(s['selected']),
|
||||
forced: flexibleBool(s['forced']),
|
||||
key: s['key'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('Skipping malformed stream in cached metadata', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
final streams = walkStreams(
|
||||
flexibleList(parts.first['Stream']),
|
||||
const PlexFileInfoStreamReader(),
|
||||
onMalformed: (error, _, __) => appLogger.d('Skipping malformed stream in cached metadata', error: error),
|
||||
);
|
||||
|
||||
return MediaSourceInfo(
|
||||
videoUrl: '',
|
||||
audioTracks: audioTracks,
|
||||
subtitleTracks: subtitleTracks,
|
||||
audioTracks: streams.audioTracks,
|
||||
subtitleTracks: streams.subtitleTracks,
|
||||
chapters: const [],
|
||||
frameRate: frameRate,
|
||||
frameRate: streams.frameRate,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user