@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user