fix(player): preserve transcoded subtitles at high speed

close #1622
This commit is contained in:
edde746
2026-07-25 04:21:37 +02:00
parent 0643787fbe
commit 2b3853a882
21 changed files with 1524 additions and 445 deletions
+3
View File
@@ -61,6 +61,7 @@ sealed class SubtitleTrack with _$SubtitleTrack {
@Default(false) bool isDefault,
@Default(false) bool isForced,
@Default(false) bool isExternal,
@Default(false) bool isContainer,
String? uri,
}) = _SubtitleTrack;
@@ -71,6 +72,7 @@ sealed class SubtitleTrack with _$SubtitleTrack {
String? codec,
bool isDefault = false,
bool isForced = false,
bool isContainer = false,
}) => SubtitleTrack(
id: 'external:$uri',
title: title,
@@ -79,6 +81,7 @@ sealed class SubtitleTrack with _$SubtitleTrack {
isDefault: isDefault,
isForced: isForced,
isExternal: true,
isContainer: isContainer,
uri: uri,
);
+21 -18
View File
@@ -789,7 +789,7 @@ as bool,
/// @nodoc
mixin _$SubtitleTrack {
String get id; String? get title; String? get language; String? get codec; bool get isDefault; bool get isForced; bool get isExternal; String? get uri;
String get id; String? get title; String? get language; String? get codec; bool get isDefault; bool get isForced; bool get isExternal; bool get isContainer; String? get uri;
/// Create a copy of SubtitleTrack
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -800,16 +800,16 @@ $SubtitleTrackCopyWith<SubtitleTrack> get copyWith => _$SubtitleTrackCopyWithImp
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.uri, uri) || other.uri == uri));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.isContainer, isContainer) || other.isContainer == isContainer)&&(identical(other.uri, uri) || other.uri == uri));
}
@override
int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,uri);
int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,isContainer,uri);
@override
String toString() {
return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, uri: $uri)';
return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, isContainer: $isContainer, uri: $uri)';
}
@@ -820,7 +820,7 @@ abstract mixin class $SubtitleTrackCopyWith<$Res> {
factory $SubtitleTrackCopyWith(SubtitleTrack value, $Res Function(SubtitleTrack) _then) = _$SubtitleTrackCopyWithImpl;
@useResult
$Res call({
String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri
String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri
});
@@ -837,7 +837,7 @@ class _$SubtitleTrackCopyWithImpl<$Res>
/// Create a copy of SubtitleTrack
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? uri = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? isContainer = null,Object? uri = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
@@ -846,6 +846,7 @@ as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullabl
as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable
as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable
as bool,isContainer: null == isContainer ? _self.isContainer : isContainer // ignore: cast_nullable_to_non_nullable
as bool,uri: freezed == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable
as String?,
));
@@ -929,10 +930,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SubtitleTrack() when $default != null:
return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.uri);case _:
return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.isContainer,_that.uri);case _:
return orElse();
}
@@ -950,10 +951,10 @@ return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri) $default,) {final _that = this;
switch (_that) {
case _SubtitleTrack():
return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.uri);}
return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.isContainer,_that.uri);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -967,10 +968,10 @@ return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri)? $default,) {final _that = this;
switch (_that) {
case _SubtitleTrack() when $default != null:
return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.uri);case _:
return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,_that.isForced,_that.isExternal,_that.isContainer,_that.uri);case _:
return null;
}
@@ -982,7 +983,7 @@ return $default(_that.id,_that.title,_that.language,_that.codec,_that.isDefault,
class _SubtitleTrack extends SubtitleTrack {
const _SubtitleTrack({required this.id, this.title, this.language, this.codec, this.isDefault = false, this.isForced = false, this.isExternal = false, this.uri}): super._();
const _SubtitleTrack({required this.id, this.title, this.language, this.codec, this.isDefault = false, this.isForced = false, this.isExternal = false, this.isContainer = false, this.uri}): super._();
@override final String id;
@@ -992,6 +993,7 @@ class _SubtitleTrack extends SubtitleTrack {
@override@JsonKey() final bool isDefault;
@override@JsonKey() final bool isForced;
@override@JsonKey() final bool isExternal;
@override@JsonKey() final bool isContainer;
@override final String? uri;
/// Create a copy of SubtitleTrack
@@ -1004,16 +1006,16 @@ _$SubtitleTrackCopyWith<_SubtitleTrack> get copyWith => __$SubtitleTrackCopyWith
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.uri, uri) || other.uri == uri));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SubtitleTrack&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.language, language) || other.language == language)&&(identical(other.codec, codec) || other.codec == codec)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault)&&(identical(other.isForced, isForced) || other.isForced == isForced)&&(identical(other.isExternal, isExternal) || other.isExternal == isExternal)&&(identical(other.isContainer, isContainer) || other.isContainer == isContainer)&&(identical(other.uri, uri) || other.uri == uri));
}
@override
int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,uri);
int get hashCode => Object.hash(runtimeType,id,title,language,codec,isDefault,isForced,isExternal,isContainer,uri);
@override
String toString() {
return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, uri: $uri)';
return 'SubtitleTrack(id: $id, title: $title, language: $language, codec: $codec, isDefault: $isDefault, isForced: $isForced, isExternal: $isExternal, isContainer: $isContainer, uri: $uri)';
}
@@ -1024,7 +1026,7 @@ abstract mixin class _$SubtitleTrackCopyWith<$Res> implements $SubtitleTrackCopy
factory _$SubtitleTrackCopyWith(_SubtitleTrack value, $Res Function(_SubtitleTrack) _then) = __$SubtitleTrackCopyWithImpl;
@override @useResult
$Res call({
String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, String? uri
String id, String? title, String? language, String? codec, bool isDefault, bool isForced, bool isExternal, bool isContainer, String? uri
});
@@ -1041,7 +1043,7 @@ class __$SubtitleTrackCopyWithImpl<$Res>
/// Create a copy of SubtitleTrack
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? uri = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = freezed,Object? language = freezed,Object? codec = freezed,Object? isDefault = null,Object? isForced = null,Object? isExternal = null,Object? isContainer = null,Object? uri = freezed,}) {
return _then(_SubtitleTrack(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
@@ -1050,6 +1052,7 @@ as String?,codec: freezed == codec ? _self.codec : codec // ignore: cast_nullabl
as String?,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
as bool,isForced: null == isForced ? _self.isForced : isForced // ignore: cast_nullable_to_non_nullable
as bool,isExternal: null == isExternal ? _self.isExternal : isExternal // ignore: cast_nullable_to_non_nullable
as bool,isContainer: null == isContainer ? _self.isContainer : isContainer // ignore: cast_nullable_to_non_nullable
as bool,uri: freezed == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable
as String?,
));
+2 -1
View File
@@ -180,7 +180,7 @@ class PlayerAndroid extends PlayerBase {
'isLive': isLive,
if (externalSubtitles != null && externalSubtitles.isNotEmpty)
'externalSubtitles': externalSubtitles
.where((s) => s.uri != null)
.where((s) => s.uri?.isNotEmpty == true)
.map(
(s) => {
'uri': s.uri,
@@ -189,6 +189,7 @@ class PlayerAndroid extends PlayerBase {
'codec': s.codec,
'isDefault': s.isDefault,
'isForced': s.isForced,
'isContainer': s.isContainer,
},
)
.toList(),
+45 -14
View File
@@ -72,7 +72,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
Duration? _timelineDuration;
int _nextPropId = 0;
final Map<int, String> _propIdToName = {};
Map<String, SubtitleTrack> _externalSubtitleMetadataByUri = const {};
Map<String, List<SubtitleTrack>> _externalSubtitleMetadataByUri = const {};
bool _primaryMediaLoadStarted = false;
bool _primaryMediaReadyEmitted = false;
@@ -499,6 +499,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
final subtitleTracks = <SubtitleTrack>[];
String? selectedAudioId;
String? selectedSubtitleId;
final containerMetadataIndexes = <String, int>{};
for (final track in trackList) {
if (track is! Map) continue;
@@ -511,6 +512,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
final selected = track['selected'] == true;
if (type == 'audio') {
final rawExternalFilename = track['external-filename'];
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
// Container sidecars are opened only to expose their subtitle tracks.
// Do not let their audio streams participate in normal track matching.
if (externalMetadata?.any((metadata) => metadata.isContainer) == true) continue;
if (selected) selectedAudioId = id;
audioTracks.add(
AudioTrack(
@@ -527,20 +535,43 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
if (selected) selectedSubtitleId = id;
final rawCodec = track['codec'];
final codec = rawCodec is String ? rawCodec : null;
final rawTitle = track['title'];
final rawLanguage = track['lang'];
final rawExternalFilename = track['external-filename'];
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
final rawTitle = track['title'];
final rawLanguage = track['lang'];
final isContainer =
track['container'] == true || externalMetadata?.any((metadata) => metadata.isContainer) == true;
SubtitleTrack? matchedMetadata;
if (externalMetadata != null && externalMetadata.isNotEmpty) {
if (isContainer && externalFilename != null) {
final metadataIndex = containerMetadataIndexes[externalFilename] ?? 0;
containerMetadataIndexes[externalFilename] = metadataIndex + 1;
if (metadataIndex < externalMetadata.length && externalMetadata[metadataIndex].isContainer) {
matchedMetadata = externalMetadata[metadataIndex];
}
} else {
matchedMetadata = externalMetadata.first;
}
}
subtitleTracks.add(
SubtitleTrack(
id: id,
title: externalMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec),
language: externalMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null),
codec: externalMetadata?.codec ?? codec,
isDefault: externalMetadata?.isDefault ?? (track['default'] == true),
isForced: externalMetadata?.isForced ?? (track['forced'] == true),
// mpv may synthesize a container track title from the signed
// source filename. Source-catalog metadata is both safer and more
// accurate there, including on builds that drop disposition flags.
// Ordinary sidecars still fall back to metadata reported by mpv.
title: isContainer
? matchedMetadata?.title
: matchedMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec),
language: isContainer
? matchedMetadata?.language
: matchedMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null),
codec: matchedMetadata?.codec ?? codec,
isDefault: matchedMetadata?.isDefault ?? (track['default'] == true),
isForced: matchedMetadata?.isForced ?? (track['forced'] == true),
isExternal: track['external'] == true,
isContainer: isContainer,
uri: externalFilename,
),
);
@@ -599,23 +630,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
@protected
void setExternalSubtitleMetadata(List<SubtitleTrack>? externalSubtitles) {
final metadataByUri = <String, SubtitleTrack>{};
final metadataByUri = <String, List<SubtitleTrack>>{};
for (final subtitle in externalSubtitles ?? const <SubtitleTrack>[]) {
final uri = subtitle.uri;
if (uri != null && uri.isNotEmpty) {
metadataByUri[uri] = subtitle;
(metadataByUri[uri] ??= <SubtitleTrack>[]).add(subtitle);
}
}
_externalSubtitleMetadataByUri = metadataByUri;
}
@protected
Map<String, SubtitleTrack> snapshotExternalSubtitleMetadata() =>
Map<String, SubtitleTrack>.of(_externalSubtitleMetadataByUri);
Map<String, List<SubtitleTrack>> snapshotExternalSubtitleMetadata() =>
Map<String, List<SubtitleTrack>>.of(_externalSubtitleMetadataByUri);
@protected
void restoreExternalSubtitleMetadata(Map<String, SubtitleTrack> snapshot) {
_externalSubtitleMetadataByUri = Map<String, SubtitleTrack>.of(snapshot);
void restoreExternalSubtitleMetadata(Map<String, List<SubtitleTrack>> snapshot) {
_externalSubtitleMetadataByUri = Map<String, List<SubtitleTrack>>.of(snapshot);
}
@protected
+1
View File
@@ -124,6 +124,7 @@ class PlayerNative extends PlayerBase {
?.map((subtitle) => subtitle.uri)
.whereType<String>()
.where((uri) => uri.isNotEmpty)
.toSet()
.map((uri) => _escapePathListEntry(uri, separator))
.toList();
if (escapedUris == null || escapedUris.isEmpty) return null;