fix(runtime): harden application service boundaries
This commit is contained in:
@@ -115,10 +115,9 @@ List<ContextMenuItem> _getLibraryMenuItems(MediaLibrary library) {
|
||||
confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title),
|
||||
isDestructive: true,
|
||||
);
|
||||
// Scan / analyze / empty trash hit Plex-only endpoints. Gating them keeps
|
||||
// [getPlexClientForLibrary] from falling back through `_resolveClient` to
|
||||
// the first online Plex server and firing the action against the wrong
|
||||
// backend.
|
||||
// Scan / analyze / empty trash hit Plex-only endpoints, so backend
|
||||
// capability gating keeps them out of Jellyfin menus. The library-qualified
|
||||
// resolver independently requires the exact owning Plex server.
|
||||
if (library.backend != MediaBackend.plex) return [refresh];
|
||||
return [
|
||||
ContextMenuItem(
|
||||
@@ -213,8 +212,8 @@ Future<void> _performLibraryAction(
|
||||
|
||||
/// Backend-neutral counterpart to [_performLibraryAction] for ops that exist
|
||||
/// on the [MediaServerClient] interface (currently just refresh metadata).
|
||||
/// Resolves the client through `getMediaClientForLibrary` so a Jellyfin
|
||||
/// library is routed to its own server, not a fallback Plex one.
|
||||
/// Resolves the client through `getMediaClientForLibrary` so the action requires
|
||||
/// the library's exact owning server.
|
||||
Future<void> _performMediaLibraryAction(
|
||||
BuildContext context, {
|
||||
required MediaLibrary library,
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../metadata_edit/metadata_edit_adapters.dart';
|
||||
import '../media/media_version.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../services/media_list_playback_launcher.dart';
|
||||
import '../services/jellyfin_sequential_launcher.dart';
|
||||
import '../services/music/music_playback_service.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../services/playlist_items_loader.dart';
|
||||
@@ -1391,7 +1392,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
// Launcher accepts both MediaItem (for collections) and MediaPlaylist.
|
||||
final launcher = MediaListPlaybackLauncher.forItem(context, widget.item);
|
||||
await launcher.launchFromCollectionOrPlaylist(item: widget.item, shuffle: shuffle, showLoadingIndicator: false);
|
||||
await launcher.launchFromCollectionOrPlaylist(
|
||||
item: widget.item,
|
||||
shuffle: shuffle,
|
||||
showLoadingIndicator: launcher is JellyfinSequentialLauncher,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async {
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../mpv/mpv.dart';
|
||||
import '../../media/media_source_info.dart';
|
||||
import '../../services/fullscreen_state_manager.dart';
|
||||
import '../../services/scrub_preview_source.dart';
|
||||
import '../../services/video_volume_controller.dart';
|
||||
import '../../utils/desktop_window_padding.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
@@ -33,9 +34,11 @@ import 'widgets/track_chapter_controls.dart';
|
||||
/// Desktop-specific video controls layout with top bar and bottom controls
|
||||
class DesktopVideoControls extends StatefulWidget {
|
||||
final Player player;
|
||||
final VideoVolumeController volumeController;
|
||||
final MediaItem metadata;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback? onPrevious;
|
||||
final VoidCallback onPlayPause;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool chaptersLoaded;
|
||||
final bool showChapterMarkersOnTimeline;
|
||||
@@ -114,9 +117,11 @@ class DesktopVideoControls extends StatefulWidget {
|
||||
const DesktopVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.volumeController,
|
||||
required this.metadata,
|
||||
this.onNext,
|
||||
this.onPrevious,
|
||||
required this.onPlayPause,
|
||||
required this.chapters,
|
||||
required this.chaptersLoaded,
|
||||
this.showChapterMarkersOnTimeline = true,
|
||||
@@ -644,6 +649,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
serverId: widget.serverId,
|
||||
canControl: _canControl,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
@@ -819,15 +825,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
index: 3,
|
||||
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||
iconSize: 32,
|
||||
onPressed: _canControl
|
||||
? () {
|
||||
if (isPlaying) {
|
||||
widget.player.pause();
|
||||
} else {
|
||||
widget.player.play();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onPressed: _canControl ? widget.onPlayPause : null,
|
||||
semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton,
|
||||
);
|
||||
},
|
||||
@@ -937,7 +935,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
// Volume control (hidden on TV — hardware handles volume)
|
||||
if (!PlatformDetector.isTV()) ...[
|
||||
VolumeControl(
|
||||
player: widget.player,
|
||||
volumeController: widget.volumeController,
|
||||
focusNode: _volumeFocusNode,
|
||||
onKeyEvent: _handleVolumeKeyEvent,
|
||||
onFocusChange: _onFocusChange,
|
||||
|
||||
@@ -313,6 +313,7 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
player: widget.player,
|
||||
chapters: widget.chapters,
|
||||
chaptersLoaded: widget.chaptersLoaded,
|
||||
canControl: widget.canControl,
|
||||
serverId: widget.serverId,
|
||||
showQueueTab: widget.showQueueTab,
|
||||
onQueueItemSelected: widget.onQueueItemSelected,
|
||||
@@ -390,12 +391,11 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||
iconSize: 72,
|
||||
onPressed: () {
|
||||
widget.onPlayPause();
|
||||
if (isPlaying) {
|
||||
widget.player.pause();
|
||||
widget.onCancelAutoHide?.call(); // Cancel auto-hide when paused
|
||||
widget.onCancelAutoHide?.call();
|
||||
} else {
|
||||
widget.player.play();
|
||||
widget.onStartAutoHide?.call(); // Start auto-hide when playing
|
||||
widget.onStartAutoHide?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -62,6 +62,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
void _activateHiddenControlsPrimaryAction() {
|
||||
if (!widget.canControl) {
|
||||
_showControlsWithFocus();
|
||||
return;
|
||||
}
|
||||
if (_isSkipMarkerButtonVisible) {
|
||||
_activateSkipMarker();
|
||||
return;
|
||||
@@ -145,6 +149,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
_nextSubtitleTrack,
|
||||
_nextChapter,
|
||||
_previousChapter,
|
||||
canControlPlayback: widget.canControl,
|
||||
canNavigateMediaItems: widget.canNavigateMediaItems,
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
onToggleShader: _toggleShader,
|
||||
onNextEpisode: widget.onNext,
|
||||
onPreviousEpisode: widget.onPrevious,
|
||||
@@ -152,9 +159,13 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
onZoomIn: widget.onZoomIn,
|
||||
onZoomOut: widget.onZoomOut,
|
||||
onZoomReset: widget.onResetVideoZoom,
|
||||
onVolumeUp: () => widget.volumeController.adjust(10),
|
||||
onVolumeDown: () => widget.volumeController.adjust(-10),
|
||||
onToggleMute: widget.volumeController.toggleMute,
|
||||
currentPositionEpoch: widget.currentPositionEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
onLiveSeekBy: widget.onLiveSeekBy,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
);
|
||||
if (result == KeyEventResult.handled) {
|
||||
_focusNode.requestFocus(); // self-heal focus
|
||||
@@ -268,6 +279,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
_nextSubtitleTrack,
|
||||
_nextChapter,
|
||||
_previousChapter,
|
||||
canControlPlayback: widget.canControl,
|
||||
canNavigateMediaItems: widget.canNavigateMediaItems,
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
onToggleShader: _toggleShader,
|
||||
onSkipMarker: _performAutoSkip,
|
||||
onNextEpisode: widget.onNext,
|
||||
@@ -276,6 +290,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
||||
onZoomIn: widget.onZoomIn,
|
||||
onZoomOut: widget.onZoomOut,
|
||||
onZoomReset: widget.onResetVideoZoom,
|
||||
onVolumeUp: () => widget.volumeController.adjust(10),
|
||||
onVolumeDown: () => widget.volumeController.adjust(-10),
|
||||
onToggleMute: widget.volumeController.toggleMute,
|
||||
currentPositionEpoch: widget.currentPositionEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
onLiveSeekBy: widget.onLiveSeekBy,
|
||||
|
||||
@@ -86,6 +86,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
Future<void> _skipMarker({bool skipAutoPlayCountdown = false}) async {
|
||||
if (!widget.canControl) return;
|
||||
if (_currentMarker == null || !_hasRenderedFirstFrame) return;
|
||||
|
||||
final marker = _currentMarker!;
|
||||
@@ -188,6 +189,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState {
|
||||
|
||||
/// Perform the appropriate skip action based on marker type and next episode availability
|
||||
void _performAutoSkip({bool skipAutoPlayCountdown = false}) {
|
||||
if (!widget.canControl) return;
|
||||
if (_currentMarker == null || !_hasRenderedFirstFrame) return;
|
||||
unawaited(_skipMarker(skipAutoPlayCountdown: skipAutoPlayCountdown));
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
child: DesktopVideoControls(
|
||||
key: _desktopControlsKey,
|
||||
player: widget.player,
|
||||
volumeController: widget.volumeController,
|
||||
metadata: widget.metadata,
|
||||
onNext: widget.onNext,
|
||||
onPrevious: widget.onPrevious,
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
chapters: _chapters,
|
||||
chaptersLoaded: _chaptersLoaded,
|
||||
showChapterMarkersOnTimeline: _showChapterMarkersOnTimeline,
|
||||
@@ -49,8 +51,8 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
onJumpToLive: widget.onJumpToLive,
|
||||
useDpadNavigation: useDpad,
|
||||
serverId: widget.metadata.serverId,
|
||||
showQueueTab: playbackState.isQueueActive,
|
||||
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null,
|
||||
showQueueTab: playbackState.isQueueActive && widget.canNavigateMediaItems,
|
||||
onQueueItemSelected: playbackState.isQueueActive && widget.canNavigateMediaItems ? _onQueueItemSelected : null,
|
||||
onCancelAutoHide: widget.chromeController.cancelAutoHide,
|
||||
onStartAutoHide: _startHideTimer,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
|
||||
@@ -74,6 +74,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
Future<void> _playOrPause() async {
|
||||
if (!widget.canControl) return;
|
||||
if (!widget.player.state.playing && _rewindOnResume > 0) {
|
||||
final target = widget.player.state.position - Duration(seconds: _rewindOnResume);
|
||||
final clamped = clampSeekPosition(widget.player, target);
|
||||
|
||||
@@ -6,24 +6,39 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
// No-op if no subtitle track is selected
|
||||
if (currentTrack == null || currentTrack.id == 'no') return;
|
||||
|
||||
final newVisible = !_subtitlesVisible;
|
||||
widget.player.setProperty('sub-visibility', newVisible ? 'yes' : 'no');
|
||||
_setControlsState(() {
|
||||
_subtitlesVisible = newVisible;
|
||||
});
|
||||
_setSubtitleVisibility(!_subtitlesVisible);
|
||||
}
|
||||
|
||||
void _onSubtitleTrackChanged(SubtitleTrack track) {
|
||||
// Reset visibility when user explicitly picks a new subtitle track
|
||||
if (track.id != 'no' && !_subtitlesVisible) {
|
||||
widget.player.setProperty('sub-visibility', 'yes');
|
||||
_setControlsState(() {
|
||||
_subtitlesVisible = true;
|
||||
});
|
||||
_setSubtitleVisibility(true);
|
||||
}
|
||||
widget.onSubtitleTrackChanged?.call(track);
|
||||
}
|
||||
|
||||
void _setSubtitleVisibility(bool visible) {
|
||||
final targetPlayer = widget.player;
|
||||
final generation = ++_subtitleVisibilityWriteGeneration;
|
||||
_setControlsState(() {
|
||||
_subtitlesVisible = visible;
|
||||
});
|
||||
|
||||
unawaited(() async {
|
||||
try {
|
||||
await targetPlayer.setProperty('sub-visibility', visible ? 'yes' : 'no');
|
||||
if (!mounted || generation != _subtitleVisibilityWriteGeneration || targetPlayer != widget.player) return;
|
||||
_confirmedSubtitlesVisible = visible;
|
||||
} 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;
|
||||
@@ -145,8 +160,8 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
canControl: widget.canControl,
|
||||
isLive: widget.isLive,
|
||||
subtitlesVisible: _subtitlesVisible,
|
||||
showQueueButton: playbackState.isQueueActive,
|
||||
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null,
|
||||
showQueueButton: playbackState.isQueueActive && widget.canNavigateMediaItems,
|
||||
onQueueItemSelected: playbackState.isQueueActive && widget.canNavigateMediaItems ? _onQueueItemSelected : null,
|
||||
ratingKey: widget.metadata.id,
|
||||
mediaTitle: widget.metadata.title,
|
||||
onSubtitleDownloaded: _onSubtitleDownloaded,
|
||||
|
||||
@@ -67,16 +67,10 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
||||
void _restartHideTimerForCurrentPlaybackState() => widget.chromeController.restartAutoHideForCurrentPlaybackState();
|
||||
|
||||
void _handlePointerSignal(PointerSignalEvent event) {
|
||||
if (event is PointerScrollEvent && _keyboardService != null) {
|
||||
_cancelAutoSkipFromUserInteraction();
|
||||
final delta = event.scrollDelta.dy;
|
||||
final volume = widget.player.state.volume;
|
||||
final maxVol = _keyboardService!.maxVolume.toDouble();
|
||||
final newVolume = (volume - delta / 20).clamp(0.0, maxVol);
|
||||
widget.player.setVolume(newVolume);
|
||||
unawaited(SettingsService.getInstance().then((s) => s.write(SettingsService.volume, newVolume)));
|
||||
_showControlsFromPointerActivity();
|
||||
}
|
||||
if (event is! PointerScrollEvent) return;
|
||||
_cancelAutoSkipFromUserInteraction();
|
||||
widget.volumeController.adjust(-event.scrollDelta.dy / 20);
|
||||
_showControlsFromPointerActivity();
|
||||
}
|
||||
|
||||
/// Show controls in response to pointer activity (mouse/trackpad movement).
|
||||
|
||||
@@ -26,6 +26,7 @@ class ChapterSheet extends StatefulWidget {
|
||||
final Player player;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool chaptersLoaded;
|
||||
final bool canControl;
|
||||
final String? serverId; // Server ID for the metadata these chapters belong to
|
||||
final Future<void> Function(Duration position)? onSeekRequested;
|
||||
final Function(Duration position)? onSeekCompleted;
|
||||
@@ -35,6 +36,7 @@ class ChapterSheet extends StatefulWidget {
|
||||
required this.player,
|
||||
required this.chapters,
|
||||
required this.chaptersLoaded,
|
||||
required this.canControl,
|
||||
this.serverId,
|
||||
this.onSeekRequested,
|
||||
this.onSeekCompleted,
|
||||
@@ -75,6 +77,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
}
|
||||
|
||||
Future<void> _handleChapterTap(Duration position) async {
|
||||
if (!widget.canControl) return;
|
||||
final clamped = clampSeekPosition(widget.player, position);
|
||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||
if (mounted) {
|
||||
@@ -155,9 +158,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
trailing: isCurrentChapter
|
||||
? AppIcon(Symbols.play_circle_rounded, fill: 1, color: Theme.of(context).colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
unawaited(_handleChapterTap(chapter.startTime));
|
||||
},
|
||||
onTap: widget.canControl ? () => unawaited(_handleChapterTap(chapter.startTime)) : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,9 +23,11 @@ import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../services/video_filter_manager.dart';
|
||||
import '../../../focus/focusable_wrapper.dart';
|
||||
import '../../../utils/dialogs.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/formatters.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../utils/quality_preset_labels.dart';
|
||||
import '../../../utils/latest_async_write.dart';
|
||||
import '../../../utils/snackbar_helper.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
@@ -91,7 +93,7 @@ class _SettingsMenuItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsToggleItem extends StatelessWidget {
|
||||
class _SettingsToggleItem extends StatefulWidget {
|
||||
final Pref<bool> pref;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
@@ -99,23 +101,80 @@ class _SettingsToggleItem extends StatelessWidget {
|
||||
|
||||
const _SettingsToggleItem({required this.pref, required this.icon, required this.title, this.onAfterWrite});
|
||||
|
||||
@override
|
||||
State<_SettingsToggleItem> createState() => _SettingsToggleItemState();
|
||||
}
|
||||
|
||||
class _SettingsToggleItemState extends State<_SettingsToggleItem> {
|
||||
static final LatestAsyncWrite<String> _writes = LatestAsyncWrite<String>();
|
||||
|
||||
bool? _pendingValue;
|
||||
int _writeGeneration = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SettingsToggleItem oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.pref != widget.pref) {
|
||||
++_writeGeneration;
|
||||
_pendingValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
++_writeGeneration;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _write(bool next) {
|
||||
final pref = widget.pref;
|
||||
final callback = widget.onAfterWrite;
|
||||
final generation = ++_writeGeneration;
|
||||
final writeToken = _writes.begin(pref.key);
|
||||
setState(() {
|
||||
_pendingValue = next;
|
||||
});
|
||||
unawaited(_commitWrite(pref, callback, next, generation, writeToken));
|
||||
}
|
||||
|
||||
Future<void> _commitWrite(
|
||||
Pref<bool> pref,
|
||||
FutureOr<void> Function(bool value)? callback,
|
||||
bool next,
|
||||
int generation,
|
||||
int writeToken,
|
||||
) async {
|
||||
try {
|
||||
final committed = await _writes.commitIfLatest(pref.key, writeToken, () async {
|
||||
if (callback != null) await callback(next);
|
||||
await SettingsService.instance.write(pref, next);
|
||||
});
|
||||
if (!committed || !mounted || generation != _writeGeneration) return;
|
||||
setState(() {
|
||||
_pendingValue = null;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.w('Failed to update playback setting', error: error, stackTrace: stackTrace);
|
||||
if (!mounted || generation != _writeGeneration) return;
|
||||
setState(() {
|
||||
_pendingValue = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = SettingsService.instance;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: settings.listenable(pref),
|
||||
valueListenable: settings.listenable(widget.pref),
|
||||
builder: (context, value, _) {
|
||||
Future<void> write(bool next) async {
|
||||
await settings.write(pref, next);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(next);
|
||||
}
|
||||
|
||||
final displayedValue = _pendingValue ?? value;
|
||||
final isPending = _pendingValue != null;
|
||||
return FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1, color: value ? Colors.amber : tokens(context).textMuted),
|
||||
title: Text(title),
|
||||
trailing: Switch(value: value, onChanged: write, activeThumbColor: Colors.amber),
|
||||
onTap: () => write(!value),
|
||||
leading: AppIcon(widget.icon, fill: 1, color: displayedValue ? Colors.amber : tokens(context).textMuted),
|
||||
title: Text(widget.title),
|
||||
trailing: Switch(value: displayedValue, onChanged: isPending ? null : _write, activeThumbColor: Colors.amber),
|
||||
onTap: isPending ? null : () => _write(!displayedValue),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -125,6 +184,12 @@ class _SettingsToggleItem extends StatelessWidget {
|
||||
/// Unified settings sheet for playback adjustments with in-sheet navigation
|
||||
class VideoSettingsSheet extends StatefulWidget {
|
||||
final Player player;
|
||||
|
||||
/// Whether this player surface supports Plezy's HDR control.
|
||||
///
|
||||
/// Defaults to the native platform capability, but can be supplied by
|
||||
/// embedders whose capability is known independently of the host platform.
|
||||
final bool? supportsHdrControl;
|
||||
final int audioSyncOffset;
|
||||
final int subtitleSyncOffset;
|
||||
final double videoZoomScale;
|
||||
@@ -170,6 +235,7 @@ class VideoSettingsSheet extends StatefulWidget {
|
||||
const VideoSettingsSheet({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.supportsHdrControl,
|
||||
required this.audioSyncOffset,
|
||||
required this.subtitleSyncOffset,
|
||||
this.videoZoomScale = 1.0,
|
||||
@@ -203,6 +269,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
late int _subtitleSyncOffset;
|
||||
late double _zoomScale;
|
||||
String _dvConversionMode = 'auto';
|
||||
int _dvConversionWriteGeneration = 0;
|
||||
|
||||
bool get _supportsHdrControl =>
|
||||
widget.supportsHdrControl ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows);
|
||||
|
||||
bool get _showDebugDvConversionMode {
|
||||
if (!kDebugMode) return false;
|
||||
@@ -237,13 +307,21 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setDebugDvConversionMode(String mode) async {
|
||||
await widget.player.setProperty('dv-conversion-mode', mode);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dvConversionMode = mode;
|
||||
});
|
||||
OverlaySheetController.of(context).close();
|
||||
void _setDebugDvConversionMode(String mode) {
|
||||
final targetPlayer = widget.player;
|
||||
final generation = ++_dvConversionWriteGeneration;
|
||||
unawaited(() async {
|
||||
try {
|
||||
await targetPlayer.setProperty('dv-conversion-mode', mode);
|
||||
if (!mounted || generation != _dvConversionWriteGeneration || targetPlayer != widget.player) return;
|
||||
setState(() {
|
||||
_dvConversionMode = mode;
|
||||
});
|
||||
OverlaySheetController.of(context).close();
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.w('Failed to update Dolby Vision conversion mode', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
void _navigateTo(_SettingsView view) {
|
||||
@@ -513,8 +591,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
onTap: () => _navigateTo(_SettingsView.subtitleSync),
|
||||
),
|
||||
|
||||
// HDR Toggle (iOS, macOS, and Windows)
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows)
|
||||
// HDR Toggle
|
||||
if (_supportsHdrControl)
|
||||
_SettingsToggleItem(
|
||||
pref: SettingsService.enableHDR,
|
||||
icon: Symbols.hdr_strong_rounded,
|
||||
|
||||
@@ -53,6 +53,7 @@ import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/device_adjustment_service.dart';
|
||||
import '../../services/scrub_preview_source.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../services/video_volume_controller.dart';
|
||||
import '../../utils/codec_utils.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
@@ -405,6 +406,7 @@ typedef _EdgeAdjustmentIndicatorState = ({bool visible, MobileEdgeAdjustmentSide
|
||||
|
||||
class PlexVideoControls extends StatefulWidget {
|
||||
final Player player;
|
||||
final VideoVolumeController volumeController;
|
||||
final MediaItem metadata;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback? onPrevious;
|
||||
@@ -458,6 +460,10 @@ class PlexVideoControls extends StatefulWidget {
|
||||
/// Whether the user can control playback (false in host-only mode for non-host).
|
||||
final bool canControl;
|
||||
|
||||
/// Whether the user may choose another queue item or episode. Watch
|
||||
/// Together guests never own this capability, even in anyone-control mode.
|
||||
final bool canNavigateMediaItems;
|
||||
|
||||
/// Notifier for whether first video frame has rendered (shows loading state when false).
|
||||
final ValueNotifier<bool>? hasFirstFrame;
|
||||
|
||||
@@ -517,6 +523,7 @@ class PlexVideoControls extends StatefulWidget {
|
||||
const PlexVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.volumeController,
|
||||
required this.metadata,
|
||||
required this.toastController,
|
||||
this.onNext,
|
||||
@@ -554,6 +561,7 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.onBack,
|
||||
this.onReachedEnd,
|
||||
this.canControl = true,
|
||||
required this.canNavigateMediaItems,
|
||||
this.hasFirstFrame,
|
||||
this.playNextFocusNode,
|
||||
required this.chromeController,
|
||||
@@ -681,6 +689,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
bool _isLongPressing = false;
|
||||
// Subtitle visibility toggle state
|
||||
bool _subtitlesVisible = true;
|
||||
bool _confirmedSubtitlesVisible = true;
|
||||
int _subtitleVisibilityWriteGeneration = 0;
|
||||
// Skip marker button focus node (for TV D-pad navigation)
|
||||
late final FocusNode _skipMarkerFocusNode;
|
||||
final ValueNotifier<bool> _fallbackHasFirstFrame = ValueNotifier<bool>(true);
|
||||
@@ -1049,8 +1059,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final playbackState = context.watch<PlaybackStateProvider>();
|
||||
final hasStripContent =
|
||||
_chapters.isNotEmpty || playbackState.isQueueActive;
|
||||
final canShowQueue =
|
||||
playbackState.isQueueActive && widget.canNavigateMediaItems;
|
||||
final hasStripContent = _chapters.isNotEmpty || canShowQueue;
|
||||
return MobileVideoControls(
|
||||
player: widget.player,
|
||||
metadata: widget.metadata,
|
||||
@@ -1067,8 +1078,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
onScrubEnd: _releaseTimelineScrub,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
// ignore: no-empty-block - play/pause handled by parent VideoControlsState
|
||||
onPlayPause: () {},
|
||||
onPlayPause: () => unawaited(_playOrPause()),
|
||||
onCancelAutoHide: widget.chromeController.cancelAutoHide,
|
||||
onStartAutoHide: widget.chromeController.startAutoHide,
|
||||
onBack: widget.onBack,
|
||||
@@ -1084,10 +1094,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
streamStartEpoch: widget.streamStartEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
serverId: widget.metadata.serverId,
|
||||
showQueueTab: playbackState.isQueueActive,
|
||||
onQueueItemSelected: playbackState.isQueueActive
|
||||
? _onQueueItemSelected
|
||||
: null,
|
||||
showQueueTab: canShowQueue,
|
||||
onQueueItemSelected: canShowQueue ? _onQueueItemSelected : null,
|
||||
chromeController: widget.chromeController,
|
||||
onStripVisibilityChanged: (visible) {
|
||||
if (visible) {
|
||||
|
||||
@@ -33,6 +33,7 @@ class ContentStrip extends StatefulWidget {
|
||||
final Player player;
|
||||
final List<MediaChapter> chapters;
|
||||
final bool chaptersLoaded;
|
||||
final bool canControl;
|
||||
final String? serverId;
|
||||
final bool showQueueTab;
|
||||
final Function(MediaItem)? onQueueItemSelected;
|
||||
@@ -54,6 +55,7 @@ class ContentStrip extends StatefulWidget {
|
||||
required this.player,
|
||||
required this.chapters,
|
||||
required this.chaptersLoaded,
|
||||
required this.canControl,
|
||||
this.serverId,
|
||||
this.showQueueTab = false,
|
||||
this.onQueueItemSelected,
|
||||
@@ -144,6 +146,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
}
|
||||
|
||||
Future<void> _handleChapterTap(Duration position) async {
|
||||
if (!widget.canControl) return;
|
||||
final clamped = clampSeekPosition(widget.player, position);
|
||||
await (widget.onSeekRequested ?? widget.player.seek)(clamped);
|
||||
if (mounted) {
|
||||
@@ -321,6 +324,8 @@ class ContentStripState extends State<ContentStrip> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_hasChapters && !_hasQueue) return const SizedBox.shrink();
|
||||
|
||||
final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600;
|
||||
final stripHeight = isTablet ? 170.0 : 106.0;
|
||||
// Add extra height for focus decoration when in focus navigation mode
|
||||
@@ -430,7 +435,9 @@ class ContentStripState extends State<ContentStrip> {
|
||||
? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!)
|
||||
: null;
|
||||
|
||||
void onTap() => unawaited(_handleChapterTap(chapter.startTime));
|
||||
final VoidCallback? onTap = widget.canControl
|
||||
? () => unawaited(_handleChapterTap(chapter.startTime))
|
||||
: null;
|
||||
|
||||
final itemKey = _itemKeyFor(_chapterItemKeys, index);
|
||||
final item = _buildStripItem(
|
||||
@@ -584,7 +591,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
required Widget? thumbnail,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
required VoidCallback? onTap,
|
||||
bool blurThumbnail = false,
|
||||
bool isTablet = false,
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../models/livetv_capture_buffer.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../focus/focusable_wrapper.dart';
|
||||
@@ -59,6 +60,34 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
|
||||
return formatClockTime(dt, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context));
|
||||
}
|
||||
|
||||
bool get _hasSeekableRange => _rangeEnd > _rangeStart;
|
||||
|
||||
int _normalizedEpoch(int epoch) {
|
||||
if (!_hasSeekableRange) return _rangeStart;
|
||||
return epoch.clamp(_rangeStart, _rangeEnd);
|
||||
}
|
||||
|
||||
int _semanticTarget(int displayPos, int deltaSeconds) {
|
||||
final current = _normalizedEpoch(displayPos);
|
||||
return (current + deltaSeconds).clamp(_rangeStart, _rangeEnd);
|
||||
}
|
||||
|
||||
String _semanticEpochValue(int epoch, {bool isCurrent = false}) {
|
||||
if ((isCurrent && widget.isAtLiveEdge) || (_hasSeekableRange && epoch >= _rangeEnd)) {
|
||||
return t.liveTv.live;
|
||||
}
|
||||
return _formatEpochTime(context, epoch);
|
||||
}
|
||||
|
||||
void _semanticSeekBy(int displayPos, int deltaSeconds) {
|
||||
final seek = widget.onSeekEnd;
|
||||
if (!widget.enabled || seek == null || !_hasSeekableRange) return;
|
||||
|
||||
final current = _normalizedEpoch(displayPos);
|
||||
final target = _semanticTarget(current, deltaSeconds);
|
||||
if (target != current) seek(target);
|
||||
}
|
||||
|
||||
double _epochToFraction(int epoch) {
|
||||
final range = _rangeEnd - _rangeStart;
|
||||
if (range <= 0) return 1.0; // No range yet → show at live edge (right)
|
||||
@@ -95,9 +124,11 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
|
||||
Widget _buildHorizontalLayout(int displayPos) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatEpochTime(context, displayPos),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
_formatEpochTime(context, displayPos),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _buildSlider(displayPos)),
|
||||
@@ -114,9 +145,15 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: .centerLeft,
|
||||
child: Text(
|
||||
_formatEpochTime(context, displayPos),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
_formatEpochTime(context, displayPos),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -126,6 +163,10 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
|
||||
|
||||
Widget _buildSlider(int displayPos) {
|
||||
final positionFraction = _epochToFraction(displayPos);
|
||||
final normalizedDisplayPos = _normalizedEpoch(displayPos);
|
||||
final semanticsEnabled = widget.enabled && widget.onSeekEnd != null && _hasSeekableRange;
|
||||
final canIncrease = semanticsEnabled && normalizedDisplayPos < _rangeEnd;
|
||||
final canDecrease = semanticsEnabled && normalizedDisplayPos > _rangeStart;
|
||||
|
||||
return FocusableWrapper(
|
||||
focusNode: widget.focusNode,
|
||||
@@ -143,28 +184,41 @@ class _LiveTimelineBarState extends State<LiveTimelineBar> {
|
||||
// from pointer-down, so ancestor recognizers can't steal the drag
|
||||
// (#1302). A plain tap is onStart+onEnd, which seeks to the
|
||||
// tapped position.
|
||||
child: RawGestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
gestures: widget.enabled
|
||||
? <Type, GestureRecognizerFactory>{
|
||||
EagerHorizontalDragGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<EagerHorizontalDragGestureRecognizer>(
|
||||
() =>
|
||||
EagerHorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..dragStartBehavior = DragStartBehavior.down,
|
||||
(instance) {
|
||||
instance.onStart = (details) => _onDragStart(details, _widthOf(context));
|
||||
instance.onUpdate = (details) => _onDragUpdate(details, _widthOf(context));
|
||||
instance.onEnd = (_) => _onDragEnd();
|
||||
instance.onCancel = _onDragEnd;
|
||||
},
|
||||
),
|
||||
}
|
||||
: const <Type, GestureRecognizerFactory>{},
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 24,
|
||||
child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)),
|
||||
child: Semantics(
|
||||
label: t.videoControls.timelineSlider,
|
||||
slider: true,
|
||||
value: _semanticEpochValue(normalizedDisplayPos, isCurrent: true),
|
||||
increasedValue: canIncrease ? _semanticEpochValue(_semanticTarget(normalizedDisplayPos, 10)) : null,
|
||||
decreasedValue: canDecrease ? _semanticEpochValue(_semanticTarget(normalizedDisplayPos, -10)) : null,
|
||||
enabled: semanticsEnabled,
|
||||
onIncrease: canIncrease ? () => _semanticSeekBy(normalizedDisplayPos, 10) : null,
|
||||
onDecrease: canDecrease ? () => _semanticSeekBy(normalizedDisplayPos, -10) : null,
|
||||
child: RawGestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
excludeFromSemantics: true,
|
||||
gestures: widget.enabled
|
||||
? <Type, GestureRecognizerFactory>{
|
||||
EagerHorizontalDragGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<EagerHorizontalDragGestureRecognizer>(
|
||||
() =>
|
||||
EagerHorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..dragStartBehavior = DragStartBehavior.down,
|
||||
(instance) {
|
||||
instance.onStart = (details) => _onDragStart(details, _widthOf(context));
|
||||
instance.onUpdate = (details) => _onDragUpdate(details, _widthOf(context));
|
||||
instance.onEnd = (_) => _onDragEnd();
|
||||
instance.onCancel = _onDragEnd;
|
||||
},
|
||||
),
|
||||
}
|
||||
: const <Type, GestureRecognizerFactory>{},
|
||||
child: ExcludeSemantics(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 24,
|
||||
child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ import '../../../mpv/mpv.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../../utils/formatters.dart';
|
||||
import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/latest_async_write.dart';
|
||||
|
||||
/// Reusable widget for adjusting sync offsets (audio or subtitle)
|
||||
class SyncOffsetControl extends StatefulWidget {
|
||||
@@ -52,6 +54,8 @@ class SyncOffsetControl extends StatefulWidget {
|
||||
State<SyncOffsetControl> createState() => _SyncOffsetControlState();
|
||||
}
|
||||
|
||||
final Expando<LatestAsyncWrite<String>> _syncOffsetWrites = Expando<LatestAsyncWrite<String>>();
|
||||
|
||||
class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
// Range constants
|
||||
static const double _sliderMin = -60_000; // ±60s for slider
|
||||
@@ -63,37 +67,74 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
static const int _sliderDivisions = 1200; // 100ms steps for ±60s range
|
||||
|
||||
late double _currentOffset;
|
||||
late double _confirmedOffset;
|
||||
int _writeGeneration = 0;
|
||||
Timer? _longPressTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentOffset = widget.initialOffset.toDouble();
|
||||
_confirmedOffset = _currentOffset;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SyncOffsetControl oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialOffset != oldWidget.initialOffset) {
|
||||
if (widget.initialOffset != oldWidget.initialOffset ||
|
||||
widget.player != oldWidget.player ||
|
||||
widget.propertyName != oldWidget.propertyName) {
|
||||
++_writeGeneration;
|
||||
_currentOffset = widget.initialOffset.toDouble();
|
||||
_confirmedOffset = _currentOffset;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
++_writeGeneration;
|
||||
_longPressTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _applyOffset(double offsetMs) async {
|
||||
// Convert milliseconds to seconds for mpv
|
||||
final offsetSeconds = offsetMs / 1000.0;
|
||||
|
||||
// Apply to player using setProperty
|
||||
await widget.player.setProperty(widget.propertyName, offsetSeconds.toString());
|
||||
|
||||
// Notify parent and save to settings
|
||||
await widget.onOffsetChanged(offsetMs.round());
|
||||
void _applyOffset(double offsetMs) {
|
||||
final targetPlayer = widget.player;
|
||||
final propertyName = widget.propertyName;
|
||||
final persistOffset = widget.onOffsetChanged;
|
||||
final coordinator = _syncOffsetWrites[targetPlayer] ??= LatestAsyncWrite<String>();
|
||||
final writeToken = coordinator.begin(propertyName);
|
||||
final generation = ++_writeGeneration;
|
||||
unawaited(() async {
|
||||
try {
|
||||
// Convert milliseconds to seconds for mpv.
|
||||
final offsetSeconds = offsetMs / 1000.0;
|
||||
await targetPlayer.setProperty(propertyName, offsetSeconds.toString());
|
||||
final committed = await coordinator.commitIfLatest(
|
||||
propertyName,
|
||||
writeToken,
|
||||
() => persistOffset(offsetMs.round()),
|
||||
);
|
||||
if (!committed ||
|
||||
!mounted ||
|
||||
generation != _writeGeneration ||
|
||||
targetPlayer != widget.player ||
|
||||
propertyName != widget.propertyName) {
|
||||
return;
|
||||
}
|
||||
_confirmedOffset = offsetMs;
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.w('Failed to update playback sync offset', error: error, stackTrace: stackTrace);
|
||||
if (!mounted ||
|
||||
generation != _writeGeneration ||
|
||||
targetPlayer != widget.player ||
|
||||
propertyName != widget.propertyName) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentOffset = _confirmedOffset;
|
||||
});
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
void _resetOffset() {
|
||||
|
||||
@@ -297,6 +297,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
player: player,
|
||||
chapters: chapters,
|
||||
chaptersLoaded: chaptersLoaded,
|
||||
canControl: canControl,
|
||||
serverId: serverId,
|
||||
onSeekRequested: onSeekRequested,
|
||||
onSeekCompleted: onSeekCompleted,
|
||||
|
||||
@@ -45,6 +45,7 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final itemTitle = metadata.title ?? t.common.unknown;
|
||||
return Row(
|
||||
children: [
|
||||
AppBarBackButton(
|
||||
@@ -53,7 +54,11 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
onPressed: onBack ?? () => Navigator.of(context).pop(true),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()),
|
||||
Expanded(
|
||||
child: style == VideoHeaderStyle.singleLine
|
||||
? _buildSingleLineTitle(itemTitle)
|
||||
: _buildMultiLineTitle(itemTitle),
|
||||
),
|
||||
Selector<WatchTogetherProvider, bool>(
|
||||
selector: (_, p) => p.isInSession,
|
||||
builder: (context, inSession, child) {
|
||||
@@ -72,15 +77,15 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSingleLineTitle() {
|
||||
final seriesName = metadata.grandparentTitle ?? metadata.title!;
|
||||
Widget _buildSingleLineTitle(String itemTitle) {
|
||||
final seriesName = metadata.grandparentTitle ?? itemTitle;
|
||||
final hasEpisodeInfo = metadata.parentIndex != null && metadata.index != null;
|
||||
|
||||
final List<String> parts = [seriesName];
|
||||
|
||||
if (hasEpisodeInfo) {
|
||||
parts.add('S${metadata.parentIndex}E${metadata.index}');
|
||||
parts.add(metadata.title!);
|
||||
parts.add(itemTitle);
|
||||
}
|
||||
|
||||
return Text(
|
||||
@@ -91,13 +96,13 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiLineTitle() {
|
||||
Widget _buildMultiLineTitle(String itemTitle) {
|
||||
final List<String> secondLineParts = [];
|
||||
|
||||
if (metadata.parentIndex != null && metadata.index != null) {
|
||||
secondLineParts.add('S${metadata.parentIndex}');
|
||||
secondLineParts.add('E${metadata.index}');
|
||||
secondLineParts.add(metadata.title!);
|
||||
secondLineParts.add(itemTitle);
|
||||
}
|
||||
|
||||
if (metadata.durationMs != null) {
|
||||
@@ -108,7 +113,7 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(
|
||||
metadata.grandparentTitle ?? metadata.title!,
|
||||
metadata.grandparentTitle ?? itemTitle,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: .bold),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../focus/dpad_navigator.dart';
|
||||
import '../../../focus/key_event_utils.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../services/video_volume_controller.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../focus/focusable_wrapper.dart';
|
||||
|
||||
/// A volume control widget that displays a mute/unmute button and volume slider.
|
||||
///
|
||||
/// This widget integrates with [Player] to control volume and persists
|
||||
/// the volume setting using [SettingsService].
|
||||
/// This widget delegates volume transitions to [VideoVolumeController].
|
||||
///
|
||||
/// When using keyboard/D-pad navigation, pressing Select enters "adjust mode"
|
||||
/// where left/right arrows adjust volume instead of navigating.
|
||||
class VolumeControl extends StatefulWidget {
|
||||
final Player player;
|
||||
final VideoVolumeController volumeController;
|
||||
|
||||
/// Optional FocusNode for D-pad/keyboard navigation.
|
||||
final FocusNode? focusNode;
|
||||
@@ -35,7 +33,7 @@ class VolumeControl extends StatefulWidget {
|
||||
|
||||
const VolumeControl({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.volumeController,
|
||||
this.focusNode,
|
||||
this.onKeyEvent,
|
||||
this.onFocusChange,
|
||||
@@ -67,14 +65,6 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _adjustVolume(double delta) async {
|
||||
final currentVolume = widget.player.state.volume;
|
||||
final maxVolume = _settings.read(SettingsService.maxVolume).toDouble();
|
||||
final newVolume = (currentVolume + delta).clamp(0.0, maxVolume);
|
||||
await widget.player.setVolume(newVolume);
|
||||
await _settings.write(SettingsService.volume, newVolume);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -92,11 +82,11 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
|
||||
// In adjust mode: left/right adjusts volume, back/escape exits
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
_adjustVolume(-_volumeStep);
|
||||
widget.volumeController.adjust(-_volumeStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
_adjustVolume(_volumeStep);
|
||||
widget.volumeController.adjust(_volumeStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
@@ -131,11 +121,9 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _settings.listenable(SettingsService.maxVolume),
|
||||
builder: (context, maxVolume, _) {
|
||||
return StreamBuilder<double>(
|
||||
stream: widget.player.streams.volume,
|
||||
initialData: widget.player.state.volume,
|
||||
builder: (context, snapshot) {
|
||||
final volume = snapshot.data ?? 100.0;
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: widget.volumeController,
|
||||
builder: (context, volume, _) {
|
||||
final isMuted = volume == 0;
|
||||
final muteButton = Semantics(
|
||||
label: isMuted ? t.videoControls.unmuteButton : t.videoControls.muteButton,
|
||||
@@ -147,11 +135,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
fill: 1,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
final transition = _settings.resolveMuteToggle(widget.player.state.volume);
|
||||
await widget.player.setVolume(transition.playerVolume);
|
||||
await _settings.write(SettingsService.volume, transition.persistedVolume);
|
||||
},
|
||||
onPressed: widget.volumeController.toggleMute,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -194,61 +178,46 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
final showMarker = maxVolume > 100;
|
||||
final markerPosition = showMarker ? (100.0 / maxVolumeDouble) : 0.0;
|
||||
|
||||
return Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
final delta = event.scrollDelta.dy;
|
||||
// Scroll up (negative delta) = increase volume, scroll down = decrease
|
||||
final volumeChange = -delta / 20; // Adjust sensitivity (higher = less sensitive)
|
||||
_adjustVolume(volumeChange);
|
||||
widget.onFocusActivity?.call();
|
||||
}
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 100,
|
||||
child: Stack(
|
||||
alignment: .centerLeft,
|
||||
children: [
|
||||
if (showMarker)
|
||||
Positioned(
|
||||
left: 100 * markerPosition - 1, // Adjust for marker width
|
||||
child: Container(
|
||||
width: 2,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 8,
|
||||
trackGap: 0,
|
||||
padding: .zero,
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 0),
|
||||
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||
),
|
||||
child: Semantics(
|
||||
label: t.videoControls.volumeSlider,
|
||||
slider: true,
|
||||
child: Slider(
|
||||
value: volume.clamp(0.0, maxVolumeDouble),
|
||||
min: 0.0,
|
||||
max: maxVolumeDouble,
|
||||
onChanged: (value) {
|
||||
widget.player.setVolume(value);
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
await _settings.write(SettingsService.volume, value);
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
||||
return SizedBox(
|
||||
width: 100,
|
||||
child: Stack(
|
||||
alignment: .centerLeft,
|
||||
children: [
|
||||
if (showMarker)
|
||||
Positioned(
|
||||
left: 100 * markerPosition - 1, // Adjust for marker width
|
||||
child: Container(
|
||||
width: 2,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 8,
|
||||
trackGap: 0,
|
||||
padding: .zero,
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 0),
|
||||
tickMarkShape: SliderTickMarkShape.noTickMark,
|
||||
),
|
||||
child: Semantics(
|
||||
label: t.videoControls.volumeSlider,
|
||||
slider: true,
|
||||
child: Slider(
|
||||
value: volume.clamp(0.0, maxVolumeDouble),
|
||||
min: 0.0,
|
||||
max: maxVolumeDouble,
|
||||
onChanged: widget.volumeController.preview,
|
||||
onChangeEnd: widget.volumeController.commit,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user