Files
plezy/lib/widgets/video_controls/parts/track_controls.dart
T
edde746 f4ce60611b fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.

Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:

Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.

Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.

The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.

Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.

close #1738

Refs #1815, #1622.
2026-08-09 07:30:47 +02:00

266 lines
11 KiB
Dart

part of '../video_controls.dart';
final Expando<LatestAsyncWrite<String>> _subtitleVisibilityWrites = Expando<LatestAsyncWrite<String>>();
extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
void _toggleSubtitles() {
// Restoring always works: backends without a renderer-level visibility
// switch hide subtitles by deselecting them, so the current track reads
// as Off while hidden and a selection check would trap the toggle.
if (!_subtitlesVisible) {
_setSubtitleVisibility(true);
return;
}
// A burned-in subtitle is pixels rather than a track: there is nothing selected to hide, and
// `setSubtitleVisibility` could not remove painted pixels anyway. Only a new negotiation can,
// and that is a real subtitle *choice* - it re-encodes the stream and the server remembers it.
// Doing that behind a transient visibility shortcut would silently overwrite the viewer's saved
// selection with Off, so the shortcut says where the control actually lives instead of
// pretending to work or doing nothing at all.
if (_hasBurnedSourceSubtitle()) {
showAppSnackBar(context, t.messages.burnedSubtitlesUseMenu);
return;
}
final currentTrack = widget.player.state.track.subtitle;
// Nothing to hide when no subtitle track is selected.
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
_setSubtitleVisibility(false);
}
/// Whether the server burned the selected subtitle into the picture.
///
/// The same rule the player screen applies to a subtitle *change*, asked with an off target: only
/// a burned current selection forces the server's hand, and a selection delivered as a file stays
/// an ordinary native track the player can hide itself. Shared rather than restated so the two
/// cannot drift.
bool _hasBurnedSourceSubtitle() {
final choice = widget.selectedSubtitleChoice;
final sourceStreamId = choice != null && !choice.isOff ? choice.sourceStreamId : null;
return PlaybackSubtitleResolver.burnRequiresRenegotiation(
isTranscoding: widget.isTranscoding,
currentSourceStreamId: sourceStreamId,
currentSelectionHasSidecar:
sourceStreamId != null &&
widget.sourceSubtitleSidecars.any((sidecar) => sidecar.sourceStreamId == sourceStreamId),
targetIsOff: true,
targetIsExternalFile: false,
);
}
void _onSubtitleTrackChanged(SubtitleTrack track) {
// Reset visibility when user explicitly picks a new subtitle track
if (track.id != 'no' && !_subtitlesVisible) {
_setSubtitleVisibility(true);
}
widget.onSubtitleTrackChanged?.call(track);
}
void _setSubtitleVisibility(bool visible) {
final targetPlayer = widget.player;
final coordinator = _subtitleVisibilityWrites[targetPlayer] ??= LatestAsyncWrite<String>();
final writeToken = coordinator.begin('sub-visibility');
final generation = ++_subtitleVisibilityWriteGeneration;
_setControlsState(() {
_subtitlesVisible = visible;
});
unawaited(() async {
try {
final committed = await coordinator.commitIfLatest('sub-visibility', writeToken, () async {
await targetPlayer.setProperty('sub-visibility', visible ? 'yes' : 'no');
if (mounted && targetPlayer == widget.player) {
// Preserve every successfully executed mutation as the rollback
// baseline, even when a newer optimistic toggle is queued.
_confirmedSubtitlesVisible = visible;
}
});
if (!committed ||
!mounted ||
generation != _subtitleVisibilityWriteGeneration ||
targetPlayer != widget.player) {
return;
}
} catch (error, stackTrace) {
appLogger.w('Failed to update subtitle visibility', error: error, stackTrace: stackTrace);
if (!mounted || generation != _subtitleVisibilityWriteGeneration || targetPlayer != widget.player) {
return;
}
_setControlsState(() {
_subtitlesVisible = _confirmedSubtitlesVisible;
});
}
}());
}
void _toggleShader() {
final shaderService = widget.shaderService;
if (shaderService == null || !shaderService.isSupported) return;
final shaderProvider = context.read<ShaderProvider>();
final targetPreset = resolveShaderTogglePreset(
currentPreset: shaderService.currentPreset,
savedPreset: shaderProvider.savedPreset,
allPresets: shaderProvider.allPresets,
);
if (targetPreset.isEnabled && widget.isAmbientLightingEnabled) {
widget.onToggleAmbientLighting?.call();
}
unawaited(
shaderService
.applyPreset(targetPreset)
.then((_) async {
if (!mounted) return;
if (targetPreset.isEnabled) {
await shaderProvider.setPreset(targetPreset);
} else {
shaderProvider.setCurrentPreset(targetPreset);
}
if (!mounted) return;
// ignore: no-empty-block - setState triggers rebuild to reflect shader changes
_setControlsState(() {});
widget.onShaderChanged?.call();
})
.catchError((Object e, StackTrace st) {
appLogger.w('Failed to toggle shader preset', error: e, stackTrace: st);
}),
);
}
void _nextAudioTrack() {
if (!widget.canControl) return;
widget.onCycleAudioTrack?.call();
}
void _nextSubtitleTrack() {
if (!widget.canControl) return;
widget.onCycleSubtitleTrack?.call();
}
void _nextChapter() => _seekToNextChapter();
void _previousChapter() => _seekToPreviousChapter();
TrackControlsState _buildTrackControlsState({
required PlaybackStateProvider playbackState,
required VoidCallback? onToggleAlwaysOnTop,
}) {
final versionQuality = effectiveVersionQualityControls(
isOfflinePlayback: widget.isOfflinePlayback,
availableVersions: widget.availableVersions,
serverSupportsTranscoding: widget.serverSupportsTranscoding,
isTranscoding: widget.isTranscoding,
sourceAudioTracks: widget.sourceAudioTracks,
selectedAudioStreamId: widget.selectedAudioStreamId,
sourceSubtitleTracks: widget.sourceSubtitleTracks,
selectedSubtitleChoice: widget.selectedSubtitleChoice,
);
final canSwitchSourceSubtitles = versionQuality.canSwitch && versionQuality.sourceSubtitleTracks.isNotEmpty;
return TrackControlsState(
availableVersions: versionQuality.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
selectedQualityPreset: widget.selectedQualityPreset,
serverSupportsTranscoding: versionQuality.serverSupportsTranscoding,
isTranscoding: versionQuality.isTranscoding,
sourceAudioTracks: versionQuality.sourceAudioTracks,
selectedAudioStreamId: versionQuality.selectedAudioStreamId,
sourceSubtitleTracks: canSwitchSourceSubtitles
? versionQuality.sourceSubtitleTracks
: const <MediaSubtitleTrack>[],
selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null,
selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null,
sourceSubtitleSidecars: canSwitchSourceSubtitles
? widget.sourceSubtitleSidecars
: const <PlaybackSubtitleSidecar>[],
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
sourceDurationMs: widget.metadata.durationMs,
boxFitMode: widget.boxFitMode,
videoZoomScale: widget.videoZoomScale,
audioSyncOffset: _audioSyncOffset,
subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked,
isFullscreen: _isFullscreen,
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onVideoZoomChanged: widget.onVideoZoomChanged,
onResetVideoZoom: widget.onResetVideoZoom,
onToggleRotationLock: _toggleRotationLock,
onToggleScreenLock: _toggleScreenLock,
onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: onToggleAlwaysOnTop,
onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null,
onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null,
onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null,
onSwitchSubtitle: canSwitchSourceSubtitles
? (choice) => _switchVersionAndQuality(newSubtitleChoice: choice)
: null,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
onLoadSeekTimes: null,
onCancelAutoHide: widget.chromeController.cancelAutoHide,
onStartAutoHide: _startHideTimer,
// Sync offsets are now driven by listenable rebuilds — the sheet writes
// to SettingsService and the parent re-reads via `_audioSyncOffset` /
// `_subtitleSyncOffset` getters. Callback kept for sheet API compat.
onSyncOffsetChanged: null,
serverId: widget.metadata.serverId,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null,
canControl: widget.canControl,
isLive: widget.isLive,
subtitlesVisible: _subtitlesVisible,
showQueueButton: playbackState.isQueueActive && widget.canNavigateMediaItems,
onQueueItemSelected: playbackState.isQueueActive && widget.canNavigateMediaItems ? _onQueueItemSelected : null,
ratingKey: widget.metadata.id,
mediaTitle: widget.metadata.title,
onSubtitleDownloaded: _onSubtitleDownloaded,
// Plex proxies OpenSubtitles via its server-side plugin; Jellyfin
// doesn't expose an equivalent so the Search Subtitles tile is hidden
// for Jellyfin items. The check uses the registered client type for
// this metadata's serverId.
subtitleSearchSupported: _isPlexBackedMetadata(),
);
}
/// True when the active server supports external subtitle search (Plex
/// today). Requires a server id because the download callback needs the
/// Plex client/token for that server.
bool _isPlexBackedMetadata() {
try {
final serverId = widget.metadata.serverId;
if (serverId == null) return false;
final manager = context.read<MultiServerProvider>().serverManager;
final c = manager.getClient(ServerId(serverId));
return c?.capabilities.externalSubtitleSearch ?? false;
} catch (_) {
return false;
}
}
Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) {
final playbackState = context.watch<PlaybackStateProvider>();
final trackControlsState = _buildTrackControlsState(
playbackState: playbackState,
onToggleAlwaysOnTop: _toggleAlwaysOnTop,
);
return TrackChapterControls(
player: widget.player,
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
trackControlsState: trackControlsState,
onSeekRequested: widget.onSeekRequested,
onSeekCompleted: widget.onSeekCompleted,
hideChaptersAndQueue: hideChaptersAndQueue,
);
}
}