feat: video player dpad support
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FocusableWrapper>
|
||||
_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<FocusableWrapper>
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<FiltersBottomSheet> {
|
||||
final Map<String, String> _tempSelectedFilters = {};
|
||||
final Map<String, String> _filterDisplayNames = {}; // Cache for display names
|
||||
late List<PlexFilter> _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<FiltersBottomSheet> {
|
||||
|
||||
@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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SortBottomSheet> {
|
||||
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<SortBottomSheet> {
|
||||
|
||||
@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<PlexSort>(
|
||||
title: Text(sort.title),
|
||||
value: sort,
|
||||
groupValue: _currentSort,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
_handleSortChange(value, value.isDefaultDescending);
|
||||
}
|
||||
},
|
||||
secondary: isSelected
|
||||
? SegmentedButton<bool>(
|
||||
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<bool> 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<PlexSort>(
|
||||
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<bool>(
|
||||
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<bool> newSelection) {
|
||||
_handleSortChange(sort, newSelection.first);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -16,8 +16,12 @@ Future<void> 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(
|
||||
|
||||
@@ -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<FileInfoBottomSheet> createState() => _FileInfoBottomSheetState();
|
||||
}
|
||||
|
||||
class _FileInfoBottomSheetState extends State<FileInfoBottomSheet> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<FocusableBottomSheet> createState() => _FocusableBottomSheetState();
|
||||
}
|
||||
|
||||
class _FocusableBottomSheetState extends State<FocusableBottomSheet> {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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<T> 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<T?>? 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<T>(
|
||||
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<bool>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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<Duration> onSeek;
|
||||
final ValueChanged<Duration> 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<PlexMediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
final int boxFitMode;
|
||||
final int audioSyncOffset;
|
||||
final int subtitleSyncOffset;
|
||||
final bool isFullscreen;
|
||||
final VoidCallback? onCycleBoxFitMode;
|
||||
final VoidCallback? onToggleFullscreen;
|
||||
final Function(int)? onSwitchVersion;
|
||||
final Function(AudioTrack)? onAudioTrackChanged;
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
final VoidCallback? onLoadSeekTimes;
|
||||
final VoidCallback? onCancelAutoHide;
|
||||
final VoidCallback? onStartAutoHide;
|
||||
final String serverId;
|
||||
|
||||
const DesktopVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -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<DesktopVideoControls> createState() => DesktopVideoControlsState();
|
||||
}
|
||||
|
||||
class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
// Focus nodes for playback control buttons
|
||||
late final FocusNode _prevItemFocusNode;
|
||||
late final FocusNode _prevChapterFocusNode;
|
||||
late final FocusNode _playPauseFocusNode;
|
||||
late final FocusNode _nextChapterFocusNode;
|
||||
late final FocusNode _nextItemFocusNode;
|
||||
late final FocusNode _timelineFocusNode;
|
||||
|
||||
// Focus node for volume control
|
||||
late final FocusNode _volumeFocusNode;
|
||||
|
||||
// Focus nodes for track/chapter controls (max 8 buttons possible)
|
||||
late final List<FocusNode> _trackControlFocusNodes;
|
||||
|
||||
// List of button focus nodes for horizontal navigation
|
||||
late final List<FocusNode> _buttonFocusNodes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prevItemFocusNode = FocusNode(debugLabel: 'PrevItem');
|
||||
_prevChapterFocusNode = FocusNode(debugLabel: 'PrevChapter');
|
||||
_playPauseFocusNode = FocusNode(debugLabel: 'PlayPause');
|
||||
_nextChapterFocusNode = FocusNode(debugLabel: 'NextChapter');
|
||||
_nextItemFocusNode = FocusNode(debugLabel: 'NextItem');
|
||||
_timelineFocusNode = FocusNode(debugLabel: 'Timeline');
|
||||
_volumeFocusNode = FocusNode(debugLabel: 'Volume');
|
||||
|
||||
// Create focus nodes for track controls (up to 8 buttons)
|
||||
_trackControlFocusNodes = List.generate(
|
||||
8,
|
||||
(i) => FocusNode(debugLabel: 'TrackControl$i'),
|
||||
);
|
||||
|
||||
_buttonFocusNodes = [
|
||||
_prevItemFocusNode,
|
||||
_prevChapterFocusNode,
|
||||
_playPauseFocusNode,
|
||||
_nextChapterFocusNode,
|
||||
_nextItemFocusNode,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_prevItemFocusNode.dispose();
|
||||
_prevChapterFocusNode.dispose();
|
||||
_playPauseFocusNode.dispose();
|
||||
_nextChapterFocusNode.dispose();
|
||||
_nextItemFocusNode.dispose();
|
||||
_timelineFocusNode.dispose();
|
||||
_volumeFocusNode.dispose();
|
||||
for (final node in _trackControlFocusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Request focus on the play/pause button (called when controls shown via keyboard)
|
||||
void requestPlayPauseFocus() {
|
||||
_playPauseFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Get focus node for volume control
|
||||
FocusNode get volumeFocusNode => _volumeFocusNode;
|
||||
|
||||
/// Get focus nodes for track controls
|
||||
List<FocusNode> get trackControlFocusNodes => _trackControlFocusNodes;
|
||||
|
||||
/// Handle left navigation from first track control - go to volume
|
||||
void navigateFromTrackToVolume() {
|
||||
_volumeFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
}
|
||||
|
||||
void _onFocusChange(bool hasFocus) {
|
||||
if (hasFocus) {
|
||||
widget.onFocusActivity?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key events for horizontal button navigation
|
||||
KeyEventResult _handleButtonKeyEvent(
|
||||
FocusNode node,
|
||||
KeyEvent event,
|
||||
int index,
|
||||
) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// LEFT arrow - move to previous button
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
if (index > 0) {
|
||||
_buttonFocusNodes[index - 1].requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.handled; // At start, consume to prevent bubbling
|
||||
}
|
||||
|
||||
// RIGHT arrow - move to next button or to volume
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (index < _buttonFocusNodes.length - 1) {
|
||||
_buttonFocusNodes[index + 1].requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// At end of playback buttons - move to volume
|
||||
_volumeFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP arrow - move focus to timeline
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
_timelineFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events for volume control navigation
|
||||
KeyEventResult _handleVolumeKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// LEFT arrow - move back to last playback button
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
_nextItemFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT arrow - move to first track control button
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (_trackControlFocusNodes.isNotEmpty) {
|
||||
_trackControlFocusNodes[0].requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP arrow - move focus to timeline
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
_timelineFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events for timeline navigation
|
||||
KeyEventResult _handleTimelineKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
final duration = widget.player.state.duration;
|
||||
final position = widget.player.state.position;
|
||||
|
||||
// UP arrow - hide controls
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
widget.onHideControls?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// DOWN arrow - move focus to play/pause button
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
_playPauseFocusNode.requestFocus();
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT/RIGHT for smooth scrubbing
|
||||
if (key == LogicalKeyboardKey.arrowLeft ||
|
||||
key == LogicalKeyboardKey.arrowRight) {
|
||||
if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
|
||||
|
||||
// Base step: 0.5% of duration, minimum 500ms
|
||||
final baseStep = Duration(
|
||||
milliseconds: (duration.inMilliseconds * 0.005)
|
||||
.clamp(500, 15000)
|
||||
.toInt(),
|
||||
);
|
||||
|
||||
// Accelerate on key repeat
|
||||
final step = event is KeyRepeatEvent
|
||||
? Duration(
|
||||
milliseconds: (baseStep.inMilliseconds * 2).clamp(500, 30000),
|
||||
)
|
||||
: baseStep;
|
||||
|
||||
final isForward = key == LogicalKeyboardKey.arrowRight;
|
||||
final newPosition = isForward ? position + step : position - step;
|
||||
|
||||
// Clamp to valid range
|
||||
final clampedPosition = Duration(
|
||||
milliseconds: newPosition.inMilliseconds.clamp(
|
||||
0,
|
||||
duration.inMilliseconds,
|
||||
),
|
||||
);
|
||||
|
||||
widget.onSeek(clampedPosition);
|
||||
widget.onFocusActivity?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@@ -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<bool>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>({
|
||||
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),
|
||||
|
||||
@@ -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<PlexChapter> chapters;
|
||||
final bool chaptersLoaded;
|
||||
@@ -46,124 +48,148 @@ class ChapterSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ChapterSheet> createState() => _ChapterSheetState();
|
||||
}
|
||||
|
||||
class _ChapterSheetState extends State<ChapterSheet> {
|
||||
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<Duration>(
|
||||
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<Duration>(
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T> extends StatelessWidget {
|
||||
class TrackSelectionSheet<T> extends StatefulWidget {
|
||||
final Player player;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
@@ -73,86 +74,121 @@ class TrackSelectionSheet<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<TrackSelectionSheet<T>> createState() => _TrackSelectionSheetState<T>();
|
||||
}
|
||||
|
||||
class _TrackSelectionSheetState<T> extends State<TrackSelectionSheet<T>> {
|
||||
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<Tracks>(
|
||||
stream: player.streams.tracks,
|
||||
initialData: player.state.tracks,
|
||||
builder: (context, snapshot) {
|
||||
final tracks = snapshot.data;
|
||||
final availableTracks = TrackFilterHelper.extractAndFilterTracks<T>(
|
||||
tracks,
|
||||
extractTracks,
|
||||
);
|
||||
return FocusableBottomSheet(
|
||||
initialFocusNode: _initialFocusNode,
|
||||
child: StreamBuilder<Tracks>(
|
||||
stream: widget.player.streams.tracks,
|
||||
initialData: widget.player.state.tracks,
|
||||
builder: (context, snapshot) {
|
||||
final tracks = snapshot.data;
|
||||
final availableTracks = TrackFilterHelper.extractAndFilterTracks<T>(
|
||||
tracks,
|
||||
widget.extractTracks,
|
||||
);
|
||||
|
||||
return BaseVideoControlSheet(
|
||||
title: title,
|
||||
icon: icon,
|
||||
child: availableTracks.isEmpty
|
||||
? TrackSelectionHelper.buildEmptyState<T>()
|
||||
: StreamBuilder<TrackSelection>(
|
||||
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<T>()
|
||||
: StreamBuilder<TrackSelection>(
|
||||
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<T>(
|
||||
isSelected: isOffSelected,
|
||||
return ListView.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// First item is "Off" if enabled
|
||||
if (widget.showOffOption && index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile<T>(
|
||||
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<T>(
|
||||
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<T>(
|
||||
label: label,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
setTrack(track);
|
||||
onTrackChanged?.call(track);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlexMediaVersion> 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<VersionSheet> 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<VersionSheet> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<VideoSettingsSheet> {
|
||||
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<void> _loadHDRSetting() async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
setState(() {
|
||||
@@ -199,6 +212,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
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<VideoSettingsSheet> {
|
||||
_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();
|
||||
}
|
||||
}(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlexVideoControls>
|
||||
int _subtitleSyncOffset = 0; // Default, loaded from settings
|
||||
bool _isRotationLocked = true; // Default locked (landscape only)
|
||||
|
||||
// GlobalKey to access DesktopVideoControls state for focus management
|
||||
final GlobalKey<DesktopVideoControlsState> _desktopControlsKey =
|
||||
GlobalKey<DesktopVideoControlsState>();
|
||||
|
||||
/// 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<PlexVideoControls>
|
||||
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<void> _initKeyboardService() async {
|
||||
@@ -734,6 +757,76 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PlexVideoControls>
|
||||
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<PlexVideoControls>
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<Duration> onSeek;
|
||||
final ValueChanged<Duration> 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<bool>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FocusNode>? focusNodes;
|
||||
|
||||
/// Called when focus changes on any button
|
||||
final ValueChanged<bool>? 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<Tracks>(
|
||||
@@ -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 = <Widget>[];
|
||||
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<AudioTrack>(tracks.audio);
|
||||
|
||||
@@ -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<bool>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool>? 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<VolumeControl> createState() => _VolumeControlState();
|
||||
}
|
||||
|
||||
class _VolumeControlState extends State<VolumeControl> {
|
||||
/// 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<void> _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<double>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user