diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index 53c9c8c9..553e0cc9 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -41,4 +41,18 @@ class FocusTheme { ), ); } + + /// Build focus decoration with background color instead of border. + /// Useful for video controls where it should match the native hover style. + static BoxDecoration focusBackgroundDecoration({ + required bool isFocused, + double borderRadius = defaultBorderRadius, + }) { + return BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + color: isFocused + ? Colors.white.withValues(alpha: 0.2) + : Colors.transparent, + ); + } } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 05e565d0..af17a06b 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -71,6 +71,14 @@ class FocusableWrapper extends StatefulWidget { /// Duration for long-press detection. final Duration longPressDuration; + /// Whether to use background color instead of border for focus indicator. + /// Useful for video controls where outline doesn't look good. + final bool useBackgroundFocus; + + /// Whether to disable the scale animation on focus. + /// Useful for elements like sliders where scaling looks odd. + final bool disableScale; + const FocusableWrapper({ super.key, required this.child, @@ -90,6 +98,8 @@ class FocusableWrapper extends StatefulWidget { this.onKeyEvent, this.enableLongPress = false, this.longPressDuration = const Duration(milliseconds: 500), + this.useBackgroundFocus = false, + this.disableScale = false, }); @override @@ -349,6 +359,18 @@ class _FocusableWrapperState extends State _animationController.duration = duration; } + // Choose decoration based on useBackgroundFocus + final decoration = widget.useBackgroundFocus + ? FocusTheme.focusBackgroundDecoration( + isFocused: showFocus, + borderRadius: widget.borderRadius, + ) + : FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: widget.borderRadius, + ); + Widget result = Focus( focusNode: _focusNode, autofocus: widget.autofocus, @@ -357,16 +379,13 @@ class _FocusableWrapperState extends State child: AnimatedBuilder( animation: _scaleAnimation, builder: (context, child) { + final shouldScale = showFocus && !widget.disableScale; return Transform.scale( - scale: showFocus ? _scaleAnimation.value : 1.0, + scale: shouldScale ? _scaleAnimation.value : 1.0, child: AnimatedContainer( duration: duration, curve: Curves.easeOutCubic, - decoration: FocusTheme.focusDecoration( - context, - isFocused: showFocus, - borderRadius: widget.borderRadius, - ), + decoration: decoration, child: widget.child, ), ); diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index 3f4097f2..3f1bab48 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../../models/plex_filter.dart'; import '../../widgets/app_bar_back_button.dart'; import '../../widgets/bottom_sheet_header.dart'; +import '../../widgets/focusable_bottom_sheet.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; @@ -30,12 +32,20 @@ class _FiltersBottomSheetState extends State { final Map _tempSelectedFilters = {}; final Map _filterDisplayNames = {}; // Cache for display names late List _sortedFilters; + late final FocusNode _initialFocusNode; @override void initState() { super.initState(); _tempSelectedFilters.addAll(widget.selectedFilters); _sortFilters(); + _initialFocusNode = FocusNode(debugLabel: 'FiltersBottomSheetInitialFocus'); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); } void _sortFilters() { @@ -103,174 +113,180 @@ class _FiltersBottomSheetState extends State { @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - if (_currentFilter != null) { - // Show filter options view - return Column( - children: [ - // Header with back button - BottomSheetHeader( - title: _currentFilter!.title, - leading: AppBarBackButton( - style: BackButtonStyle.plain, - onPressed: _goBack, + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + if (_currentFilter != null) { + // Show filter options view + return Column( + children: [ + // Header with back button + BottomSheetHeader( + title: _currentFilter!.title, + leading: AppBarBackButton( + style: BackButtonStyle.plain, + onPressed: _goBack, + ), ), - ), - // Filter options list - if (_isLoadingValues) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _filterValues.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - final isSelected = !_tempSelectedFilters.containsKey( + // Filter options list + if (_isLoadingValues) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey( + _currentFilter!.filter, + ); + return FocusableListTile( + focusNode: _initialFocusNode, + title: Text(t.libraries.all), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove( + _currentFilter!.filter, + ); + }); + _applyFilters(); + }, + ); + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue( + value.key, _currentFilter!.filter, ); - return ListTile( - title: Text(t.libraries.all), + final isSelected = + _tempSelectedFilters[_currentFilter!.filter] == + filterValue; + + return FocusableListTile( + title: Text(value.title), selected: isSelected, onTap: () { setState(() { - _tempSelectedFilters.remove( - _currentFilter!.filter, - ); + _tempSelectedFilters[_currentFilter!.filter] = + filterValue; + // Cache the display name for this filter value + _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = + value.title; }); _applyFilters(); }, ); - } + }, + ), + ), + ], + ); + } - final value = _filterValues[index - 1]; - final filterValue = _extractFilterValue( - value.key, - _currentFilter!.filter, - ); - final isSelected = - _tempSelectedFilters[_currentFilter!.filter] == - filterValue; - - return ListTile( - title: Text(value.title), - selected: isSelected, - onTap: () { + // Show main filters view + return Column( + children: [ + // Header + BottomSheetHeader( + title: t.libraries.filters, + leading: const Icon(Icons.filter_alt), + action: _tempSelectedFilters.isNotEmpty + ? TextButton.icon( + onPressed: () { setState(() { - _tempSelectedFilters[_currentFilter!.filter] = - filterValue; - // Cache the display name for this filter value - _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = - value.title; + _tempSelectedFilters.clear(); }); _applyFilters(); }, + icon: const Icon(Icons.clear_all), + label: Text(t.libraries.clearAll), + ) + : null, + ), + + // All Filters (boolean toggles first, then regular filters) + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && + _tempSelectedFilters[filter.filter] == '1'; + return FocusableSwitchListTile( + focusNode: index == 0 ? _initialFocusNode : null, + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), ); - }, - ), + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = + _filterDisplayNames['${filter.filter}:$selectedValue'] ?? + selectedValue; + } + + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _loadFilterValues(filter), + ); + }, ), + ), ], ); - } - - // Show main filters view - return Column( - children: [ - // Header - BottomSheetHeader( - title: t.libraries.filters, - leading: const Icon(Icons.filter_alt), - action: _tempSelectedFilters.isNotEmpty - ? TextButton.icon( - onPressed: () { - setState(() { - _tempSelectedFilters.clear(); - }); - _applyFilters(); - }, - icon: const Icon(Icons.clear_all), - label: Text(t.libraries.clearAll), - ) - : null, - ), - - // All Filters (boolean toggles first, then regular filters) - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _sortedFilters.length, - itemBuilder: (context, index) { - final filter = _sortedFilters[index]; - - // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) - if (_isBooleanFilter(filter)) { - final isActive = - _tempSelectedFilters.containsKey(filter.filter) && - _tempSelectedFilters[filter.filter] == '1'; - return SwitchListTile( - value: isActive, - onChanged: (value) { - setState(() { - if (value) { - _tempSelectedFilters[filter.filter] = '1'; - } else { - _tempSelectedFilters.remove(filter.filter); - } - }); - _applyFilters(); - }, - title: Text(filter.title), - ); - } - - // Regular navigable filters - show selected value instead of checkmark - final selectedValue = _tempSelectedFilters[filter.filter]; - String? displayValue; - if (selectedValue != null) { - // Try to get the cached display name, fall back to the value itself - displayValue = - _filterDisplayNames['${filter.filter}:$selectedValue'] ?? - selectedValue; - } - - return ListTile( - title: Text(filter.title), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (displayValue != null) - Flexible( - child: Text( - displayValue, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (displayValue != null) const SizedBox(width: 8), - const Icon(Icons.chevron_right), - ], - ), - onTap: () => _loadFilterValues(filter), - ); - }, - ), - ), - ], - ); - }, + }, + ), ); } } diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 882d56bd..6ca87fef 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../../models/plex_sort.dart'; import '../../widgets/bottom_sheet_header.dart'; +import '../../widgets/focusable_bottom_sheet.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../i18n/strings.g.dart'; class SortBottomSheet extends StatefulWidget { @@ -26,12 +28,20 @@ class SortBottomSheet extends StatefulWidget { class _SortBottomSheetState extends State { late PlexSort? _currentSort; late bool _currentDescending; + late final FocusNode _initialFocusNode; @override void initState() { super.initState(); _currentSort = widget.selectedSort; _currentDescending = widget.isSortDescending; + _initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus'); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); } void _handleSortChange(PlexSort sort, bool descending) { @@ -54,67 +64,71 @@ class _SortBottomSheetState extends State { @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - BottomSheetHeader( - title: t.libraries.sortBy, - action: widget.onClear != null - ? TextButton( - onPressed: _handleClear, - child: Text(t.common.clear), - ) - : null, - ), - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.sortOptions.length, - itemBuilder: (context, index) { - final sort = widget.sortOptions[index]; - final isSelected = _currentSort?.key == sort.key; - - return RadioListTile( - title: Text(sort.title), - value: sort, - groupValue: _currentSort, - onChanged: (value) { - if (value != null) { - _handleSortChange(value, value.isDefaultDescending); - } - }, - secondary: isSelected - ? SegmentedButton( - showSelectedIcon: false, - segments: const [ - ButtonSegment( - value: false, - icon: Icon(Icons.arrow_upward, size: 16), - ), - ButtonSegment( - value: true, - icon: Icon(Icons.arrow_downward, size: 16), - ), - ], - selected: {_currentDescending}, - onSelectionChanged: (Set newSelection) { - _handleSortChange(sort, newSelection.first); - }, - ) - : null, - ); - }, + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + BottomSheetHeader( + title: t.libraries.sortBy, + action: widget.onClear != null + ? TextButton( + onPressed: _handleClear, + child: Text(t.common.clear), + ) + : null, ), - ), - ], - ); - }, + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: widget.sortOptions.length, + itemBuilder: (context, index) { + final sort = widget.sortOptions[index]; + final isSelected = _currentSort?.key == sort.key; + + return FocusableRadioListTile( + focusNode: index == 0 ? _initialFocusNode : null, + title: Text(sort.title), + value: sort, + groupValue: _currentSort, + onChanged: (value) { + if (value != null) { + _handleSortChange(value, value.isDefaultDescending); + } + }, + secondary: isSelected + ? SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.arrow_upward, size: 16), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.arrow_downward, size: 16), + ), + ], + selected: {_currentDescending}, + onSelectionChanged: (Set newSelection) { + _handleSortChange(sort, newSelection.first); + }, + ) + : null, + ); + }, + ), + ), + ], + ); + }, + ), ); } } diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index e1b9ca81..9fe4d479 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -176,7 +176,9 @@ class GamepadService { final keyDownEvent = KeyDownEvent( physicalKey: _getPhysicalKey(logicalKey), logicalKey: logicalKey, - timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + timeStamp: Duration( + milliseconds: DateTime.now().millisecondsSinceEpoch, + ), ); // Dispatch through the focus system by walking up the focus tree @@ -196,7 +198,9 @@ class GamepadService { final keyUpEvent = KeyUpEvent( physicalKey: _getPhysicalKey(logicalKey), logicalKey: logicalKey, - timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + timeStamp: Duration( + milliseconds: DateTime.now().millisecondsSinceEpoch, + ), ); node = focusNode; @@ -237,9 +241,11 @@ class GamepadService { bool _isDpadXAxis(String key) => key == 'dpad - xaxis'; // Face buttons - macOS uses SF Symbol names for PlayStation controllers - bool _isButtonA(String key) => key == 'xmark.circle'; // Cross/X button (bottom) - bool _isButtonB(String key) => key == 'circle.circle'; // Circle/O button (right) - bool _isButtonX(String key) => key == 'square.circle'; // Square button (left) + bool _isButtonA(String key) => + key == 'xmark.circle'; // Cross/X button (bottom) + bool _isButtonB(String key) => + key == 'circle.circle'; // Circle/O button (right) + bool _isButtonX(String key) => key == 'square.circle'; // Square button (left) // Analog sticks bool _isLeftStickX(String key) => key == 'l.joystick - xaxis'; diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 47b67ff4..2de17119 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -58,7 +58,9 @@ class PlayQueueLauncher { final isPlaylist = item is PlexPlaylist; if (!isCollection && !isPlaylist) { - return PlayQueueError(Exception('Item must be either a collection or playlist')); + return PlayQueueError( + Exception('Item must be either a collection or playlist'), + ); } return _executeWithLoading( @@ -153,7 +155,9 @@ class PlayQueueLauncher { final itemType = metadata.type.toLowerCase(); if (itemType != 'show' && itemType != 'season') { - return PlayQueueError(Exception('Shuffle play only works for shows and seasons')); + return PlayQueueError( + Exception('Shuffle play only works for shows and seasons'), + ); } return _executeWithLoading( diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart index 8a1da4b5..c4c9efae 100644 --- a/lib/utils/collection_playlist_play_helper.dart +++ b/lib/utils/collection_playlist_play_helper.dart @@ -16,8 +16,12 @@ Future playCollectionOrPlaylist({ final launcher = PlayQueueLauncher( context: context, client: client, - serverId: item is PlexMetadata ? item.serverId : (item as PlexPlaylist).serverId, - serverName: item is PlexMetadata ? item.serverName : (item as PlexPlaylist).serverName, + serverId: item is PlexMetadata + ? item.serverId + : (item as PlexPlaylist).serverId, + serverName: item is PlexMetadata + ? item.serverName + : (item as PlexPlaylist).serverName, ); await launcher.launchFromCollectionOrPlaylist( diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index 58b92d48..333377ad 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import '../models/plex_file_info.dart'; import '../i18n/strings.g.dart'; +import 'focusable_bottom_sheet.dart'; -class FileInfoBottomSheet extends StatelessWidget { +class FileInfoBottomSheet extends StatefulWidget { final PlexFileInfo fileInfo; final String title; @@ -12,174 +13,208 @@ class FileInfoBottomSheet extends StatelessWidget { required this.title, }); + @override + State createState() => _FileInfoBottomSheetState(); +} + +class _FileInfoBottomSheetState extends State { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode( + debugLabel: 'FileInfoBottomSheetInitialFocus', + ); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), ), - ), - child: SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - // Header - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon( - Icons.info_outline, - color: Colors.white, - size: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - t.fileInfo.title, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, + child: SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon( + Icons.info_outline, + color: Colors.white, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + t.fileInfo.title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), ), - ), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + IconButton( + focusNode: _initialFocusNode, + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), ), - ), - const Divider(color: Colors.grey, height: 1), - // Content - Expanded( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Title - if (title.isNotEmpty) ...[ - Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w500, + const Divider(color: Colors.grey, height: 1), + // Content + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Title + if (widget.title.isNotEmpty) ...[ + Text( + widget.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), + const SizedBox(height: 20), + ], + + // Video Section + _buildSectionHeader(t.fileInfo.video), + const SizedBox(height: 8), + _buildInfoRow( + t.fileInfo.codec, + widget.fileInfo.videoCodec ?? t.common.unknown, + ), + _buildInfoRow( + t.fileInfo.resolution, + widget.fileInfo.resolutionFormatted, + ), + _buildInfoRow( + t.fileInfo.bitrate, + widget.fileInfo.bitrateFormatted, + ), + _buildInfoRow( + t.fileInfo.frameRate, + widget.fileInfo.frameRateFormatted, + ), + _buildInfoRow( + t.fileInfo.aspectRatio, + widget.fileInfo.aspectRatioFormatted, + ), + if (widget.fileInfo.videoProfile != null) + _buildInfoRow( + t.fileInfo.profile, + widget.fileInfo.videoProfile!, + ), + if (widget.fileInfo.bitDepth != null) + _buildInfoRow( + t.fileInfo.bitDepth, + '${widget.fileInfo.bitDepth} bit', + ), + if (widget.fileInfo.colorSpace != null) + _buildInfoRow( + t.fileInfo.colorSpace, + widget.fileInfo.colorSpace!, + ), + if (widget.fileInfo.colorRange != null) + _buildInfoRow( + t.fileInfo.colorRange, + widget.fileInfo.colorRange!, + ), + if (widget.fileInfo.colorPrimaries != null) + _buildInfoRow( + t.fileInfo.colorPrimaries, + widget.fileInfo.colorPrimaries!, + ), + if (widget.fileInfo.chromaSubsampling != null) + _buildInfoRow( + t.fileInfo.chromaSubsampling, + widget.fileInfo.chromaSubsampling!, + ), + const SizedBox(height: 20), + + // Audio Section + _buildSectionHeader(t.fileInfo.audio), + const SizedBox(height: 8), + _buildInfoRow( + t.fileInfo.codec, + widget.fileInfo.audioCodec ?? t.common.unknown, + ), + _buildInfoRow( + t.fileInfo.channels, + widget.fileInfo.audioChannelsFormatted, + ), + if (widget.fileInfo.audioProfile != null) + _buildInfoRow( + t.fileInfo.profile, + widget.fileInfo.audioProfile!, + ), + const SizedBox(height: 20), + + // File Section + _buildSectionHeader(t.fileInfo.file), + const SizedBox(height: 8), + if (widget.fileInfo.filePath != null) + _buildInfoRow( + t.fileInfo.path, + widget.fileInfo.filePath!, + isMonospace: true, + ), + _buildInfoRow( + t.fileInfo.size, + widget.fileInfo.fileSizeFormatted, + ), + _buildInfoRow( + t.fileInfo.container, + widget.fileInfo.container ?? t.common.unknown, + ), + _buildInfoRow( + t.fileInfo.duration, + widget.fileInfo.durationFormatted, ), const SizedBox(height: 20), + + // Advanced Section + _buildSectionHeader(t.fileInfo.advanced), + const SizedBox(height: 8), + _buildInfoRow( + t.fileInfo.optimizedForStreaming, + widget.fileInfo.optimizedForStreaming == true + ? t.common.yes + : t.common.no, + ), + _buildInfoRow( + t.fileInfo.has64bitOffsets, + widget.fileInfo.has64bitOffsets == true + ? t.common.yes + : t.common.no, + ), ], - - // Video Section - _buildSectionHeader(t.fileInfo.video), - const SizedBox(height: 8), - _buildInfoRow( - t.fileInfo.codec, - fileInfo.videoCodec ?? t.common.unknown, - ), - _buildInfoRow( - t.fileInfo.resolution, - fileInfo.resolutionFormatted, - ), - _buildInfoRow( - t.fileInfo.bitrate, - fileInfo.bitrateFormatted, - ), - _buildInfoRow( - t.fileInfo.frameRate, - fileInfo.frameRateFormatted, - ), - _buildInfoRow( - t.fileInfo.aspectRatio, - fileInfo.aspectRatioFormatted, - ), - if (fileInfo.videoProfile != null) - _buildInfoRow(t.fileInfo.profile, fileInfo.videoProfile!), - if (fileInfo.bitDepth != null) - _buildInfoRow( - t.fileInfo.bitDepth, - '${fileInfo.bitDepth} bit', - ), - if (fileInfo.colorSpace != null) - _buildInfoRow( - t.fileInfo.colorSpace, - fileInfo.colorSpace!, - ), - if (fileInfo.colorRange != null) - _buildInfoRow( - t.fileInfo.colorRange, - fileInfo.colorRange!, - ), - if (fileInfo.colorPrimaries != null) - _buildInfoRow( - t.fileInfo.colorPrimaries, - fileInfo.colorPrimaries!, - ), - if (fileInfo.chromaSubsampling != null) - _buildInfoRow( - t.fileInfo.chromaSubsampling, - fileInfo.chromaSubsampling!, - ), - const SizedBox(height: 20), - - // Audio Section - _buildSectionHeader(t.fileInfo.audio), - const SizedBox(height: 8), - _buildInfoRow( - t.fileInfo.codec, - fileInfo.audioCodec ?? t.common.unknown, - ), - _buildInfoRow( - t.fileInfo.channels, - fileInfo.audioChannelsFormatted, - ), - if (fileInfo.audioProfile != null) - _buildInfoRow(t.fileInfo.profile, fileInfo.audioProfile!), - const SizedBox(height: 20), - - // File Section - _buildSectionHeader(t.fileInfo.file), - const SizedBox(height: 8), - if (fileInfo.filePath != null) - _buildInfoRow( - t.fileInfo.path, - fileInfo.filePath!, - isMonospace: true, - ), - _buildInfoRow(t.fileInfo.size, fileInfo.fileSizeFormatted), - _buildInfoRow( - t.fileInfo.container, - fileInfo.container ?? t.common.unknown, - ), - _buildInfoRow( - t.fileInfo.duration, - fileInfo.durationFormatted, - ), - const SizedBox(height: 20), - - // Advanced Section - _buildSectionHeader(t.fileInfo.advanced), - const SizedBox(height: 8), - _buildInfoRow( - t.fileInfo.optimizedForStreaming, - fileInfo.optimizedForStreaming == true - ? t.common.yes - : t.common.no, - ), - _buildInfoRow( - t.fileInfo.has64bitOffsets, - fileInfo.has64bitOffsets == true - ? t.common.yes - : t.common.no, - ), - ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/widgets/focusable_bottom_sheet.dart b/lib/widgets/focusable_bottom_sheet.dart new file mode 100644 index 00000000..5bf0f5f0 --- /dev/null +++ b/lib/widgets/focusable_bottom_sheet.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../focus/input_mode_tracker.dart'; + +/// A wrapper widget that provides autofocus functionality for bottom sheets. +/// +/// When the sheet opens and keyboard/controller mode is active, this widget +/// will automatically request focus on the provided [initialFocusNode]. +/// This enables keyboard/controller navigation within the sheet. +/// +/// When opened via touch/mouse, no autofocus occurs to avoid showing +/// focus indicators unnecessarily. +class FocusableBottomSheet extends StatefulWidget { + /// The content of the bottom sheet. + final Widget child; + + /// The FocusNode to focus when the sheet opens in keyboard mode. + /// If null, no autofocus occurs. + final FocusNode? initialFocusNode; + + const FocusableBottomSheet({ + super.key, + required this.child, + this.initialFocusNode, + }); + + @override + State createState() => _FocusableBottomSheetState(); +} + +class _FocusableBottomSheetState extends State { + @override + void initState() { + super.initState(); + _requestInitialFocus(); + } + + void _requestInitialFocus() { + if (widget.initialFocusNode == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + // Only autofocus when in keyboard/controller mode + if (InputModeTracker.isKeyboardMode(context)) { + widget.initialFocusNode?.requestFocus(); + } + }); + } + + @override + void didUpdateWidget(FocusableBottomSheet oldWidget) { + super.didUpdateWidget(oldWidget); + + // If the focus node changed, request focus on the new one + if (widget.initialFocusNode != oldWidget.initialFocusNode) { + _requestInitialFocus(); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart new file mode 100644 index 00000000..37b059a0 --- /dev/null +++ b/lib/widgets/focusable_list_tile.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; + +/// A ListTile that accepts a FocusNode for keyboard/controller navigation. +/// +/// Uses Flutter's native ListTile focus support - no custom styling wrapper. +/// The focusNode allows programmatic focus control (e.g., auto-focus first item). +class FocusableListTile extends StatelessWidget { + /// The primary content of the list tile. + final Widget? title; + + /// Additional content displayed below the title. + final Widget? subtitle; + + /// A widget to display before the title. + final Widget? leading; + + /// A widget to display after the title. + final Widget? trailing; + + /// Called when the user taps this list tile. + final VoidCallback? onTap; + + /// Called when the user long-presses this list tile. + final VoidCallback? onLongPress; + + /// Whether this list tile is part of a vertically dense list. + final bool dense; + + /// Whether this list tile is interactive. + final bool enabled; + + /// If true, the tile is rendered with a selected highlight. + final bool selected; + + /// Optional FocusNode for keyboard/controller navigation. + final FocusNode? focusNode; + + /// Whether this tile should autofocus when first built. + final bool autofocus; + + /// The tile's internal padding. + final EdgeInsetsGeometry? contentPadding; + + const FocusableListTile({ + super.key, + this.title, + this.subtitle, + this.leading, + this.trailing, + this.onTap, + this.onLongPress, + this.dense = false, + this.enabled = true, + this.selected = false, + this.focusNode, + this.autofocus = false, + this.contentPadding, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: title, + subtitle: subtitle, + leading: leading, + trailing: trailing, + onTap: onTap, + onLongPress: onLongPress, + dense: dense, + enabled: enabled, + selected: selected, + contentPadding: contentPadding, + focusNode: focusNode, + autofocus: autofocus, + ); + } +} + +/// A RadioListTile that accepts a FocusNode for keyboard/controller navigation. +/// +/// Uses Flutter's native RadioListTile focus support - no custom styling wrapper. +class FocusableRadioListTile extends StatelessWidget { + /// The primary content of the list tile. + final Widget? title; + + /// Additional content displayed below the title. + final Widget? subtitle; + + /// A widget to display on the opposite side from the radio. + final Widget? secondary; + + /// The value represented by this radio button. + final T value; + + /// The currently selected value for this group of radio buttons. + final T? groupValue; + + /// Called when the user selects this radio button. + final ValueChanged? onChanged; + + /// Whether this radio button is part of a vertically dense list. + final bool dense; + + /// Optional FocusNode for keyboard/controller navigation. + final FocusNode? focusNode; + + /// Whether this tile should autofocus when first built. + final bool autofocus; + + const FocusableRadioListTile({ + super.key, + this.title, + this.subtitle, + this.secondary, + required this.value, + required this.groupValue, + required this.onChanged, + this.dense = false, + this.focusNode, + this.autofocus = false, + }); + + @override + Widget build(BuildContext context) { + return RadioListTile( + title: title, + subtitle: subtitle, + secondary: secondary, + value: value, + groupValue: groupValue, + onChanged: onChanged, + dense: dense, + focusNode: focusNode, + autofocus: autofocus, + ); + } +} + +/// A SwitchListTile that accepts a FocusNode for keyboard/controller navigation. +/// +/// Uses Flutter's native SwitchListTile focus support - no custom styling wrapper. +class FocusableSwitchListTile extends StatelessWidget { + /// The primary content of the list tile. + final Widget? title; + + /// Additional content displayed below the title. + final Widget? subtitle; + + /// A widget to display on the opposite side from the switch. + final Widget? secondary; + + /// Whether this switch is checked. + final bool value; + + /// Called when the user toggles the switch. + final ValueChanged? onChanged; + + /// Whether this switch is part of a vertically dense list. + final bool dense; + + /// Optional FocusNode for keyboard/controller navigation. + final FocusNode? focusNode; + + /// Whether this tile should autofocus when first built. + final bool autofocus; + + const FocusableSwitchListTile({ + super.key, + this.title, + this.subtitle, + this.secondary, + required this.value, + required this.onChanged, + this.dense = false, + this.focusNode, + this.autofocus = false, + }); + + @override + Widget build(BuildContext context) { + return SwitchListTile( + title: title, + subtitle: subtitle, + secondary: secondary, + value: value, + onChanged: onChanged, + dense: dense, + focusNode: focusNode, + autofocus: autofocus, + ); + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 2199310e..1e6c6075 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -13,6 +13,8 @@ import '../utils/library_refresh_notifier.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; +import '../widgets/focusable_bottom_sheet.dart'; +import '../widgets/focusable_list_tile.dart'; import '../i18n/strings.g.dart'; /// Helper class to store menu action data @@ -1383,29 +1385,49 @@ class _FocusableContextMenuSheet extends StatefulWidget { class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet> { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'ContextMenuSheetInitialFocus'); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - widget.title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, + return FocusableBottomSheet( + initialFocusNode: widget.focusFirstItem ? _initialFocusNode : null, + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ), - ...widget.actions.map((action) { - return ListTile( - leading: Icon(action.icon), - title: Text(action.label), - onTap: () => Navigator.pop(context, action.value), - ); - }), - ], + ...widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + leading: Icon(action.icon), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + ); + }), + ], + ), ), ); } @@ -1428,6 +1450,27 @@ class _FocusablePopupMenu extends StatefulWidget { } class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'PopupMenuInitialFocus'); + if (widget.focusFirstItem) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initialFocusNode.requestFocus(); + } + }); + } + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; @@ -1474,22 +1517,14 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.actions.map((action) { - return InkWell( + children: widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + leading: Icon(action.icon, size: 20), + title: Text(action.label), onTap: () => Navigator.pop(context, action.value), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - Icon(action.icon, size: 20), - const SizedBox(width: 12), - Expanded(child: Text(action.label)), - ], - ), - ), ); }).toList(), ), diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 565b13c3..8cbd8842 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -1,18 +1,23 @@ import 'dart:io' show Platform; import 'package:flutter/material.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 StatelessWidget { +class DesktopVideoControls extends StatefulWidget { final Player player; final PlexMetadata metadata; final VoidCallback? onNext; @@ -24,11 +29,35 @@ class DesktopVideoControls extends StatelessWidget { final VoidCallback onSeekToNextChapter; final ValueChanged onSeek; final ValueChanged onSeekEnd; - final Widget volumeControl; - final Widget trackChapterControls; 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 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, @@ -42,12 +71,254 @@ class DesktopVideoControls extends StatelessWidget { required this.onSeekToNextChapter, required this.onSeek, required this.onSeekEnd, - required this.volumeControl, - required this.trackChapterControls, 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 createState() => DesktopVideoControlsState(); +} + +class DesktopVideoControlsState extends State { + // 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 _trackControlFocusNodes; + + // List of button focus nodes for horizontal navigation + late final List _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 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( @@ -84,7 +355,7 @@ class DesktopVideoControls extends StatelessWidget { final topBar = Padding( padding: EdgeInsets.only(left: leftPadding, right: 16), child: VideoControlsHeader( - metadata: metadata, + metadata: widget.metadata, style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine, @@ -101,117 +372,162 @@ class DesktopVideoControls extends StatelessWidget { children: [ // Row 1: Timeline with time indicators VideoTimelineBar( - player: player, - chapters: chapters, - chaptersLoaded: chaptersLoaded, - onSeek: onSeek, - onSeekEnd: onSeekEnd, + 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 - Semantics( - label: t.videoControls.previousButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - Icons.skip_previous, - color: onPrevious != null ? Colors.white : Colors.white54, - ), - onPressed: onPrevious, - ), + _buildFocusableButton( + focusNode: _prevItemFocusNode, + index: 0, + icon: Icons.skip_previous, + color: widget.onPrevious != null + ? Colors.white + : Colors.white54, + onPressed: widget.onPrevious, + semanticLabel: t.videoControls.previousButton, ), // Previous chapter (or skip backward if no chapters) - Semantics( - label: chapters.isEmpty - ? t.videoControls.seekBackwardButton(seconds: seekTimeSmall) + _buildFocusableButton( + focusNode: _prevChapterFocusNode, + index: 1, + icon: widget.chapters.isEmpty + ? widget.getReplayIcon(widget.seekTimeSmall) + : Icons.fast_rewind, + onPressed: widget.onSeekToPreviousChapter, + semanticLabel: widget.chapters.isEmpty + ? t.videoControls.seekBackwardButton( + seconds: widget.seekTimeSmall, + ) : t.videoControls.previousChapterButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - chapters.isEmpty - ? getReplayIcon(seekTimeSmall) - : Icons.fast_rewind, - color: Colors.white, - ), - onPressed: onSeekToPreviousChapter, - ), ), // Play/Pause StreamBuilder( - stream: player.streams.playing, - initialData: player.state.playing, + stream: widget.player.streams.playing, + initialData: widget.player.state.playing, builder: (context, snapshot) { final isPlaying = snapshot.data ?? false; - return Semantics( - label: isPlaying + return _buildFocusableButton( + focusNode: _playPauseFocusNode, + index: 2, + icon: isPlaying ? Icons.pause : Icons.play_arrow, + iconSize: 32, + onPressed: () { + if (isPlaying) { + widget.player.pause(); + } else { + widget.player.play(); + } + }, + semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 32, - ), - iconSize: 32, - onPressed: () { - if (isPlaying) { - player.pause(); - } else { - player.play(); - } - }, - ), ); }, ), // Next chapter (or skip forward if no chapters) - Semantics( - label: chapters.isEmpty - ? t.videoControls.seekForwardButton(seconds: seekTimeSmall) + _buildFocusableButton( + focusNode: _nextChapterFocusNode, + index: 3, + icon: widget.chapters.isEmpty + ? widget.getForwardIcon(widget.seekTimeSmall) + : Icons.fast_forward, + onPressed: widget.onSeekToNextChapter, + semanticLabel: widget.chapters.isEmpty + ? t.videoControls.seekForwardButton( + seconds: widget.seekTimeSmall, + ) : t.videoControls.nextChapterButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - chapters.isEmpty - ? getForwardIcon(seekTimeSmall) - : Icons.fast_forward, - color: Colors.white, - ), - onPressed: onSeekToNextChapter, - ), ), // Next item - Semantics( - label: t.videoControls.nextButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - Icons.skip_next, - color: onNext != null ? Colors.white : Colors.white54, - ), - onPressed: onNext, - ), + _buildFocusableButton( + focusNode: _nextItemFocusNode, + index: 4, + icon: Icons.skip_next, + color: widget.onNext != null ? Colors.white : Colors.white54, + onPressed: widget.onNext, + semanticLabel: t.videoControls.nextButton, ), const Spacer(), // Volume control - volumeControl, + VolumeControl( + player: widget.player, + focusNode: _volumeFocusNode, + onKeyEvent: _handleVolumeKeyEvent, + onFocusChange: _onFocusChange, + onFocusActivity: widget.onFocusActivity, + ), const SizedBox(width: 16), // Audio track, subtitle, and chapter controls - trackChapterControls, + 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: Icon(icon, color: color, size: iconSize), + iconSize: iconSize, + onPressed: onPressed, + ), + ), + ); + } } diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 2515a0cb..2c8d6d45 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; +import '../../../widgets/focusable_list_tile.dart'; /// Helper class for shared track selection logic /// @@ -47,11 +48,13 @@ class TrackSelectionHelper { static Widget buildOffTile({ required bool isSelected, required VoidCallback onTap, + FocusNode? focusNode, }) { return _buildSelectableTile( label: 'Off', isSelected: isSelected, onTap: onTap, + focusNode: focusNode, ); } @@ -60,11 +63,13 @@ class TrackSelectionHelper { required String label, required bool isSelected, required VoidCallback onTap, + FocusNode? focusNode, }) { return _buildSelectableTile( label: label, isSelected: isSelected, onTap: onTap, + focusNode: focusNode, ); } @@ -72,8 +77,10 @@ class TrackSelectionHelper { required String label, required bool isSelected, required VoidCallback onTap, + FocusNode? focusNode, }) { - return ListTile( + return FocusableListTile( + focusNode: focusNode, title: Text( label, style: TextStyle(color: isSelected ? Colors.blue : Colors.white), diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 4a2721cd..a72494d5 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -5,12 +5,14 @@ import '../../../services/plex_client.dart'; import '../../../models/plex_media_info.dart'; import '../../../utils/duration_formatter.dart'; import '../../../utils/provider_extensions.dart'; +import '../../../widgets/focusable_bottom_sheet.dart'; +import '../../../widgets/focusable_list_tile.dart'; import 'base_video_control_sheet.dart'; import 'video_control_sheet_launcher.dart'; import '../../plex_optimized_image.dart'; /// Bottom sheet for selecting chapters -class ChapterSheet extends StatelessWidget { +class ChapterSheet extends StatefulWidget { final Player player; final List chapters; final bool chaptersLoaded; @@ -46,124 +48,148 @@ class ChapterSheet extends StatelessWidget { ); } + @override + State createState() => _ChapterSheetState(); +} + +class _ChapterSheetState extends State { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'ChapterSheetInitialFocus'); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + /// Get the correct PlexClient for the metadata's server PlexClient _getClientForChapters(BuildContext context) { - return context.getClientForServer(serverId!); + return context.getClientForServer(widget.serverId!); } @override Widget build(BuildContext context) { - return StreamBuilder( - stream: player.streams.position, - initialData: player.state.position, - builder: (context, positionSnapshot) { - final currentPosition = positionSnapshot.data ?? Duration.zero; - final currentPositionMs = currentPosition.inMilliseconds; + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, positionSnapshot) { + final currentPosition = positionSnapshot.data ?? Duration.zero; + final currentPositionMs = currentPosition.inMilliseconds; - // Find the current chapter based on position - int? currentChapterIndex; - for (int i = 0; i < chapters.length; i++) { - final chapter = chapters[i]; - final startMs = chapter.startTimeOffset ?? 0; - final endMs = - chapter.endTimeOffset ?? - (i < chapters.length - 1 - ? chapters[i + 1].startTimeOffset ?? 0 - : double.maxFinite.toInt()); + // Find the current chapter based on position + int? currentChapterIndex; + for (int i = 0; i < widget.chapters.length; i++) { + final chapter = widget.chapters[i]; + final startMs = chapter.startTimeOffset ?? 0; + final endMs = + chapter.endTimeOffset ?? + (i < widget.chapters.length - 1 + ? widget.chapters[i + 1].startTimeOffset ?? 0 + : double.maxFinite.toInt()); - if (currentPositionMs >= startMs && currentPositionMs < endMs) { - currentChapterIndex = i; - break; + if (currentPositionMs >= startMs && currentPositionMs < endMs) { + currentChapterIndex = i; + break; + } } - } - Widget content; - if (!chaptersLoaded) { - content = const Center(child: CircularProgressIndicator()); - } else if (chapters.isEmpty) { - content = const Center( - child: Text( - 'No chapters available', - style: TextStyle(color: Colors.white70), - ), - ); - } else { - content = ListView.builder( - itemCount: chapters.length, - itemBuilder: (context, index) { - final chapter = chapters[index]; - final isCurrentChapter = currentChapterIndex == index; + Widget content; + if (!widget.chaptersLoaded) { + content = const Center(child: CircularProgressIndicator()); + } else if (widget.chapters.isEmpty) { + content = const Center( + child: Text( + 'No chapters available', + style: TextStyle(color: Colors.white70), + ), + ); + } else { + content = ListView.builder( + itemCount: widget.chapters.length, + itemBuilder: (context, index) { + final chapter = widget.chapters[index]; + final isCurrentChapter = currentChapterIndex == index; - return ListTile( - leading: chapter.thumb != null - ? Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: PlexOptimizedImage.thumb( - client: _getClientForChapters(context), - imagePath: chapter.thumb, - width: 60, - height: 34, - fit: BoxFit.cover, - errorWidget: (context, url, error) => const Icon( - Icons.image, - color: Colors.white54, - size: 34, + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + leading: chapter.thumb != null + ? Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: PlexOptimizedImage.thumb( + client: _getClientForChapters(context), + imagePath: chapter.thumb, + width: 60, + height: 34, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), ), ), - ), - if (isCurrentChapter) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.blue, - width: 2, + if (isCurrentChapter) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.blue, + width: 2, + ), ), ), ), - ), - ], - ) - : null, - title: Text( - chapter.label, - style: TextStyle( - color: isCurrentChapter ? Colors.blue : Colors.white, - fontWeight: isCurrentChapter - ? FontWeight.bold - : FontWeight.normal, + ], + ) + : null, + title: Text( + chapter.label, + style: TextStyle( + color: isCurrentChapter ? Colors.blue : Colors.white, + fontWeight: isCurrentChapter + ? FontWeight.bold + : FontWeight.normal, + ), ), - ), - subtitle: Text( - formatDurationTimestamp(chapter.startTime), - style: TextStyle( - color: isCurrentChapter - ? Colors.blue.withValues(alpha: 0.7) - : Colors.white70, - fontSize: 12, + subtitle: Text( + formatDurationTimestamp(chapter.startTime), + style: TextStyle( + color: isCurrentChapter + ? Colors.blue.withValues(alpha: 0.7) + : Colors.white70, + fontSize: 12, + ), ), - ), - trailing: isCurrentChapter - ? const Icon(Icons.play_circle_filled, color: Colors.blue) - : null, - onTap: () { - player.seek(chapter.startTime); - Navigator.pop(context); - }, - ); - }, - ); - } + trailing: isCurrentChapter + ? const Icon(Icons.play_circle_filled, color: Colors.blue) + : null, + onTap: () { + widget.player.seek(chapter.startTime); + Navigator.pop(context); + }, + ); + }, + ); + } - return BaseVideoControlSheet( - title: 'Chapters', - icon: Icons.video_library, - child: content, - ); - }, + return BaseVideoControlSheet( + title: 'Chapters', + icon: Icons.video_library, + child: content, + ); + }, + ), ); } } diff --git a/lib/widgets/video_controls/sheets/track_selection_sheet.dart b/lib/widgets/video_controls/sheets/track_selection_sheet.dart index 854582c8..5e5e9b19 100644 --- a/lib/widgets/video_controls/sheets/track_selection_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_selection_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; +import '../../../widgets/focusable_bottom_sheet.dart'; import 'base_video_control_sheet.dart'; import 'video_control_sheet_launcher.dart'; import '../helpers/track_filter_helper.dart'; @@ -9,7 +10,7 @@ import '../helpers/track_selection_helper.dart'; /// Generic track selection sheet for audio and subtitle tracks /// /// Type parameter [T] should be either [AudioTrack] or [SubtitleTrack] -class TrackSelectionSheet extends StatelessWidget { +class TrackSelectionSheet extends StatefulWidget { final Player player; final String title; final IconData icon; @@ -73,86 +74,121 @@ class TrackSelectionSheet extends StatelessWidget { ); } + @override + State> createState() => _TrackSelectionSheetState(); +} + +class _TrackSelectionSheetState extends State> { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'TrackSelectionInitialFocus'); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return StreamBuilder( - stream: player.streams.tracks, - initialData: player.state.tracks, - builder: (context, snapshot) { - final tracks = snapshot.data; - final availableTracks = TrackFilterHelper.extractAndFilterTracks( - tracks, - extractTracks, - ); + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: StreamBuilder( + stream: widget.player.streams.tracks, + initialData: widget.player.state.tracks, + builder: (context, snapshot) { + final tracks = snapshot.data; + final availableTracks = TrackFilterHelper.extractAndFilterTracks( + tracks, + widget.extractTracks, + ); - return BaseVideoControlSheet( - title: title, - icon: icon, - child: availableTracks.isEmpty - ? TrackSelectionHelper.buildEmptyState() - : StreamBuilder( - stream: player.streams.track, - initialData: player.state.track, - builder: (context, selectedSnapshot) { - final currentTrack = - selectedSnapshot.data ?? player.state.track; - final selectedTrack = getCurrentTrack(currentTrack); + return BaseVideoControlSheet( + title: widget.title, + icon: widget.icon, + child: availableTracks.isEmpty + ? TrackSelectionHelper.buildEmptyState() + : StreamBuilder( + stream: widget.player.streams.track, + initialData: widget.player.state.track, + builder: (context, selectedSnapshot) { + final currentTrack = + selectedSnapshot.data ?? widget.player.state.track; + final selectedTrack = widget.getCurrentTrack( + currentTrack, + ); - // Determine if "Off" is selected (null or explicit off) - final isOffSelected = TrackSelectionHelper.isOffSelected( - selectedTrack, - isOffTrack, - ); + // Determine if "Off" is selected (null or explicit off) + final isOffSelected = TrackSelectionHelper.isOffSelected( + selectedTrack, + widget.isOffTrack, + ); - final itemCount = - availableTracks.length + (showOffOption ? 1 : 0); + final itemCount = + availableTracks.length + + (widget.showOffOption ? 1 : 0); - return ListView.builder( - itemCount: itemCount, - itemBuilder: (context, index) { - // First item is "Off" if enabled - if (showOffOption && index == 0) { - return TrackSelectionHelper.buildOffTile( - isSelected: isOffSelected, + return ListView.builder( + itemCount: itemCount, + itemBuilder: (context, index) { + // First item is "Off" if enabled + if (widget.showOffOption && index == 0) { + return TrackSelectionHelper.buildOffTile( + isSelected: isOffSelected, + focusNode: _initialFocusNode, + onTap: () { + if (widget.createOffTrack != null) { + final offTrack = widget.createOffTrack!(); + widget.setTrack(offTrack); + widget.onTrackChanged?.call(offTrack); + } + Navigator.pop(context); + }, + ); + } + + // Subsequent items are tracks + final trackIndex = widget.showOffOption + ? index - 1 + : index; + final track = availableTracks[trackIndex]; + + // Check if this track is selected + final trackId = TrackSelectionHelper.getTrackId( + track, + ); + final selectedId = selectedTrack == null + ? '' + : TrackSelectionHelper.getTrackId(selectedTrack); + + final isSelected = trackId == selectedId; + final label = widget.buildLabel(track, trackIndex); + + // Focus the first actual track if no "Off" option + final shouldFocus = + !widget.showOffOption && index == 0; + + return TrackSelectionHelper.buildTrackTile( + label: label, + isSelected: isSelected, + focusNode: shouldFocus ? _initialFocusNode : null, onTap: () { - if (createOffTrack != null) { - final offTrack = createOffTrack!(); - setTrack(offTrack); - onTrackChanged?.call(offTrack); - } + widget.setTrack(track); + widget.onTrackChanged?.call(track); Navigator.pop(context); }, ); - } - - // Subsequent items are tracks - final trackIndex = showOffOption ? index - 1 : index; - final track = availableTracks[trackIndex]; - - // Check if this track is selected - final trackId = TrackSelectionHelper.getTrackId(track); - final selectedId = selectedTrack == null - ? '' - : TrackSelectionHelper.getTrackId(selectedTrack); - - final isSelected = trackId == selectedId; - final label = buildLabel(track, trackIndex); - - return TrackSelectionHelper.buildTrackTile( - label: label, - isSelected: isSelected, - onTap: () { - setTrack(track); - onTrackChanged?.call(track); - Navigator.pop(context); - }, - ); - }, - ); - }, - ), - ); - }, + }, + ); + }, + ), + ); + }, + ), ); } } diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index 7251450e..b21a59eb 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import '../../../models/plex_media_version.dart'; +import '../../../widgets/focusable_bottom_sheet.dart'; +import '../../../widgets/focusable_list_tile.dart'; import 'base_video_control_sheet.dart'; import 'video_control_sheet_launcher.dart'; /// Bottom sheet for selecting video version -class VersionSheet extends StatelessWidget { +class VersionSheet extends StatefulWidget { final List availableVersions; final int selectedMediaIndex; final Function(int) onVersionSelected; @@ -37,30 +39,55 @@ class VersionSheet extends StatelessWidget { } @override - Widget build(BuildContext context) { - return BaseVideoControlSheet( - title: 'Video Version', - icon: Icons.video_file, - child: ListView.builder( - itemCount: availableVersions.length, - itemBuilder: (context, index) { - final version = availableVersions[index]; - final isSelected = index == selectedMediaIndex; + State createState() => _VersionSheetState(); +} - return ListTile( - title: Text( - version.displayLabel, - style: TextStyle(color: isSelected ? Colors.blue : Colors.white), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - Navigator.pop(context); - onVersionSelected(index); - }, - ); - }, +class _VersionSheetState extends State { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'VersionSheetInitialFocus'); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: BaseVideoControlSheet( + title: 'Video Version', + icon: Icons.video_file, + child: ListView.builder( + itemCount: widget.availableVersions.length, + itemBuilder: (context, index) { + final version = widget.availableVersions[index]; + final isSelected = index == widget.selectedMediaIndex; + + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + title: Text( + version.displayLabel, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, + ), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + Navigator.pop(context); + widget.onVersionSelected(index); + }, + ); + }, + ), ), ); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 39792611..2b0b77a6 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -7,6 +7,8 @@ import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/duration_formatter.dart'; import '../../../utils/platform_detector.dart'; +import '../../../widgets/focusable_bottom_sheet.dart'; +import '../../../widgets/focusable_list_tile.dart'; import '../widgets/sync_offset_control.dart'; import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; @@ -22,6 +24,7 @@ class _SettingsMenuItem extends StatelessWidget { final VoidCallback onTap; final bool isHighlighted; final bool allowValueOverflow; + final FocusNode? focusNode; const _SettingsMenuItem({ required this.icon, @@ -30,6 +33,7 @@ class _SettingsMenuItem extends StatelessWidget { required this.onTap, this.isHighlighted = false, this.allowValueOverflow = false, + this.focusNode, }); @override @@ -43,7 +47,8 @@ class _SettingsMenuItem extends StatelessWidget { overflow: allowValueOverflow ? TextOverflow.ellipsis : null, ); - return ListTile( + return FocusableListTile( + focusNode: focusNode, leading: Icon(icon, color: isHighlighted ? Colors.amber : Colors.white70), title: Text(title, style: const TextStyle(color: Colors.white)), trailing: Row( @@ -101,15 +106,23 @@ class _VideoSettingsSheetState extends State { late int _audioSyncOffset; late int _subtitleSyncOffset; bool _enableHDR = true; + late final FocusNode _initialFocusNode; @override void initState() { super.initState(); _audioSyncOffset = widget.audioSyncOffset; _subtitleSyncOffset = widget.subtitleSyncOffset; + _initialFocusNode = FocusNode(debugLabel: 'VideoSettingsInitialFocus'); _loadHDRSetting(); } + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + Future _loadHDRSetting() async { final settings = await SettingsService.getInstance(); setState(() { @@ -199,6 +212,7 @@ class _VideoSettingsSheetState extends State { builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; return _SettingsMenuItem( + focusNode: _initialFocusNode, icon: Icons.speed, title: 'Playback Speed', valueText: _formatSpeed(currentRate), @@ -416,27 +430,30 @@ class _VideoSettingsSheetState extends State { _audioSyncOffset != 0 || _subtitleSyncOffset != 0); - return BaseVideoControlSheet( - title: _getTitle(), - icon: _getIcon(), - iconColor: isIconActive ? Colors.amber : Colors.white, - onBack: _currentView != _SettingsView.menu ? _navigateBack : null, - child: () { - switch (_currentView) { - case _SettingsView.menu: - return _buildMenuView(); - case _SettingsView.speed: - return _buildSpeedView(); - case _SettingsView.sleep: - return _buildSleepView(); - case _SettingsView.audioSync: - return _buildAudioSyncView(); - case _SettingsView.subtitleSync: - return _buildSubtitleSyncView(); - case _SettingsView.audioDevice: - return _buildAudioDeviceView(); - } - }(), + return FocusableBottomSheet( + initialFocusNode: _initialFocusNode, + child: BaseVideoControlSheet( + title: _getTitle(), + icon: _getIcon(), + iconColor: isIconActive ? Colors.amber : Colors.white, + onBack: _currentView != _SettingsView.menu ? _navigateBack : null, + child: () { + switch (_currentView) { + case _SettingsView.menu: + return _buildMenuView(); + case _SettingsView.speed: + return _buildSpeedView(); + case _SettingsView.sleep: + return _buildSleepView(); + case _SettingsView.audioSync: + return _buildAudioSyncView(); + case _SettingsView.subtitleSync: + return _buildSubtitleSyncView(); + case _SettingsView.audioDevice: + return _buildAudioDeviceView(); + } + }(), + ), ); } } diff --git a/lib/widgets/video_controls/video_control_button.dart b/lib/widgets/video_controls/video_control_button.dart index e48fd441..fe5c508a 100644 --- a/lib/widgets/video_controls/video_control_button.dart +++ b/lib/widgets/video_controls/video_control_button.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../focus/focusable_wrapper.dart'; /// A standardized button for video player controls with improved tap targets. /// @@ -26,6 +29,19 @@ class VideoControlButton extends StatelessWidget { /// When true, the icon color defaults to amber instead of white. final bool isActive; + /// Optional FocusNode for D-pad/keyboard navigation. + /// When provided, the button becomes focusable with visual focus indicator. + final FocusNode? focusNode; + + /// Custom key event handler for focus navigation. + final KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent; + + /// Called when focus changes. + final ValueChanged? onFocusChange; + + /// Whether this button should autofocus when first built. + final bool autofocus; + const VideoControlButton({ super.key, required this.icon, @@ -34,6 +50,10 @@ class VideoControlButton extends StatelessWidget { this.tooltip, this.semanticLabel, this.isActive = false, + this.focusNode, + this.onKeyEvent, + this.onFocusChange, + this.autofocus = false, }); @override @@ -48,7 +68,7 @@ class VideoControlButton extends StatelessWidget { constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ); - return semanticLabel != null + Widget result = semanticLabel != null ? Semantics( label: semanticLabel, button: true, @@ -56,5 +76,23 @@ class VideoControlButton extends StatelessWidget { child: button, ) : button; + + // Wrap with FocusableWrapper when focusNode is provided + if (focusNode != null) { + result = FocusableWrapper( + focusNode: focusNode, + onSelect: onPressed, + onKeyEvent: onKeyEvent, + onFocusChange: onFocusChange, + autofocus: autofocus, + semanticLabel: semanticLabel, + borderRadius: 20, // Circular for icon buttons + autoScroll: false, // Video controls don't scroll + useBackgroundFocus: true, // Use background highlight for video controls + child: result, + ); + } + + return result; } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 5de8312c..0db1f193 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -3,7 +3,13 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:rate_limiter/rate_limiter.dart'; -import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; +import 'package:flutter/services.dart' + show + SystemChrome, + DeviceOrientation, + LogicalKeyboardKey, + KeyDownEvent, + KeyRepeatEvent; import 'package:macos_window_utils/macos_window_utils.dart'; import 'package:window_manager/window_manager.dart'; @@ -21,7 +27,7 @@ import '../../utils/player_utils.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/video_control_icons.dart'; import '../../i18n/strings.g.dart'; -import 'widgets/volume_control.dart'; +import '../../focus/input_mode_tracker.dart'; import 'widgets/track_chapter_controls.dart'; import 'mobile_video_controls.dart'; import 'desktop_video_controls.dart'; @@ -98,6 +104,10 @@ class _PlexVideoControlsState extends State int _subtitleSyncOffset = 0; // Default, loaded from settings bool _isRotationLocked = true; // Default locked (landscape only) + // GlobalKey to access DesktopVideoControls state for focus management + final GlobalKey _desktopControlsKey = + GlobalKey(); + /// Get the correct PlexClient for this metadata's server PlexClient _getClientForMetadata() { return context.getClientForServer(widget.metadata.serverId!); @@ -152,6 +162,19 @@ class _PlexVideoControlsState extends State if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { windowManager.addListener(this); } + // Focus play/pause button on first frame if in keyboard mode + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusPlayPauseIfKeyboardMode(); + }); + } + + /// Focus play/pause button if we're in keyboard navigation mode (desktop only) + void _focusPlayPauseIfKeyboardMode() { + if (!mounted) return; + final isMobile = PlatformDetector.isMobile(context); + if (!isMobile && InputModeTracker.isKeyboardMode(context)) { + _desktopControlsKey.currentState?.requestPlayPauseFocus(); + } } Future _initKeyboardService() async { @@ -734,6 +757,76 @@ class _PlexVideoControlsState extends State } } + /// Check if a key is a directional key (arrow keys) + bool _isDirectionalKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight; + } + + /// Check if a key is a back/escape key + bool _isBackKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.browserBack || + key == LogicalKeyboardKey.gameButtonB; + } + + /// Check if a key is a select/enter key + bool _isSelectKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.gameButtonA; + } + + /// Show controls and focus play/pause on keyboard input (desktop only) + void _showControlsWithFocus() { + if (!_showControls) { + setState(() { + _showControls = true; + _controlsFullyHidden = false; + }); + if (Platform.isLinux) { + widget.player.setControlsVisible(true); + } + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + _startHideTimer(); + + // Request focus on play/pause button after controls are shown + WidgetsBinding.instance.addPostFrameCallback((_) { + _desktopControlsKey.currentState?.requestPlayPauseFocus(); + }); + } + + /// Hide controls when navigating up from timeline (keyboard mode) + void _hideControlsFromKeyboard() { + if (_showControls) { + setState(() { + _showControls = false; + }); + // Return focus to the main focus node + _focusNode.requestFocus(); + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + if (Platform.isLinux) { + Future.delayed(const Duration(milliseconds: 250), () { + if (mounted && !_showControls) { + setState(() { + _controlsFullyHidden = true; + }); + widget.player.setControlsVisible(false); + } + }); + } + } + } + @override Widget build(BuildContext context) { final isMobile = PlatformDetector.isMobile(context); @@ -742,6 +835,50 @@ class _PlexVideoControlsState extends State focusNode: _focusNode, autofocus: true, onKeyEvent: (node, event) { + // Only handle KeyDown and KeyRepeat events + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + // Reset hide timer on any keyboard/controller input when controls are visible + if (_showControls) { + _restartHideTimerIfPlaying(); + } + + final key = event.logicalKey; + + // Handle Back/Escape: show controls if hidden, navigate back if visible + if (_isBackKey(key)) { + if (!_showControls) { + _showControlsWithFocus(); + return KeyEventResult.handled; + } + // Controls visible - navigate back + Navigator.of(context).pop(true); + return KeyEventResult.handled; + } + + // Handle Select/Enter when controls are hidden: pause and show controls + // Only intercept if this Focus node itself has primary focus (not a descendant) + if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) { + widget.player.playOrPause(); + _showControlsWithFocus(); + return KeyEventResult.handled; + } + + // On desktop, show controls and focus play/pause on directional input + if (!isMobile && _isDirectionalKey(key)) { + // If controls are hidden, show them and focus play/pause + if (!_showControls) { + _showControlsWithFocus(); + return KeyEventResult.handled; + } + // If controls are shown, let the event propagate to the focused control + // The DesktopVideoControls will handle navigation + return KeyEventResult.ignored; + } + + // Pass other events to the keyboard shortcuts service if (_keyboardService == null) return KeyEventResult.ignored; return _keyboardService!.handleVideoPlayerKeyEvent( @@ -795,73 +932,100 @@ class _PlexVideoControlsState extends State offstage: Platform.isLinux && _controlsFullyHidden, child: IgnorePointer( ignoring: !_showControls, - child: AnimatedOpacity( - opacity: _showControls ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: GestureDetector( - onTap: _toggleControls, - behavior: HitTestBehavior.deferToChild, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.transparent, - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.2, 0.8, 1.0], + child: FocusScope( + // Prevent focus from entering controls when hidden + canRequestFocus: _showControls, + child: AnimatedOpacity( + opacity: _showControls ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: GestureDetector( + onTap: _toggleControls, + behavior: HitTestBehavior.deferToChild, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + stops: const [0.0, 0.2, 0.8, 1.0], + ), ), - ), - child: isMobile - ? Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => - _restartHideTimerIfPlaying(), - child: MobileVideoControls( - player: widget.player, - metadata: widget.metadata, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - trackChapterControls: - _buildTrackChapterControlsWidget(), - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - onPlayPause: - () {}, // Not used, handled internally - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - ), - ) - : Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => - _restartHideTimerIfPlaying(), - child: DesktopVideoControls( - player: widget.player, - metadata: widget.metadata, - onNext: widget.onNext, - onPrevious: widget.onPrevious, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - volumeControl: VolumeControl( + child: isMobile + ? Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => + _restartHideTimerIfPlaying(), + child: MobileVideoControls( player: widget.player, + metadata: widget.metadata, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + trackChapterControls: + _buildTrackChapterControlsWidget(), + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + onPlayPause: + () {}, // Not used, handled internally + onCancelAutoHide: () => + _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + ), + ) + : Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => + _restartHideTimerIfPlaying(), + child: DesktopVideoControls( + key: _desktopControlsKey, + player: widget.player, + metadata: widget.metadata, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + onSeekToPreviousChapter: + _seekToPreviousChapter, + onSeekToNextChapter: _seekToNextChapter, + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + getReplayIcon: getReplayIcon, + getForwardIcon: getForwardIcon, + onFocusActivity: _restartHideTimerIfPlaying, + onHideControls: _hideControlsFromKeyboard, + // Track chapter controls data + availableVersions: widget.availableVersions, + selectedMediaIndex: + widget.selectedMediaIndex, + boxFitMode: widget.boxFitMode, + audioSyncOffset: _audioSyncOffset, + subtitleSyncOffset: _subtitleSyncOffset, + isFullscreen: _isFullscreen, + onCycleBoxFitMode: widget.onCycleBoxFitMode, + onToggleFullscreen: _toggleFullscreen, + onSwitchVersion: _switchMediaVersion, + onAudioTrackChanged: + widget.onAudioTrackChanged, + onSubtitleTrackChanged: + widget.onSubtitleTrackChanged, + onLoadSeekTimes: () async { + if (mounted) { + await _loadSeekTimes(); + } + }, + onCancelAutoHide: () => + _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + serverId: widget.metadata.serverId ?? '', ), - trackChapterControls: - _buildTrackChapterControlsWidget(), - onSeekToPreviousChapter: - _seekToPreviousChapter, - onSeekToNextChapter: _seekToNextChapter, - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - getReplayIcon: getReplayIcon, - getForwardIcon: getForwardIcon, ), - ), + ), ), ), ), diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index d480c9c3..9c441e90 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../models/plex_media_info.dart'; import '../../../i18n/strings.g.dart'; +import '../../../focus/focusable_wrapper.dart'; import '../painters/chapter_marker_painter.dart'; /// Timeline slider with chapter markers for video playback @@ -16,6 +18,15 @@ class TimelineSlider extends StatelessWidget { final ValueChanged onSeek; final ValueChanged onSeekEnd; + /// Optional FocusNode for D-pad/keyboard navigation. + final FocusNode? focusNode; + + /// Custom key event handler for focus navigation. + final KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent; + + /// Called when focus changes. + final ValueChanged? onFocusChange; + const TimelineSlider({ super.key, required this.position, @@ -24,11 +35,14 @@ class TimelineSlider extends StatelessWidget { required this.chaptersLoaded, required this.onSeek, required this.onSeekEnd, + this.focusNode, + this.onKeyEvent, + this.onFocusChange, }); @override Widget build(BuildContext context) { - return Stack( + Widget slider = Stack( alignment: Alignment.center, children: [ // Chapter markers layer @@ -106,5 +120,22 @@ class TimelineSlider extends StatelessWidget { ), ], ); + + // Wrap with FocusableWrapper when focusNode is provided + if (focusNode != null) { + slider = FocusableWrapper( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + onFocusChange: onFocusChange, + borderRadius: 8, + autoScroll: false, + useBackgroundFocus: true, + disableScale: true, + semanticLabel: t.videoControls.timelineSlider, + child: slider, + ); + } + + return slider; } } diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 0065a296..bd1ca932 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -1,6 +1,7 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../mpv/mpv.dart'; import '../../../models/plex_media_info.dart'; @@ -39,6 +40,15 @@ class TrackChapterControls extends StatelessWidget { final VoidCallback? onStartAutoHide; final String serverId; + /// List of FocusNodes for the buttons (passed from parent for navigation) + final List? focusNodes; + + /// Called when focus changes on any button + final ValueChanged? onFocusChange; + + /// Called to navigate left from the first button + final VoidCallback? onNavigateLeft; + const TrackChapterControls({ super.key, required this.player, @@ -61,8 +71,51 @@ class TrackChapterControls extends StatelessWidget { this.onLoadSeekTimes, this.onCancelAutoHide, this.onStartAutoHide, + this.focusNodes, + this.onFocusChange, + this.onNavigateLeft, }); + /// Handle key event for button navigation + KeyEventResult _handleButtonKeyEvent( + FocusNode node, + KeyEvent event, + int index, + int totalButtons, + ) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + // LEFT arrow - move to previous button or exit to volume + if (key == LogicalKeyboardKey.arrowLeft) { + if (index > 0 && focusNodes != null && focusNodes!.length > index - 1) { + focusNodes![index - 1].requestFocus(); + return KeyEventResult.handled; + } else if (index == 0) { + onNavigateLeft?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.handled; + } + + // RIGHT arrow - move to next button + if (key == LogicalKeyboardKey.arrowRight) { + if (index < totalButtons - 1 && + focusNodes != null && + focusNodes!.length > index + 1) { + focusNodes![index + 1].requestFocus(); + return KeyEventResult.handled; + } + // At end, consume to prevent bubbling + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { return StreamBuilder( @@ -70,126 +123,291 @@ class TrackChapterControls extends StatelessWidget { initialData: player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; + final isMobile = PlatformDetector.isMobile(context); + final isDesktop = + Platform.isWindows || Platform.isLinux || Platform.isMacOS; + + // Build list of buttons dynamically to track indices + final buttons = []; + int buttonIndex = 0; + + // Settings button (always shown) + buttons.add( + ListenableBuilder( + listenable: SleepTimerService(), + builder: (context, _) { + final sleepTimer = SleepTimerService(); + final isActive = + sleepTimer.isActive || + audioSyncOffset != 0 || + subtitleSyncOffset != 0; + final currentIndex = 0; + return VideoControlButton( + icon: Icons.tune, + isActive: isActive, + semanticLabel: t.videoControls.settingsButton, + focusNode: focusNodes != null && focusNodes!.isNotEmpty + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: () async { + await VideoSettingsSheet.show( + context, + player, + audioSyncOffset, + subtitleSyncOffset, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, + ); + onLoadSeekTimes?.call(); + }, + ); + }, + ), + ); + buttonIndex++; + + // Audio track button + if (_hasMultipleAudioTracks(tracks)) { + final currentIndex = buttonIndex; + buttons.add( + VideoControlButton( + icon: Icons.audiotrack, + semanticLabel: t.videoControls.audioTrackButton, + focusNode: focusNodes != null && focusNodes!.length > currentIndex + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: () => AudioTrackSheet.show( + context, + player, + onTrackChanged: onAudioTrackChanged, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, + ), + ), + ); + buttonIndex++; + } + + // Subtitles button + if (_hasSubtitles(tracks)) { + final currentIndex = buttonIndex; + buttons.add( + VideoControlButton( + icon: Icons.subtitles, + semanticLabel: t.videoControls.subtitlesButton, + focusNode: focusNodes != null && focusNodes!.length > currentIndex + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: () => SubtitleTrackSheet.show( + context, + player, + onTrackChanged: onSubtitleTrackChanged, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, + ), + ), + ); + buttonIndex++; + } + + // Chapters button + if (chapters.isNotEmpty) { + final currentIndex = buttonIndex; + buttons.add( + VideoControlButton( + icon: Icons.video_library, + semanticLabel: t.videoControls.chaptersButton, + focusNode: focusNodes != null && focusNodes!.length > currentIndex + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: () => ChapterSheet.show( + context, + player, + chapters, + chaptersLoaded, + serverId: serverId, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, + ), + ), + ); + buttonIndex++; + } + + // Versions button + if (availableVersions.length > 1 && onSwitchVersion != null) { + final currentIndex = buttonIndex; + buttons.add( + VideoControlButton( + icon: Icons.video_file, + semanticLabel: t.videoControls.versionsButton, + focusNode: focusNodes != null && focusNodes!.length > currentIndex + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: () => VersionSheet.show( + context, + availableVersions, + selectedMediaIndex, + onSwitchVersion!, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, + ), + ), + ); + buttonIndex++; + } + + // BoxFit mode button + if (onCycleBoxFitMode != null) { + final currentIndex = buttonIndex; + buttons.add( + VideoControlButton( + icon: _getBoxFitIcon(boxFitMode), + tooltip: _getBoxFitTooltip(boxFitMode), + semanticLabel: t.videoControls.aspectRatioButton, + focusNode: focusNodes != null && focusNodes!.length > currentIndex + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: onCycleBoxFitMode, + ), + ); + buttonIndex++; + } + + // Rotation lock button (mobile only) + if (isMobile) { + final currentIndex = buttonIndex; + buttons.add( + VideoControlButton( + icon: isRotationLocked + ? Icons.screen_lock_rotation + : Icons.screen_rotation, + tooltip: isRotationLocked + ? t.videoControls.unlockRotation + : t.videoControls.lockRotation, + semanticLabel: t.videoControls.rotationLockButton, + focusNode: focusNodes != null && focusNodes!.length > currentIndex + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: onToggleRotationLock, + ), + ); + buttonIndex++; + } + + // Fullscreen button (desktop only) + if (isDesktop) { + final currentIndex = buttonIndex; + buttons.add( + VideoControlButton( + icon: isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + semanticLabel: isFullscreen + ? t.videoControls.exitFullscreenButton + : t.videoControls.fullscreenButton, + focusNode: focusNodes != null && focusNodes!.length > currentIndex + ? focusNodes![currentIndex] + : null, + onKeyEvent: focusNodes != null + ? (node, event) => _handleButtonKeyEvent( + node, + event, + currentIndex, + _getButtonCount(tracks, isMobile, isDesktop), + ) + : null, + onFocusChange: onFocusChange, + onPressed: onToggleFullscreen, + ), + ); + } + return IntrinsicHeight( child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Unified settings button (speed, sleep timer, audio sync, subtitle sync) - ListenableBuilder( - listenable: SleepTimerService(), - builder: (context, _) { - final sleepTimer = SleepTimerService(); - final isActive = - sleepTimer.isActive || - audioSyncOffset != 0 || - subtitleSyncOffset != 0; - return VideoControlButton( - icon: Icons.tune, - isActive: isActive, - semanticLabel: t.videoControls.settingsButton, - onPressed: () async { - await VideoSettingsSheet.show( - context, - player, - audioSyncOffset, - subtitleSyncOffset, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ); - // Sheet is now closed, reload immediately - onLoadSeekTimes?.call(); - }, - ); - }, - ), - if (_hasMultipleAudioTracks(tracks)) - VideoControlButton( - icon: Icons.audiotrack, - semanticLabel: t.videoControls.audioTrackButton, - onPressed: () => AudioTrackSheet.show( - context, - player, - onTrackChanged: onAudioTrackChanged, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), - ), - if (_hasSubtitles(tracks)) - VideoControlButton( - icon: Icons.subtitles, - semanticLabel: t.videoControls.subtitlesButton, - onPressed: () => SubtitleTrackSheet.show( - context, - player, - onTrackChanged: onSubtitleTrackChanged, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), - ), - if (chapters.isNotEmpty) - VideoControlButton( - icon: Icons.video_library, - semanticLabel: t.videoControls.chaptersButton, - onPressed: () => ChapterSheet.show( - context, - player, - chapters, - chaptersLoaded, - serverId: serverId, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), - ), - if (availableVersions.length > 1 && onSwitchVersion != null) - VideoControlButton( - icon: Icons.video_file, - semanticLabel: t.videoControls.versionsButton, - onPressed: () => VersionSheet.show( - context, - availableVersions, - selectedMediaIndex, - onSwitchVersion!, - onOpen: onCancelAutoHide, - onClose: onStartAutoHide, - ), - ), - // BoxFit mode cycle button - if (onCycleBoxFitMode != null) - VideoControlButton( - icon: _getBoxFitIcon(boxFitMode), - tooltip: _getBoxFitTooltip(boxFitMode), - semanticLabel: t.videoControls.aspectRatioButton, - onPressed: onCycleBoxFitMode, - ), - // Rotation lock toggle (mobile only) - if (PlatformDetector.isMobile(context)) - VideoControlButton( - icon: isRotationLocked - ? Icons.screen_lock_rotation - : Icons.screen_rotation, - tooltip: isRotationLocked - ? t.videoControls.unlockRotation - : t.videoControls.lockRotation, - semanticLabel: t.videoControls.rotationLockButton, - onPressed: onToggleRotationLock, - ), - // Fullscreen toggle (desktop only) - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) - VideoControlButton( - icon: isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, - semanticLabel: isFullscreen - ? t.videoControls.exitFullscreenButton - : t.videoControls.fullscreenButton, - onPressed: onToggleFullscreen, - ), - ], + children: buttons, ), ); }, ); } + /// Calculate total button count for navigation + int _getButtonCount(Tracks? tracks, bool isMobile, bool isDesktop) { + int count = 1; // Settings button always shown + if (_hasMultipleAudioTracks(tracks)) count++; + if (_hasSubtitles(tracks)) count++; + if (chapters.isNotEmpty) count++; + if (availableVersions.length > 1 && onSwitchVersion != null) count++; + if (onCycleBoxFitMode != null) count++; + if (isMobile) count++; + if (isDesktop) count++; + return count; + } + bool _hasMultipleAudioTracks(Tracks? tracks) { if (tracks == null) return false; return TrackFilterHelper.hasMultipleTracks(tracks.audio); diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 86d06ba3..4b36f03f 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../mpv/mpv.dart'; import '../../../models/plex_media_info.dart'; @@ -21,6 +22,15 @@ class VideoTimelineBar extends StatelessWidget { /// If false, timestamps are shown in a row below the slider (mobile layout). final bool horizontalLayout; + /// Optional FocusNode for D-pad/keyboard navigation. + final FocusNode? focusNode; + + /// Custom key event handler for focus navigation. + final KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent; + + /// Called when focus changes. + final ValueChanged? onFocusChange; + const VideoTimelineBar({ super.key, required this.player, @@ -29,6 +39,9 @@ class VideoTimelineBar extends StatelessWidget { required this.onSeek, required this.onSeekEnd, this.horizontalLayout = true, + this.focusNode, + this.onKeyEvent, + this.onFocusChange, }); @override @@ -60,9 +73,7 @@ class VideoTimelineBar extends StatelessWidget { children: [ _buildTimestamp(position), const SizedBox(width: 12), - Expanded( - child: _buildSlider(position, duration), - ), + Expanded(child: _buildSlider(position, duration)), const SizedBox(width: 12), _buildTimestamp(duration), ], @@ -77,10 +88,7 @@ class VideoTimelineBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildTimestamp(position), - _buildTimestamp(duration), - ], + children: [_buildTimestamp(position), _buildTimestamp(duration)], ), ), ], @@ -90,10 +98,7 @@ class VideoTimelineBar extends StatelessWidget { Widget _buildTimestamp(Duration time) { return Text( formatDurationTimestamp(time), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), + style: const TextStyle(color: Colors.white, fontSize: 14), ); } @@ -105,6 +110,9 @@ class VideoTimelineBar extends StatelessWidget { chaptersLoaded: chaptersLoaded, onSeek: onSeek, onSeekEnd: onSeekEnd, + focusNode: focusNode, + onKeyEvent: onKeyEvent, + onFocusChange: onFocusChange, ); } } diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index 90ff10c1..f7650608 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -1,87 +1,228 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../mpv/mpv.dart'; import '../../../services/settings_service.dart'; import '../../../i18n/strings.g.dart'; +import '../../../focus/focusable_wrapper.dart'; +import '../../../focus/input_mode_tracker.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]. -class VolumeControl extends StatelessWidget { +/// +/// 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; - const VolumeControl({super.key, required this.player}); + /// Optional FocusNode for D-pad/keyboard navigation. + final FocusNode? focusNode; + + /// Custom key event handler for focus navigation (used when NOT in adjust mode). + final KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent; + + /// Called when focus changes. + final ValueChanged? onFocusChange; + + /// Called on any keyboard activity (to reset hide timer). + final VoidCallback? onFocusActivity; + + const VolumeControl({ + super.key, + required this.player, + this.focusNode, + this.onKeyEvent, + this.onFocusChange, + this.onFocusActivity, + }); + + @override + State createState() => _VolumeControlState(); +} + +class _VolumeControlState extends State { + /// Whether we're in volume adjust mode (left/right adjusts volume). + bool _isAdjustMode = false; + + /// Volume step size for keyboard adjustment. + static const double _volumeStep = 5.0; + + void _enterAdjustMode() { + setState(() { + _isAdjustMode = true; + }); + } + + void _exitAdjustMode() { + setState(() { + _isAdjustMode = false; + }); + } + + Future _adjustVolume(double delta) async { + final currentVolume = widget.player.state.volume; + final newVolume = (currentVolume + delta).clamp(0.0, 100.0); + widget.player.setVolume(newVolume); + final settings = await SettingsService.getInstance(); + await settings.setVolume(newVolume); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + if (_isAdjustMode) { + // Notify activity on any key in adjust mode (to reset hide timer) + widget.onFocusActivity?.call(); + + // In adjust mode: left/right adjusts volume, back/escape exits + if (key == LogicalKeyboardKey.arrowLeft) { + _adjustVolume(-_volumeStep); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowRight) { + _adjustVolume(_volumeStep); + return KeyEventResult.handled; + } + if (_isBackKey(key) || _isSelectKey(key)) { + _exitAdjustMode(); + return KeyEventResult.handled; + } + // UP/DOWN exits adjust mode and lets navigation continue + if (key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown) { + _exitAdjustMode(); + // Pass through to normal navigation handler + return widget.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; + } + // Consume other keys in adjust mode + return KeyEventResult.handled; + } + + // Not in adjust mode: use the provided key event handler for navigation + return widget.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; + } + + bool _isBackKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.browserBack || + key == LogicalKeyboardKey.gameButtonB; + } + + bool _isSelectKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.gameButtonA; + } + + void _handleFocusChange(bool hasFocus) { + // Exit adjust mode when focus is lost + if (!hasFocus && _isAdjustMode) { + _exitAdjustMode(); + } + widget.onFocusChange?.call(hasFocus); + } @override Widget build(BuildContext context) { return StreamBuilder( - stream: player.streams.volume, - initialData: player.state.volume, + stream: widget.player.streams.volume, + initialData: widget.player.state.volume, builder: (context, snapshot) { final volume = snapshot.data ?? 100.0; final isMuted = volume == 0; + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + final muteButton = Semantics( + label: isMuted + ? t.videoControls.unmuteButton + : t.videoControls.muteButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + ), + onPressed: () async { + final newVolume = isMuted ? 100.0 : 0.0; + widget.player.setVolume(newVolume); + final settings = await SettingsService.getInstance(); + await settings.setVolume(newVolume); + }, + ), + ); return Row( mainAxisSize: MainAxisSize.min, children: [ - Semantics( - label: isMuted - ? t.videoControls.unmuteButton - : t.videoControls.muteButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - isMuted ? Icons.volume_off : Icons.volume_up, - color: Colors.white, - ), - onPressed: () async { - final newVolume = isMuted ? 100.0 : 0.0; - player.setVolume(newVolume); - final settings = await SettingsService.getInstance(); - await settings.setVolume(newVolume); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ), + if (widget.focusNode != null) + FocusableWrapper( + focusNode: widget.focusNode, + onSelect: _enterAdjustMode, + onKeyEvent: _handleKeyEvent, + onFocusChange: _handleFocusChange, + borderRadius: 20, + autoScroll: false, + useBackgroundFocus: true, + disableScale: true, + semanticLabel: _isAdjustMode + ? t.videoControls.volumeSlider + : (isMuted + ? t.videoControls.unmuteButton + : t.videoControls.muteButton), + child: muteButton, + ) + else + muteButton, const SizedBox(width: 8), - SizedBox( - width: 100, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 3, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 6, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 12, - ), - ), - child: Semantics( - label: t.videoControls.volumeSlider, - slider: true, - child: Slider( - value: volume, - min: 0.0, - max: 100.0, - onChanged: (value) { - player.setVolume(value); - }, - onChangeEnd: (value) async { - final settings = await SettingsService.getInstance(); - await settings.setVolume(value); - }, - activeColor: Colors.white, - inactiveColor: Colors.white.withValues(alpha: 0.3), - ), - ), - ), - ), + _buildVolumeSlider(volume, isKeyboardMode), ], ); }, ); } + + Widget _buildVolumeSlider(double volume, bool isKeyboardMode) { + // Show visual indicator when in adjust mode with keyboard + final showAdjustIndicator = _isAdjustMode && isKeyboardMode; + + return SizedBox( + width: 100, + child: SliderTheme( + data: SliderThemeData( + trackHeight: showAdjustIndicator ? 4 : 3, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: showAdjustIndicator ? 8 : 6, + ), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Semantics( + label: t.videoControls.volumeSlider, + slider: true, + child: Slider( + value: volume, + min: 0.0, + max: 100.0, + onChanged: (value) { + widget.player.setVolume(value); + }, + onChangeEnd: (value) async { + final settings = await SettingsService.getInstance(); + await settings.setVolume(value); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + ), + ); + } }