part of '../video_controls.dart'; final Expando> _subtitleVisibilityWrites = Expando>(); extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { void _toggleSubtitles() { final currentTrack = widget.player.state.track.subtitle; // No-op if no subtitle track is selected if (currentTrack == null || currentTrack.id == 'no') return; _setSubtitleVisibility(!_subtitlesVisible); } 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(); 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(); 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 [], selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null, selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null, sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const {}, sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null, sourceDurationMs: widget.metadata.durationMs, boxFitMode: widget.boxFitMode, videoZoomScale: widget.videoZoomScale, audioSyncOffset: _audioSyncOffset, subtitleSyncOffset: _subtitleSyncOffset, isRotationLocked: _isRotationLocked, isScreenLocked: _isScreenLocked, 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().serverManager; final c = manager.getClient(ServerId(serverId)); return c?.capabilities.externalSubtitleSearch ?? false; } catch (_) { return false; } } Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { final playbackState = context.watch(); 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, ); } }