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
@@ -8,9 +8,7 @@ import 'dpad_navigator.dart';
/// Tracks the timer and physical key state for a D-pad SELECT long press.
///
/// Focus loss, context-menu dispatch, and transferred/touch gesture suppression
/// stay with the caller because their behavior differs between widgets. The TV
/// guide program selector also stays local: it captures a program at key-down
/// and resets its state before opening program details.
/// stay with the caller because their behavior differs between widgets.
class DpadSelectLongPressController {
static const defaultDuration = Duration(milliseconds: 500);
+21
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dpad_navigator.dart';
import 'focusable_wrapper.dart';
@@ -10,9 +11,14 @@ class FocusableSlider extends StatefulWidget {
final double max;
final int? divisions;
final ValueChanged<double>? onChanged;
final ValueChanged<double>? onChangeStart;
final ValueChanged<double>? onChangeEnd;
final VoidCallback? onSelect;
final FocusNode? focusNode;
final bool autofocus;
final Color? activeColor;
final Color? inactiveColor;
const FocusableSlider({
super.key,
required this.value,
@@ -20,8 +26,13 @@ class FocusableSlider extends StatefulWidget {
this.max = 1.0,
this.divisions,
this.onChanged,
this.onChangeStart,
this.onChangeEnd,
this.onSelect,
this.focusNode,
this.autofocus = false,
this.activeColor,
this.inactiveColor,
});
@override
@@ -44,10 +55,16 @@ class _FocusableSliderState extends State<FocusableSlider> {
if (event.isActionable && widget.onChanged != null) {
final delta = key.isRightKey ? _step : -_step;
final newValue = (widget.value + delta).clamp(widget.min, widget.max);
widget.onChangeStart?.call(widget.value);
widget.onChanged!(newValue);
widget.onChangeEnd?.call(newValue);
}
return KeyEventResult.handled;
}
if (key.isSelectKey && event is KeyDownEvent && widget.onSelect != null) {
widget.onSelect!();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@@ -74,6 +91,10 @@ class _FocusableSliderState extends State<FocusableSlider> {
max: widget.max,
divisions: widget.divisions,
onChanged: widget.onChanged,
onChangeStart: widget.onChangeStart,
onChangeEnd: widget.onChangeEnd,
activeColor: widget.activeColor,
inactiveColor: widget.inactiveColor,
),
),
);
+16 -5
View File
@@ -32,25 +32,36 @@ import 'dpad_navigator.dart';
class BackKeyCoordinator {
static bool _handledThisFrame = false;
static bool _clearScheduled = false;
static int _clearGeneration = 0;
static void markHandled() {
_handledThisFrame = true;
if (_clearScheduled) return;
_clearScheduled = true;
// Clear on next frame to avoid blocking unrelated future back presses.
final clearGeneration = _clearGeneration;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (clearGeneration != _clearGeneration) return;
_handledThisFrame = false;
_clearScheduled = false;
});
// addPostFrameCallback does not request a frame. Ensure the one-shot
// marker cannot leak when handling Back does not otherwise schedule one.
WidgetsBinding.instance.scheduleFrame();
}
static void clear() {
_handledThisFrame = false;
_clearScheduled = false;
// Invalidate a pending callback so it cannot clear a newer marker.
_clearGeneration++;
}
static bool consumeIfHandled() {
if (_handledThisFrame) {
_handledThisFrame = false;
if (!_handledThisFrame) return false;
clear();
return true;
}
return false;
}
}
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
+11 -1
View File
@@ -336,6 +336,8 @@
"clearShortcut": "Clear shortcut",
"noShortcutSet": "No shortcut set",
"currentShortcut": "Current shortcut:",
"pressToRecord": "Select to record a shortcut",
"recordingShortcut": "Press the shortcut now",
"actions": {
"playPause": "Play/Pause",
"volumeUp": "Volume Up",
@@ -434,7 +436,9 @@
"hue": "Hue",
"saturation": "Saturation",
"brightness": "Brightness",
"hexColor": "Hex color"
"hexColor": "Hex color",
"expandText": "Expand text",
"collapseText": "Collapse text"
},
"tooltips": {
"shufflePlay": "Shuffle play",
@@ -1048,6 +1052,8 @@
"hostingSession": "Hosting Session",
"inSession": "In Session",
"sessionCode": "Session Code",
"openSessionControls": "Open Watch Together session controls",
"copySessionCode": "Copy session code",
"hostControlsPlayback": "Host controls playback",
"anyoneCanControl": "Anyone can control playback",
"hostControls": "Host controls",
@@ -1358,6 +1364,8 @@
"artworkUpdated": "Artwork updated",
"artworkUpdateFailed": "Failed to update artwork",
"noArtworkAvailable": "No artwork available",
"artworkOption": "Artwork option ${index}",
"selectedArtworkOption": "Artwork option ${index}, selected",
"notSet": "Not set",
"libraryDefault": "Library default",
"accountDefault": "Account default",
@@ -1485,6 +1493,7 @@
"title": "Activate Plezy on ${service}",
"body": "Visit ${url} and enter this code:",
"openToActivate": "Open ${service} to activate",
"copyCode": "Copy activation code",
"waitingForAuthorization": "Waiting for authorization…",
"codeCopied": "Code copied"
},
@@ -1492,6 +1501,7 @@
"title": "Sign in to ${service}",
"body": "Scan this QR code or open the URL on any device.",
"openToSignIn": "Open ${service} to sign in",
"copyUrl": "Copy sign-in URL",
"urlCopied": "URL copied"
},
"libraryFilter": {
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 22514 (1407 per locale)
/// Strings: 22524 (1407 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+44 -4
View File
@@ -1127,6 +1127,12 @@ class TranslationsHotkeysEn {
/// en: 'Current shortcut:'
String get currentShortcut => 'Current shortcut:';
/// en: 'Select to record a shortcut'
String get pressToRecord => 'Select to record a shortcut';
/// en: 'Press the shortcut now'
String get recordingShortcut => 'Press the shortcut now';
late final TranslationsHotkeysActionsEn actions = TranslationsHotkeysActionsEn.internal(_root);
}
@@ -1356,6 +1362,12 @@ class TranslationsAccessibilityEn {
/// en: 'Hex color'
String get hexColor => 'Hex color';
/// en: 'Expand text'
String get expandText => 'Expand text';
/// en: 'Collapse text'
String get collapseText => 'Collapse text';
}
// Path: tooltips
@@ -3026,6 +3038,12 @@ class TranslationsWatchTogetherEn {
/// en: 'Session Code'
String get sessionCode => 'Session Code';
/// en: 'Open Watch Together session controls'
String get openSessionControls => 'Open Watch Together session controls';
/// en: 'Copy session code'
String get copySessionCode => 'Copy session code';
/// en: 'Host controls playback'
String get hostControlsPlayback => 'Host controls playback';
@@ -3775,6 +3793,12 @@ class TranslationsMetadataEditEn {
/// en: 'No artwork available'
String get noArtworkAvailable => 'No artwork available';
/// en: 'Artwork option ${index}'
String artworkOption({required Object index}) => 'Artwork option ${index}';
/// en: 'Artwork option ${index}, selected'
String selectedArtworkOption({required Object index}) => 'Artwork option ${index}, selected';
/// en: 'Not set'
String get notSet => 'Not set';
@@ -4836,6 +4860,9 @@ class TranslationsServicesDeviceCodeEn {
/// en: 'Open ${service} to activate'
String openToActivate({required Object service}) => 'Open ${service} to activate';
/// en: 'Copy activation code'
String get copyCode => 'Copy activation code';
/// en: 'Waiting for authorization…'
String get waitingForAuthorization => 'Waiting for authorization…';
@@ -4860,6 +4887,9 @@ class TranslationsServicesOauthProxyEn {
/// en: 'Open ${service} to sign in'
String openToSignIn({required Object service}) => 'Open ${service} to sign in';
/// en: 'Copy sign-in URL'
String get copyUrl => 'Copy sign-in URL';
/// en: 'URL copied'
String get urlCopied => 'URL copied';
}
@@ -5239,6 +5269,8 @@ extension on Translations {
'hotkeys.clearShortcut' => 'Clear shortcut',
'hotkeys.noShortcutSet' => 'No shortcut set',
'hotkeys.currentShortcut' => 'Current shortcut:',
'hotkeys.pressToRecord' => 'Select to record a shortcut',
'hotkeys.recordingShortcut' => 'Press the shortcut now',
'hotkeys.actions.playPause' => 'Play/Pause',
'hotkeys.actions.volumeUp' => 'Volume Up',
'hotkeys.actions.volumeDown' => 'Volume Down',
@@ -5328,6 +5360,8 @@ extension on Translations {
'accessibility.saturation' => 'Saturation',
'accessibility.brightness' => 'Brightness',
'accessibility.hexColor' => 'Hex color',
'accessibility.expandText' => 'Expand text',
'accessibility.collapseText' => 'Collapse text',
'tooltips.shufflePlay' => 'Shuffle play',
'tooltips.playTrailer' => 'Play trailer',
'tooltips.markAsWatched' => 'Mark as watched',
@@ -5425,12 +5459,12 @@ extension on Translations {
'messages.fileInfoNotAvailable' => 'File information not available',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
'messages.errorLoadingSeries' => 'Error loading series',
_ => null,
} ?? switch (path) {
'messages.musicNotSupported' => 'Music playback is not yet supported',
'messages.noDescriptionAvailable' => 'No description available',
'messages.noProfilesAvailable' => 'No profiles available',
'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item',
'messages.logsCleared' => 'Logs cleared',
'messages.logsCopied' => 'Logs copied to clipboard',
@@ -5876,6 +5910,8 @@ extension on Translations {
'watchTogether.hostingSession' => 'Hosting Session',
'watchTogether.inSession' => 'In Session',
'watchTogether.sessionCode' => 'Session Code',
'watchTogether.openSessionControls' => 'Open Watch Together session controls',
'watchTogether.copySessionCode' => 'Copy session code',
'watchTogether.hostControlsPlayback' => 'Host controls playback',
'watchTogether.anyoneCanControl' => 'Anyone can control playback',
'watchTogether.hostControls' => 'Host controls',
@@ -5937,14 +5973,14 @@ extension on Translations {
'downloads.downloadNow' => 'Download',
'downloads.deleteDownload' => 'Delete download',
'downloads.retryDownload' => 'Retry download',
_ => null,
} ?? switch (path) {
'downloads.downloadQueued' => 'Download queued',
'downloads.downloadResumed' => 'Download resumed',
'downloads.serverErrorBitrate' => 'Server error: file may exceed the remote bitrate limit',
'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download',
'downloads.downloadDeleted' => 'Download deleted',
'downloads.deleteConfirm' => ({required Object title}) => 'Delete "${title}" from this device?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Cancelled Download',
'downloads.cancelledDownloadMessage' => 'This download was cancelled. What would you like to do?',
'downloads.allEpisodesAlreadyDownloaded' => 'All episodes already downloaded',
@@ -6166,6 +6202,8 @@ extension on Translations {
'metadataEdit.artworkUpdated' => 'Artwork updated',
'metadataEdit.artworkUpdateFailed' => 'Failed to update artwork',
'metadataEdit.noArtworkAvailable' => 'No artwork available',
'metadataEdit.artworkOption' => ({required Object index}) => 'Artwork option ${index}',
'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Artwork option ${index}, selected',
'metadataEdit.notSet' => 'Not set',
'metadataEdit.libraryDefault' => 'Library default',
'metadataEdit.accountDefault' => 'Account default',
@@ -6280,11 +6318,13 @@ extension on Translations {
'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}',
'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:',
'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate',
'services.deviceCode.copyCode' => 'Copy activation code',
'services.deviceCode.waitingForAuthorization' => 'Waiting for authorization…',
'services.deviceCode.codeCopied' => 'Code copied',
'services.oauthProxy.title' => ({required Object service}) => 'Sign in to ${service}',
'services.oauthProxy.body' => 'Scan this QR code or open the URL on any device.',
'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Open ${service} to sign in',
'services.oauthProxy.copyUrl' => 'Copy sign-in URL',
'services.oauthProxy.urlCopied' => 'URL copied',
'services.libraryFilter.title' => 'Library filter',
'services.libraryFilter.subtitleAllSyncing' => 'Syncing all libraries',
+4 -2
View File
@@ -319,6 +319,7 @@ class _AuthScreenState extends State<AuthScreen> {
FocusableButton(
autofocus: true,
onPressed: busy ? null : startQr,
useBackgroundFocus: true,
child: ElevatedButton(
onPressed: busy ? null : startQr,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
@@ -347,6 +348,7 @@ class _AuthScreenState extends State<AuthScreen> {
] else ...[
FocusableButton(
onPressed: busy ? null : startBrowser,
useBackgroundFocus: true,
child: ElevatedButton.icon(
onPressed: busy ? null : startBrowser,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
@@ -512,8 +514,8 @@ class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDis
],
),
actions: [
DialogActionButton(onPressed: _busy ? () {} : () => Navigator.of(context).pop(), label: t.common.cancel),
DialogActionButton(onPressed: _busy ? () {} : _submit, label: t.auth.authenticate, isPrimary: true),
DialogActionButton(onPressed: _busy ? null : () => Navigator.of(context).pop(), label: t.common.cancel),
DialogActionButton(onPressed: _busy ? null : _submit, label: t.auth.authenticate, isPrimary: true),
],
);
}
+12 -4
View File
@@ -446,6 +446,11 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
);
}
void _handleSystemBack() {
if (BackKeyCoordinator.consumeIfHandled()) return;
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final item = widget.item;
@@ -459,8 +464,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
// remain route-driven. The overlay host always gets first refusal.
return OverlaySheetHost(
canPop: !blockSystemBack,
child: Focus(
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
onSystemBack: _handleSystemBack,
child: Builder(
builder: (hostContext) => Focus(
onKeyEvent: (_, event) => handleBackKeyNavigation(hostContext, event),
child: Scaffold(
body: Stack(
children: [
@@ -561,7 +568,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
tooltip: t.seerr.request,
onPressed: () => unawaited(
showSeerrRequestSheet(
context,
hostContext,
source: seerr,
kind: item.kind,
tmdbId: item.ids.tmdb!,
@@ -600,13 +607,14 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
const AppBarBackButton(style: BackButtonStyle.circular),
context: context,
context: hostContext,
)!,
),
],
),
),
),
),
);
}
}
+30 -4
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_text_field.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/debounced_media_search.dart';
@@ -29,6 +30,7 @@ class CatalogSearchScreen extends StatefulWidget {
}
class _CatalogSearchScreenState extends State<CatalogSearchScreen> with DebouncedMediaSearch {
final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear');
@override
String get searchDebugLabel => 'CatalogSearch';
@@ -44,6 +46,17 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
}
@override
void dispose() {
_clearFocusNode.dispose();
super.dispose();
}
void _clearSearch() {
searchController.clear();
searchFocusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
final sourceName = widget.source.displayName;
@@ -53,21 +66,34 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
child: FocusableTextField(
child: Stack(
alignment: Alignment.centerRight,
children: [
FocusableTextField(
controller: searchController,
focusNode: searchFocusNode,
textInputAction: TextInputAction.search,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
decoration: pillInputDecoration(
context,
hintText: t.explore.searchHint(source: sourceName),
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
suffixIcon: searchController.text.isNotEmpty
? IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: searchController.clear)
: null,
suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null,
),
),
if (searchController.text.isNotEmpty)
FocusableButton(
focusNode: _clearFocusNode,
onPressed: _clearSearch,
onNavigateLeft: searchFocusNode.requestFocus,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
autoScroll: false,
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
),
],
),
),
),
if (isSearching)
@@ -135,7 +135,9 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
_sendCommand(RemoteCommandType.tabSearch);
}
final provider = context.read<CompanionRemoteProvider>();
OverlaySheetController.of(context).show(builder: (_) => _SearchBottomSheet(provider: provider));
OverlaySheetController.of(
context,
).show(showDragHandle: true, builder: (_) => _SearchBottomSheet(provider: provider));
}
void _sendCommand(RemoteCommandType type) {
+7 -1
View File
@@ -9,6 +9,7 @@ import '../widgets/server_activities_button.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/focusable_button.dart';
import '../focus/hub_vertical_navigation.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
@@ -1202,7 +1203,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
FilledButton(onPressed: _discover.load, child: Text(t.common.retry)),
FocusableButton(
autofocus: true,
onPressed: _discover.load,
useBackgroundFocus: true,
child: FilledButton(onPressed: _discover.load, child: Text(t.common.retry)),
),
],
),
),
+23 -8
View File
@@ -25,6 +25,7 @@ import '../widgets/app_menu.dart';
import '../widgets/catalog_source_logo.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/hub_section.dart';
import '../widgets/focusable_popup_menu_button.dart';
import '../widgets/settings_builder.dart';
import '../widgets/rasterized_gradient.dart';
import '../widgets/tv_browse_rail.dart';
@@ -154,14 +155,9 @@ class ExploreScreenState extends State<ExploreScreen>
CatalogSource active, {
TextStyle? textStyle,
AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start,
bool parentOwnsFocus = false,
}) {
return AppMenuButton<CatalogSourceId>(
key: _sourceMenuKey,
tooltip: t.explore.selectSource,
anchorAlignment: anchorAlignment,
onSelected: (id) => unawaited(sources.setActiveSource(id)),
entriesBuilder: (context) => _sourceMenuEntries(sources, active),
child: Container(
final trigger = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: .min,
@@ -173,7 +169,25 @@ class ExploreScreenState extends State<ExploreScreen>
const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24),
],
),
),
);
if (parentOwnsFocus) {
return AppMenuButton<CatalogSourceId>(
key: _sourceMenuKey,
tooltip: t.explore.selectSource,
anchorAlignment: anchorAlignment,
onSelected: (id) => unawaited(sources.setActiveSource(id)),
entriesBuilder: (context) => _sourceMenuEntries(sources, active),
child: trigger,
);
}
return FocusablePopupMenuButton<CatalogSourceId>(
menuKey: _sourceMenuKey,
tooltip: t.explore.selectSource,
semanticLabel: t.explore.selectSource,
anchorAlignment: anchorAlignment,
onSelected: (id) => unawaited(sources.setActiveSource(id)),
itemBuilder: (context) => _sourceMenuEntries(sources, active),
child: trigger,
);
}
@@ -352,6 +366,7 @@ class ExploreScreenState extends State<ExploreScreen>
context,
).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600),
anchorAlignment: AppMenuAnchorAlignment.end,
parentOwnsFocus: true,
),
),
if (active != null)
+15 -1
View File
@@ -24,6 +24,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/overlay_sheet.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/focusable_button.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import 'libraries/sort_bottom_sheet.dart';
@@ -76,6 +77,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
/// Key for getting a context below OverlaySheetHost
final GlobalKey _overlayChildKey = GlobalKey();
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'hub_continuation_retry');
@override
bool get hasItems => _filteredItems.isNotEmpty;
@@ -120,6 +122,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
@override
void dispose() {
_continuation.dispose();
_continuationRetryFocusNode.dispose();
disposeFocusResources();
super.dispose();
}
@@ -426,7 +429,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
children: [
Text(error, textAlign: TextAlign.center),
const SizedBox(height: 8),
TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)),
FocusableButton(
focusNode: _continuationRetryFocusNode,
onPressed: _retryHubContinuation,
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent,
child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)),
),
],
),
),
@@ -527,6 +536,11 @@ class _HubDetailScreenState extends State<HubDetailScreen>
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
onNavigateDown:
_continuation.error != null &&
position.index >= position.itemCount - position.columnCount
? _continuationRetryFocusNode.requestFocus
: null,
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
@@ -487,7 +487,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
if (!mounted) return;
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
final controller = OverlaySheetController.of(context);
controller.show(builder: (sheetContext) => _buildBrowseOptionsSheet(sheetContext));
controller.show(showDragHandle: true, builder: (sheetContext) => _buildBrowseOptionsSheet(sheetContext));
}
/// Reset transient browse state before loading a different library.
@@ -45,12 +45,14 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
/// Open the program-details bottom sheet. The poster is resolved from
/// [posterThumb] on the server identified by [posterServerId].
void showProgramDetails({
BuildContext? sheetContext,
required LiveTvProgram program,
required LiveTvChannel? channel,
required String? posterThumb,
required String? posterServerId,
}) {
final multiServer = context.read<MultiServerProvider>();
final effectiveContext = sheetContext ?? context;
final multiServer = effectiveContext.read<MultiServerProvider>();
final serverId = serverIdOrNull(posterServerId);
final client = serverId == null ? null : multiServer.getClientForServer(serverId);
String? posterUrl;
@@ -60,13 +62,13 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
thumbPath: posterThumb,
maxWidth: 80,
maxHeight: 120,
devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context),
devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(effectiveContext),
imageType: ImageType.poster,
);
}
showProgramDetailsSheet(
context,
effectiveContext,
program: program,
channel: channel,
posterUrl: posterUrl,
+3
View File
@@ -535,6 +535,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
OverlaySheetController.showAdaptive(
context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => ReorderFavoritesSheet(
favorites: List.from(_favoriteChannels),
channelMap: channelMap,
@@ -712,6 +714,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
FocusableButton(
autofocus: true,
onPressed: _loadChannels,
useBackgroundFocus: true,
child: FilledButton.icon(
onPressed: _loadChannels,
icon: const AppIcon(Symbols.refresh_rounded),
@@ -96,7 +96,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
return client?.liveTvDvr != null;
}
Future<void> _onRecordShow() async {
Future<void> _onRecordShow(BuildContext hostContext) async {
final client = context.read<MultiServerProvider>().getClientForServer(ServerId(widget.serverId));
if (client == null) return;
// Use the first program with a guid as the seed for `getSubscriptionTemplate`.
@@ -110,7 +110,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
}
}
if (seed == null) return;
await recordProgram(context, client, seed);
await recordProgram(hostContext, client, seed);
}
@override
@@ -119,7 +119,8 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
return OverlaySheetHost(
// Close an open sheet on system back instead of popping the screen.
canPop: true,
child: FocusedScrollScaffold(
child: Builder(
builder: (hostContext) => FocusedScrollScaffold(
title: Text(widget.showTitle),
actions: showRecord
? [
@@ -128,7 +129,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
FocusableAction(
icon: Symbols.fiber_manual_record_rounded,
tooltip: t.liveTv.recordShow,
onPressed: _onRecordShow,
onPressed: () => _onRecordShow(hostContext),
),
],
),
@@ -142,15 +143,18 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
else
SliverToBoxAdapter(
child: SettingsGroup(
children: [for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index)],
children: [
for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index, hostContext),
],
),
),
],
),
),
);
}
Widget _buildScheduleItem(int index) {
Widget _buildScheduleItem(int index, BuildContext hostContext) {
final program = _programs[index];
final channel = findChannelForProgram(program);
void onTap() {
@@ -158,6 +162,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
tuneChannel(channel);
} else {
showProgramDetails(
sheetContext: hostContext,
program: program,
channel: channel,
posterThumb: program.thumb,
@@ -173,7 +178,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
useBackgroundFocus: true,
disableScale: true,
onSelect: onTap,
onBack: () => Navigator.pop(context),
onBack: () => Navigator.pop(hostContext),
child: _ScheduleListTile(program: program, channel: channel, onTap: onTap),
);
}
@@ -37,6 +37,7 @@ void showProgramDetailsSheet(
}) {
OverlaySheetController.showAdaptive(
context,
showDragHandle: true,
builder: (sheetContext) {
return _ProgramDetailsSheetContent(
program: program,
@@ -257,6 +258,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
onNavigateRight: index < total - 1 ? () => _focusButton(index + 1) : null,
onNavigateUp: onNavigateUp,
onBack: _closeSheet,
useBackgroundFocus: true,
child: child,
);
}
@@ -44,6 +44,8 @@ class RecordOptionsSheet {
}) {
return OverlaySheetController.pushAdaptive<RecordOutcome>(
context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) =>
_RecordOptionsContent(client: client, headerTitle: program.displayTitle, entries: entries),
);
@@ -58,6 +60,8 @@ class RecordOptionsSheet {
}) {
return OverlaySheetController.pushAdaptive<RecordOutcome>(
context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _RecordOptionsContent(
client: client,
headerTitle: rule.title ?? '',
@@ -343,6 +347,7 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> {
FocusableButton(
focusNode: _saveFocusNode,
onPressed: _saving ? null : _save,
useBackgroundFocus: true,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
@@ -227,6 +227,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
Expanded(
child: Focus(
focusNode: _listFocusNode,
descendantsAreFocusable: false,
autofocus: isKeyboardMode,
onKeyEvent: _handleKeyEvent,
child: ReorderableListView.builder(
+13 -41
View File
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/dpad_select_long_press_controller.dart';
import '../../../focus/focus_theme.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../focus/key_event_utils.dart';
@@ -30,7 +31,6 @@ import '../../../utils/platform_detector.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/app_menu.dart';
import '../../../widgets/clickable_cursor.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../widgets/optimized_media_image.dart';
import '../livetv_styles.dart';
import '../program_details_sheet.dart';
@@ -81,7 +81,6 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
static const _sourceHeaderRowHeight = 40.0;
static const _timeHeaderHeight = 40.0;
static const _minutesPerSlot = 30;
static const _longPressDuration = Duration(milliseconds: 500);
/// Minimum time away (backgrounded or on another section) before the
/// viewport is realigned to the live line on return.
@@ -101,7 +100,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
bool _syncingScroll = false;
Timer? _timeIndicatorTimer;
Timer? _programSelectLongPressTimer;
final _programSelectController = DpadSelectLongPressController();
final _dayPickerKey = GlobalKey();
// Stale-window catch-up state (#1297). The grid window is only auto
@@ -121,7 +120,6 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
final ValueNotifier<bool> _hasFocusNotifier = ValueNotifier(false);
LiveTvProgram? _focusedProgram;
bool _pendingFocus = false;
bool _isProgramSelectKeyDown = false;
/// Focus into the guide content (called from tab bar navigation or initial load).
void focusContent() {
@@ -217,7 +215,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_programSelectLongPressTimer?.cancel();
_programSelectController.dispose();
_guideFocusNode.dispose();
_gridVerticalController.dispose();
_gridHorizontalController.removeListener(_syncGridToHeader);
@@ -237,10 +235,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
_hasFocusNotifier.value = hasFocus;
}
void _resetProgramSelectLongPressState() {
_programSelectLongPressTimer?.cancel();
_isProgramSelectKeyDown = false;
}
void _resetProgramSelectLongPressState() => _programSelectController.reset();
void _syncGridToHeader() {
if (_syncingScroll) return;
@@ -580,39 +575,18 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
}
KeyEventResult _handleFocusedProgramSelectKey(KeyEvent event) {
if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored;
final target = _focusedProgramTarget();
if (target == null) return KeyEventResult.ignored;
if (event is KeyDownEvent) {
if (!_isProgramSelectKeyDown) {
_isProgramSelectKeyDown = true;
_programSelectLongPressTimer?.cancel();
_programSelectLongPressTimer = Timer(_longPressDuration, () {
if (!mounted || !_isProgramSelectKeyDown) return;
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
_resetProgramSelectLongPressState();
return _programSelectController.handleKeyEvent(
event,
isOwnerActive: () => mounted && _focusedProgramTarget() == target,
onShortPress: () => _activateProgram(target.channel, target.program),
onLongPress: () {
_programSelectController.reset();
_showProgramDetails(target.channel, target.program);
});
}
return KeyEventResult.handled;
}
if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
final timerWasActive = _programSelectLongPressTimer?.isActive ?? false;
_programSelectLongPressTimer?.cancel();
if (timerWasActive && _isProgramSelectKeyDown) {
_activateProgram(target.channel, target.program);
}
_isProgramSelectKeyDown = false;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
}
KeyEventResult _handleFocusedProgramContextMenuKey(KeyEvent event) {
@@ -875,13 +849,11 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
return const Center(child: CircularProgressIndicator());
}
return OverlaySheetHost(
child: Focus(
return Focus(
focusNode: _guideFocusNode,
onFocusChange: _handleGuideFocusChange,
onKeyEvent: _handleKeyEvent,
child: _buildGuideGrid(theme),
),
);
}
+1 -4
View File
@@ -18,7 +18,6 @@ import '../../../utils/app_logger.dart';
import '../../../utils/dialogs.dart';
import '../../../utils/formatters.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../widgets/settings_section.dart';
import '../live_tv_refresh_lifecycle.dart';
import '../livetv_recording_actions.dart';
@@ -265,8 +264,7 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
return Center(child: _EmptyMessage(text: t.liveTv.noScheduledRecordings));
}
return OverlaySheetHost(
child: ListView(
return ListView(
padding: const EdgeInsets.only(bottom: 8),
children: [
if (grabs.isNotEmpty)
@@ -300,7 +298,6 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
],
),
],
),
);
}
}
+1 -4
View File
@@ -17,7 +17,6 @@ import '../../../providers/multi_server_provider.dart';
import '../../../services/settings_service.dart';
import '../../../utils/app_logger.dart';
import '../../../widgets/hub_section.dart';
import '../../../widgets/overlay_sheet.dart';
import '../live_tv_actions_mixin.dart';
import '../live_tv_show_schedule_screen.dart';
import '../live_tv_refresh_lifecycle.dart';
@@ -202,8 +201,7 @@ class WhatsOnTabState extends State<WhatsOnTab>
return Center(child: Text(t.liveTv.noPrograms));
}
return OverlaySheetHost(
child: ListView.builder(
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
clipBehavior: Clip.none,
itemCount: _hubs.length,
@@ -230,7 +228,6 @@ class WhatsOnTabState extends State<WhatsOnTab>
onBack: widget.onBack,
);
},
),
);
}
}
+29 -80
View File
@@ -768,6 +768,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
Navigator.pop(context, _watchStateChanged);
}
void _handleMediaDetailSystemBack() {
if (BackKeyCoordinator.consumeIfHandled()) return;
_popMediaDetailIfBackNotSuppressed();
}
bool _isTvDetailReadyToReveal(MediaItem metadata) {
if (_isLoadingMetadata) return false;
if (!_hasLoadedTvDetailSupplementalSections(metadata)) return false;
@@ -1082,8 +1087,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
void _showRatingDialog(BuildContext sheetContext, MediaItem metadata) {
OverlaySheetController.showAdaptive(
sheetContext,
OverlaySheetController.of(sheetContext).show(
showDragHandle: true,
builder: (context) => RatingBottomSheet(
item: metadata,
serverClient: _getMediaClientForMetadata(this.context),
@@ -2196,50 +2201,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// the rendered cards can never disagree.
double _getResponsiveCardWidth() => CastMemberStrip.responsiveCardWidth(context);
/// Handle key events for the overview section
KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isBackKey) return KeyEventResult.ignored;
if (!event.isActionable) return KeyEventResult.ignored;
final metadata = _fullMetadata ?? _metadata;
// UP: always play button (overview is directly below play)
if (key.isUpKey) {
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
_playButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
if (key.isDownKey) {
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (_episodes.isNotEmpty) {
_firstEpisodeFocusNode.requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (metadata.roles != null && metadata.roles!.isNotEmpty) {
_castStripKey.currentState?.requestFocus();
_scrollSectionIntoView(_castSectionKey);
} else if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey);
} else if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
} else if (_hasInfoRows) {
_focusInfoRows();
}
return KeyEventResult.handled;
}
// LEFT/RIGHT/SELECT: consume to prevent unwanted traversal
if (key.isLeftKey || key.isRightKey || key.isSelectKey) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Show context menu for a season tab
void _showSeasonTabContextMenu(int index, {Offset? position}) {
final key = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey<MediaContextMenuState>());
@@ -2536,7 +2497,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
/// Handle key events for the trailing info rows (studio / contentRating).
/// UP returns to the previous focusable section; all other directions consume.
/// UP returns to the previous focusable section; terminal geometry is trapped.
KeyEventResult _handleInfoRowsKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isBackKey) return KeyEventResult.ignored;
@@ -2547,9 +2508,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled;
}
// DOWN / LEFT / RIGHT / SELECT: consume — info rows are the terminal row.
if (key.isDownKey || key.isLeftKey || key.isRightKey || key.isSelectKey) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
IconData _getRelatedHubIcon(MediaHub hub) {
final lower = hub.title.toLowerCase();
@@ -3120,8 +3083,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
return PopScope(
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
// ignore: no-empty-block - required callback, blocks system back on Android TV
onPopInvokedWithResult: (didPop, result) {},
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _handleMediaDetailSystemBack();
},
child: loading,
);
}
@@ -3146,6 +3110,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// keyboard/TV (the key handler owns dpad back); elsewhere canPop:true
// keeps the iOS swipe-back. The host also closes an open sheet on back.
canPop: !blockSystemBack,
onSystemBack: _handleMediaDetailSystemBack,
child: Focus(
onKeyEvent: _handleMediaDetailBackKey,
child: Scaffold(
@@ -3176,40 +3141,23 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[
Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle),
const SizedBox(height: 12),
Focus(
focusNode: _overviewFocusNode,
onKeyEvent: _handleOverviewKeyEvent,
child: ListenableBuilder(
listenable: _overviewFocusNode,
builder: (context, _) {
final showFocus =
_overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: showFocus
? theme.colorScheme.primary.withValues(alpha: 0.5)
: Colors.transparent,
width: 2,
),
),
child: () {
final summaryStyle = theme.textTheme.bodyLarge?.copyWith(height: 1.6);
if (isTv) {
return Text(metadata.summary!, style: summaryStyle);
}
return CollapsibleText(
CollapsibleText(
text: metadata.summary!,
maxLines: isMobile ? 6 : 4,
style: summaryStyle,
);
}(),
style: theme.textTheme.bodyLarge?.copyWith(height: 1.6),
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: () {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
_playButtonFocusNode.requestFocus();
},
),
onNavigateDown: _focusBelowActionRow,
onNavigateLeft: () {},
onNavigateRight: () {},
),
const SizedBox(height: 24),
],
@@ -3451,6 +3399,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// blockSystemBack keeps the route from double-popping on Android keyboard/
// TV (the key handler owns dpad back); the host also closes an open sheet.
canPop: !blockSystemBack,
onSystemBack: _handleMediaDetailSystemBack,
child: Focus(
onKeyEvent: handleBack,
child: Scaffold(
+18 -17
View File
@@ -3,8 +3,8 @@ import '../media/ids.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../metadata_edit/metadata_edit_adapters.dart';
@@ -274,11 +274,19 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList();
return FocusedScrollScaffold(
title: Text(t.metadataEdit.screenTitle),
focusableAppBarActions: true,
actions: [
if (_isSaving)
const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24))
else
IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)),
FocusableButton(
onPressed: _hasChanges ? _save : null,
child: IconButton(
onPressed: _hasChanges ? _save : null,
icon: const AppIcon(Symbols.check_rounded, fill: 1),
tooltip: t.common.save,
),
),
],
slivers: [
SliverPadding(
@@ -492,27 +500,17 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
),
actions: [
if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)),
FocusableButton(
onPressed: _addFromUrl,
child: TextButton.icon(
DialogActionButton(
onPressed: _addFromUrl,
label: t.metadataEdit.fromUrl,
icon: const AppIcon(Symbols.link_rounded, size: 18),
label: Text(t.metadataEdit.fromUrl),
),
),
FocusableButton(
onPressed: _uploadFile,
child: TextButton.icon(
DialogActionButton(
onPressed: _uploadFile,
label: t.metadataEdit.uploadFile,
icon: const AppIcon(Symbols.upload_rounded, size: 18),
label: Text(t.metadataEdit.uploadFile),
),
),
FocusableButton(
autofocus: true,
onPressed: () => Navigator.pop(context),
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
),
DialogActionButton(autofocus: true, onPressed: () => Navigator.pop(context), label: t.common.cancel),
],
);
}
@@ -533,6 +531,9 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
final artwork = _artworkList![index];
return FocusableWrapper(
borderRadius: 8,
semanticLabel: artwork.selected
? t.metadataEdit.selectedArtworkOption(index: index + 1)
: t.metadataEdit.artworkOption(index: index + 1),
onSelect: () => _selectArtwork(artwork),
child: GestureDetector(
onTap: () => _selectArtwork(artwork),
+9 -1
View File
@@ -452,7 +452,13 @@ class _ArtistLinkState extends State<_ArtistLink> {
if (widget.onTap == null) return Text(widget.name, style: style);
final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
return Focus(
return Semantics(
button: true,
enabled: true,
label: widget.name,
onTap: widget.onTap,
child: ExcludeSemantics(
child: Focus(
focusNode: _focusNode,
onFocusChange: (hasFocus) => setState(() => _focused = hasFocus),
onKeyEvent: dpadKeyHandler(onSelect: widget.onTap),
@@ -468,6 +474,8 @@ class _ArtistLinkState extends State<_ArtistLink> {
),
),
),
),
),
);
}
}
+27 -37
View File
@@ -10,6 +10,8 @@ import 'package:provider/provider.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_theme.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_slider.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
@@ -466,11 +468,14 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
// Inset past the macOS traffic lights — this screen is a fullscreen
// route, so the close button would otherwise sit underneath them.
DesktopAppBarHelper.buildAdjustedLeading(
IconButton(
FocusableButton(
onPressed: _pop,
child: IconButton(
icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text),
tooltip: t.common.close,
onPressed: _pop,
),
),
context: context,
)!,
Expanded(
@@ -502,7 +507,9 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
final cluster = Row(
mainAxisSize: .min,
children: [
IconButton(
FocusableButton(
onPressed: _toggleLyrics,
child: IconButton(
icon: AppIcon(
Symbols.lyrics_rounded,
fill: 1,
@@ -512,6 +519,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
tooltip: t.music.lyrics,
onPressed: _toggleLyrics,
),
),
if (PlatformDetector.isDesktop(context)) ...[const SizedBox(width: 4), _buildVolumeCluster(service)],
],
);
@@ -558,7 +566,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
),
child: Slider(
child: FocusableSlider(
value: volume.clamp(0.0, 100.0),
max: 100,
onChanged: (value) => unawaited(service.setVolume(value, persist: false)),
@@ -576,39 +584,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
/// entry. On TV ([focusable]) it joins the d-pad chain above the seek bar.
Widget _buildOverflowButton(MediaItem track, {bool focusable = false}) {
final tk = tokens(context);
final button = IconButton(
icon: AppIcon(Symbols.more_vert_rounded, fill: 1, color: tk.text),
onPressed: () => contextMenuKey.currentState?.showContextMenu(context),
);
Widget child = button;
if (focusable) {
child = ListenableBuilder(
listenable: _overflowFocusNode,
builder: (context, _) {
final showFocus = _overflowFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
return Focus(
focusNode: _overflowFocusNode,
descendantsAreFocusable: false,
onKeyEvent: (node, event) {
final backResult = handleBackKeyAction(event, _pop);
if (backResult != KeyEventResult.ignored) return backResult;
return dpadKeyHandler(
onSelect: () => contextMenuKey.currentState?.showContextMenu(context),
onDown: _seekFocusNode.requestFocus,
onUp: () {},
trapHorizontalEdges: true,
)(node, event);
},
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: 20),
child: button,
),
);
},
);
}
void showMenu() => contextMenuKey.currentState?.showContextMenu(context);
return MediaContextMenu(
key: contextMenuKey,
@@ -616,7 +592,21 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
extraEntries: [
MediaMenuExtraEntry(icon: Symbols.bedtime_rounded, label: t.music.sleepTimer, onSelected: _showSleepTimerSheet),
],
child: child,
child: FocusableButton(
focusNode: focusable ? _overflowFocusNode : null,
onPressed: showMenu,
onNavigateDown: focusable ? _seekFocusNode.requestFocus : null,
onNavigateUp: focusable ? () {} : null,
onNavigateLeft: focusable ? () {} : null,
onNavigateRight: focusable ? () {} : null,
onBack: focusable ? _pop : null,
useBackgroundFocus: focusable,
child: IconButton(
icon: AppIcon(Symbols.more_vert_rounded, fill: 1, color: tk.text),
onPressed: showMenu,
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
),
),
);
}
+15 -17
View File
@@ -4,6 +4,7 @@ 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/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../services/music/music_playback_service.dart';
@@ -19,7 +20,7 @@ import '../../widgets/overlay_sheet.dart';
/// [OverlaySheetHost] ancestor (all now-playing layouts do) so TV back
/// handling stays centralized in the host.
Future<void> showQueueSheet(BuildContext context) {
return OverlaySheetController.showAdaptive<void>(context, showDragHandle: true, builder: (_) => const QueueSheet());
return OverlaySheetController.of(context).show<void>(showDragHandle: true, builder: (_) => const QueueSheet());
}
/// Sheet chrome around [QueueList]: header with track count, shuffle/repeat
@@ -48,33 +49,30 @@ class QueueSheet extends StatelessWidget {
style: TextStyle(fontSize: 13, color: tk.textMuted),
),
),
IconButton(
icon: AppIcon(
Symbols.shuffle_rounded,
fill: 1,
size: 20,
color: service.shuffled ? colorScheme.primary : tk.textMuted,
),
FocusableActionBar(
actions: [
FocusableAction(
icon: Symbols.shuffle_rounded,
iconColor: service.shuffled ? colorScheme.primary : tk.textMuted,
tooltip: t.common.shuffle,
onPressed: service.toggleShuffle,
),
IconButton(
icon: AppIcon(
repeatModeIcon(service.repeatMode),
fill: 1,
size: 20,
color: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary,
),
FocusableAction(
icon: repeatModeIcon(service.repeatMode),
iconColor: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary,
tooltip: repeatModeLabel(service.repeatMode),
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
),
IconButton(
icon: AppIcon(Symbols.clear_all_rounded, fill: 1, size: 20, color: tk.textMuted),
FocusableAction(
icon: Symbols.clear_all_rounded,
iconColor: tk.textMuted,
tooltip: t.music.clearQueue,
onPressed: service.clearUpcoming,
),
],
),
],
),
),
const Flexible(child: QueueList(autofocusCurrent: true)),
],
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../media/library_query.dart';
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
@@ -187,6 +188,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
// Focus management for regular (non-smart) reorderable lists
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry');
// Navigation state for regular (non-smart) playlists
int _focusedIndex = 0;
@@ -219,6 +221,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
void dispose() {
_continuation.dispose();
_listFocusNode.dispose();
_continuationRetryFocusNode.dispose();
_focusRevision.dispose();
disposeFocusResources();
super.dispose();
@@ -637,13 +640,19 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
return KeyEventResult.handled;
}
if (key.isDownKey && _focusedIndex < items.length - 1) {
if (key.isDownKey) {
if (_focusedIndex < items.length - 1) {
_focusedIndex++;
_focusedColumn = 0;
_notifyFocusChanged();
_ensureFocusedVisible();
return KeyEventResult.handled;
}
if (_continuation.error != null) {
_continuationRetryFocusNode.requestFocus();
return KeyEventResult.handled;
}
}
if (key.isLeftKey) {
// Navigate left within columns
if (_focusedColumn == 0 && _canEditPlaylist) {
@@ -871,7 +880,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
children: [
Text(error, textAlign: TextAlign.center),
const SizedBox(height: 8),
TextButton(onPressed: _retryPlaylistContinuation, child: Text(t.common.retry)),
FocusableButton(
focusNode: _continuationRetryFocusNode,
onPressed: _retryPlaylistContinuation,
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
onBack: handleBackFromContent,
child: TextButton(onPressed: _retryPlaylistContinuation, child: Text(t.common.retry)),
),
],
),
),
@@ -368,6 +368,7 @@ class _BorrowTile extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
canRequestFocus: false,
onTap: onTap,
borderRadius: borderRadius,
child: Padding(
@@ -274,6 +274,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
isActive: isActive && !widget.requireSelection,
chips: _chipsFor(profile, view),
onTap: () => _switchTo(profile),
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
// Manage available for any profile — adding/removing
// borrowed connections is supported on plex_home too. Delete
// stays local-only (Plex Home users are owned by Plex).
@@ -371,6 +372,7 @@ class _ProfileTile extends StatelessWidget {
final BorderRadius borderRadius;
final List<_ChipData> chips;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final VoidCallback? onManage;
final VoidCallback? onDelete;
final VoidCallback? onSignOut;
@@ -384,6 +386,7 @@ class _ProfileTile extends StatelessWidget {
required this.borderRadius,
required this.chips,
required this.onTap,
this.onLongPress,
this.onManage,
this.onDelete,
this.onSignOut,
@@ -397,7 +400,9 @@ class _ProfileTile extends StatelessWidget {
final theme = Theme.of(context);
final hasMenu = onManage != null || onDelete != null || onSignOut != null;
return InkWell(
canRequestFocus: false,
onTap: isActive ? null : onTap,
onLongPress: onLongPress,
borderRadius: borderRadius,
child: Padding(
padding: const EdgeInsets.all(12),
+35 -10
View File
@@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/focusable_text_field.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/debounced_media_search.dart';
@@ -32,6 +33,7 @@ class _SearchScreenState extends State<SearchScreen>
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
String? _focusResultsForQuery;
final _tvKeyboardController = TvKeyboardController();
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
@override
void initState() {
@@ -39,6 +41,17 @@ class _SearchScreenState extends State<SearchScreen>
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
}
@override
void dispose() {
_clearFocusNode.dispose();
super.dispose();
}
void _clearSearch() {
searchController.clear();
searchFocusNode.requestFocus();
}
@override
String get searchDebugLabel => 'Search';
@@ -192,13 +205,19 @@ class _SearchScreenState extends State<SearchScreen>
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
child: FocusableTextField(
child: Stack(
alignment: Alignment.centerRight,
children: [
FocusableTextField(
controller: searchController,
focusNode: searchFocusNode,
tvKeyboardController: _tvKeyboardController,
textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
onNavigateDown: searchResults.isNotEmpty && !isSearching
? firstResultFocusNode.requestFocus
: null,
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
onBack: () {
if (searchController.text.isNotEmpty) {
@@ -211,16 +230,22 @@ class _SearchScreenState extends State<SearchScreen>
context,
hintText: t.search.hint,
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
onPressed: () {
searchController.clear();
},
)
: null,
suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null,
),
),
if (searchController.text.isNotEmpty)
FocusableButton(
focusNode: _clearFocusNode,
onPressed: _clearSearch,
onNavigateLeft: searchFocusNode.requestFocus,
onNavigateDown: searchResults.isNotEmpty && !isSearching
? firstResultFocusNode.requestFocus
: null,
autoScroll: false,
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
),
],
),
),
),
if (isSearching)
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/settings_section.dart';
import '../../i18n/strings.g.dart';
import 'licenses_screen.dart';
@@ -56,7 +57,7 @@ class AboutScreen extends StatelessWidget {
SettingsGroup(
margin: EdgeInsets.zero,
children: [
ListTile(
FocusableListTile(
leading: const AppIcon(Symbols.description_rounded, fill: 1),
title: Text(t.about.openSourceLicenses),
subtitle: Text(t.about.viewLicensesDescription),
@@ -16,6 +16,7 @@ import '../../focus/focusable_slider.dart';
import '../../services/device_performance.dart';
import '../../utils/platform_detector.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/setting_tile.dart';
import '../../widgets/settings_page.dart';
import '../../widgets/settings_builder.dart';
@@ -224,7 +225,7 @@ class AppearanceSettingsScreen extends StatelessWidget {
}
Widget _languageSelector(BuildContext context) {
return ListTile(
return FocusableListTile(
leading: const AppIcon(Symbols.language_rounded, fill: 1),
title: Text(t.settings.language),
subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)),
@@ -5,6 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/widgets/app_icon.dart';
import '../../widgets/dialog_action_button.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../i18n/strings.g.dart';
@@ -12,6 +13,7 @@ import '../../models/external_player_models.dart';
import '../../services/settings_service.dart';
import '../../utils/dialogs.dart';
import '../../widgets/expressive_button_group.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/setting_tile.dart';
import '../../widgets/settings_builder.dart';
import '../../widgets/settings_page.dart';
@@ -57,7 +59,7 @@ class ExternalPlayerScreen extends StatelessWidget {
title: t.externalPlayer.customPlayers,
children: [
for (final p in custom) _PlayerTile(player: p, selectedId: selected.id, isCustom: true),
ListTile(
FocusableListTile(
leading: const AppIcon(Symbols.add_rounded, fill: 1),
title: Text(t.externalPlayer.addCustomPlayer),
onTap: () => _showAddCustomPlayerDialog(context),
@@ -105,17 +107,20 @@ class _PlayerTile extends StatelessWidget {
leading = const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
}
return ListTile(
return FocusableListTile(
leading: leading,
title: Text(player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name),
trailing: Row(
mainAxisSize: .min,
children: [
if (isCustom)
IconButton(
FocusableButton(
onPressed: () => svc.removeCustomExternalPlayer(player.id),
autoScroll: false,
child: IconButton(
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
onPressed: () => svc.removeCustomExternalPlayer(player.id),
),
),
AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
@@ -231,15 +236,8 @@ class _AddCustomPlayerDialogState extends State<_AddCustomPlayerDialog> {
),
),
actions: [
FocusableButton(
onPressed: () => Navigator.pop(context),
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
),
FocusableButton(
focusNode: _saveFocusNode,
onPressed: _submit,
child: FilledButton(onPressed: _submit, child: Text(t.common.save)),
),
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
DialogActionButton(focusNode: _saveFocusNode, onPressed: _submit, label: t.common.save, isPrimary: true),
],
);
}
@@ -4,6 +4,9 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../models/hotkey_model.dart';
import '../../widgets/hotkey_recorder.dart';
import '../../i18n/strings.g.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_wrapper.dart';
import '../../widgets/dialog_action_button.dart';
class HotKeyRecorderWidget extends StatefulWidget {
final String actionName;
@@ -25,6 +28,11 @@ class HotKeyRecorderWidget extends StatefulWidget {
class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
HotKey? _recordedHotKey;
bool _isCapturing = false;
final _recorderFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.record');
final _clearFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.clear');
final _cancelFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.cancel');
final _saveFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.save');
@override
void initState() {
@@ -32,8 +40,43 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
_recordedHotKey = widget.currentHotKey;
}
@override
void dispose() {
_recorderFocusNode.dispose();
_clearFocusNode.dispose();
_cancelFocusNode.dispose();
_saveFocusNode.dispose();
super.dispose();
}
void _startCapturing() {
setState(() => _isCapturing = true);
_recorderFocusNode.requestFocus();
}
void _handleHotKeyRecorded(HotKey hotKey) {
setState(() {
_recordedHotKey = hotKey;
_isCapturing = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _saveFocusNode.requestFocus();
});
}
void _clearShortcut() {
setState(() {
_recordedHotKey = null;
_isCapturing = false;
});
_recorderFocusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
final hasShortcut = _recordedHotKey != null;
final recordLabel = _isCapturing ? t.hotkeys.recordingShortcut : t.hotkeys.pressToRecord;
return AlertDialog(
title: Text(t.hotkeys.setShortcutFor(actionName: widget.actionName)),
content: SizedBox(
@@ -48,43 +91,62 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .bold),
),
const SizedBox(height: 6),
Container(
Row(
children: [
Expanded(
child: FocusableWrapper(
focusNode: _recorderFocusNode,
autofocus: true,
onSelect: _startCapturing,
onBack: widget.onCancel,
onNavigateRight: hasShortcut ? _clearFocusNode.requestFocus : null,
onNavigateDown: (hasShortcut ? _saveFocusNode : _cancelFocusNode).requestFocus,
semanticLabel: recordLabel,
descendantsAreFocusable: false,
useBackgroundFocus: true,
child: GestureDetector(
onTap: _startCapturing,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)),
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: Row(
children: [
Expanded(
child: HotKeyRecorder(
child: hasShortcut
? HotKeyRecorder(
initalHotKey: _recordedHotKey,
onHotKeyRecorded: (hotKey) {
setState(() {
_recordedHotKey = hotKey;
});
},
enabled: _isCapturing,
onHotKeyRecorded: _handleHotKeyRecorded,
)
: Text(recordLabel),
),
),
if (_recordedHotKey != null)
IconButton(
),
),
if (hasShortcut) ...[
const SizedBox(width: 8),
FocusableButton(
focusNode: _clearFocusNode,
onPressed: _clearShortcut,
onBack: widget.onCancel,
onNavigateLeft: _recorderFocusNode.requestFocus,
onNavigateDown: _saveFocusNode.requestFocus,
autoScroll: false,
child: IconButton(
icon: const AppIcon(Symbols.backspace_rounded, fill: 1, size: 18),
onPressed: () {
setState(() {
_recordedHotKey = null;
});
},
onPressed: _clearShortcut,
padding: .zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
tooltip: t.hotkeys.clearShortcut,
),
],
),
],
],
),
const SizedBox(height: 8),
Text(
'Press any key combination to set a new shortcut',
recordLabel,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)),
@@ -95,10 +157,22 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
),
),
actions: [
TextButton(onPressed: widget.onCancel, child: Text(t.common.cancel)),
TextButton(
onPressed: _recordedHotKey != null ? () => widget.onHotKeyRecorded(_recordedHotKey!) : null,
child: Text(t.common.save),
DialogActionButton(
focusNode: _cancelFocusNode,
onPressed: widget.onCancel,
onBack: widget.onCancel,
onNavigateUp: _recorderFocusNode.requestFocus,
onNavigateRight: _saveFocusNode.requestFocus,
label: t.common.cancel,
),
DialogActionButton(
focusNode: _saveFocusNode,
onPressed: hasShortcut ? () => widget.onHotKeyRecorded(_recordedHotKey!) : null,
onBack: widget.onCancel,
onNavigateUp: _recorderFocusNode.requestFocus,
onNavigateLeft: _cancelFocusNode.requestFocus,
label: t.common.save,
isPrimary: true,
),
],
);
@@ -9,6 +9,7 @@ import '../../utils/snackbar_helper.dart';
import '../../focus/focusable_button.dart';
import '../../theme/mono_tokens.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/settings_section.dart';
import 'hotkey_recorder_widget.dart';
@@ -47,7 +48,7 @@ class KeyboardShortcutsScreen extends StatelessWidget {
child: SettingsGroup(
children: [
for (final action in actions)
ListTile(
FocusableListTile(
title: Text(keyboardService.getActionDisplayName(action)),
subtitle: Text(action),
trailing: Container(
+2 -6
View File
@@ -10,7 +10,7 @@ import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../widgets/dialog_action_button.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/mounted_set_state_mixin.dart';
@@ -216,11 +216,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
],
),
actions: [
FocusableButton(
autofocus: true,
onPressed: () => Navigator.of(ctx).pop(),
child: TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close)),
),
DialogActionButton(autofocus: true, onPressed: () => Navigator.of(ctx).pop(), label: t.common.close),
],
),
),
+3 -2
View File
@@ -16,6 +16,7 @@ import '../../services/settings_service.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/focusable_popup_menu_button.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/settings_builder.dart';
import '../../widgets/settings_section.dart';
@@ -199,7 +200,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
// The page already pads its slivers by 16.
margin: EdgeInsets.zero,
children: [
ListTile(
FocusableListTile(
focusNode: _savePresetFocusNode,
leading: const AppIcon(Symbols.save_rounded, fill: 1),
title: Text(t.mpvConfig.saveAsPreset),
@@ -208,7 +209,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
),
if (presets.isNotEmpty)
...presets.map(
(preset) => ListTile(
(preset) => FocusableListTile(
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
title: Text(preset.name),
trailing: FocusablePopupMenuButton<String>(
@@ -9,6 +9,7 @@ import '../../models/seerr/seerr_session.dart';
import '../../providers/seerr_account_provider.dart';
import '../../utils/dialogs.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/settings_page.dart';
import '../../widgets/settings_section.dart';
@@ -71,7 +72,7 @@ class SeerrSettingsScreen extends StatelessWidget {
const SizedBox(height: 24),
SettingsGroup(
children: [
ListTile(
FocusableListTile(
leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error),
title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)),
onTap: () => unawaited(_disconnect(context, account)),
@@ -9,6 +9,7 @@ import '../../providers/trakt_account_provider.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/catalog_source_logo.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/settings_section.dart';
import 'seerr_connect_screen.dart';
import 'seerr_settings_screen.dart';
@@ -144,7 +145,7 @@ class _ServiceHubRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
return FocusableListTile(
leading: leading,
title: Text(title),
subtitle: Text(username != null ? t.services.connectedAs(username: username!) : t.services.notConnected),
+12 -11
View File
@@ -36,6 +36,7 @@ import '../../utils/platform_detector.dart';
import '../../utils/update_dialog.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/dialog_action_button.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/library_management_sheet.dart';
import '../../widgets/setting_tile.dart';
import '../../widgets/settings_builder.dart';
@@ -190,7 +191,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
}
Widget _buildDonateTile() {
return ListTile(
return FocusableListTile(
focusNode: _focusTracker.get(_kDonate),
leading: const AppIcon(Symbols.favorite_rounded, fill: 1),
title: Text(t.settings.supportDeveloper),
@@ -328,7 +329,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
future: storageService.getCurrentDownloadPathDisplay(),
builder: (context, snapshot) {
final currentPath = snapshot.data ?? '...';
return ListTile(
return FocusableListTile(
focusNode: _focusTracker.get(_kDownloadLocation),
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault),
@@ -389,7 +390,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
return SettingsGroup(
title: t.settings.advanced,
children: [
ListTile(
FocusableListTile(
focusNode: _focusTracker.get(_kWatchTogetherRelay),
leading: const AppIcon(Symbols.dns_rounded, fill: 1),
title: Text(t.settings.watchTogetherRelay),
@@ -418,7 +419,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
subtitle: t.settings.viewLogsDescription,
destinationBuilder: (context) => const LogsScreen(),
),
ListTile(
FocusableListTile(
focusNode: _focusTracker.get(_kClearCache),
leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1),
title: Text(t.settings.clearCache),
@@ -426,7 +427,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showClearCacheDialog(),
),
ListTile(
FocusableListTile(
focusNode: _focusTracker.get(_kResetSettings),
leading: const AppIcon(Symbols.restore_rounded, fill: 1),
title: Text(t.settings.resetSettings),
@@ -435,7 +436,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
onTap: () => _showResetSettingsDialog(),
),
if (kDebugMode)
ListTile(
FocusableListTile(
leading: const AppIcon(Symbols.error_rounded, fill: 1),
title: const Text('Test Sentry'),
subtitle: const Text('Send a test error'),
@@ -445,7 +446,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
},
),
if (kDebugMode)
ListTile(
FocusableListTile(
leading: const AppIcon(Symbols.timer_rounded, fill: 1),
title: const Text('Test ANR'),
subtitle: const Text('Block the main thread for 10 seconds'),
@@ -464,7 +465,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
return SettingsGroup(
title: t.settings.backup,
children: [
ListTile(
FocusableListTile(
focusNode: _focusTracker.get(_kExportSettings),
leading: const AppIcon(Symbols.upload_rounded, fill: 1),
title: Text(t.settings.exportSettings),
@@ -472,7 +473,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: _handleExportSettings,
),
ListTile(
FocusableListTile(
focusNode: _focusTracker.get(_kImportSettings),
leading: const AppIcon(Symbols.download_rounded, fill: 1),
title: Text(t.settings.importSettings),
@@ -497,7 +498,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
return SettingsGroup(
title: t.settings.updates,
children: [
ListTile(
FocusableListTile(
focusNode: _focusTracker.get(_kCheckForUpdates),
leading: const AppIcon(Symbols.system_update_rounded, fill: 1),
title: Text(t.settings.checkForUpdates),
@@ -514,7 +515,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
return SettingsGroup(
title: t.settings.updates,
children: [
ListTile(
FocusableListTile(
focusNode: _focusTracker.get(_kCheckForUpdates),
leading: AppIcon(
hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded,
@@ -7,6 +7,7 @@ import '../../i18n/strings.g.dart';
import '../../services/settings_service.dart';
import '../../services/trackers/tracker_constants.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/setting_tile.dart';
import '../../widgets/settings_builder.dart';
import '../../widgets/settings_page.dart';
@@ -76,7 +77,7 @@ class TrackerAccountSettingsBody extends StatelessWidget {
prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)],
builder: (context) {
final settings = SettingsService.instance;
return ListTile(
return FocusableListTile(
leading: const AppIcon(Symbols.filter_list_rounded, fill: 1),
title: Text(t.services.libraryFilter.title),
subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, service)),
@@ -92,7 +93,7 @@ class TrackerAccountSettingsBody extends StatelessWidget {
const SizedBox(height: 24),
SettingsGroup(
children: [
ListTile(
FocusableListTile(
leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error),
title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)),
onTap: () => unawaited(Future<void>.sync(onDisconnect)),
@@ -237,6 +237,7 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
onNavigateLeft: () => cancelFocusNode.requestFocus(),
onNavigateUp: () {},
onNavigateDown: () {},
useBackgroundFocus: true,
child: FilledButton(
onPressed: onPlayNext,
style: FilledButton.styleFrom(
@@ -401,6 +402,7 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
onNavigateLeft: () => pauseFocusNode.requestFocus(),
onNavigateUp: () {},
onNavigateDown: () {},
useBackgroundFocus: true,
child: FilledButton(
onPressed: onContinue,
style: FilledButton.styleFrom(
+15 -18
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/input_mode_tracker.dart';
import '../i18n/strings.g.dart';
@@ -47,24 +46,24 @@ Future<bool> showConfirmDialog(
title: Text(title),
content: Text(message),
actions: [
FocusableButton(
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, false),
child: TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
label: cancelText ?? t.common.cancel,
style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
child: Text(cancelText ?? t.common.cancel),
),
),
FocusableButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: FilledButton(
DialogActionButton(
onPressed: () => Navigator.pop(dialogContext, true),
label: confirmText,
isPrimary: true,
style: isDestructive
? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError)
: null,
child: Text(confirmText),
),
? FilledButton.styleFrom(
padding: _buttonPadding,
shape: _buttonShape,
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
)
: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
),
],
);
@@ -93,14 +92,12 @@ Future<void> showServerLimitDialog(BuildContext context) async {
title: Text(t.messages.serverLimitTitle),
content: Text(t.messages.serverLimitBody),
actions: [
FocusableButton(
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.of(ctx).pop(),
child: FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
label: t.common.close,
isPrimary: true,
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
child: Text(t.common.close),
),
),
],
),
@@ -171,6 +171,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
child: FocusableButton(
autofocus: _recentRooms.isEmpty,
onPressed: _isBusy ? null : _createSession,
useBackgroundFocus: true,
child: FilledButton.icon(
onPressed: _isBusy ? null : _createSession,
icon: _isCreating ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.add_rounded),
@@ -431,6 +432,7 @@ class _RecentRoomTile extends StatelessWidget {
: null,
trailing: IconButton(icon: const Icon(Symbols.more_vert_rounded), onPressed: () => _showActions(context)),
onTap: isBusy ? null : onTap,
onLongPress: () => _showActions(context),
),
),
),
@@ -438,8 +440,8 @@ class _RecentRoomTile extends StatelessWidget {
}
void _showActions(BuildContext context) {
OverlaySheetController.showAdaptive(
context,
OverlaySheetController.of(context).show(
showDragHandle: true,
builder: (context) => AppMenuSheet<String>(
entries: [
AppMenuItem(value: 'rename', icon: Symbols.edit_rounded, label: t.watchTogether.renameRoom),
@@ -714,6 +716,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> {
child: FocusableButton(
autofocus: true,
onPressed: _isJoining ? null : _joinCurrentPlayback,
useBackgroundFocus: true,
child: FilledButton.icon(
onPressed: _isJoining ? null : _joinCurrentPlayback,
icon: _isJoining ? const LoadingIndicatorBox() : const Icon(Symbols.play_arrow_rounded),
@@ -112,6 +112,7 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
focusNode: _joinFocusNode,
onPressed: _join,
onNavigateUp: _sessionIdFocusNode.requestFocus,
useBackgroundFocus: true,
child: FilledButton.icon(
onPressed: _join,
icon: const Icon(Symbols.group_add),
@@ -5,19 +5,24 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../utils/platform_detector.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/bottom_sheet_header.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/overlay_sheet.dart';
import '../models/watch_session.dart';
import '../providers/watch_together_provider.dart';
class WatchTogetherSessionIndicator extends StatelessWidget {
final VoidCallback? onLeaveSession;
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
const WatchTogetherSessionIndicator({super.key, this.onLeaveSession});
const WatchTogetherSessionIndicator({super.key, this.onLeaveSession, this.onCancelAutoHide, this.onStartAutoHide});
@override
Widget build(BuildContext context) {
@@ -36,8 +41,14 @@ class WatchTogetherSessionIndicator extends StatelessWidget {
}
void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) {
OverlaySheetController.of(context).show(
onCancelAutoHide?.call();
unawaited(
OverlaySheetController.of(context)
.show(
showDragHandle: true,
builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession),
)
.whenComplete(() => onStartAutoHide?.call()),
);
}
}
@@ -61,10 +72,17 @@ class _SessionIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
return FocusableWrapper(
onSelect: onTap,
semanticLabel: t.watchTogether.openSessionControls,
descendantsAreFocusable: false,
borderRadius: 20,
useBackgroundFocus: true,
child: Material(
color: Colors.black54,
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: InkWell(
canRequestFocus: false,
onTap: onTap,
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: Padding(
@@ -72,7 +90,6 @@ class _SessionIndicator extends StatelessWidget {
child: Row(
mainAxisSize: .min,
children: [
// Sync indicator or group icon
if (isSyncing)
SizedBox(
width: 16,
@@ -83,16 +100,11 @@ class _SessionIndicator extends StatelessWidget {
)
else
Icon(Symbols.group, size: 18, color: isHost ? Colors.amber : Colors.white),
const SizedBox(width: 6),
// Participant count
Text(
'$participantCount',
style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 14),
),
// Host badge
if (isHost) ...[
const SizedBox(width: 6),
Container(
@@ -111,6 +123,7 @@ class _SessionIndicator extends StatelessWidget {
),
),
),
),
);
}
}
@@ -125,32 +138,14 @@ class _SessionMenuSheet extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
// Header
Row(
children: [
Icon(Symbols.group, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text(t.watchTogether.title, style: theme.textTheme.titleMedium),
Text(
provider.isHost ? t.watchTogether.youAreHost : t.watchTogether.watchingWithOthers,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
// Control mode badge
Container(
BottomSheetHeader(
title: t.watchTogether.title,
icon: Symbols.group,
iconColor: theme.colorScheme.primary,
action: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
@@ -163,13 +158,25 @@ class _SessionMenuSheet extends StatelessWidget {
style: theme.textTheme.labelSmall,
),
),
],
),
// Session code with copy button
Flexible(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
provider.isHost ? t.watchTogether.youAreHost : t.watchTogether.watchingWithOthers,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
if (provider.sessionId != null) ...[
const SizedBox(height: 12),
InkWell(
FocusableWrapper(
onSelect: () => _copySessionCode(context, provider.sessionId!),
semanticLabel: t.watchTogether.copySessionCode,
descendantsAreFocusable: false,
borderRadius: 8,
useBackgroundFocus: true,
child: InkWell(
canRequestFocus: false,
onTap: () => _copySessionCode(context, provider.sessionId!),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
@@ -195,28 +202,28 @@ class _SessionMenuSheet extends StatelessWidget {
),
),
),
),
],
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Participants list
Text(t.watchTogether.participants, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...provider.participants.map(
(p) => ListTile(
for (final participant in provider.participants)
ListTile(
leading: CircleAvatar(
backgroundColor: p.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
backgroundColor: participant.isHost
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
child: Icon(
p.isHost ? Symbols.star : Symbols.person,
color: p.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
participant.isHost ? Symbols.star : Symbols.person,
color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
size: 20,
),
),
title: Text(p.displayName),
subtitle: p.isHost ? Text(t.watchTogether.host) : null,
trailing: p.isBuffering
title: Text(participant.displayName),
subtitle: participant.isHost ? Text(t.watchTogether.host) : null,
trailing: participant.isBuffering
? SizedBox(
width: 16,
height: 16,
@@ -228,28 +235,22 @@ class _SessionMenuSheet extends StatelessWidget {
dense: true,
contentPadding: .zero,
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Actions
ListTile(
FocusableListTile(
leading: Icon(Symbols.logout, color: theme.colorScheme.error),
title: Text(
provider.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession,
style: TextStyle(color: theme.colorScheme.error),
),
onTap: () {
OverlaySheetController.of(context).close();
_confirmLeave(context);
},
onTap: () => unawaited(_confirmLeave(context)),
contentPadding: .zero,
),
],
),
),
],
);
}
@@ -258,7 +259,7 @@ class _SessionMenuSheet extends StatelessWidget {
showSuccessSnackBar(context, t.watchTogether.sessionCodeCopied);
}
void _confirmLeave(BuildContext context) async {
Future<void> _confirmLeave(BuildContext context) async {
final confirmed = await showConfirmDialog(
context,
title: provider.isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion,
@@ -267,11 +268,11 @@ class _SessionMenuSheet extends StatelessWidget {
isDestructive: true,
);
if (confirmed) {
if (!confirmed || !context.mounted) return;
OverlaySheetController.closeAdaptive(context);
unawaited(provider.leaveSession());
onLeaveSession?.call();
}
}
}
class ParticipantNotificationOverlay extends StatefulWidget {
+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;
}
+16 -53
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,
);
},
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,
),
],
);
+10
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,7 +46,14 @@ class DeviceCodeDialog extends StatelessWidget {
Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
Center(
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(
@@ -61,11 +69,13 @@ class DeviceCodeDialog extends StatelessWidget {
),
),
),
),
const SizedBox(height: 16),
SizedBox(
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,
),
);
}
+19
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,6 +105,7 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
child: Scaffold(
body: CustomScrollView(
slivers: [
if (!widget.focusableAppBarActions || !_appBarFocusEnabled)
ExcludeFocus(
child: CustomAppBar(
title: widget.title,
@@ -101,6 +113,13 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
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;
+13 -33
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,48 +564,20 @@ 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(
BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded),
Flexible(
child: Focus(
focusNode: _listFocusNode,
descendantsAreFocusable: false,
autofocus: InputModeTracker.isKeyboardMode(context),
onKeyEvent: _handleKeyEvent,
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys),
),
),
],
);
},
);
}
/// Build library list for dialog (TV) using ListView with scroll-into-view support
+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)],
),
);
}
+55 -37
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,23 +274,35 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
color: tk.surface,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(tk.radiusLg),
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: 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(
@@ -318,38 +338,42 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
],
),
),
],
),
),
),
),
FocusableActionBar(
key: _transportKey,
onNavigateLeft: _detailsFocusNode.requestFocus,
actions: [
if (widget.desktop)
IconButton(
icon: AppIcon(Symbols.skip_previous_rounded, fill: 1, color: tk.text),
FocusableAction(
icon: Symbols.skip_previous_rounded,
iconColor: 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,
),
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()),
),
IconButton(
icon: AppIcon(Symbols.skip_next_rounded, fill: 1, color: tk.text),
FocusableAction(
icon: Symbols.skip_next_rounded,
iconColor: 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),
FocusableAction(
icon: Symbols.close_rounded,
iconColor: tk.textMuted,
tooltip: t.music.stopPlayback,
onPressed: widget.onDismissed,
),
),
],
),
],
),
@@ -357,7 +381,6 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
],
),
),
),
);
card = DecoratedBox(
@@ -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;
}
}
+11 -1
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,7 +56,14 @@ class OAuthProxyDialog extends StatelessWidget {
),
),
const SizedBox(height: 16),
InkWell(
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(
@@ -70,11 +78,13 @@ class OAuthProxyDialog extends StatelessWidget {
),
),
),
),
const SizedBox(height: 8),
SizedBox(
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),
+19 -20
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,14 +43,14 @@ class SettingSwitchTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<bool>(
valueListenable: svc.listenable(pref),
builder: (_, value, _) => ClickableCursor(
enabled: enabled,
child: SwitchListTile(
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);
@@ -59,7 +59,6 @@ class SettingSwitchTile extends StatelessWidget {
}
: null,
),
),
);
}
}
@@ -87,15 +86,15 @@ class SettingNavigationTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClickableCursor(
child: ListTile(
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,12 +129,13 @@ class SettingNumberTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<int>(
valueListenable: svc.listenable(pref),
builder: (_, value, _) => ClickableCursor(
child: ListTile(
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,
@@ -151,7 +151,6 @@ class SettingNumberTile extends StatelessWidget {
},
),
),
),
);
}
}
@@ -188,12 +187,13 @@ class SettingSelectionTile<T, S> extends StatelessWidget {
valueListenable: svc.listenable(pref),
builder: (_, raw, _) {
final value = decode(raw);
return ClickableCursor(
child: ListTile(
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,
@@ -206,7 +206,6 @@ class SettingSelectionTile<T, S> extends StatelessWidget {
final callback = onAfterWrite;
if (callback != null) await callback(picked);
},
),
);
},
);
@@ -237,12 +236,13 @@ class SettingRegexTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<String>(
valueListenable: svc.listenable(pref),
builder: (_, value, _) => ClickableCursor(
child: ListTile(
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,
@@ -255,7 +255,6 @@ class SettingRegexTile extends StatelessWidget {
},
),
),
),
);
}
}
@@ -329,8 +328,7 @@ class SettingColorTile extends StatelessWidget {
final svc = _TileBase._svc;
return ValueListenableBuilder<String>(
valueListenable: svc.listenable(pref),
builder: (_, hex, _) => ClickableCursor(
child: ListTile(
builder: (_, hex, _) => FocusableListTile(
leading: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
@@ -343,6 +341,8 @@ class SettingColorTile extends StatelessWidget {
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
),
dense: false,
visualDensity: VisualDensity.standard,
onTap: () => showColorInputDialog(
context: context,
title: title,
@@ -354,7 +354,6 @@ class SettingColorTile extends StatelessWidget {
},
),
),
),
);
}
}
+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,19 +232,9 @@ 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(
child: FocusableSlider(
focusNode: widget.sliderFocusNode,
value: sliderValue,
min: _sliderMin,
@@ -253,15 +242,9 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
divisions: _sliderDivisions,
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
onChanged: (value) {
setState(() {
_currentOffset = value;
});
},
onChangeEnd: (value) {
_applyOffset(value);
},
),
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,
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_slider.dart';
void main() {
testWidgets('D-pad adjustment reports a complete persisted change', (tester) async {
final focusNode = FocusNode(debugLabel: 'slider');
addTearDown(focusNode.dispose);
final starts = <double>[];
final changes = <double>[];
final ends = <double>[];
var value = 0.0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => FocusableSlider(
focusNode: focusNode,
value: value,
min: 0,
max: 10,
divisions: 10,
onChangeStart: starts.add,
onChanged: (next) {
changes.add(next);
setState(() => value = next);
},
onChangeEnd: ends.add,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(starts, [0.0]);
expect(changes, [1.0]);
expect(ends, [1.0]);
expect(value, 1.0);
});
testWidgets('SELECT invokes the slider action once', (tester) async {
final focusNode = FocusNode(debugLabel: 'slider');
addTearDown(focusNode.dispose);
var selected = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableSlider(focusNode: focusNode, value: 0, onChanged: (_) {}, onSelect: () => selected++),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(selected, 1);
});
}
+26
View File
@@ -12,6 +12,7 @@ void main() {
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
});
testWidgets('tvOS physical keyboard back runs on key down and suppresses key up', (tester) async {
@@ -74,6 +75,31 @@ void main() {
await tester.pump();
});
group('BackKeyCoordinator', () {
testWidgets('suppresses one parallel back dispatch in the current frame', (tester) async {
BackKeyCoordinator.markHandled();
expect(BackKeyCoordinator.consumeIfHandled(), isTrue);
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
await tester.pump();
});
testWidgets('does not suppress an independent system back in a later frame', (tester) async {
BackKeyCoordinator.markHandled();
await tester.pump();
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
});
testWidgets('clear discards a pending duplicate marker', (tester) async {
BackKeyCoordinator.markHandled();
BackKeyCoordinator.clear();
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
await tester.pump();
});
});
group('dpadKeyHandler trapHorizontalEdges', () {
testWidgets('consumes edge LEFT/RIGHT so focus cannot escape the group', (tester) async {
final trapped = FocusNode(debugLabel: 'trapped');
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
void main() {
testWidgets('autofocus and back routing are forwarded to the focus wrapper', (tester) async {
final focusNode = FocusNode(debugLabel: 'dialog action');
addTearDown(focusNode.dispose);
var backed = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialogActionButton(
focusNode: focusNode,
autofocus: true,
onPressed: () {},
onBack: () => backed++,
label: 'Save',
),
),
),
);
await tester.pump();
expect(focusNode.hasFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(backed, 1);
});
testWidgets('nullable callback keeps its graph position while disabling activation', (tester) async {
final focusNode = FocusNode(debugLabel: 'disabled dialog action');
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialogActionButton(
focusNode: focusNode,
autofocus: true,
onPressed: null,
label: 'Unavailable',
isPrimary: true,
),
),
),
);
await tester.pump();
expect(focusNode.hasFocus, isTrue);
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(focusNode.hasFocus, isTrue);
});
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
void main() {
testWidgets('switch tile toggles once from SELECT', (tester) async {
final focusNode = FocusNode(debugLabel: 'switch');
addTearDown(focusNode.dispose);
var value = false;
var changes = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => FocusableSwitchListTile(
focusNode: focusNode,
value: value,
title: const Text('Switch'),
onChanged: (next) {
changes++;
setState(() => value = next);
},
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(value, isTrue);
expect(changes, 1);
});
testWidgets('checkbox tile toggles once from SELECT', (tester) async {
final focusNode = FocusNode(debugLabel: 'checkbox');
addTearDown(focusNode.dispose);
var value = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => FocusableCheckboxListTile(
focusNode: focusNode,
value: value,
title: const Text('Checkbox'),
onChanged: (next) => setState(() => value = next ?? false),
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(value, isTrue);
});
testWidgets('disabled switch tile cannot be focused or activated', (tester) async {
final focusNode = FocusNode(debugLabel: 'disabled switch');
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableSwitchListTile(
focusNode: focusNode,
value: false,
title: const Text('Disabled'),
onChanged: null,
),
),
),
);
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, isFalse);
});
}
+16
View File
@@ -240,5 +240,21 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
});
testWidgets('system back in a later frame is not mistaken for a duplicate TV key', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(find.text('Open'), findsOneWidget);
expect(backs, 0);
});
});
}
+7 -1
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
@@ -524,7 +525,8 @@ void main() {
var parentBuilds = 0;
await tester.pumpWidget(
MaterialApp(
InputModeTracker(
child: MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: Builder(
@@ -541,12 +543,16 @@ void main() {
),
),
),
),
);
final item = find.byType(NavigationRailItem);
expect(_railItemDecoration(tester, item)?.color, isNull);
expect(parentBuilds, 1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
focusNode.requestFocus();
await tester.pump();