Files
plezy/lib/widgets/video_controls/desktop_video_controls.dart
T
2025-12-12 15:57:22 +01:00

536 lines
18 KiB
Dart

import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../../mpv/mpv.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_media_version.dart';
import '../../models/plex_metadata.dart';
import '../../services/fullscreen_state_manager.dart';
import '../../utils/desktop_window_padding.dart';
import '../../i18n/strings.g.dart';
import '../../focus/focusable_wrapper.dart';
import 'widgets/video_controls_header.dart';
import 'widgets/video_timeline_bar.dart';
import 'widgets/volume_control.dart';
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 PlexMetadata metadata;
final VoidCallback? onNext;
final VoidCallback? onPrevious;
final List<PlexChapter> chapters;
final bool chaptersLoaded;
final int seekTimeSmall;
final VoidCallback onSeekToPreviousChapter;
final VoidCallback onSeekToNextChapter;
final ValueChanged<Duration> onSeek;
final ValueChanged<Duration> onSeekEnd;
final IconData Function(int) getReplayIcon;
final IconData Function(int) getForwardIcon;
/// Called when focus activity occurs (to reset hide timer)
final VoidCallback? onFocusActivity;
/// Called to request focus on play/pause button (e.g., when controls shown via keyboard)
final VoidCallback? onRequestPlayPauseFocus;
/// Called when user navigates up from timeline (to hide controls)
final VoidCallback? onHideControls;
// Track chapter controls parameters
final List<PlexMediaVersion> availableVersions;
final int selectedMediaIndex;
final int boxFitMode;
final int audioSyncOffset;
final int subtitleSyncOffset;
final bool isFullscreen;
final VoidCallback? onCycleBoxFitMode;
final VoidCallback? onToggleFullscreen;
final Function(int)? onSwitchVersion;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final VoidCallback? onLoadSeekTimes;
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
final String serverId;
const DesktopVideoControls({
super.key,
required this.player,
required this.metadata,
this.onNext,
this.onPrevious,
required this.chapters,
required this.chaptersLoaded,
required this.seekTimeSmall,
required this.onSeekToPreviousChapter,
required this.onSeekToNextChapter,
required this.onSeek,
required this.onSeekEnd,
required this.getReplayIcon,
required this.getForwardIcon,
this.onFocusActivity,
this.onRequestPlayPauseFocus,
this.onHideControls,
this.availableVersions = const [],
this.selectedMediaIndex = 0,
this.boxFitMode = 0,
this.audioSyncOffset = 0,
this.subtitleSyncOffset = 0,
this.isFullscreen = false,
this.onCycleBoxFitMode,
this.onToggleFullscreen,
this.onSwitchVersion,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onLoadSeekTimes,
this.onCancelAutoHide,
this.onStartAutoHide,
this.serverId = '',
});
@override
State<DesktopVideoControls> createState() => DesktopVideoControlsState();
}
class DesktopVideoControlsState extends State<DesktopVideoControls> {
// Focus nodes for playback control buttons
late final FocusNode _prevItemFocusNode;
late final FocusNode _prevChapterFocusNode;
late final FocusNode _playPauseFocusNode;
late final FocusNode _nextChapterFocusNode;
late final FocusNode _nextItemFocusNode;
late final FocusNode _timelineFocusNode;
// Focus node for volume control
late final FocusNode _volumeFocusNode;
// Focus nodes for track/chapter controls (max 8 buttons possible)
late final List<FocusNode> _trackControlFocusNodes;
// List of button focus nodes for horizontal navigation
late final List<FocusNode> _buttonFocusNodes;
@override
void initState() {
super.initState();
_prevItemFocusNode = FocusNode(debugLabel: 'PrevItem');
_prevChapterFocusNode = FocusNode(debugLabel: 'PrevChapter');
_playPauseFocusNode = FocusNode(debugLabel: 'PlayPause');
_nextChapterFocusNode = FocusNode(debugLabel: 'NextChapter');
_nextItemFocusNode = FocusNode(debugLabel: 'NextItem');
_timelineFocusNode = FocusNode(debugLabel: 'Timeline');
_volumeFocusNode = FocusNode(debugLabel: 'Volume');
// Create focus nodes for track controls (up to 8 buttons)
_trackControlFocusNodes = List.generate(
8,
(i) => FocusNode(debugLabel: 'TrackControl$i'),
);
_buttonFocusNodes = [
_prevItemFocusNode,
_prevChapterFocusNode,
_playPauseFocusNode,
_nextChapterFocusNode,
_nextItemFocusNode,
];
}
@override
void dispose() {
_prevItemFocusNode.dispose();
_prevChapterFocusNode.dispose();
_playPauseFocusNode.dispose();
_nextChapterFocusNode.dispose();
_nextItemFocusNode.dispose();
_timelineFocusNode.dispose();
_volumeFocusNode.dispose();
for (final node in _trackControlFocusNodes) {
node.dispose();
}
super.dispose();
}
/// Request focus on the play/pause button (called when controls shown via keyboard)
void requestPlayPauseFocus() {
_playPauseFocusNode.requestFocus();
}
/// Get focus node for volume control
FocusNode get volumeFocusNode => _volumeFocusNode;
/// Get focus nodes for track controls
List<FocusNode> get trackControlFocusNodes => _trackControlFocusNodes;
/// Handle left navigation from first track control - go to volume
void navigateFromTrackToVolume() {
_volumeFocusNode.requestFocus();
widget.onFocusActivity?.call();
}
void _onFocusChange(bool hasFocus) {
if (hasFocus) {
widget.onFocusActivity?.call();
}
}
/// Handle key events for horizontal button navigation
KeyEventResult _handleButtonKeyEvent(
FocusNode node,
KeyEvent event,
int index,
) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// LEFT arrow - move to previous button
if (key == LogicalKeyboardKey.arrowLeft) {
if (index > 0) {
_buttonFocusNodes[index - 1].requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
return KeyEventResult.handled; // At start, consume to prevent bubbling
}
// RIGHT arrow - move to next button or to volume
if (key == LogicalKeyboardKey.arrowRight) {
if (index < _buttonFocusNodes.length - 1) {
_buttonFocusNodes[index + 1].requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
// At end of playback buttons - move to volume
_volumeFocusNode.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
// UP arrow - move focus to timeline
if (key == LogicalKeyboardKey.arrowUp) {
_timelineFocusNode.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Handle key events for volume control navigation
KeyEventResult _handleVolumeKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// LEFT arrow - move back to last playback button
if (key == LogicalKeyboardKey.arrowLeft) {
_nextItemFocusNode.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
// RIGHT arrow - move to first track control button
if (key == LogicalKeyboardKey.arrowRight) {
if (_trackControlFocusNodes.isNotEmpty) {
_trackControlFocusNodes[0].requestFocus();
widget.onFocusActivity?.call();
}
return KeyEventResult.handled;
}
// UP arrow - move focus to timeline
if (key == LogicalKeyboardKey.arrowUp) {
_timelineFocusNode.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Handle key events for timeline navigation
KeyEventResult _handleTimelineKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
final duration = widget.player.state.duration;
final position = widget.player.state.position;
// UP arrow - hide controls
if (key == LogicalKeyboardKey.arrowUp) {
widget.onHideControls?.call();
return KeyEventResult.handled;
}
// DOWN arrow - move focus to play/pause button
if (key == LogicalKeyboardKey.arrowDown) {
_playPauseFocusNode.requestFocus();
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
// LEFT/RIGHT for smooth scrubbing
if (key == LogicalKeyboardKey.arrowLeft ||
key == LogicalKeyboardKey.arrowRight) {
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
// Base step: 0.5% of duration, minimum 500ms
final baseStep = Duration(
milliseconds: (duration.inMilliseconds * 0.005)
.clamp(500, 15000)
.toInt(),
);
// Accelerate on key repeat
final step = event is KeyRepeatEvent
? Duration(
milliseconds: (baseStep.inMilliseconds * 2).clamp(500, 30000),
)
: baseStep;
final isForward = key == LogicalKeyboardKey.arrowRight;
final newPosition = isForward ? position + step : position - step;
// Clamp to valid range
final clampedPosition = Duration(
milliseconds: newPosition.inMilliseconds.clamp(
0,
duration.inMilliseconds,
),
);
widget.onSeek(clampedPosition);
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Top bar with back button and title
_buildTopBar(context),
const Spacer(),
// Bottom controls
_buildBottomControls(context),
],
);
}
Widget _buildTopBar(BuildContext context) {
// Use global fullscreen state for padding
return ListenableBuilder(
listenable: FullscreenStateManager(),
builder: (context, _) {
final isFullscreen = FullscreenStateManager().isFullscreen;
// In fullscreen on macOS, use less left padding since traffic lights auto-hide
// In normal mode on macOS, need more padding to avoid traffic lights
final leftPadding = Platform.isMacOS
? (isFullscreen
? DesktopWindowPadding.macOSLeftFullscreen
: DesktopWindowPadding.macOSLeft)
: DesktopWindowPadding.macOSLeftFullscreen;
return _buildTopBarContent(context, leftPadding);
},
);
}
Widget _buildTopBarContent(BuildContext context, double leftPadding) {
final topBar = Padding(
padding: EdgeInsets.only(left: leftPadding, right: 16),
child: VideoControlsHeader(
metadata: widget.metadata,
style: Platform.isMacOS
? VideoHeaderStyle.singleLine
: VideoHeaderStyle.multiLine,
),
);
return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true);
}
Widget _buildBottomControls(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
// Row 1: Timeline with time indicators
VideoTimelineBar(
player: widget.player,
chapters: widget.chapters,
chaptersLoaded: widget.chaptersLoaded,
onSeek: widget.onSeek,
onSeekEnd: widget.onSeekEnd,
horizontalLayout: true,
focusNode: _timelineFocusNode,
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
),
const SizedBox(height: 4),
// Row 2: Playback controls and options
Row(
children: [
// Previous item
_buildFocusableButton(
focusNode: _prevItemFocusNode,
index: 0,
icon: Symbols.skip_previous_rounded,
color: widget.onPrevious != null
? Colors.white
: Colors.white54,
onPressed: widget.onPrevious,
semanticLabel: t.videoControls.previousButton,
),
// Previous chapter (or skip backward if no chapters)
_buildFocusableButton(
focusNode: _prevChapterFocusNode,
index: 1,
icon: widget.chapters.isEmpty
? widget.getReplayIcon(widget.seekTimeSmall)
: Symbols.fast_rewind_rounded,
onPressed: widget.onSeekToPreviousChapter,
semanticLabel: widget.chapters.isEmpty
? t.videoControls.seekBackwardButton(
seconds: widget.seekTimeSmall,
)
: t.videoControls.previousChapterButton,
),
// Play/Pause
StreamBuilder<bool>(
stream: widget.player.streams.playing,
initialData: widget.player.state.playing,
builder: (context, snapshot) {
final isPlaying = snapshot.data ?? false;
return _buildFocusableButton(
focusNode: _playPauseFocusNode,
index: 2,
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
iconSize: 32,
onPressed: () {
if (isPlaying) {
widget.player.pause();
} else {
widget.player.play();
}
},
semanticLabel: isPlaying
? t.videoControls.pauseButton
: t.videoControls.playButton,
);
},
),
// Next chapter (or skip forward if no chapters)
_buildFocusableButton(
focusNode: _nextChapterFocusNode,
index: 3,
icon: widget.chapters.isEmpty
? widget.getForwardIcon(widget.seekTimeSmall)
: Symbols.fast_forward_rounded,
onPressed: widget.onSeekToNextChapter,
semanticLabel: widget.chapters.isEmpty
? t.videoControls.seekForwardButton(
seconds: widget.seekTimeSmall,
)
: t.videoControls.nextChapterButton,
),
// Next item
_buildFocusableButton(
focusNode: _nextItemFocusNode,
index: 4,
icon: Symbols.skip_next_rounded,
color: widget.onNext != null ? Colors.white : Colors.white54,
onPressed: widget.onNext,
semanticLabel: t.videoControls.nextButton,
),
const Spacer(),
// Volume control
VolumeControl(
player: widget.player,
focusNode: _volumeFocusNode,
onKeyEvent: _handleVolumeKeyEvent,
onFocusChange: _onFocusChange,
onFocusActivity: widget.onFocusActivity,
),
const SizedBox(width: 16),
// Audio track, subtitle, and chapter controls
TrackChapterControls(
player: widget.player,
chapters: widget.chapters,
chaptersLoaded: widget.chaptersLoaded,
availableVersions: widget.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: widget.boxFitMode,
audioSyncOffset: widget.audioSyncOffset,
subtitleSyncOffset: widget.subtitleSyncOffset,
isRotationLocked: false, // Desktop doesn't have rotation lock
isFullscreen: widget.isFullscreen,
serverId: widget.serverId,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onToggleFullscreen: widget.onToggleFullscreen,
onSwitchVersion: widget.onSwitchVersion,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: widget.onSubtitleTrackChanged,
onLoadSeekTimes: widget.onLoadSeekTimes,
onCancelAutoHide: widget.onCancelAutoHide,
onStartAutoHide: widget.onStartAutoHide,
focusNodes: _trackControlFocusNodes,
onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume,
),
],
),
],
),
);
}
Widget _buildFocusableButton({
required FocusNode focusNode,
required int index,
required IconData icon,
required VoidCallback? onPressed,
required String semanticLabel,
Color color = Colors.white,
double iconSize = 24,
}) {
return FocusableWrapper(
focusNode: focusNode,
onSelect: onPressed,
onKeyEvent: (node, event) => _handleButtonKeyEvent(node, event, index),
onFocusChange: _onFocusChange,
borderRadius: 20,
autoScroll: false,
useBackgroundFocus: true,
semanticLabel: semanticLabel,
child: Semantics(
label: semanticLabel,
button: true,
excludeSemantics: true,
child: IconButton(
icon: AppIcon(icon, fill: 1, color: color, size: iconSize),
iconSize: iconSize,
onPressed: onPressed,
),
),
);
}
}