fix: align UI focus and sheet behavior
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user