fix: align UI focus and sheet behavior

This commit is contained in:
edde746
2026-07-13 11:28:32 +02:00
parent e6e7d8cdfd
commit e4db04fa62
82 changed files with 1860 additions and 1216 deletions
+1 -1
View File
@@ -387,7 +387,7 @@ class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableT
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final enabled = item.enabled && widget.onPressed != null;
final active = enabled && (_isFocused || _isHovered);
final active = enabled && ((_isFocused && InputModeTracker.isKeyboardMode(context)) || _isHovered);
final foreground = _foregroundColor(context, active: active);
final subtitleColor = foreground.withValues(alpha: active && item.stateLayerColor != null ? 0.86 : 0.68);
final background = _backgroundColor(context, active: active);
+1 -1
View File
@@ -18,7 +18,7 @@ class BottomSheetHeader extends StatelessWidget {
final Widget? action;
/// Optional callback when close button is pressed
/// Defaults to Navigator.pop(context)
/// Defaults to closing the nearest hosted sheet, with modal-route fallback.
final VoidCallback? onClose;
/// Optional icon to display as leading widget
+9 -6
View File
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../focus/card_focus_scope.dart';
import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
import '../media/media_server_client.dart';
import '../services/settings_service.dart';
import '../theme/mono_tokens.dart';
@@ -132,8 +133,14 @@ class CastMemberStripState extends State<CastMemberStrip> {
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isBackKey || !event.isActionable) return KeyEventResult.ignored;
if (widget.members.isEmpty) return KeyEventResult.ignored;
if (key.isBackKey || widget.members.isEmpty) return KeyEventResult.ignored;
final onMemberTap = widget.onMemberTap;
if (onMemberTap != null) {
final selectResult = handleOneShotSelect(event, () => onMemberTap(_focusedIndex));
if (selectResult != KeyEventResult.ignored) return selectResult;
}
if (!event.isActionable) return KeyEventResult.ignored;
if (key.isLeftKey) {
_moveFocus(-1);
@@ -151,10 +158,6 @@ class CastMemberStripState extends State<CastMemberStrip> {
widget.onNavigateDown!();
return KeyEventResult.handled;
}
if (key.isSelectKey && widget.onMemberTap != null) {
widget.onMemberTap!(_focusedIndex);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
+18 -55
View File
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../focus/focusable_wrapper.dart';
import '../i18n/strings.g.dart';
import 'clickable_cursor.dart';
class CollapsibleText extends StatefulWidget {
@@ -54,33 +53,6 @@ class _CollapsibleTextState extends State<CollapsibleText> {
});
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final selectResult = handleOneShotSelect(event, _toggleExpanded);
if (selectResult != KeyEventResult.ignored) return selectResult;
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isUpKey && widget.onNavigateUp != null) {
widget.onNavigateUp!();
return KeyEventResult.handled;
}
if (key.isDownKey && widget.onNavigateDown != null) {
widget.onNavigateDown!();
return KeyEventResult.handled;
}
if (key.isLeftKey && widget.onNavigateLeft != null) {
widget.onNavigateLeft!();
return KeyEventResult.handled;
}
if (key.isRightKey && widget.onNavigateRight != null) {
widget.onNavigateRight!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final style = widget.style ?? DefaultTextStyle.of(context).style;
@@ -123,31 +95,22 @@ class _CollapsibleTextState extends State<CollapsibleText> {
),
);
final focusNode = widget.focusNode;
if (focusNode != null) {
result = Focus(
focusNode: focusNode,
skipTraversal: widget.skipTraversal,
onKeyEvent: _handleKeyEvent,
child: ListenableBuilder(
listenable: focusNode,
builder: (context, child) {
final showFocus = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: showFocus
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: child,
);
},
child: result,
),
);
result = FocusableWrapper(
focusNode: widget.focusNode,
onSelect: _toggleExpanded,
onNavigateUp: widget.onNavigateUp,
onNavigateDown: widget.onNavigateDown,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
semanticLabel: _expanded ? t.accessibility.collapseText : t.accessibility.expandText,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
borderRadius: 8,
child: result,
);
if (widget.skipTraversal) {
result = ExcludeFocusTraversal(child: result);
}
return ClickableCursor(
@@ -9,6 +9,7 @@ import '../../services/settings_service.dart';
import '../../utils/dialogs.dart';
import '../../focus/focusable_button.dart';
import '../../focus/key_event_utils.dart';
import '../dialog_action_button.dart';
class RemoteSessionDialog extends StatefulWidget {
const RemoteSessionDialog({super.key});
@@ -115,22 +116,22 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> with MountedS
title: Text(t.common.error),
content: Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')),
actions: [
FocusableButton(
DialogActionButton(
autofocus: true,
focusNode: _errorCloseFocusNode,
onPressed: _close,
onBack: _close,
onNavigateRight: () => _errorRetryFocusNode.requestFocus(),
useBackgroundFocus: true,
child: TextButton(onPressed: _close, child: Text(t.common.close)),
label: t.common.close,
),
FocusableButton(
DialogActionButton(
focusNode: _errorRetryFocusNode,
onPressed: _startServer,
onBack: _close,
onNavigateLeft: () => _errorCloseFocusNode.requestFocus(),
useBackgroundFocus: true,
child: TextButton(onPressed: _startServer, child: Text(t.common.retry)),
label: t.common.retry,
),
],
);
+21 -11
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../i18n/strings.g.dart';
import '../models/trackers/device_code.dart';
import '../utils/snackbar_helper.dart';
@@ -45,17 +46,25 @@ class DeviceCodeDialog extends StatelessWidget {
Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
Center(
child: InkWell(
onTap: () => _copy(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
code.userCode,
style: theme.textTheme.displaySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
letterSpacing: 4,
fontWeight: .w600,
child: FocusableWrapper(
onSelect: () => _copy(context),
semanticLabel: t.services.deviceCode.copyCode,
descendantsAreFocusable: false,
useBackgroundFocus: true,
borderRadius: 8,
child: InkWell(
canRequestFocus: false,
onTap: () => _copy(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
code.userCode,
style: theme.textTheme.displaySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
letterSpacing: 4,
fontWeight: .w600,
),
),
),
),
@@ -66,6 +75,7 @@ class DeviceCodeDialog extends StatelessWidget {
width: double.infinity,
child: FocusableButton(
onPressed: _open,
useBackgroundFocus: true,
child: FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(t.services.deviceCode.openToActivate(service: serviceName)),
+27 -6
View File
@@ -9,40 +9,61 @@ import '../focus/focusable_button.dart';
/// `FocusableButton(onPressed: ..., child: TextButton(onPressed: ..., ...))`
/// boilerplate with a single call.
class DialogActionButton extends StatelessWidget {
final VoidCallback onPressed;
final VoidCallback? onPressed;
final String label;
final FocusNode? focusNode;
final bool autofocus;
final bool isPrimary;
final bool? useBackgroundFocus;
final VoidCallback? onBack;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
final ButtonStyle? style;
final Widget? icon;
const DialogActionButton({
super.key,
required this.onPressed,
required this.label,
this.focusNode,
this.autofocus = false,
this.isPrimary = false,
this.useBackgroundFocus,
this.onBack,
this.onNavigateUp,
this.onNavigateDown,
this.onNavigateLeft,
this.onNavigateRight,
this.style,
this.icon,
});
@override
Widget build(BuildContext context) {
final button = switch ((isPrimary, icon)) {
(true, final Widget icon) => FilledButton.icon(
onPressed: onPressed,
style: style,
icon: icon,
label: Text(label),
),
(true, null) => FilledButton(onPressed: onPressed, style: style, child: Text(label)),
(false, final Widget icon) => TextButton.icon(onPressed: onPressed, style: style, icon: icon, label: Text(label)),
(false, null) => TextButton(onPressed: onPressed, style: style, child: Text(label)),
};
return FocusableButton(
focusNode: focusNode,
autofocus: autofocus,
onPressed: onPressed,
useBackgroundFocus: isPrimary,
useBackgroundFocus: useBackgroundFocus ?? isPrimary,
onBack: onBack,
onNavigateUp: onNavigateUp,
onNavigateDown: onNavigateDown,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
child: isPrimary
? FilledButton(onPressed: onPressed, child: Text(label))
: TextButton(onPressed: onPressed, child: Text(label)),
child: button,
);
}
}
+82
View File
@@ -355,3 +355,85 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
);
}
}
/// A CheckboxListTile that accepts a FocusNode for keyboard/controller navigation.
///
/// Uses Flutter's native CheckboxListTile focus support - no custom styling wrapper.
class FocusableCheckboxListTile extends StatefulWidget {
final Widget? title;
final Widget? subtitle;
final Widget? secondary;
final bool? value;
final ValueChanged<bool?>? onChanged;
final bool tristate;
final bool dense;
final FocusNode? focusNode;
final bool autofocus;
final VisualDensity? visualDensity;
final EdgeInsetsGeometry? contentPadding;
final ListTileControlAffinity controlAffinity;
const FocusableCheckboxListTile({
super.key,
this.title,
this.subtitle,
this.secondary,
required this.value,
required this.onChanged,
this.tristate = false,
this.dense = true,
this.focusNode,
this.autofocus = false,
this.visualDensity = const VisualDensity(vertical: -3),
this.contentPadding,
this.controlAffinity = ListTileControlAffinity.platform,
});
@override
State<FocusableCheckboxListTile> createState() => _FocusableCheckboxListTileState();
}
class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
with FocusableTileStateMixin<FocusableCheckboxListTile> {
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@override
void initState() {
super.initState();
initFocusNode();
}
@override
void didUpdateWidget(FocusableCheckboxListTile oldWidget) {
super.didUpdateWidget(oldWidget);
updateFocusNode(oldWidget.focusNode);
}
@override
void dispose() {
disposeFocusNode();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClickableCursor(
enabled: widget.onChanged != null,
child: CheckboxListTile(
title: widget.title,
subtitle: widget.subtitle,
secondary: widget.secondary,
value: widget.value,
onChanged: widget.onChanged,
tristate: widget.tristate,
dense: widget.dense,
visualDensity: widget.visualDensity,
contentPadding: widget.contentPadding,
focusNode: effectiveFocusNode,
autofocus: widget.autofocus,
controlAffinity: widget.controlAffinity,
),
);
}
}
+25 -3
View File
@@ -6,10 +6,17 @@ import 'app_menu.dart';
/// An [AppMenuButton] that can be focused and opened with D-pad select.
class FocusablePopupMenuButton<T> extends StatefulWidget {
final Widget? icon;
final Widget? child;
final String? tooltip;
final bool enabled;
final AppMenuEntryBuilder<T> itemBuilder;
final ValueChanged<T>? onSelected;
final GlobalKey<AppMenuButtonState<T>>? menuKey;
final AppMenuAnchorAlignment anchorAlignment;
final Offset alignmentOffset;
final double minWidth;
final double? maxWidth;
final EdgeInsetsGeometry? childPadding;
final FocusNode? focusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
@@ -23,10 +30,17 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
const FocusablePopupMenuButton({
super.key,
this.icon,
this.child,
this.tooltip,
this.enabled = true,
required this.itemBuilder,
this.onSelected,
this.menuKey,
this.anchorAlignment = AppMenuAnchorAlignment.start,
this.alignmentOffset = Offset.zero,
this.minWidth = 220,
this.maxWidth,
this.childPadding,
this.focusNode,
this.onNavigateUp,
this.onNavigateDown,
@@ -36,7 +50,7 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
this.borderRadius = 100,
this.useBackgroundFocus = true,
this.enableLongPress = true,
});
}) : assert(icon != null || child != null, 'FocusablePopupMenuButton requires icon or child');
@override
State<FocusablePopupMenuButton<T>> createState() => _FocusablePopupMenuButtonState<T>();
@@ -53,6 +67,7 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
Widget build(BuildContext context) {
return FocusableWrapper(
focusNode: widget.focusNode,
canRequestFocus: widget.enabled,
disableScale: true,
borderRadius: widget.borderRadius,
useBackgroundFocus: widget.useBackgroundFocus,
@@ -63,14 +78,21 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
onNavigateDown: widget.onNavigateDown,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
onSelect: _showMenu,
onLongPress: widget.enableLongPress ? _showMenu : null,
onSelect: widget.enabled ? _showMenu : null,
onLongPress: widget.enabled && widget.enableLongPress ? _showMenu : null,
child: AppMenuButton<T>(
key: _menuKey,
icon: widget.icon,
tooltip: widget.tooltip,
enabled: widget.enabled,
onSelected: widget.onSelected,
entriesBuilder: widget.itemBuilder,
anchorAlignment: widget.anchorAlignment,
alignmentOffset: widget.alignmentOffset,
minWidth: widget.minWidth,
maxWidth: widget.maxWidth,
childPadding: widget.childPadding,
child: widget.child,
),
);
}
+22 -3
View File
@@ -26,6 +26,12 @@ class FocusedScrollScaffold extends StatefulWidget {
/// Optional actions to display in the app bar (e.g., IconButton widgets).
final List<Widget>? actions;
/// Whether app-bar controls participate in keyboard/controller traversal.
///
/// They remain excluded while initial focus is assigned so the first
/// content control still receives focus when the screen opens.
final bool focusableAppBarActions;
/// Whether the app bar should remain visible when scrolling.
/// Defaults to true.
final bool pinned;
@@ -44,6 +50,7 @@ class FocusedScrollScaffold extends StatefulWidget {
required this.title,
required this.slivers,
this.actions,
this.focusableAppBarActions = false,
this.pinned = true,
this.automaticallyImplyLeading = true,
this.onBackPressed,
@@ -56,6 +63,7 @@ class FocusedScrollScaffold extends StatefulWidget {
class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
final _scopeNode = FocusScopeNode();
bool _focusRequested = false;
bool _appBarFocusEnabled = false;
@override
void dispose() {
@@ -71,6 +79,9 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
if (_scopeNode.focusedChild != null) return;
_scopeNode.requestFocus();
_scopeNode.nextFocus();
if (widget.focusableAppBarActions && !_appBarFocusEnabled) {
setState(() => _appBarFocusEnabled = true);
}
});
}
@@ -94,14 +105,22 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
child: Scaffold(
body: CustomScrollView(
slivers: [
ExcludeFocus(
child: CustomAppBar(
if (!widget.focusableAppBarActions || !_appBarFocusEnabled)
ExcludeFocus(
child: CustomAppBar(
title: widget.title,
pinned: widget.pinned,
actions: widget.actions,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
),
)
else
CustomAppBar(
title: widget.title,
pinned: widget.pinned,
actions: widget.actions,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
),
),
...widget.slivers,
],
),
+11 -1
View File
@@ -5,10 +5,11 @@ import '../models/hotkey_model.dart';
/// Captures a key combination from the user and calls [onHotKeyRecorded].
class HotKeyRecorder extends StatefulWidget {
const HotKeyRecorder({super.key, this.initalHotKey, required this.onHotKeyRecorded});
const HotKeyRecorder({super.key, this.initalHotKey, required this.onHotKeyRecorded, this.enabled = true});
final HotKey? initalHotKey;
final ValueChanged<HotKey> onHotKeyRecorded;
final bool enabled;
@override
State<HotKeyRecorder> createState() => _HotKeyRecorderState();
@@ -24,6 +25,14 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
}
@override
void didUpdateWidget(HotKeyRecorder oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initalHotKey != oldWidget.initalHotKey) {
_hotKey = widget.initalHotKey;
}
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
@@ -31,6 +40,7 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
}
bool _handleKeyEvent(KeyEvent keyEvent) {
if (!widget.enabled) return false;
if (keyEvent is KeyUpEvent) return false;
final physicalKeysPressed = HardwareKeyboard.instance.physicalKeysPressed;
+22 -42
View File
@@ -24,6 +24,7 @@ import '../utils/provider_extensions.dart';
import '../utils/snackbar_helper.dart';
import 'app_icon.dart';
import 'app_menu.dart';
import 'bottom_sheet_header.dart';
import 'overlay_sheet.dart';
/// A menu action item for context menus
@@ -320,6 +321,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
List<MediaLibrary>? _originalOrder; // Original order before move (for cancel)
final FocusNode _listFocusNode = FocusNode();
final ScrollController _dialogScrollController = ScrollController();
final ScrollController _sheetScrollController = ScrollController();
bool _backKeyDownSeen = false;
@override
@@ -332,6 +334,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
void dispose() {
_listFocusNode.dispose();
_dialogScrollController.dispose();
_sheetScrollController.dispose();
super.dispose();
}
@@ -505,7 +508,11 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
for (final item in menuItems)
AppMenuItem<String>(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive),
],
onSelected: (value) => widget.onLibraryMenuAction(value, library),
closeOnSelected: false,
onSelected: (value) {
OverlaySheetController.popAdaptive(context, value);
widget.onLibraryMenuAction(value, library);
},
),
);
}
@@ -547,6 +554,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
),
body: Focus(
focusNode: _listFocusNode,
descendantsAreFocusable: false,
autofocus: InputModeTracker.isKeyboardMode(context),
onKeyEvent: _handleKeyEvent,
child: _buildFlatLibraryListDialog(hiddenLibraryKeys),
@@ -556,47 +564,19 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
);
}
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Row(
children: [
const AppIcon(Symbols.edit_rounded, fill: 1),
const SizedBox(width: 12),
Expanded(
child: Text(t.libraries.manageLibraries, style: const TextStyle(fontSize: 20, fontWeight: .bold)),
),
IconButton(
icon: const AppIcon(Symbols.close_rounded, fill: 1),
onPressed: () => OverlaySheetController.popAdaptive(context),
),
],
),
),
// Library list (grouped by server if multiple servers)
Expanded(
child: Focus(
focusNode: _listFocusNode,
autofocus: InputModeTracker.isKeyboardMode(context),
onKeyEvent: _handleKeyEvent,
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
),
),
],
);
},
return Column(
children: [
BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded),
Flexible(
child: Focus(
focusNode: _listFocusNode,
descendantsAreFocusable: false,
autofocus: InputModeTracker.isKeyboardMode(context),
onKeyEvent: _handleKeyEvent,
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys),
),
),
],
);
}
+4 -7
View File
@@ -43,7 +43,7 @@ import '../utils/platform_detector.dart';
import '../utils/snackbar_helper.dart';
import '../utils/dialogs.dart';
import '../services/external_player_service.dart';
import '../focus/focusable_button.dart';
import 'dialog_action_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/key_event_utils.dart';
import '../screens/plex_match_screen.dart';
@@ -588,6 +588,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
selected = await OverlaySheetController.showAdaptive<String>(
this.context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => AppMenuSheet<String>(
title: _itemDisplayTitle(),
entries: _menuEntries(menuActions),
@@ -1320,6 +1321,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
await OverlaySheetController.showAdaptive(
this.context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => RatingBottomSheet(
item: item,
serverClient: client,
@@ -1959,12 +1961,7 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
],
),
),
actions: [
FocusableButton(
onPressed: () => Navigator.pop(context),
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
),
],
actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)],
),
);
}
+112 -94
View File
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../media/ids.dart';
import '../../media/media_item.dart';
@@ -16,7 +18,6 @@ import '../../utils/music_navigation.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/video_player_navigation.dart';
import '../app_icon.dart';
import '../media_context_menu.dart';
import '../optimized_media_image.dart';
import '../overlay_sheet.dart';
@@ -252,7 +253,14 @@ class _MiniPlayerCard extends StatefulWidget {
}
class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMixin<_MiniPlayerCard> {
bool _hovered = false;
final _detailsFocusNode = FocusNode(debugLabel: 'mini_player_details');
final _transportKey = GlobalKey<FocusableActionBarState>();
@override
void dispose() {
_detailsFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -266,96 +274,111 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
color: tk.surface,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(tk.radiusLg),
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: () => unawaited(openNowPlaying(context)),
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
child: SizedBox(
height: _MusicMiniPlayerOverlayState._cardHeight,
child: Stack(
children: [
// Played fraction tints the card background itself — the card
// fills up as the track progresses (clipped by the Material's
// rounded corners above).
const Positioned.fill(child: _MiniPlayerProgress()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tk.radiusSm),
child: OptimizedMediaImage(
client: client,
imagePath: widget.track.thumbPath,
imageType: ImageType.square,
width: 48,
height: 48,
fallbackIcon: Symbols.music_note_rounded,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .start,
children: [
Text(
widget.track.title ?? '',
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text),
),
if (artist != null && artist.isNotEmpty)
Text(
artist,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 12, color: tk.textMuted),
child: SizedBox(
height: _MusicMiniPlayerOverlayState._cardHeight,
child: Stack(
children: [
const Positioned.fill(child: _MiniPlayerProgress()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: FocusableWrapper(
focusNode: _detailsFocusNode,
onSelect: () => unawaited(openNowPlaying(context)),
enableLongPress: true,
onLongPress: showContextMenuFromTap,
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
semanticLabel: widget.track.title,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
borderRadius: tk.radiusLg,
child: InkWell(
canRequestFocus: false,
mouseCursor: SystemMouseCursors.click,
onTap: () => unawaited(openNowPlaying(context)),
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tk.radiusSm),
child: OptimizedMediaImage(
client: client,
imagePath: widget.track.thumbPath,
imageType: ImageType.square,
width: 48,
height: 48,
fallbackIcon: Symbols.music_note_rounded,
),
),
],
),
),
if (widget.desktop)
IconButton(
icon: AppIcon(Symbols.skip_previous_rounded, fill: 1, color: tk.text),
tooltip: t.music.previousTrack,
onPressed: () => unawaited(service.previous()),
),
IconButton(
icon: AppIcon(
isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
fill: 1,
color: tk.text,
),
tooltip: isPlaying ? t.common.pause : t.common.play,
onPressed: () => unawaited(service.togglePlayPause()),
),
IconButton(
icon: AppIcon(Symbols.skip_next_rounded, fill: 1, color: tk.text),
tooltip: t.music.nextTrack,
onPressed: () => unawaited(service.next()),
),
if (widget.desktop)
AnimatedOpacity(
opacity: _hovered ? 1 : 0,
duration: tk.fast,
child: IgnorePointer(
ignoring: !_hovered,
child: IconButton(
icon: AppIcon(Symbols.close_rounded, fill: 1, size: 20, color: tk.textMuted),
tooltip: t.music.stopPlayback,
onPressed: widget.onDismissed,
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .start,
children: [
Text(
widget.track.title ?? '',
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text),
),
if (artist != null && artist.isNotEmpty)
Text(
artist,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 12, color: tk.textMuted),
),
],
),
),
],
),
),
],
),
),
),
FocusableActionBar(
key: _transportKey,
onNavigateLeft: _detailsFocusNode.requestFocus,
actions: [
if (widget.desktop)
FocusableAction(
icon: Symbols.skip_previous_rounded,
iconColor: tk.text,
tooltip: t.music.previousTrack,
onPressed: () => unawaited(service.previous()),
),
FocusableAction(
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
iconColor: tk.text,
tooltip: isPlaying ? t.common.pause : t.common.play,
onPressed: () => unawaited(service.togglePlayPause()),
),
FocusableAction(
icon: Symbols.skip_next_rounded,
iconColor: tk.text,
tooltip: t.music.nextTrack,
onPressed: () => unawaited(service.next()),
),
if (widget.desktop)
FocusableAction(
icon: Symbols.close_rounded,
iconColor: tk.textMuted,
tooltip: t.music.stopPlayback,
onPressed: widget.onDismissed,
),
],
),
],
),
],
),
),
],
),
),
);
@@ -377,12 +400,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
child: card,
);
if (!widget.desktop) return card;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: card,
);
return card;
}
}
+21 -11
View File
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../i18n/strings.g.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../services/trackers/oauth_proxy_client.dart';
import '../utils/snackbar_helper.dart';
import 'dialog_action_button.dart';
@@ -55,18 +56,26 @@ class OAuthProxyDialog extends StatelessWidget {
),
),
const SizedBox(height: 16),
InkWell(
onTap: () => _copyUrl(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
start.url,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
FocusableWrapper(
onSelect: () => _copyUrl(context),
semanticLabel: t.services.oauthProxy.copyUrl,
descendantsAreFocusable: false,
borderRadius: 8,
useBackgroundFocus: true,
child: InkWell(
canRequestFocus: false,
onTap: () => _copyUrl(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
start.url,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
),
),
@@ -75,6 +84,7 @@ class OAuthProxyDialog extends StatelessWidget {
width: double.infinity,
child: FocusableButton(
onPressed: _open,
useBackgroundFocus: true,
child: FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)),
+22
View File
@@ -156,20 +156,41 @@ class OverlaySheetController {
/// Push a sub-page using the overlay system if available, otherwise fall
/// back to [showModalBottomSheet]. Returns the result from the page.
///
/// Presentation options apply only to the modal fallback. A hosted push
/// retains the root sheet's presentation and changes only its page content.
static Future<T?> pushAdaptive<T>(
BuildContext context, {
required WidgetBuilder builder,
FocusNode? initialFocusNode,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
bool isScrollControlled = false,
bool showDragHandle = false,
}) async {
final controller = maybeOf(context);
if (controller != null) {
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
}
final effectiveConstraints =
constraints ??
() {
final size = MediaQuery.sizeOf(context);
final isDesktop = size.width > 600;
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
}();
BackKeyCoordinator.clear();
openSheetCount.value++;
try {
return await showModalBottomSheet<T>(
context: context,
builder: (context) => SafeArea(top: false, child: builder(context)),
constraints: effectiveConstraints,
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
isDismissible: barrierDismissible,
isScrollControlled: isScrollControlled,
showDragHandle: showDragHandle,
);
} finally {
openSheetCount.value--;
@@ -310,6 +331,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
Alignment alignment = Alignment.bottomCenter,
bool showDragHandle = false,
}) {
BackKeyCoordinator.clear();
// If already open, close first (instant)
final wasOpen = _isOpen;
if (_isOpen) {
+9 -4
View File
@@ -21,6 +21,7 @@ import '../utils/snackbar_helper.dart';
import 'app_icon.dart';
import 'app_menu.dart';
import 'loading_indicator_box.dart';
import 'focusable_list_tile.dart';
import 'overlay_sheet.dart';
import 'stat_chip.dart';
@@ -396,7 +397,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
else ...[
if (!_isMovie && _partialSeasons) ..._buildSeasonSection(theme),
if (_can4k)
SwitchListTile(
FocusableSwitchListTile(
value: _is4k,
onChanged: _submitting ? null : _toggle4k,
title: Text(t.seerr.request4k),
@@ -432,7 +433,11 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
children: [
Text(t.seerr.requestsLoadFailed, style: theme.textTheme.bodyMedium),
const SizedBox(height: 12),
OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)),
FocusableButton(
autofocus: true,
onPressed: () => unawaited(_load()),
child: OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)),
),
],
),
);
@@ -480,7 +485,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
final number = season.seasonNumber;
final blockedLabel = _seasonBlockedLabel(number);
final episodeCount = season.episodeCount;
return CheckboxListTile(
return FocusableCheckboxListTile(
focusNode: _seasonFocusNodes[index],
value: blockedLabel != null || _selectedSeasons.contains(number),
onChanged: blockedLabel != null || _submitting
@@ -604,7 +609,7 @@ class _PickerTile<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
return FocusableListTile(
leading: AppIcon(icon, fill: 1),
title: Text(label),
subtitle: value.isEmpty ? null : Text(value, maxLines: 1, overflow: TextOverflow.ellipsis),
+104 -105
View File
@@ -6,7 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../screens/settings/settings_utils.dart';
import '../services/settings_service.dart';
import 'app_icon.dart';
import 'clickable_cursor.dart';
import 'focusable_list_tile.dart';
import 'settings_section.dart';
/// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable].
@@ -43,22 +43,21 @@ class SettingSwitchTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<bool>(
valueListenable: svc.listenable(pref),
builder: (_, value, _) => ClickableCursor(
enabled: enabled,
child: SwitchListTile(
focusNode: focusNode,
secondary: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
value: value,
onChanged: enabled
? (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
}
: null,
),
builder: (_, value, _) => FocusableSwitchListTile(
focusNode: focusNode,
secondary: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
value: value,
dense: false,
visualDensity: VisualDensity.standard,
onChanged: enabled
? (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
}
: null,
),
);
}
@@ -87,15 +86,15 @@ class SettingNavigationTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClickableCursor(
child: ListTile(
focusNode: focusNode,
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: AppIcon(trailingIcon, fill: 1),
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)),
),
return FocusableListTile(
focusNode: focusNode,
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: AppIcon(trailingIcon, fill: 1),
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)),
dense: false,
visualDensity: VisualDensity.standard,
);
}
}
@@ -130,26 +129,26 @@ class SettingNumberTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<int>(
valueListenable: svc.listenable(pref),
builder: (_, value, _) => ClickableCursor(
child: ListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: Text(subtitleBuilder(value)),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => showNumericInputDialog(
context: context,
title: title,
labelText: labelText,
suffixText: suffixText,
min: min,
max: max,
currentValue: value,
onSave: (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
},
),
builder: (_, value, _) => FocusableListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: Text(subtitleBuilder(value)),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
dense: false,
visualDensity: VisualDensity.standard,
onTap: () => showNumericInputDialog(
context: context,
title: title,
labelText: labelText,
suffixText: suffixText,
min: min,
max: max,
currentValue: value,
onSave: (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
},
),
),
);
@@ -188,25 +187,25 @@ class SettingSelectionTile<T, S> extends StatelessWidget {
valueListenable: svc.listenable(pref),
builder: (_, raw, _) {
final value = decode(raw);
return ClickableCursor(
child: ListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: Text(subtitleBuilder(value)),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () async {
final picked = await showSelectionDialog<T>(
context: context,
title: title,
options: options,
currentValue: value,
);
if (picked == null) return;
await svc.write(pref, encode(picked));
final callback = onAfterWrite;
if (callback != null) await callback(picked);
},
),
return FocusableListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: Text(subtitleBuilder(value)),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
dense: false,
visualDensity: VisualDensity.standard,
onTap: () async {
final picked = await showSelectionDialog<T>(
context: context,
title: title,
options: options,
currentValue: value,
);
if (picked == null) return;
await svc.write(pref, encode(picked));
final callback = onAfterWrite;
if (callback != null) await callback(picked);
},
);
},
);
@@ -237,23 +236,23 @@ class SettingRegexTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<String>(
valueListenable: svc.listenable(pref),
builder: (_, value, _) => ClickableCursor(
child: ListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: Text(subtitle),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => showRegexInputDialog(
context: context,
title: title,
currentValue: value,
defaultValue: defaultValue,
onSave: (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
},
),
builder: (_, value, _) => FocusableListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: Text(subtitle),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
dense: false,
visualDensity: VisualDensity.standard,
onTap: () => showRegexInputDialog(
context: context,
title: title,
currentValue: value,
defaultValue: defaultValue,
onSave: (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
},
),
),
);
@@ -329,31 +328,31 @@ class SettingColorTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<String>(
valueListenable: svc.listenable(pref),
builder: (_, hex, _) => ClickableCursor(
child: ListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: hexToColor(hex),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
),
onTap: () => showColorInputDialog(
context: context,
title: title,
currentHex: hex,
onSave: (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
},
builder: (_, hex, _) => FocusableListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: hexToColor(hex),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
),
dense: false,
visualDensity: VisualDensity.standard,
onTap: () => showColorInputDialog(
context: context,
title: title,
currentHex: hex,
onSave: (v) async {
await svc.write(pref, v);
final callback = onAfterWrite;
if (callback != null) await callback(v);
},
),
),
);
}
+8 -4
View File
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_memory_tracker.dart';
import '../focus/input_mode_tracker.dart';
import '../media/media_item.dart';
import '../media/media_library.dart';
import '../mixins/mounted_set_state_mixin.dart';
@@ -103,7 +104,7 @@ class NavigationRailItem extends StatelessWidget {
return ListenableBuilder(
listenable: focusNode,
builder: (context, _) {
final focused = focusNode.hasFocus;
final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
return Focus(
focusNode: focusNode,
autofocus: autofocus,
@@ -1014,9 +1015,10 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
child: Container(
decoration: BoxDecoration(
color: () {
if (isCollapsed) return librariesFocusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null;
final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null;
if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1);
if (librariesFocusNode.hasFocus) return t.text.withValues(alpha: 0.08);
if (showFocus) return t.text.withValues(alpha: 0.08);
return null;
}(),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
@@ -1240,7 +1242,9 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
borderRadius: radius,
child: Container(
decoration: BoxDecoration(
color: focusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null,
color: focusNode.hasFocus && InputModeTracker.isKeyboardMode(context)
? t.text.withValues(alpha: 0.08)
: null,
borderRadius: radius,
),
clipBehavior: Clip.hardEdge,
+1
View File
@@ -300,6 +300,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper
return Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
descendantsAreFocusable: false,
onFocusChange: (hasFocus) {
setState(() => _isFocused = hasFocus);
if (!hasFocus) stopRepeat();
+1
View File
@@ -130,6 +130,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
return Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
descendantsAreFocusable: false,
onFocusChange: (hasFocus) {
setState(() => _isFocused = hasFocus);
if (!hasFocus) stopRepeat();
@@ -693,6 +693,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
metadata: widget.metadata,
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
onBack: widget.onBack,
onCancelAutoHide: widget.onCancelAutoHide,
onStartAutoHide: widget.onStartAutoHide,
),
),
if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[
@@ -344,6 +344,8 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
child: VideoControlsHeader(
metadata: widget.metadata,
style: VideoHeaderStyle.multiLine,
onCancelAutoHide: widget.onCancelAutoHide,
onStartAutoHide: widget.onStartAutoHide,
trailing: widget.trackChapterControls,
onBack: widget.onBack,
),
@@ -139,6 +139,13 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
OverlaySheetController.of(context).refocus();
}
void _hideLanguagePickerView() {
setState(() => _showLanguagePicker = false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _languageFocusNode.requestFocus();
});
}
void _focusFirstResult() {
if (_results != null && _results!.isNotEmpty && !_isSearching && _error == null) {
_firstResultFocusNode.requestFocus();
@@ -219,7 +226,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
return _LanguagePickerView(
currentCode: _languageCode,
onSelected: _onLanguageSelected,
onBack: () => setState(() => _showLanguagePicker = false),
onBack: _hideLanguagePickerView,
);
}
@@ -268,8 +268,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay';
final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset;
// Created here so we can pass it as initialFocusNode to the overlay sheet,
// ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar.
// Created here so it can be passed as the overlay's initial focus target.
// The creator disposes it after the overlay's lifecycle completes.
final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider');
// show() with new alignment replaces the current sheet (completing the
@@ -299,6 +299,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
),
)
.whenComplete(() {
sliderFocusNode.dispose();
widget.onStartAutoHide?.call();
});
@@ -1073,7 +1074,6 @@ class _CompactSyncBarState extends State<_CompactSyncBar> {
@override
void dispose() {
widget.sliderFocusNode.dispose();
_resetFocusNode.dispose();
_closeFocusNode.dispose();
super.dispose();
@@ -70,6 +70,7 @@ class SleepTimerActiveStatus extends StatelessWidget {
sleepTimer.cancelTimer();
onCancel?.call();
},
useBackgroundFocus: true,
child: FilledButton.icon(
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
label: Text(t.common.cancel),
@@ -1,11 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/focusable_slider.dart';
import '../../../focus/focusable_button.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../mpv/mpv.dart';
@@ -233,35 +232,19 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
onLongPressStart: _startLongPressDecrement,
),
Expanded(
child: Focus(
onKeyEvent: (node, event) {
// Select/enter on the slider jumps focus to the close button
if (event.logicalKey.isSelectKey && event is KeyDownEvent) {
widget.closeFocusNode?.requestFocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
canRequestFocus: false,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark),
child: Slider(
focusNode: widget.sliderFocusNode,
value: sliderValue,
min: _sliderMin,
max: _sliderMax,
divisions: _sliderDivisions,
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
onChanged: (value) {
setState(() {
_currentOffset = value;
});
},
onChangeEnd: (value) {
_applyOffset(value);
},
),
child: SliderTheme(
data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark),
child: FocusableSlider(
focusNode: widget.sliderFocusNode,
value: sliderValue,
min: _sliderMin,
max: _sliderMax,
divisions: _sliderDivisions,
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
onSelect: widget.closeFocusNode?.requestFocus,
onChanged: (value) => setState(() => _currentOffset = value),
onChangeEnd: _applyOffset,
),
),
),
@@ -373,6 +356,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
// Reset button
FocusableButton(
onPressed: _currentOffset != 0 ? _resetOffset : null,
useBackgroundFocus: true,
child: ElevatedButton.icon(
onPressed: _currentOffset != 0 ? _resetOffset : null,
icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1),
@@ -30,6 +30,8 @@ class VideoControlsHeader extends StatelessWidget {
/// Optional callback for back button. If null, defaults to Navigator.pop(true).
final VoidCallback? onBack;
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
const VideoControlsHeader({
super.key,
@@ -37,6 +39,8 @@ class VideoControlsHeader extends StatelessWidget {
this.style = VideoHeaderStyle.multiLine,
this.trailing,
this.onBack,
this.onCancelAutoHide,
this.onStartAutoHide,
});
@override
@@ -54,7 +58,13 @@ class VideoControlsHeader extends StatelessWidget {
selector: (_, p) => p.isInSession,
builder: (context, inSession, child) {
if (!inSession) return const SizedBox.shrink();
return const Padding(padding: .only(right: 8), child: WatchTogetherSessionIndicator());
return Padding(
padding: const EdgeInsets.only(right: 8),
child: WatchTogetherSessionIndicator(
onCancelAutoHide: onCancelAutoHide,
onStartAutoHide: onStartAutoHide,
),
);
},
),
?trailing,