fix(player): load subtitle sidecars with media

close #1583
This commit is contained in:
edde746
2026-07-17 23:09:07 +02:00
parent 70cd7e8c67
commit a1b6a89714
77 changed files with 2879 additions and 699 deletions
@@ -5,9 +5,12 @@ import '../../../media/media_version.dart';
import '../../../media/media_source_info.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../mpv/mpv.dart';
import '../../../services/playback_subtitle_resolver.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
enum SubtitleDownloadApplyOutcome { applied, timedOut, busy, superseded, unavailable, failed }
/// Immutable configuration for track/chapter control widgets.
class TrackControlsState {
final List<MediaVersion> availableVersions;
@@ -18,7 +21,9 @@ class TrackControlsState {
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final int? selectedSubtitleStreamId;
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
final int? selectedSecondarySubtitleStreamId;
final Set<int> sourceSubtitleSidecarIds;
final int? sourcePartId;
/// Total media duration in milliseconds. Used by the version/quality sheet
@@ -43,7 +48,7 @@ class TrackControlsState {
final Function(int)? onSwitchVersion;
final ValueChanged<TranscodeQualityPreset>? onSwitchQualityPreset;
final ValueChanged<int>? onSwitchAudioStreamId;
final ValueChanged<int>? onSwitchSubtitleStreamId;
final ValueChanged<PlaybackSourceSubtitleChoice>? onSwitchSubtitle;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
@@ -63,7 +68,8 @@ class TrackControlsState {
final Function(MediaItem)? onQueueItemSelected;
final String ratingKey;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Future<SubtitleDownloadApplyOutcome> Function({required String serverId, required String ratingKey})?
onSubtitleDownloaded;
/// Whether OpenSubtitles search is reachable for this server. The Plex
/// server proxies the OpenSubtitles plugin; Jellyfin doesn't expose an
@@ -80,7 +86,9 @@ class TrackControlsState {
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.sourceSubtitleTracks = const [],
this.selectedSubtitleStreamId,
this.selectedSubtitleChoice,
this.selectedSecondarySubtitleStreamId,
this.sourceSubtitleSidecarIds = const <int>{},
this.sourcePartId,
this.sourceDurationMs,
this.boxFitMode = 0,
@@ -102,7 +110,7 @@ class TrackControlsState {
this.onSwitchVersion,
this.onSwitchQualityPreset,
this.onSwitchAudioStreamId,
this.onSwitchSubtitleStreamId,
this.onSwitchSubtitle,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
@@ -126,10 +134,15 @@ class TrackControlsState {
this.subtitleSearchSupported = true,
});
/// Source subtitles can only be selected when playback can be re-opened with
/// a Plex source subtitle stream id.
bool get canUseSourceSubtitles =>
isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null;
/// Transcoded subtitle choices must be negotiated with the server and
/// therefore replace the native rendition track list.
bool get canUseSourceSubtitles => isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitle != null;
/// Direct play keeps embedded/native switching instant while still exposing
/// unloaded server sidecars that require one source reopen when selected.
List<MediaSubtitleTrack> get directPlaySourceSidecars => !isTranscoding && onSwitchSubtitle != null
? sourceSubtitleTracks.where((track) => sourceSubtitleSidecarIds.contains(track.id)).toList(growable: false)
: const <MediaSubtitleTrack>[];
/// External subtitle search needs both a searchable media item and a server
/// that can proxy the OpenSubtitles request.
@@ -140,6 +153,9 @@ class TrackControlsState {
/// the single source of truth shared by the toolbar icon and the sheet layout.
bool hasSubtitleControls(Tracks? tracks) {
final playerSubtitles = tracks?.subtitle ?? const <SubtitleTrack>[];
return canUseSourceSubtitles || TrackFilterHelper.hasTracks<SubtitleTrack>(playerSubtitles) || canSearchSubtitles;
return canUseSourceSubtitles ||
directPlaySourceSidecars.isNotEmpty ||
TrackFilterHelper.hasTracks<SubtitleTrack>(playerSubtitles) ||
canSearchSubtitles;
}
}
@@ -67,74 +67,68 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
videoPlayerState?.navigateToQueueItem(item);
}
Future<void> _onSubtitleDownloaded() async {
if (!mounted) return;
Future<SubtitleDownloadApplyOutcome> _onSubtitleDownloaded({
required String serverId,
required String ratingKey,
}) async {
if (!mounted) return SubtitleDownloadApplyOutcome.unavailable;
// Plex-only: the OpenSubtitles polling flow uses [getVideoPlaybackData]
// and the Plex token. Jellyfin has no analogue and the entry point
// (`subtitleSearchSupported`) is already gated on backend, but guard
// here too in case a future caller wires the same handler elsewhere.
if (widget.metadata.backend != MediaBackend.plex) return;
final serverId = widget.metadata.serverId;
if (serverId == null) return;
if (widget.metadata.backend != MediaBackend.plex) return SubtitleDownloadApplyOutcome.unavailable;
if (widget.metadata.serverId != serverId || widget.metadata.id != ratingKey) {
return SubtitleDownloadApplyOutcome.superseded;
}
final switchSource = widget.onPlaybackSourceChanged;
if (switchSource == null) return SubtitleDownloadApplyOutcome.unavailable;
final itemKey = widget.metadata.globalKey;
bool targetIsCurrent() =>
mounted &&
widget.metadata.globalKey == itemKey &&
widget.metadata.serverId == serverId &&
widget.metadata.id == ratingKey;
try {
final client = context.getPlexClientForServer(ServerId(serverId));
final token = client.config.token;
if (token == null) return;
// Plex's OpenSubtitles download is asynchronous: the PUT returns immediately
// but the new stream entry shows up in metadata seconds later. Poll until it
// appears. Up to 15s matches what Plex-web tolerates before giving up.
// Snapshot what's already attached so we can identify the new download.
final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet();
// Snapshot the authoritative source IDs so we can identify the new
// download without asking mpv to synchronously open its remote URL.
final existingSourceIds = widget.sourceSubtitleTracks.map((track) => track.id).toSet();
final deadline = DateTime.now().add(const Duration(seconds: 15));
MediaSubtitleTrack? newTrack;
String? newUrl;
MediaSourceInfo? latestInfo;
while (mounted && DateTime.now().isBefore(deadline)) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
if (!mounted) return SubtitleDownloadApplyOutcome.superseded;
try {
final data = await client.getVideoPlaybackData(widget.metadata.id);
if (!mounted) return;
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
final data = await client.getVideoPlaybackData(ratingKey);
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
if (data.mediaInfo == null) continue;
latestInfo = data.mediaInfo;
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
if (!plexTrack.isExternal) continue;
final url = client.buildExternalSubtitleUrl(plexTrack);
if (url == null) continue;
if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue;
newTrack = plexTrack;
newUrl = url;
break;
}
newTrack = findNewExternalSubtitleTrack(data.mediaInfo!.subtitleTracks, existingSourceIds);
if (newTrack != null) break;
} catch (e) {
appLogger.w('Subtitle download poll iteration failed', error: e);
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
}
}
if (!mounted || newTrack == null || newUrl == null) return;
await widget.player.addSubtitleTrack(
uri: newUrl,
title: newTrack.displayTitle ?? newTrack.language ?? t.videoControls.downloadedSubtitle,
language: newTrack.languageCode,
select: true,
);
final partId = latestInfo?.partId;
if (partId != null) {
await client.selectStreams(partId, subtitleStreamID: newTrack.id);
}
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
if (newTrack == null) return SubtitleDownloadApplyOutcome.timedOut;
final outcome = await switchSource(newSubtitleChoice: PlaybackSourceSubtitleChoice.source(newTrack.id));
return subtitleDownloadApplyOutcomeFor(outcome);
} catch (e) {
appLogger.w('Failed to refresh subtitles after download', error: e);
return SubtitleDownloadApplyOutcome.failed;
}
}
@@ -145,7 +139,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
}) async {
final onPlaybackSourceChanged = widget.onPlaybackSourceChanged;
if (onPlaybackSourceChanged == null) return;
@@ -154,7 +148,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
newMediaIndex: newMediaIndex,
newPreset: newPreset,
newAudioStreamId: newAudioStreamId,
newSubtitleStreamId: newSubtitleStreamId,
newSubtitleChoice: newSubtitleChoice,
);
} catch (e) {
if (mounted) {
@@ -86,10 +86,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
sourceAudioTracks: widget.sourceAudioTracks,
selectedAudioStreamId: widget.selectedAudioStreamId,
sourceSubtitleTracks: widget.sourceSubtitleTracks,
selectedSubtitleStreamId: widget.selectedSubtitleStreamId,
selectedSubtitleChoice: widget.selectedSubtitleChoice,
);
final canSwitchSourceSubtitles =
versionQuality.canSwitch && versionQuality.isTranscoding && widget.metadata.backend == MediaBackend.plex;
final canSwitchSourceSubtitles = versionQuality.canSwitch && versionQuality.sourceSubtitleTracks.isNotEmpty;
return TrackControlsState(
availableVersions: versionQuality.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
@@ -101,7 +100,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
sourceSubtitleTracks: canSwitchSourceSubtitles
? versionQuality.sourceSubtitleTracks
: const <MediaSubtitleTrack>[],
selectedSubtitleStreamId: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleStreamId : null,
selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null,
selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null,
sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const <int>{},
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
sourceDurationMs: widget.metadata.durationMs,
boxFitMode: widget.boxFitMode,
@@ -123,8 +124,8 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null,
onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null,
onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null,
onSwitchSubtitleStreamId: canSwitchSourceSubtitles
? (id) => _switchVersionAndQuality(newSubtitleStreamId: id)
onSwitchSubtitle: canSwitchSourceSubtitles
? (choice) => _switchVersionAndQuality(newSubtitleChoice: choice)
: null,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
@@ -21,6 +21,7 @@ import '../../../widgets/overlay_sheet.dart';
import '../../../widgets/pill_input_decoration.dart';
import 'base_video_control_sheet.dart';
import '../../loading_indicator_box.dart';
import '../models/track_controls_state.dart';
@visibleForTesting
String resolveSubtitleSearchLanguageCode({String? savedLanguageCode, required Locale systemLocale}) {
@@ -33,7 +34,8 @@ class SubtitleSearchSheet extends StatefulWidget {
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Future<SubtitleDownloadApplyOutcome> Function({required String serverId, required String ratingKey})?
onSubtitleDownloaded;
const SubtitleSearchSheet({
super.key,
@@ -205,10 +207,17 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
if (!mounted) return;
if (success) {
await widget.onSubtitleDownloaded?.call();
final outcome =
await widget.onSubtitleDownloaded?.call(serverId: widget.serverId, ratingKey: widget.ratingKey) ??
SubtitleDownloadApplyOutcome.unavailable;
if (!mounted) return;
showSuccessSnackBar(context, t.videoControls.subtitleDownloaded);
OverlaySheetController.of(context).close();
if (outcome == SubtitleDownloadApplyOutcome.applied) {
showSuccessSnackBar(context, t.videoControls.subtitleDownloaded);
OverlaySheetController.of(context).close();
} else {
showErrorSnackBar(context, t.videoControls.subtitleDownloadedNotApplied);
setState(() => _downloadingKey = null);
}
} else {
showErrorSnackBar(context, t.videoControls.subtitleDownloadFailed);
setState(() => _downloadingKey = null);
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../media/media_source_info.dart';
import '../../../mpv/mpv.dart';
import '../../../services/playback_subtitle_resolver.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/scroll_utils.dart';
import '../../../utils/track_label_builder.dart';
@@ -103,6 +104,7 @@ class TrackSheet extends StatelessWidget {
supportsSecondary: supportsSecondary,
showHeader: showHeader,
trackControlsState: state,
sourceSidecars: state.directPlaySourceSidecars,
);
}
@@ -221,8 +223,9 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
@override
Widget build(BuildContext context) {
final selectedId = _effectiveSelectedStreamId();
final selectedIndex = selectedId == 0 ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
final selectedChoice = _effectiveSelectedChoice();
final selectedId = selectedChoice.sourceStreamId;
final selectedIndex = selectedChoice.isOff ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
_initialScroll.maybeScrollTo(selectedIndex);
return Column(
@@ -237,10 +240,10 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
context: context,
key: _initialScroll.firstItemKey,
isSelected: selectedId == 0,
isSelected: selectedChoice.isOff,
onTap: () {
OverlaySheetController.of(context).close();
widget.trackControlsState.onSwitchSubtitleStreamId!(0);
widget.trackControlsState.onSwitchSubtitle!(const PlaybackSourceSubtitleChoice.off());
},
);
}
@@ -252,7 +255,7 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
isSelected: track.id == selectedId,
onTap: () {
OverlaySheetController.of(context).close();
widget.trackControlsState.onSwitchSubtitleStreamId!(track.id);
widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(track.id));
},
);
},
@@ -263,13 +266,15 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
);
}
int _effectiveSelectedStreamId() {
final explicit = widget.trackControlsState.selectedSubtitleStreamId;
if (explicit != null && (explicit == 0 || widget.tracks.any((track) => track.id == explicit))) return explicit;
for (final track in widget.tracks) {
if (track.selected) return track.id;
PlaybackSourceSubtitleChoice _effectiveSelectedChoice() {
final explicit = widget.trackControlsState.selectedSubtitleChoice;
if (explicit != null && (explicit.isOff || widget.tracks.any((track) => track.id == explicit.sourceStreamId))) {
return explicit;
}
return 0;
for (final track in widget.tracks) {
if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id);
}
return const PlaybackSourceSubtitleChoice.off();
}
}
@@ -349,6 +354,7 @@ class _SubtitleColumn extends StatefulWidget {
final bool supportsSecondary;
final bool showHeader;
final TrackControlsState trackControlsState;
final List<MediaSubtitleTrack> sourceSidecars;
const _SubtitleColumn({
required this.tracks,
@@ -357,6 +363,7 @@ class _SubtitleColumn extends StatefulWidget {
this.supportsSecondary = false,
required this.showHeader,
required this.trackControlsState,
this.sourceSidecars = const [],
});
@override
@@ -378,9 +385,15 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
final secondarySub = widget.selection.secondarySubtitle;
final isOffSelected = selectedSub == null || selectedSub.id == 'no';
final hasSecondary = widget.supportsSecondary && secondarySub != null;
final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId;
final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId;
final unloadedSourceSidecars = widget.sourceSidecars
.where((track) => track.id != selectedSourceId && track.id != selectedSecondarySourceId)
.toList(growable: false);
// +1 for "Off" row
final itemCount = widget.tracks.length + 1;
// +1 for "Off" row. The selected direct-play sidecar is already present
// in [tracks], so only the other server sidecars are appended.
final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1;
final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
_initialScroll.maybeScrollTo(selectedIndex);
@@ -423,7 +436,22 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
);
}
final track = widget.tracks[index - 1];
final trackIndex = index - 1;
if (trackIndex >= widget.tracks.length) {
final sourceIndex = trackIndex - widget.tracks.length;
final sourceTrack = unloadedSourceSidecars[sourceIndex];
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
context: context,
label: sourceTrack.labelForIndex(trackIndex),
isSelected: false,
onTap: () {
OverlaySheetController.of(context).close();
widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(sourceTrack.id));
},
);
}
final track = widget.tracks[trackIndex];
final isPrimary = !isOffSelected && track.id == selectedSub.id;
final isSecondary = hasSecondary && track.id == secondarySub.id;
final label = TrackLabelBuilder.subtitleLabel(
@@ -431,7 +459,7 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
language: track.language,
codec: track.codec,
forced: track.isForced,
index: index - 1,
index: trackIndex,
);
Widget? badge;
+54 -17
View File
@@ -30,6 +30,7 @@ import 'package:flutter/services.dart'
import '../../services/fullscreen_state_manager.dart';
import '../../services/macos_window_service.dart';
import '../../services/pip_service.dart';
import '../../services/playback_subtitle_resolver.dart';
import 'package:window_manager/window_manager.dart';
import '../../mixins/settings_effect_mixin.dart';
@@ -96,23 +97,53 @@ part 'parts/visibility.dart';
/// Subtitle tracks offered in the player's "source" subtitle list.
///
/// While transcoding, only tracks Plex can deliver in HLS are shown: keyed
/// sidecars, text codecs that can become WebVTT, and image codecs that can be
/// burned into the rendition. Outside transcode the full list is returned
/// unchanged, since the player has direct access to every embedded stream.
/// Direct play exposes embedded tracks in the native player and can attach
/// arbitrary sidecars itself. A transcode can only deliver external sidecars
/// or embedded codecs that the server can convert/burn into the rendition.
List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
List<MediaSubtitleTrack> tracks, {
required bool isTranscoding,
required Set<int> sidecarSourceIds,
required bool supportsEmbeddedTranscodeSelection,
}) {
if (!isTranscoding) return tracks;
if (!isTranscoding) {
return tracks
.where((track) {
final requiresSidecar =
track.isExternalFile || (!track.usesExternalDelivery && track.key != null && track.key!.isNotEmpty);
return !requiresSidecar || sidecarSourceIds.contains(track.id);
})
.toList(growable: false);
}
return tracks
.where((track) {
final hasKey = track.key != null && track.key!.isNotEmpty;
return hasKey || CodecUtils.isTranscodableSubtitleCodec(track.codec);
})
.where(
(track) =>
sidecarSourceIds.contains(track.id) ||
(supportsEmbeddedTranscodeSelection && CodecUtils.isTranscodableSubtitleCodec(track.codec)),
)
.toList(growable: false);
}
@visibleForTesting
MediaSubtitleTrack? findNewExternalSubtitleTrack(List<MediaSubtitleTrack> tracks, Set<int> existingSourceIds) {
for (final track in tracks) {
if (track.isExternal && !existingSourceIds.contains(track.id)) return track;
}
return null;
}
@visibleForTesting
SubtitleDownloadApplyOutcome subtitleDownloadApplyOutcomeFor(PlaybackSourceChangeOutcome outcome) {
return switch (outcome) {
PlaybackSourceChangeOutcome.applied ||
PlaybackSourceChangeOutcome.unchanged => SubtitleDownloadApplyOutcome.applied,
PlaybackSourceChangeOutcome.busy => SubtitleDownloadApplyOutcome.busy,
PlaybackSourceChangeOutcome.superseded => SubtitleDownloadApplyOutcome.superseded,
PlaybackSourceChangeOutcome.unavailable => SubtitleDownloadApplyOutcome.unavailable,
PlaybackSourceChangeOutcome.failed => SubtitleDownloadApplyOutcome.failed,
};
}
@visibleForTesting
ShaderPreset resolveShaderTogglePreset({
required ShaderPreset currentPreset,
@@ -132,7 +163,7 @@ ShaderPreset resolveShaderTogglePreset({
List<MediaAudioTrack> sourceAudioTracks,
int? selectedAudioStreamId,
List<MediaSubtitleTrack> sourceSubtitleTracks,
int? selectedSubtitleStreamId,
PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
bool canSwitch,
})
effectiveVersionQualityControls({
@@ -143,7 +174,7 @@ effectiveVersionQualityControls({
required List<MediaAudioTrack> sourceAudioTracks,
required int? selectedAudioStreamId,
required List<MediaSubtitleTrack> sourceSubtitleTracks,
required int? selectedSubtitleStreamId,
required PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
}) {
if (isOfflinePlayback) {
return (
@@ -153,7 +184,7 @@ effectiveVersionQualityControls({
sourceAudioTracks: const <MediaAudioTrack>[],
selectedAudioStreamId: null,
sourceSubtitleTracks: const <MediaSubtitleTrack>[],
selectedSubtitleStreamId: null,
selectedSubtitleChoice: null,
canSwitch: false,
);
}
@@ -164,7 +195,7 @@ effectiveVersionQualityControls({
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: selectedAudioStreamId,
sourceSubtitleTracks: sourceSubtitleTracks,
selectedSubtitleStreamId: selectedSubtitleStreamId,
selectedSubtitleChoice: selectedSubtitleChoice,
canSwitch: true,
);
}
@@ -358,13 +389,15 @@ bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, req
}
typedef PlaybackSourceChangeCallback =
Future<void> Function({
Future<PlaybackSourceChangeOutcome> Function({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
});
enum PlaybackSourceChangeOutcome { applied, unchanged, busy, unavailable, superseded, failed }
typedef _EdgeAdjustmentIndicatorState = ({bool visible, MobileEdgeAdjustmentSide? side, double value});
class PlexVideoControls extends StatefulWidget {
@@ -381,7 +414,9 @@ class PlexVideoControls extends StatefulWidget {
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final int? selectedSubtitleStreamId;
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
final int? selectedSecondarySubtitleStreamId;
final Set<int> sourceSubtitleSidecarIds;
final int? sourcePartId;
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
final int boxFitMode;
@@ -492,7 +527,9 @@ class PlexVideoControls extends StatefulWidget {
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.sourceSubtitleTracks = const [],
this.selectedSubtitleStreamId,
this.selectedSubtitleChoice,
this.selectedSecondarySubtitleStreamId,
this.sourceSubtitleSidecarIds = const <int>{},
this.sourcePartId,
this.onPlaybackSourceChanged,
this.boxFitMode = 0,