feat: video player dpad support

This commit is contained in:
edde746
2025-12-07 22:43:47 +01:00
parent e804a2a3cc
commit 851d74e418
23 changed files with 2396 additions and 963 deletions
+14
View File
@@ -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,
);
}
}
+25 -6
View File
@@ -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,
),
);
+164 -148
View File
@@ -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),
);
},
),
),
],
);
},
},
),
);
}
}
+74 -60
View File
@@ -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,
);
},
),
),
],
);
},
),
);
}
}
+11 -5
View File
@@ -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';
+6 -2
View File
@@ -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(
+193 -158
View File
@@ -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,
),
],
),
),
),
],
],
),
),
),
),
+65
View File
@@ -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;
}
}
+192
View File
@@ -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,
);
}
}
+70 -35
View File
@@ -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;
}
}
+229 -65
View File
@@ -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),
),
),
),
);
}
}