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.
|
/// Tracks the timer and physical key state for a D-pad SELECT long press.
|
||||||
///
|
///
|
||||||
/// Focus loss, context-menu dispatch, and transferred/touch gesture suppression
|
/// Focus loss, context-menu dispatch, and transferred/touch gesture suppression
|
||||||
/// stay with the caller because their behavior differs between widgets. The TV
|
/// stay with the caller because their behavior differs between widgets.
|
||||||
/// guide program selector also stays local: it captures a program at key-down
|
|
||||||
/// and resets its state before opening program details.
|
|
||||||
class DpadSelectLongPressController {
|
class DpadSelectLongPressController {
|
||||||
static const defaultDuration = Duration(milliseconds: 500);
|
static const defaultDuration = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'dpad_navigator.dart';
|
import 'dpad_navigator.dart';
|
||||||
import 'focusable_wrapper.dart';
|
import 'focusable_wrapper.dart';
|
||||||
@@ -10,9 +11,14 @@ class FocusableSlider extends StatefulWidget {
|
|||||||
final double max;
|
final double max;
|
||||||
final int? divisions;
|
final int? divisions;
|
||||||
final ValueChanged<double>? onChanged;
|
final ValueChanged<double>? onChanged;
|
||||||
|
final ValueChanged<double>? onChangeStart;
|
||||||
|
final ValueChanged<double>? onChangeEnd;
|
||||||
|
final VoidCallback? onSelect;
|
||||||
final FocusNode? focusNode;
|
final FocusNode? focusNode;
|
||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
|
|
||||||
|
final Color? activeColor;
|
||||||
|
final Color? inactiveColor;
|
||||||
const FocusableSlider({
|
const FocusableSlider({
|
||||||
super.key,
|
super.key,
|
||||||
required this.value,
|
required this.value,
|
||||||
@@ -20,8 +26,13 @@ class FocusableSlider extends StatefulWidget {
|
|||||||
this.max = 1.0,
|
this.max = 1.0,
|
||||||
this.divisions,
|
this.divisions,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
|
this.onChangeStart,
|
||||||
|
this.onChangeEnd,
|
||||||
|
this.onSelect,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
|
this.activeColor,
|
||||||
|
this.inactiveColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,10 +55,16 @@ class _FocusableSliderState extends State<FocusableSlider> {
|
|||||||
if (event.isActionable && widget.onChanged != null) {
|
if (event.isActionable && widget.onChanged != null) {
|
||||||
final delta = key.isRightKey ? _step : -_step;
|
final delta = key.isRightKey ? _step : -_step;
|
||||||
final newValue = (widget.value + delta).clamp(widget.min, widget.max);
|
final newValue = (widget.value + delta).clamp(widget.min, widget.max);
|
||||||
|
widget.onChangeStart?.call(widget.value);
|
||||||
widget.onChanged!(newValue);
|
widget.onChanged!(newValue);
|
||||||
|
widget.onChangeEnd?.call(newValue);
|
||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
if (key.isSelectKey && event is KeyDownEvent && widget.onSelect != null) {
|
||||||
|
widget.onSelect!();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +91,10 @@ class _FocusableSliderState extends State<FocusableSlider> {
|
|||||||
max: widget.max,
|
max: widget.max,
|
||||||
divisions: widget.divisions,
|
divisions: widget.divisions,
|
||||||
onChanged: widget.onChanged,
|
onChanged: widget.onChanged,
|
||||||
|
onChangeStart: widget.onChangeStart,
|
||||||
|
onChangeEnd: widget.onChangeEnd,
|
||||||
|
activeColor: widget.activeColor,
|
||||||
|
inactiveColor: widget.inactiveColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,24 +32,35 @@ import 'dpad_navigator.dart';
|
|||||||
class BackKeyCoordinator {
|
class BackKeyCoordinator {
|
||||||
static bool _handledThisFrame = false;
|
static bool _handledThisFrame = false;
|
||||||
static bool _clearScheduled = false;
|
static bool _clearScheduled = false;
|
||||||
|
static int _clearGeneration = 0;
|
||||||
|
|
||||||
static void markHandled() {
|
static void markHandled() {
|
||||||
_handledThisFrame = true;
|
_handledThisFrame = true;
|
||||||
if (_clearScheduled) return;
|
if (_clearScheduled) return;
|
||||||
|
|
||||||
_clearScheduled = true;
|
_clearScheduled = true;
|
||||||
// Clear on next frame to avoid blocking unrelated future back presses.
|
final clearGeneration = _clearGeneration;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (clearGeneration != _clearGeneration) return;
|
||||||
_handledThisFrame = false;
|
_handledThisFrame = false;
|
||||||
_clearScheduled = 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() {
|
static bool consumeIfHandled() {
|
||||||
if (_handledThisFrame) {
|
if (!_handledThisFrame) return false;
|
||||||
_handledThisFrame = false;
|
clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -336,6 +336,8 @@
|
|||||||
"clearShortcut": "Clear shortcut",
|
"clearShortcut": "Clear shortcut",
|
||||||
"noShortcutSet": "No shortcut set",
|
"noShortcutSet": "No shortcut set",
|
||||||
"currentShortcut": "Current shortcut:",
|
"currentShortcut": "Current shortcut:",
|
||||||
|
"pressToRecord": "Select to record a shortcut",
|
||||||
|
"recordingShortcut": "Press the shortcut now",
|
||||||
"actions": {
|
"actions": {
|
||||||
"playPause": "Play/Pause",
|
"playPause": "Play/Pause",
|
||||||
"volumeUp": "Volume Up",
|
"volumeUp": "Volume Up",
|
||||||
@@ -434,7 +436,9 @@
|
|||||||
"hue": "Hue",
|
"hue": "Hue",
|
||||||
"saturation": "Saturation",
|
"saturation": "Saturation",
|
||||||
"brightness": "Brightness",
|
"brightness": "Brightness",
|
||||||
"hexColor": "Hex color"
|
"hexColor": "Hex color",
|
||||||
|
"expandText": "Expand text",
|
||||||
|
"collapseText": "Collapse text"
|
||||||
},
|
},
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"shufflePlay": "Shuffle play",
|
"shufflePlay": "Shuffle play",
|
||||||
@@ -1048,6 +1052,8 @@
|
|||||||
"hostingSession": "Hosting Session",
|
"hostingSession": "Hosting Session",
|
||||||
"inSession": "In Session",
|
"inSession": "In Session",
|
||||||
"sessionCode": "Session Code",
|
"sessionCode": "Session Code",
|
||||||
|
"openSessionControls": "Open Watch Together session controls",
|
||||||
|
"copySessionCode": "Copy session code",
|
||||||
"hostControlsPlayback": "Host controls playback",
|
"hostControlsPlayback": "Host controls playback",
|
||||||
"anyoneCanControl": "Anyone can control playback",
|
"anyoneCanControl": "Anyone can control playback",
|
||||||
"hostControls": "Host controls",
|
"hostControls": "Host controls",
|
||||||
@@ -1358,6 +1364,8 @@
|
|||||||
"artworkUpdated": "Artwork updated",
|
"artworkUpdated": "Artwork updated",
|
||||||
"artworkUpdateFailed": "Failed to update artwork",
|
"artworkUpdateFailed": "Failed to update artwork",
|
||||||
"noArtworkAvailable": "No artwork available",
|
"noArtworkAvailable": "No artwork available",
|
||||||
|
"artworkOption": "Artwork option ${index}",
|
||||||
|
"selectedArtworkOption": "Artwork option ${index}, selected",
|
||||||
"notSet": "Not set",
|
"notSet": "Not set",
|
||||||
"libraryDefault": "Library default",
|
"libraryDefault": "Library default",
|
||||||
"accountDefault": "Account default",
|
"accountDefault": "Account default",
|
||||||
@@ -1485,6 +1493,7 @@
|
|||||||
"title": "Activate Plezy on ${service}",
|
"title": "Activate Plezy on ${service}",
|
||||||
"body": "Visit ${url} and enter this code:",
|
"body": "Visit ${url} and enter this code:",
|
||||||
"openToActivate": "Open ${service} to activate",
|
"openToActivate": "Open ${service} to activate",
|
||||||
|
"copyCode": "Copy activation code",
|
||||||
"waitingForAuthorization": "Waiting for authorization…",
|
"waitingForAuthorization": "Waiting for authorization…",
|
||||||
"codeCopied": "Code copied"
|
"codeCopied": "Code copied"
|
||||||
},
|
},
|
||||||
@@ -1492,6 +1501,7 @@
|
|||||||
"title": "Sign in to ${service}",
|
"title": "Sign in to ${service}",
|
||||||
"body": "Scan this QR code or open the URL on any device.",
|
"body": "Scan this QR code or open the URL on any device.",
|
||||||
"openToSignIn": "Open ${service} to sign in",
|
"openToSignIn": "Open ${service} to sign in",
|
||||||
|
"copyUrl": "Copy sign-in URL",
|
||||||
"urlCopied": "URL copied"
|
"urlCopied": "URL copied"
|
||||||
},
|
},
|
||||||
"libraryFilter": {
|
"libraryFilter": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 16
|
/// Locales: 16
|
||||||
/// Strings: 22514 (1407 per locale)
|
/// Strings: 22524 (1407 per locale)
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|||||||
@@ -1127,6 +1127,12 @@ class TranslationsHotkeysEn {
|
|||||||
/// en: 'Current shortcut:'
|
/// en: 'Current shortcut:'
|
||||||
String get currentShortcut => '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);
|
late final TranslationsHotkeysActionsEn actions = TranslationsHotkeysActionsEn.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1356,6 +1362,12 @@ class TranslationsAccessibilityEn {
|
|||||||
|
|
||||||
/// en: 'Hex color'
|
/// en: 'Hex color'
|
||||||
String get hexColor => '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
|
// Path: tooltips
|
||||||
@@ -3026,6 +3038,12 @@ class TranslationsWatchTogetherEn {
|
|||||||
/// en: 'Session Code'
|
/// en: 'Session Code'
|
||||||
String get sessionCode => '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'
|
/// en: 'Host controls playback'
|
||||||
String get hostControlsPlayback => 'Host controls playback';
|
String get hostControlsPlayback => 'Host controls playback';
|
||||||
|
|
||||||
@@ -3775,6 +3793,12 @@ class TranslationsMetadataEditEn {
|
|||||||
/// en: 'No artwork available'
|
/// en: 'No artwork available'
|
||||||
String get noArtworkAvailable => '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'
|
/// en: 'Not set'
|
||||||
String get notSet => 'Not set';
|
String get notSet => 'Not set';
|
||||||
|
|
||||||
@@ -4836,6 +4860,9 @@ class TranslationsServicesDeviceCodeEn {
|
|||||||
/// en: 'Open ${service} to activate'
|
/// en: 'Open ${service} to activate'
|
||||||
String openToActivate({required Object service}) => '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…'
|
/// en: 'Waiting for authorization…'
|
||||||
String get waitingForAuthorization => 'Waiting for authorization…';
|
String get waitingForAuthorization => 'Waiting for authorization…';
|
||||||
|
|
||||||
@@ -4860,6 +4887,9 @@ class TranslationsServicesOauthProxyEn {
|
|||||||
/// en: 'Open ${service} to sign in'
|
/// en: 'Open ${service} to sign in'
|
||||||
String openToSignIn({required Object service}) => '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'
|
/// en: 'URL copied'
|
||||||
String get urlCopied => 'URL copied';
|
String get urlCopied => 'URL copied';
|
||||||
}
|
}
|
||||||
@@ -5239,6 +5269,8 @@ extension on Translations {
|
|||||||
'hotkeys.clearShortcut' => 'Clear shortcut',
|
'hotkeys.clearShortcut' => 'Clear shortcut',
|
||||||
'hotkeys.noShortcutSet' => 'No shortcut set',
|
'hotkeys.noShortcutSet' => 'No shortcut set',
|
||||||
'hotkeys.currentShortcut' => 'Current shortcut:',
|
'hotkeys.currentShortcut' => 'Current shortcut:',
|
||||||
|
'hotkeys.pressToRecord' => 'Select to record a shortcut',
|
||||||
|
'hotkeys.recordingShortcut' => 'Press the shortcut now',
|
||||||
'hotkeys.actions.playPause' => 'Play/Pause',
|
'hotkeys.actions.playPause' => 'Play/Pause',
|
||||||
'hotkeys.actions.volumeUp' => 'Volume Up',
|
'hotkeys.actions.volumeUp' => 'Volume Up',
|
||||||
'hotkeys.actions.volumeDown' => 'Volume Down',
|
'hotkeys.actions.volumeDown' => 'Volume Down',
|
||||||
@@ -5328,6 +5360,8 @@ extension on Translations {
|
|||||||
'accessibility.saturation' => 'Saturation',
|
'accessibility.saturation' => 'Saturation',
|
||||||
'accessibility.brightness' => 'Brightness',
|
'accessibility.brightness' => 'Brightness',
|
||||||
'accessibility.hexColor' => 'Hex color',
|
'accessibility.hexColor' => 'Hex color',
|
||||||
|
'accessibility.expandText' => 'Expand text',
|
||||||
|
'accessibility.collapseText' => 'Collapse text',
|
||||||
'tooltips.shufflePlay' => 'Shuffle play',
|
'tooltips.shufflePlay' => 'Shuffle play',
|
||||||
'tooltips.playTrailer' => 'Play trailer',
|
'tooltips.playTrailer' => 'Play trailer',
|
||||||
'tooltips.markAsWatched' => 'Mark as watched',
|
'tooltips.markAsWatched' => 'Mark as watched',
|
||||||
@@ -5425,12 +5459,12 @@ extension on Translations {
|
|||||||
'messages.fileInfoNotAvailable' => 'File information not available',
|
'messages.fileInfoNotAvailable' => 'File information not available',
|
||||||
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
|
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
|
||||||
'messages.errorLoadingSeries' => 'Error loading series',
|
'messages.errorLoadingSeries' => 'Error loading series',
|
||||||
|
_ => null,
|
||||||
|
} ?? switch (path) {
|
||||||
'messages.musicNotSupported' => 'Music playback is not yet supported',
|
'messages.musicNotSupported' => 'Music playback is not yet supported',
|
||||||
'messages.noDescriptionAvailable' => 'No description available',
|
'messages.noDescriptionAvailable' => 'No description available',
|
||||||
'messages.noProfilesAvailable' => 'No profiles available',
|
'messages.noProfilesAvailable' => 'No profiles available',
|
||||||
'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles',
|
'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles',
|
||||||
_ => null,
|
|
||||||
} ?? switch (path) {
|
|
||||||
'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item',
|
'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item',
|
||||||
'messages.logsCleared' => 'Logs cleared',
|
'messages.logsCleared' => 'Logs cleared',
|
||||||
'messages.logsCopied' => 'Logs copied to clipboard',
|
'messages.logsCopied' => 'Logs copied to clipboard',
|
||||||
@@ -5876,6 +5910,8 @@ extension on Translations {
|
|||||||
'watchTogether.hostingSession' => 'Hosting Session',
|
'watchTogether.hostingSession' => 'Hosting Session',
|
||||||
'watchTogether.inSession' => 'In Session',
|
'watchTogether.inSession' => 'In Session',
|
||||||
'watchTogether.sessionCode' => 'Session Code',
|
'watchTogether.sessionCode' => 'Session Code',
|
||||||
|
'watchTogether.openSessionControls' => 'Open Watch Together session controls',
|
||||||
|
'watchTogether.copySessionCode' => 'Copy session code',
|
||||||
'watchTogether.hostControlsPlayback' => 'Host controls playback',
|
'watchTogether.hostControlsPlayback' => 'Host controls playback',
|
||||||
'watchTogether.anyoneCanControl' => 'Anyone can control playback',
|
'watchTogether.anyoneCanControl' => 'Anyone can control playback',
|
||||||
'watchTogether.hostControls' => 'Host controls',
|
'watchTogether.hostControls' => 'Host controls',
|
||||||
@@ -5937,14 +5973,14 @@ extension on Translations {
|
|||||||
'downloads.downloadNow' => 'Download',
|
'downloads.downloadNow' => 'Download',
|
||||||
'downloads.deleteDownload' => 'Delete download',
|
'downloads.deleteDownload' => 'Delete download',
|
||||||
'downloads.retryDownload' => 'Retry download',
|
'downloads.retryDownload' => 'Retry download',
|
||||||
|
_ => null,
|
||||||
|
} ?? switch (path) {
|
||||||
'downloads.downloadQueued' => 'Download queued',
|
'downloads.downloadQueued' => 'Download queued',
|
||||||
'downloads.downloadResumed' => 'Download resumed',
|
'downloads.downloadResumed' => 'Download resumed',
|
||||||
'downloads.serverErrorBitrate' => 'Server error: file may exceed the remote bitrate limit',
|
'downloads.serverErrorBitrate' => 'Server error: file may exceed the remote bitrate limit',
|
||||||
'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download',
|
'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download',
|
||||||
'downloads.downloadDeleted' => 'Download deleted',
|
'downloads.downloadDeleted' => 'Download deleted',
|
||||||
'downloads.deleteConfirm' => ({required Object title}) => 'Delete "${title}" from this device?',
|
'downloads.deleteConfirm' => ({required Object title}) => 'Delete "${title}" from this device?',
|
||||||
_ => null,
|
|
||||||
} ?? switch (path) {
|
|
||||||
'downloads.cancelledDownloadTitle' => 'Cancelled Download',
|
'downloads.cancelledDownloadTitle' => 'Cancelled Download',
|
||||||
'downloads.cancelledDownloadMessage' => 'This download was cancelled. What would you like to do?',
|
'downloads.cancelledDownloadMessage' => 'This download was cancelled. What would you like to do?',
|
||||||
'downloads.allEpisodesAlreadyDownloaded' => 'All episodes already downloaded',
|
'downloads.allEpisodesAlreadyDownloaded' => 'All episodes already downloaded',
|
||||||
@@ -6166,6 +6202,8 @@ extension on Translations {
|
|||||||
'metadataEdit.artworkUpdated' => 'Artwork updated',
|
'metadataEdit.artworkUpdated' => 'Artwork updated',
|
||||||
'metadataEdit.artworkUpdateFailed' => 'Failed to update artwork',
|
'metadataEdit.artworkUpdateFailed' => 'Failed to update artwork',
|
||||||
'metadataEdit.noArtworkAvailable' => 'No artwork available',
|
'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.notSet' => 'Not set',
|
||||||
'metadataEdit.libraryDefault' => 'Library default',
|
'metadataEdit.libraryDefault' => 'Library default',
|
||||||
'metadataEdit.accountDefault' => 'Account default',
|
'metadataEdit.accountDefault' => 'Account default',
|
||||||
@@ -6280,11 +6318,13 @@ extension on Translations {
|
|||||||
'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}',
|
'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}',
|
||||||
'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:',
|
'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:',
|
||||||
'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate',
|
'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate',
|
||||||
|
'services.deviceCode.copyCode' => 'Copy activation code',
|
||||||
'services.deviceCode.waitingForAuthorization' => 'Waiting for authorization…',
|
'services.deviceCode.waitingForAuthorization' => 'Waiting for authorization…',
|
||||||
'services.deviceCode.codeCopied' => 'Code copied',
|
'services.deviceCode.codeCopied' => 'Code copied',
|
||||||
'services.oauthProxy.title' => ({required Object service}) => 'Sign in to ${service}',
|
'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.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.openToSignIn' => ({required Object service}) => 'Open ${service} to sign in',
|
||||||
|
'services.oauthProxy.copyUrl' => 'Copy sign-in URL',
|
||||||
'services.oauthProxy.urlCopied' => 'URL copied',
|
'services.oauthProxy.urlCopied' => 'URL copied',
|
||||||
'services.libraryFilter.title' => 'Library filter',
|
'services.libraryFilter.title' => 'Library filter',
|
||||||
'services.libraryFilter.subtitleAllSyncing' => 'Syncing all libraries',
|
'services.libraryFilter.subtitleAllSyncing' => 'Syncing all libraries',
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
FocusableButton(
|
FocusableButton(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onPressed: busy ? null : startQr,
|
onPressed: busy ? null : startQr,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: busy ? null : startQr,
|
onPressed: busy ? null : startQr,
|
||||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||||
@@ -347,6 +348,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
] else ...[
|
] else ...[
|
||||||
FocusableButton(
|
FocusableButton(
|
||||||
onPressed: busy ? null : startBrowser,
|
onPressed: busy ? null : startBrowser,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: busy ? null : startBrowser,
|
onPressed: busy ? null : startBrowser,
|
||||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||||
@@ -512,8 +514,8 @@ class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDis
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
DialogActionButton(onPressed: _busy ? () {} : () => Navigator.of(context).pop(), label: t.common.cancel),
|
DialogActionButton(onPressed: _busy ? null : () => Navigator.of(context).pop(), label: t.common.cancel),
|
||||||
DialogActionButton(onPressed: _busy ? () {} : _submit, label: t.auth.authenticate, isPrimary: true),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final item = widget.item;
|
final item = widget.item;
|
||||||
@@ -459,151 +464,154 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
// remain route-driven. The overlay host always gets first refusal.
|
// remain route-driven. The overlay host always gets first refusal.
|
||||||
return OverlaySheetHost(
|
return OverlaySheetHost(
|
||||||
canPop: !blockSystemBack,
|
canPop: !blockSystemBack,
|
||||||
child: Focus(
|
onSystemBack: _handleSystemBack,
|
||||||
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
|
child: Builder(
|
||||||
child: Scaffold(
|
builder: (hostContext) => Focus(
|
||||||
body: Stack(
|
onKeyEvent: (_, event) => handleBackKeyNavigation(hostContext, event),
|
||||||
children: [
|
child: Scaffold(
|
||||||
SingleChildScrollView(
|
body: Stack(
|
||||||
key: const Key('catalog_detail_scroll'),
|
children: [
|
||||||
controller: _scrollController,
|
SingleChildScrollView(
|
||||||
// The backdrop lives inside the scrollable so it moves with
|
key: const Key('catalog_detail_scroll'),
|
||||||
// the content (it extends under the status bar, so the safe
|
controller: _scrollController,
|
||||||
// areas are baked into the content padding instead of a
|
// The backdrop lives inside the scrollable so it moves with
|
||||||
// SafeArea around the scroll view).
|
// the content (it extends under the status bar, so the safe
|
||||||
child: Stack(
|
// areas are baked into the content padding instead of a
|
||||||
children: [
|
// SafeArea around the scroll view).
|
||||||
if (item.backdropUrl != null)
|
child: Stack(
|
||||||
Positioned(
|
children: [
|
||||||
top: 0,
|
if (item.backdropUrl != null)
|
||||||
left: 0,
|
Positioned(
|
||||||
right: 0,
|
top: 0,
|
||||||
height: 320,
|
left: 0,
|
||||||
child: ShaderMask(
|
right: 0,
|
||||||
shaderCallback: (rect) => LinearGradient(
|
height: 320,
|
||||||
begin: Alignment.topCenter,
|
child: ShaderMask(
|
||||||
end: Alignment.bottomCenter,
|
shaderCallback: (rect) => LinearGradient(
|
||||||
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
|
begin: Alignment.topCenter,
|
||||||
stops: const [0.3, 1.0],
|
end: Alignment.bottomCenter,
|
||||||
).createShader(rect),
|
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
|
||||||
blendMode: BlendMode.dstIn,
|
stops: const [0.3, 1.0],
|
||||||
child: OptimizedMediaImage.thumb(
|
).createShader(rect),
|
||||||
imagePath: item.backdropUrl,
|
blendMode: BlendMode.dstIn,
|
||||||
width: double.infinity,
|
child: OptimizedMediaImage.thumb(
|
||||||
height: 320,
|
imagePath: item.backdropUrl,
|
||||||
fit: BoxFit.cover,
|
width: double.infinity,
|
||||||
fallbackIcon: null,
|
height: 320,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallbackIcon: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
Padding(
|
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
|
||||||
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: .start,
|
||||||
crossAxisAlignment: .start,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Row(
|
crossAxisAlignment: .start,
|
||||||
crossAxisAlignment: .start,
|
children: [
|
||||||
children: [
|
ClipRRect(
|
||||||
ClipRRect(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210),
|
||||||
child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210),
|
),
|
||||||
),
|
const SizedBox(width: 20),
|
||||||
const SizedBox(width: 20),
|
Expanded(
|
||||||
Expanded(
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: .start,
|
||||||
crossAxisAlignment: .start,
|
children: [
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.title,
|
|
||||||
style: theme.textTheme.headlineMedium,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: .ellipsis,
|
|
||||||
),
|
|
||||||
if (_metaLine.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
Text(
|
||||||
_metaLine,
|
item.title,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.headlineMedium,
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
maxLines: 3,
|
||||||
),
|
overflow: .ellipsis,
|
||||||
),
|
),
|
||||||
],
|
if (_metaLine.isNotEmpty) ...[
|
||||||
if (item.genres?.isNotEmpty ?? false) ...[
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Text(
|
_metaLine,
|
||||||
item.genres!.join(' • '),
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
if (item.genres?.isNotEmpty ?? false) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 8),
|
||||||
if (_watchlistSource != null || _requestSource != null)
|
Text(
|
||||||
FocusableActionBar(
|
item.genres!.join(' • '),
|
||||||
key: _actionBarKey,
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
onNavigateDown: _focusSectionBelowActions,
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
actions: [
|
),
|
||||||
if (_watchlistSource != null)
|
),
|
||||||
FocusableAction(
|
],
|
||||||
icon: onWatchlist ?? false
|
const SizedBox(height: 16),
|
||||||
? Symbols.bookmark_added_rounded
|
if (_watchlistSource != null || _requestSource != null)
|
||||||
: Symbols.bookmark_add_rounded,
|
FocusableActionBar(
|
||||||
tooltip: onWatchlist ?? false
|
key: _actionBarKey,
|
||||||
? t.explore.removeFromWatchlist
|
onNavigateDown: _focusSectionBelowActions,
|
||||||
: t.explore.addToWatchlist,
|
actions: [
|
||||||
onPressed: onWatchlist == null
|
if (_watchlistSource != null)
|
||||||
? () {}
|
FocusableAction(
|
||||||
: () => unawaited(_toggleWatchlist()),
|
icon: onWatchlist ?? false
|
||||||
),
|
? Symbols.bookmark_added_rounded
|
||||||
if (_requestSource case final SeerrCatalogSource seerr)
|
: Symbols.bookmark_add_rounded,
|
||||||
FocusableAction(
|
tooltip: onWatchlist ?? false
|
||||||
icon: Symbols.download_rounded,
|
? t.explore.removeFromWatchlist
|
||||||
tooltip: t.seerr.request,
|
: t.explore.addToWatchlist,
|
||||||
onPressed: () => unawaited(
|
onPressed: onWatchlist == null
|
||||||
showSeerrRequestSheet(
|
? () {}
|
||||||
context,
|
: () => unawaited(_toggleWatchlist()),
|
||||||
source: seerr,
|
),
|
||||||
kind: item.kind,
|
if (_requestSource case final SeerrCatalogSource seerr)
|
||||||
tmdbId: item.ids.tmdb!,
|
FocusableAction(
|
||||||
title: item.title,
|
icon: Symbols.download_rounded,
|
||||||
|
tooltip: t.seerr.request,
|
||||||
|
onPressed: () => unawaited(
|
||||||
|
showSeerrRequestSheet(
|
||||||
|
hostContext,
|
||||||
|
source: seerr,
|
||||||
|
kind: item.kind,
|
||||||
|
tmdbId: item.ids.tmdb!,
|
||||||
|
title: item.title,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildLibrarySection(theme),
|
||||||
|
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_buildCastSection(theme, cast),
|
||||||
|
],
|
||||||
|
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildRelatedSection(related),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips],
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildLibrarySection(theme),
|
|
||||||
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
_buildCastSection(theme, cast),
|
|
||||||
],
|
],
|
||||||
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
),
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildRelatedSection(related),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(
|
||||||
Positioned(
|
top: 0,
|
||||||
top: 0,
|
left: 0,
|
||||||
left: 0,
|
child: DesktopAppBarHelper.buildAdjustedLeading(
|
||||||
child: DesktopAppBarHelper.buildAdjustedLeading(
|
const AppBarBackButton(style: BackButtonStyle.circular),
|
||||||
const AppBarBackButton(style: BackButtonStyle.circular),
|
context: hostContext,
|
||||||
context: context,
|
)!,
|
||||||
)!,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
import '../focus/focusable_text_field.dart';
|
import '../focus/focusable_text_field.dart';
|
||||||
|
import '../focus/focusable_button.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../mixins/debounced_media_search.dart';
|
import '../mixins/debounced_media_search.dart';
|
||||||
@@ -29,6 +30,7 @@ class CatalogSearchScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CatalogSearchScreenState extends State<CatalogSearchScreen> with DebouncedMediaSearch {
|
class _CatalogSearchScreenState extends State<CatalogSearchScreen> with DebouncedMediaSearch {
|
||||||
|
final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear');
|
||||||
@override
|
@override
|
||||||
String get searchDebugLabel => 'CatalogSearch';
|
String get searchDebugLabel => 'CatalogSearch';
|
||||||
|
|
||||||
@@ -44,6 +46,17 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
|
|||||||
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_clearFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSearch() {
|
||||||
|
searchController.clear();
|
||||||
|
searchFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sourceName = widget.source.displayName;
|
final sourceName = widget.source.displayName;
|
||||||
@@ -53,20 +66,33 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||||
child: FocusableTextField(
|
child: Stack(
|
||||||
controller: searchController,
|
alignment: Alignment.centerRight,
|
||||||
focusNode: searchFocusNode,
|
children: [
|
||||||
textInputAction: TextInputAction.search,
|
FocusableTextField(
|
||||||
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
controller: searchController,
|
||||||
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
focusNode: searchFocusNode,
|
||||||
decoration: pillInputDecoration(
|
textInputAction: TextInputAction.search,
|
||||||
context,
|
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
||||||
hintText: t.explore.searchHint(source: sourceName),
|
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
|
||||||
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
||||||
suffixIcon: searchController.text.isNotEmpty
|
decoration: pillInputDecoration(
|
||||||
? IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: searchController.clear)
|
context,
|
||||||
: null,
|
hintText: t.explore.searchHint(source: sourceName),
|
||||||
),
|
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
|||||||
_sendCommand(RemoteCommandType.tabSearch);
|
_sendCommand(RemoteCommandType.tabSearch);
|
||||||
}
|
}
|
||||||
final provider = context.read<CompanionRemoteProvider>();
|
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) {
|
void _sendCommand(RemoteCommandType type) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../widgets/server_activities_button.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../focus/focusable_action_bar.dart';
|
import '../focus/focusable_action_bar.dart';
|
||||||
|
import '../focus/focusable_button.dart';
|
||||||
import '../focus/hub_vertical_navigation.dart';
|
import '../focus/hub_vertical_navigation.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../focus/input_mode_tracker.dart';
|
||||||
import '../focus/key_event_utils.dart';
|
import '../focus/key_event_utils.dart';
|
||||||
@@ -1202,7 +1203,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_errorMessage!),
|
Text(_errorMessage!),
|
||||||
const SizedBox(height: 16),
|
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/catalog_source_logo.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../widgets/hub_section.dart';
|
import '../widgets/hub_section.dart';
|
||||||
|
import '../widgets/focusable_popup_menu_button.dart';
|
||||||
import '../widgets/settings_builder.dart';
|
import '../widgets/settings_builder.dart';
|
||||||
import '../widgets/rasterized_gradient.dart';
|
import '../widgets/rasterized_gradient.dart';
|
||||||
import '../widgets/tv_browse_rail.dart';
|
import '../widgets/tv_browse_rail.dart';
|
||||||
@@ -154,26 +155,39 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
CatalogSource active, {
|
CatalogSource active, {
|
||||||
TextStyle? textStyle,
|
TextStyle? textStyle,
|
||||||
AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start,
|
AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start,
|
||||||
|
bool parentOwnsFocus = false,
|
||||||
}) {
|
}) {
|
||||||
return AppMenuButton<CatalogSourceId>(
|
final trigger = Container(
|
||||||
key: _sourceMenuKey,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
children: [
|
||||||
|
CatalogSourceLogo(active.id, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(active.displayName, style: textStyle ?? Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
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,
|
tooltip: t.explore.selectSource,
|
||||||
|
semanticLabel: t.explore.selectSource,
|
||||||
anchorAlignment: anchorAlignment,
|
anchorAlignment: anchorAlignment,
|
||||||
onSelected: (id) => unawaited(sources.setActiveSource(id)),
|
onSelected: (id) => unawaited(sources.setActiveSource(id)),
|
||||||
entriesBuilder: (context) => _sourceMenuEntries(sources, active),
|
itemBuilder: (context) => _sourceMenuEntries(sources, active),
|
||||||
child: Container(
|
child: trigger,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
children: [
|
|
||||||
CatalogSourceLogo(active.id, size: 22),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(active.displayName, style: textStyle ?? Theme.of(context).textTheme.titleLarge),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +366,7 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
context,
|
context,
|
||||||
).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600),
|
).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600),
|
||||||
anchorAlignment: AppMenuAnchorAlignment.end,
|
anchorAlignment: AppMenuAnchorAlignment.end,
|
||||||
|
parentOwnsFocus: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (active != null)
|
if (active != null)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '../widgets/desktop_app_bar.dart';
|
|||||||
import '../widgets/loading_indicator_box.dart';
|
import '../widgets/loading_indicator_box.dart';
|
||||||
import '../widgets/overlay_sheet.dart';
|
import '../widgets/overlay_sheet.dart';
|
||||||
import '../focus/focusable_action_bar.dart';
|
import '../focus/focusable_action_bar.dart';
|
||||||
|
import '../focus/focusable_button.dart';
|
||||||
import '../focus/key_event_utils.dart';
|
import '../focus/key_event_utils.dart';
|
||||||
import '../mixins/grid_focus_node_mixin.dart';
|
import '../mixins/grid_focus_node_mixin.dart';
|
||||||
import 'libraries/sort_bottom_sheet.dart';
|
import 'libraries/sort_bottom_sheet.dart';
|
||||||
@@ -76,6 +77,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
|
|
||||||
/// Key for getting a context below OverlaySheetHost
|
/// Key for getting a context below OverlaySheetHost
|
||||||
final GlobalKey _overlayChildKey = GlobalKey();
|
final GlobalKey _overlayChildKey = GlobalKey();
|
||||||
|
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'hub_continuation_retry');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get hasItems => _filteredItems.isNotEmpty;
|
bool get hasItems => _filteredItems.isNotEmpty;
|
||||||
@@ -120,6 +122,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_continuation.dispose();
|
_continuation.dispose();
|
||||||
|
_continuationRetryFocusNode.dispose();
|
||||||
disposeFocusResources();
|
disposeFocusResources();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -426,7 +429,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
children: [
|
children: [
|
||||||
Text(error, textAlign: TextAlign.center),
|
Text(error, textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 8),
|
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,
|
isInContinueWatching: widget.isInContinueWatching,
|
||||||
usesContinueWatchingAction: widget.usesContinueWatchingAction,
|
usesContinueWatchingAction: widget.usesContinueWatchingAction,
|
||||||
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
||||||
|
onNavigateDown:
|
||||||
|
_continuation.error != null &&
|
||||||
|
position.index >= position.itemCount - position.columnCount
|
||||||
|
? _continuationRetryFocusNode.requestFocus
|
||||||
|
: null,
|
||||||
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
|
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
|
||||||
onBack: handleBackFromContent,
|
onBack: handleBackFromContent,
|
||||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
|
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
|
||||||
final controller = OverlaySheetController.of(context);
|
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.
|
/// 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
|
/// Open the program-details bottom sheet. The poster is resolved from
|
||||||
/// [posterThumb] on the server identified by [posterServerId].
|
/// [posterThumb] on the server identified by [posterServerId].
|
||||||
void showProgramDetails({
|
void showProgramDetails({
|
||||||
|
BuildContext? sheetContext,
|
||||||
required LiveTvProgram program,
|
required LiveTvProgram program,
|
||||||
required LiveTvChannel? channel,
|
required LiveTvChannel? channel,
|
||||||
required String? posterThumb,
|
required String? posterThumb,
|
||||||
required String? posterServerId,
|
required String? posterServerId,
|
||||||
}) {
|
}) {
|
||||||
final multiServer = context.read<MultiServerProvider>();
|
final effectiveContext = sheetContext ?? context;
|
||||||
|
final multiServer = effectiveContext.read<MultiServerProvider>();
|
||||||
final serverId = serverIdOrNull(posterServerId);
|
final serverId = serverIdOrNull(posterServerId);
|
||||||
final client = serverId == null ? null : multiServer.getClientForServer(serverId);
|
final client = serverId == null ? null : multiServer.getClientForServer(serverId);
|
||||||
String? posterUrl;
|
String? posterUrl;
|
||||||
@@ -60,13 +62,13 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
|
|||||||
thumbPath: posterThumb,
|
thumbPath: posterThumb,
|
||||||
maxWidth: 80,
|
maxWidth: 80,
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context),
|
devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(effectiveContext),
|
||||||
imageType: ImageType.poster,
|
imageType: ImageType.poster,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
showProgramDetailsSheet(
|
showProgramDetailsSheet(
|
||||||
context,
|
effectiveContext,
|
||||||
program: program,
|
program: program,
|
||||||
channel: channel,
|
channel: channel,
|
||||||
posterUrl: posterUrl,
|
posterUrl: posterUrl,
|
||||||
|
|||||||
@@ -535,6 +535,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
|||||||
|
|
||||||
OverlaySheetController.showAdaptive(
|
OverlaySheetController.showAdaptive(
|
||||||
context,
|
context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
builder: (sheetContext) => ReorderFavoritesSheet(
|
builder: (sheetContext) => ReorderFavoritesSheet(
|
||||||
favorites: List.from(_favoriteChannels),
|
favorites: List.from(_favoriteChannels),
|
||||||
channelMap: channelMap,
|
channelMap: channelMap,
|
||||||
@@ -712,6 +714,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
|||||||
FocusableButton(
|
FocusableButton(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onPressed: _loadChannels,
|
onPressed: _loadChannels,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _loadChannels,
|
onPressed: _loadChannels,
|
||||||
icon: const AppIcon(Symbols.refresh_rounded),
|
icon: const AppIcon(Symbols.refresh_rounded),
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
|
|||||||
return client?.liveTvDvr != null;
|
return client?.liveTvDvr != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRecordShow() async {
|
Future<void> _onRecordShow(BuildContext hostContext) async {
|
||||||
final client = context.read<MultiServerProvider>().getClientForServer(ServerId(widget.serverId));
|
final client = context.read<MultiServerProvider>().getClientForServer(ServerId(widget.serverId));
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
// Use the first program with a guid as the seed for `getSubscriptionTemplate`.
|
// 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;
|
if (seed == null) return;
|
||||||
await recordProgram(context, client, seed);
|
await recordProgram(hostContext, client, seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -119,38 +119,42 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
|
|||||||
return OverlaySheetHost(
|
return OverlaySheetHost(
|
||||||
// Close an open sheet on system back instead of popping the screen.
|
// Close an open sheet on system back instead of popping the screen.
|
||||||
canPop: true,
|
canPop: true,
|
||||||
child: FocusedScrollScaffold(
|
child: Builder(
|
||||||
title: Text(widget.showTitle),
|
builder: (hostContext) => FocusedScrollScaffold(
|
||||||
actions: showRecord
|
title: Text(widget.showTitle),
|
||||||
? [
|
actions: showRecord
|
||||||
FocusableActionBar(
|
? [
|
||||||
actions: [
|
FocusableActionBar(
|
||||||
FocusableAction(
|
actions: [
|
||||||
icon: Symbols.fiber_manual_record_rounded,
|
FocusableAction(
|
||||||
tooltip: t.liveTv.recordShow,
|
icon: Symbols.fiber_manual_record_rounded,
|
||||||
onPressed: _onRecordShow,
|
tooltip: t.liveTv.recordShow,
|
||||||
),
|
onPressed: () => _onRecordShow(hostContext),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
slivers: [
|
||||||
|
if (_isLoading)
|
||||||
|
LoadingIndicatorBox.sliver
|
||||||
|
else if (_programs.isEmpty)
|
||||||
|
SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms)))
|
||||||
|
else
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index, hostContext),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
|
||||||
: null,
|
|
||||||
slivers: [
|
|
||||||
if (_isLoading)
|
|
||||||
LoadingIndicatorBox.sliver
|
|
||||||
else if (_programs.isEmpty)
|
|
||||||
SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms)))
|
|
||||||
else
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SettingsGroup(
|
|
||||||
children: [for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index)],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScheduleItem(int index) {
|
Widget _buildScheduleItem(int index, BuildContext hostContext) {
|
||||||
final program = _programs[index];
|
final program = _programs[index];
|
||||||
final channel = findChannelForProgram(program);
|
final channel = findChannelForProgram(program);
|
||||||
void onTap() {
|
void onTap() {
|
||||||
@@ -158,6 +162,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
|
|||||||
tuneChannel(channel);
|
tuneChannel(channel);
|
||||||
} else {
|
} else {
|
||||||
showProgramDetails(
|
showProgramDetails(
|
||||||
|
sheetContext: hostContext,
|
||||||
program: program,
|
program: program,
|
||||||
channel: channel,
|
channel: channel,
|
||||||
posterThumb: program.thumb,
|
posterThumb: program.thumb,
|
||||||
@@ -173,7 +178,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
|
|||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
disableScale: true,
|
disableScale: true,
|
||||||
onSelect: onTap,
|
onSelect: onTap,
|
||||||
onBack: () => Navigator.pop(context),
|
onBack: () => Navigator.pop(hostContext),
|
||||||
child: _ScheduleListTile(program: program, channel: channel, onTap: onTap),
|
child: _ScheduleListTile(program: program, channel: channel, onTap: onTap),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ void showProgramDetailsSheet(
|
|||||||
}) {
|
}) {
|
||||||
OverlaySheetController.showAdaptive(
|
OverlaySheetController.showAdaptive(
|
||||||
context,
|
context,
|
||||||
|
showDragHandle: true,
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return _ProgramDetailsSheetContent(
|
return _ProgramDetailsSheetContent(
|
||||||
program: program,
|
program: program,
|
||||||
@@ -257,6 +258,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
|
|||||||
onNavigateRight: index < total - 1 ? () => _focusButton(index + 1) : null,
|
onNavigateRight: index < total - 1 ? () => _focusButton(index + 1) : null,
|
||||||
onNavigateUp: onNavigateUp,
|
onNavigateUp: onNavigateUp,
|
||||||
onBack: _closeSheet,
|
onBack: _closeSheet,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class RecordOptionsSheet {
|
|||||||
}) {
|
}) {
|
||||||
return OverlaySheetController.pushAdaptive<RecordOutcome>(
|
return OverlaySheetController.pushAdaptive<RecordOutcome>(
|
||||||
context,
|
context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
builder: (sheetContext) =>
|
builder: (sheetContext) =>
|
||||||
_RecordOptionsContent(client: client, headerTitle: program.displayTitle, entries: entries),
|
_RecordOptionsContent(client: client, headerTitle: program.displayTitle, entries: entries),
|
||||||
);
|
);
|
||||||
@@ -58,6 +60,8 @@ class RecordOptionsSheet {
|
|||||||
}) {
|
}) {
|
||||||
return OverlaySheetController.pushAdaptive<RecordOutcome>(
|
return OverlaySheetController.pushAdaptive<RecordOutcome>(
|
||||||
context,
|
context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
builder: (sheetContext) => _RecordOptionsContent(
|
builder: (sheetContext) => _RecordOptionsContent(
|
||||||
client: client,
|
client: client,
|
||||||
headerTitle: rule.title ?? '',
|
headerTitle: rule.title ?? '',
|
||||||
@@ -343,6 +347,7 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> {
|
|||||||
FocusableButton(
|
FocusableButton(
|
||||||
focusNode: _saveFocusNode,
|
focusNode: _saveFocusNode,
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving ? null : _save,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving ? null : _save,
|
||||||
icon: _saving
|
icon: _saving
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Focus(
|
child: Focus(
|
||||||
focusNode: _listFocusNode,
|
focusNode: _listFocusNode,
|
||||||
|
descendantsAreFocusable: false,
|
||||||
autofocus: isKeyboardMode,
|
autofocus: isKeyboardMode,
|
||||||
onKeyEvent: _handleKeyEvent,
|
onKeyEvent: _handleKeyEvent,
|
||||||
child: ReorderableListView.builder(
|
child: ReorderableListView.builder(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../../focus/dpad_navigator.dart';
|
import '../../../focus/dpad_navigator.dart';
|
||||||
|
import '../../../focus/dpad_select_long_press_controller.dart';
|
||||||
import '../../../focus/focus_theme.dart';
|
import '../../../focus/focus_theme.dart';
|
||||||
import '../../../focus/input_mode_tracker.dart';
|
import '../../../focus/input_mode_tracker.dart';
|
||||||
import '../../../focus/key_event_utils.dart';
|
import '../../../focus/key_event_utils.dart';
|
||||||
@@ -30,7 +31,6 @@ import '../../../utils/platform_detector.dart';
|
|||||||
import '../../../widgets/app_icon.dart';
|
import '../../../widgets/app_icon.dart';
|
||||||
import '../../../widgets/app_menu.dart';
|
import '../../../widgets/app_menu.dart';
|
||||||
import '../../../widgets/clickable_cursor.dart';
|
import '../../../widgets/clickable_cursor.dart';
|
||||||
import '../../../widgets/overlay_sheet.dart';
|
|
||||||
import '../../../widgets/optimized_media_image.dart';
|
import '../../../widgets/optimized_media_image.dart';
|
||||||
import '../livetv_styles.dart';
|
import '../livetv_styles.dart';
|
||||||
import '../program_details_sheet.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 _sourceHeaderRowHeight = 40.0;
|
||||||
static const _timeHeaderHeight = 40.0;
|
static const _timeHeaderHeight = 40.0;
|
||||||
static const _minutesPerSlot = 30;
|
static const _minutesPerSlot = 30;
|
||||||
static const _longPressDuration = Duration(milliseconds: 500);
|
|
||||||
|
|
||||||
/// Minimum time away (backgrounded or on another section) before the
|
/// Minimum time away (backgrounded or on another section) before the
|
||||||
/// viewport is realigned to the live line on return.
|
/// viewport is realigned to the live line on return.
|
||||||
@@ -101,7 +100,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
|
|||||||
bool _syncingScroll = false;
|
bool _syncingScroll = false;
|
||||||
|
|
||||||
Timer? _timeIndicatorTimer;
|
Timer? _timeIndicatorTimer;
|
||||||
Timer? _programSelectLongPressTimer;
|
final _programSelectController = DpadSelectLongPressController();
|
||||||
final _dayPickerKey = GlobalKey();
|
final _dayPickerKey = GlobalKey();
|
||||||
|
|
||||||
// Stale-window catch-up state (#1297). The grid window is only auto
|
// 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);
|
final ValueNotifier<bool> _hasFocusNotifier = ValueNotifier(false);
|
||||||
LiveTvProgram? _focusedProgram;
|
LiveTvProgram? _focusedProgram;
|
||||||
bool _pendingFocus = false;
|
bool _pendingFocus = false;
|
||||||
bool _isProgramSelectKeyDown = false;
|
|
||||||
|
|
||||||
/// Focus into the guide content (called from tab bar navigation or initial load).
|
/// Focus into the guide content (called from tab bar navigation or initial load).
|
||||||
void focusContent() {
|
void focusContent() {
|
||||||
@@ -217,7 +215,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_programSelectLongPressTimer?.cancel();
|
_programSelectController.dispose();
|
||||||
_guideFocusNode.dispose();
|
_guideFocusNode.dispose();
|
||||||
_gridVerticalController.dispose();
|
_gridVerticalController.dispose();
|
||||||
_gridHorizontalController.removeListener(_syncGridToHeader);
|
_gridHorizontalController.removeListener(_syncGridToHeader);
|
||||||
@@ -237,10 +235,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
|
|||||||
_hasFocusNotifier.value = hasFocus;
|
_hasFocusNotifier.value = hasFocus;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetProgramSelectLongPressState() {
|
void _resetProgramSelectLongPressState() => _programSelectController.reset();
|
||||||
_programSelectLongPressTimer?.cancel();
|
|
||||||
_isProgramSelectKeyDown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncGridToHeader() {
|
void _syncGridToHeader() {
|
||||||
if (_syncingScroll) return;
|
if (_syncingScroll) return;
|
||||||
@@ -580,39 +575,18 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
|
|||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _handleFocusedProgramSelectKey(KeyEvent event) {
|
KeyEventResult _handleFocusedProgramSelectKey(KeyEvent event) {
|
||||||
if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored;
|
|
||||||
final target = _focusedProgramTarget();
|
final target = _focusedProgramTarget();
|
||||||
if (target == null) return KeyEventResult.ignored;
|
if (target == null) return KeyEventResult.ignored;
|
||||||
|
|
||||||
if (event is KeyDownEvent) {
|
return _programSelectController.handleKeyEvent(
|
||||||
if (!_isProgramSelectKeyDown) {
|
event,
|
||||||
_isProgramSelectKeyDown = true;
|
isOwnerActive: () => mounted && _focusedProgramTarget() == target,
|
||||||
_programSelectLongPressTimer?.cancel();
|
onShortPress: () => _activateProgram(target.channel, target.program),
|
||||||
_programSelectLongPressTimer = Timer(_longPressDuration, () {
|
onLongPress: () {
|
||||||
if (!mounted || !_isProgramSelectKeyDown) return;
|
_programSelectController.reset();
|
||||||
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
|
_showProgramDetails(target.channel, target.program);
|
||||||
_resetProgramSelectLongPressState();
|
},
|
||||||
_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) {
|
KeyEventResult _handleFocusedProgramContextMenuKey(KeyEvent event) {
|
||||||
@@ -875,13 +849,11 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
return OverlaySheetHost(
|
return Focus(
|
||||||
child: Focus(
|
focusNode: _guideFocusNode,
|
||||||
focusNode: _guideFocusNode,
|
onFocusChange: _handleGuideFocusChange,
|
||||||
onFocusChange: _handleGuideFocusChange,
|
onKeyEvent: _handleKeyEvent,
|
||||||
onKeyEvent: _handleKeyEvent,
|
child: _buildGuideGrid(theme),
|
||||||
child: _buildGuideGrid(theme),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import '../../../utils/app_logger.dart';
|
|||||||
import '../../../utils/dialogs.dart';
|
import '../../../utils/dialogs.dart';
|
||||||
import '../../../utils/formatters.dart';
|
import '../../../utils/formatters.dart';
|
||||||
import '../../../widgets/app_icon.dart';
|
import '../../../widgets/app_icon.dart';
|
||||||
import '../../../widgets/overlay_sheet.dart';
|
|
||||||
import '../../../widgets/settings_section.dart';
|
import '../../../widgets/settings_section.dart';
|
||||||
import '../live_tv_refresh_lifecycle.dart';
|
import '../live_tv_refresh_lifecycle.dart';
|
||||||
import '../livetv_recording_actions.dart';
|
import '../livetv_recording_actions.dart';
|
||||||
@@ -265,42 +264,40 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
|
|||||||
return Center(child: _EmptyMessage(text: t.liveTv.noScheduledRecordings));
|
return Center(child: _EmptyMessage(text: t.liveTv.noScheduledRecordings));
|
||||||
}
|
}
|
||||||
|
|
||||||
return OverlaySheetHost(
|
return ListView(
|
||||||
child: ListView(
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
children: [
|
||||||
children: [
|
if (grabs.isNotEmpty)
|
||||||
if (grabs.isNotEmpty)
|
SettingsGroup(
|
||||||
SettingsGroup(
|
title: t.liveTv.scheduledRecordings,
|
||||||
title: t.liveTv.scheduledRecordings,
|
children: [
|
||||||
children: [
|
for (var i = 0; i < grabs.length; i++)
|
||||||
for (var i = 0; i < grabs.length; i++)
|
_GrabTile(
|
||||||
_GrabTile(
|
entry: grabs[i],
|
||||||
entry: grabs[i],
|
autofocus: i == 0,
|
||||||
autofocus: i == 0,
|
focusNode: i == 0 ? _firstTileFocusNode : null,
|
||||||
focusNode: i == 0 ? _firstTileFocusNode : null,
|
onTap: () => _onCancelGrab(grabs[i]),
|
||||||
onTap: () => _onCancelGrab(grabs[i]),
|
onNavigateUp: i == 0 ? widget.onNavigateUp : null,
|
||||||
onNavigateUp: i == 0 ? widget.onNavigateUp : null,
|
onBack: widget.onBack,
|
||||||
onBack: widget.onBack,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
if (rules.isNotEmpty)
|
||||||
if (rules.isNotEmpty)
|
SettingsGroup(
|
||||||
SettingsGroup(
|
title: t.liveTv.recordingRules,
|
||||||
title: t.liveTv.recordingRules,
|
children: [
|
||||||
children: [
|
for (var i = 0; i < rules.length; i++)
|
||||||
for (var i = 0; i < rules.length; i++)
|
_RuleTile(
|
||||||
_RuleTile(
|
entry: rules[i],
|
||||||
entry: rules[i],
|
autofocus: grabs.isEmpty && i == 0,
|
||||||
autofocus: grabs.isEmpty && i == 0,
|
focusNode: grabs.isEmpty && i == 0 ? _firstTileFocusNode : null,
|
||||||
focusNode: grabs.isEmpty && i == 0 ? _firstTileFocusNode : null,
|
onTap: () => _onRuleTap(rules[i]),
|
||||||
onTap: () => _onRuleTap(rules[i]),
|
onNavigateUp: grabs.isEmpty && i == 0 ? widget.onNavigateUp : null,
|
||||||
onNavigateUp: grabs.isEmpty && i == 0 ? widget.onNavigateUp : null,
|
onBack: widget.onBack,
|
||||||
onBack: widget.onBack,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import '../../../providers/multi_server_provider.dart';
|
|||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../utils/app_logger.dart';
|
import '../../../utils/app_logger.dart';
|
||||||
import '../../../widgets/hub_section.dart';
|
import '../../../widgets/hub_section.dart';
|
||||||
import '../../../widgets/overlay_sheet.dart';
|
|
||||||
import '../live_tv_actions_mixin.dart';
|
import '../live_tv_actions_mixin.dart';
|
||||||
import '../live_tv_show_schedule_screen.dart';
|
import '../live_tv_show_schedule_screen.dart';
|
||||||
import '../live_tv_refresh_lifecycle.dart';
|
import '../live_tv_refresh_lifecycle.dart';
|
||||||
@@ -202,35 +201,33 @@ class WhatsOnTabState extends State<WhatsOnTab>
|
|||||||
return Center(child: Text(t.liveTv.noPrograms));
|
return Center(child: Text(t.liveTv.noPrograms));
|
||||||
}
|
}
|
||||||
|
|
||||||
return OverlaySheetHost(
|
return ListView.builder(
|
||||||
child: ListView.builder(
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
clipBehavior: Clip.none,
|
||||||
clipBehavior: Clip.none,
|
itemCount: _hubs.length,
|
||||||
itemCount: _hubs.length,
|
itemBuilder: (context, index) {
|
||||||
itemBuilder: (context, index) {
|
final hub = _hubs[index];
|
||||||
final hub = _hubs[index];
|
return HubSection(
|
||||||
return HubSection(
|
key: _hubKeys[index],
|
||||||
key: _hubKeys[index],
|
hub: hub.mediaHub,
|
||||||
hub: hub.mediaHub,
|
icon: Symbols.live_tv_rounded,
|
||||||
icon: Symbols.live_tv_rounded,
|
cardSizing: HubCardSizing.grid,
|
||||||
cardSizing: HubCardSizing.grid,
|
episodePosterModeOverride: EpisodePosterMode.seriesPoster,
|
||||||
episodePosterModeOverride: EpisodePosterMode.seriesPoster,
|
onItemTap: (item) => _onItemTap(hub.entryFor(item)),
|
||||||
onItemTap: (item) => _onItemTap(hub.entryFor(item)),
|
onItemLongPress: (item) {
|
||||||
onItemLongPress: (item) {
|
final entry = hub.entryFor(item);
|
||||||
final entry = hub.entryFor(item);
|
showProgramDetails(
|
||||||
showProgramDetails(
|
program: entry.program,
|
||||||
program: entry.program,
|
channel: findChannelForProgram(entry.program),
|
||||||
channel: findChannelForProgram(entry.program),
|
posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath,
|
||||||
posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath,
|
posterServerId: entry.metadata.serverId,
|
||||||
posterServerId: entry.metadata.serverId,
|
);
|
||||||
);
|
},
|
||||||
},
|
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
|
||||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
|
onNavigateToSidebar: widget.onBack,
|
||||||
onNavigateToSidebar: widget.onBack,
|
onBack: widget.onBack,
|
||||||
onBack: widget.onBack,
|
);
|
||||||
);
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -768,6 +768,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
Navigator.pop(context, _watchStateChanged);
|
Navigator.pop(context, _watchStateChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleMediaDetailSystemBack() {
|
||||||
|
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||||
|
_popMediaDetailIfBackNotSuppressed();
|
||||||
|
}
|
||||||
|
|
||||||
bool _isTvDetailReadyToReveal(MediaItem metadata) {
|
bool _isTvDetailReadyToReveal(MediaItem metadata) {
|
||||||
if (_isLoadingMetadata) return false;
|
if (_isLoadingMetadata) return false;
|
||||||
if (!_hasLoadedTvDetailSupplementalSections(metadata)) return false;
|
if (!_hasLoadedTvDetailSupplementalSections(metadata)) return false;
|
||||||
@@ -1082,8 +1087,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showRatingDialog(BuildContext sheetContext, MediaItem metadata) {
|
void _showRatingDialog(BuildContext sheetContext, MediaItem metadata) {
|
||||||
OverlaySheetController.showAdaptive(
|
OverlaySheetController.of(sheetContext).show(
|
||||||
sheetContext,
|
showDragHandle: true,
|
||||||
builder: (context) => RatingBottomSheet(
|
builder: (context) => RatingBottomSheet(
|
||||||
item: metadata,
|
item: metadata,
|
||||||
serverClient: _getMediaClientForMetadata(this.context),
|
serverClient: _getMediaClientForMetadata(this.context),
|
||||||
@@ -2196,50 +2201,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
/// the rendered cards can never disagree.
|
/// the rendered cards can never disagree.
|
||||||
double _getResponsiveCardWidth() => CastMemberStrip.responsiveCardWidth(context);
|
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
|
/// Show context menu for a season tab
|
||||||
void _showSeasonTabContextMenu(int index, {Offset? position}) {
|
void _showSeasonTabContextMenu(int index, {Offset? position}) {
|
||||||
final key = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey<MediaContextMenuState>());
|
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).
|
/// 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) {
|
KeyEventResult _handleInfoRowsKeyEvent(FocusNode _, KeyEvent event) {
|
||||||
final key = event.logicalKey;
|
final key = event.logicalKey;
|
||||||
if (key.isBackKey) return KeyEventResult.ignored;
|
if (key.isBackKey) return KeyEventResult.ignored;
|
||||||
@@ -2547,8 +2508,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
return KeyEventResult.handled;
|
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.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getRelatedHubIcon(MediaHub hub) {
|
IconData _getRelatedHubIcon(MediaHub hub) {
|
||||||
@@ -3120,8 +3083,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}
|
}
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
|
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,
|
child: loading,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3146,6 +3110,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
// keyboard/TV (the key handler owns dpad back); elsewhere canPop:true
|
// 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.
|
// keeps the iOS swipe-back. The host also closes an open sheet on back.
|
||||||
canPop: !blockSystemBack,
|
canPop: !blockSystemBack,
|
||||||
|
onSystemBack: _handleMediaDetailSystemBack,
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: _handleMediaDetailBackKey,
|
onKeyEvent: _handleMediaDetailBackKey,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -3176,40 +3141,23 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[
|
if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[
|
||||||
Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle),
|
Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Focus(
|
CollapsibleText(
|
||||||
|
text: metadata.summary!,
|
||||||
|
maxLines: isMobile ? 6 : 4,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(height: 1.6),
|
||||||
focusNode: _overviewFocusNode,
|
focusNode: _overviewFocusNode,
|
||||||
onKeyEvent: _handleOverviewKeyEvent,
|
skipTraversal: false,
|
||||||
child: ListenableBuilder(
|
onNavigateUp: () {
|
||||||
listenable: _overviewFocusNode,
|
_scrollController.animateTo(
|
||||||
builder: (context, _) {
|
0,
|
||||||
final showFocus =
|
duration: const Duration(milliseconds: 200),
|
||||||
_overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
curve: Curves.easeOut,
|
||||||
return AnimatedContainer(
|
);
|
||||||
duration: const Duration(milliseconds: 150),
|
_playButtonFocusNode.requestFocus();
|
||||||
padding: const EdgeInsets.all(4),
|
},
|
||||||
decoration: BoxDecoration(
|
onNavigateDown: _focusBelowActionRow,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
onNavigateLeft: () {},
|
||||||
border: Border.all(
|
onNavigateRight: () {},
|
||||||
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(
|
|
||||||
text: metadata.summary!,
|
|
||||||
maxLines: isMobile ? 6 : 4,
|
|
||||||
style: summaryStyle,
|
|
||||||
);
|
|
||||||
}(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
@@ -3451,6 +3399,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
// blockSystemBack keeps the route from double-popping on Android keyboard/
|
// blockSystemBack keeps the route from double-popping on Android keyboard/
|
||||||
// TV (the key handler owns dpad back); the host also closes an open sheet.
|
// TV (the key handler owns dpad back); the host also closes an open sheet.
|
||||||
canPop: !blockSystemBack,
|
canPop: !blockSystemBack,
|
||||||
|
onSystemBack: _handleMediaDetailSystemBack,
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: handleBack,
|
onKeyEvent: handleBack,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import '../media/ids.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
import '../focus/focusable_button.dart';
|
|
||||||
import '../focus/focusable_wrapper.dart';
|
import '../focus/focusable_wrapper.dart';
|
||||||
|
import '../focus/focusable_button.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../metadata_edit/metadata_edit_adapters.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();
|
final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList();
|
||||||
return FocusedScrollScaffold(
|
return FocusedScrollScaffold(
|
||||||
title: Text(t.metadataEdit.screenTitle),
|
title: Text(t.metadataEdit.screenTitle),
|
||||||
|
focusableAppBarActions: true,
|
||||||
actions: [
|
actions: [
|
||||||
if (_isSaving)
|
if (_isSaving)
|
||||||
const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24))
|
const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24))
|
||||||
else
|
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: [
|
slivers: [
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
@@ -492,27 +500,17 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)),
|
if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)),
|
||||||
FocusableButton(
|
DialogActionButton(
|
||||||
onPressed: _addFromUrl,
|
onPressed: _addFromUrl,
|
||||||
child: TextButton.icon(
|
label: t.metadataEdit.fromUrl,
|
||||||
onPressed: _addFromUrl,
|
icon: const AppIcon(Symbols.link_rounded, size: 18),
|
||||||
icon: const AppIcon(Symbols.link_rounded, size: 18),
|
|
||||||
label: Text(t.metadataEdit.fromUrl),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
FocusableButton(
|
DialogActionButton(
|
||||||
onPressed: _uploadFile,
|
onPressed: _uploadFile,
|
||||||
child: TextButton.icon(
|
label: t.metadataEdit.uploadFile,
|
||||||
onPressed: _uploadFile,
|
icon: const AppIcon(Symbols.upload_rounded, size: 18),
|
||||||
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];
|
final artwork = _artworkList![index];
|
||||||
return FocusableWrapper(
|
return FocusableWrapper(
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
semanticLabel: artwork.selected
|
||||||
|
? t.metadataEdit.selectedArtworkOption(index: index + 1)
|
||||||
|
: t.metadataEdit.artworkOption(index: index + 1),
|
||||||
onSelect: () => _selectArtwork(artwork),
|
onSelect: () => _selectArtwork(artwork),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _selectArtwork(artwork),
|
onTap: () => _selectArtwork(artwork),
|
||||||
|
|||||||
@@ -452,19 +452,27 @@ class _ArtistLinkState extends State<_ArtistLink> {
|
|||||||
if (widget.onTap == null) return Text(widget.name, style: style);
|
if (widget.onTap == null) return Text(widget.name, style: style);
|
||||||
|
|
||||||
final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
|
final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
|
||||||
return Focus(
|
return Semantics(
|
||||||
focusNode: _focusNode,
|
button: true,
|
||||||
onFocusChange: (hasFocus) => setState(() => _focused = hasFocus),
|
enabled: true,
|
||||||
onKeyEvent: dpadKeyHandler(onSelect: widget.onTap),
|
label: widget.name,
|
||||||
child: MouseRegion(
|
onTap: widget.onTap,
|
||||||
cursor: SystemMouseCursors.click,
|
child: ExcludeSemantics(
|
||||||
child: GestureDetector(
|
child: Focus(
|
||||||
onTap: widget.onTap,
|
focusNode: _focusNode,
|
||||||
child: AnimatedContainer(
|
onFocusChange: (hasFocus) => setState(() => _focused = hasFocus),
|
||||||
duration: FocusTheme.getAnimationDuration(context),
|
onKeyEvent: dpadKeyHandler(onSelect: widget.onTap),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
child: MouseRegion(
|
||||||
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus),
|
cursor: SystemMouseCursors.click,
|
||||||
child: Text(widget.name, style: style),
|
child: GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: FocusTheme.getAnimationDuration(context),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus),
|
||||||
|
child: Text(widget.name, style: style),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../focus/dpad_navigator.dart';
|
import '../../focus/dpad_navigator.dart';
|
||||||
import '../../focus/focus_theme.dart';
|
import '../../focus/focus_theme.dart';
|
||||||
import '../../focus/focusable_action_bar.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/input_mode_tracker.dart';
|
||||||
import '../../focus/key_event_utils.dart';
|
import '../../focus/key_event_utils.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
@@ -466,10 +468,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
|||||||
// Inset past the macOS traffic lights — this screen is a fullscreen
|
// Inset past the macOS traffic lights — this screen is a fullscreen
|
||||||
// route, so the close button would otherwise sit underneath them.
|
// route, so the close button would otherwise sit underneath them.
|
||||||
DesktopAppBarHelper.buildAdjustedLeading(
|
DesktopAppBarHelper.buildAdjustedLeading(
|
||||||
IconButton(
|
FocusableButton(
|
||||||
icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text),
|
|
||||||
tooltip: t.common.close,
|
|
||||||
onPressed: _pop,
|
onPressed: _pop,
|
||||||
|
child: IconButton(
|
||||||
|
icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text),
|
||||||
|
tooltip: t.common.close,
|
||||||
|
onPressed: _pop,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
context: context,
|
context: context,
|
||||||
)!,
|
)!,
|
||||||
@@ -502,15 +507,18 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
|||||||
final cluster = Row(
|
final cluster = Row(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
FocusableButton(
|
||||||
icon: AppIcon(
|
|
||||||
Symbols.lyrics_rounded,
|
|
||||||
fill: 1,
|
|
||||||
size: 22,
|
|
||||||
color: _showLyrics ? colorScheme.primary : tk.textMuted,
|
|
||||||
),
|
|
||||||
tooltip: t.music.lyrics,
|
|
||||||
onPressed: _toggleLyrics,
|
onPressed: _toggleLyrics,
|
||||||
|
child: IconButton(
|
||||||
|
icon: AppIcon(
|
||||||
|
Symbols.lyrics_rounded,
|
||||||
|
fill: 1,
|
||||||
|
size: 22,
|
||||||
|
color: _showLyrics ? colorScheme.primary : tk.textMuted,
|
||||||
|
),
|
||||||
|
tooltip: t.music.lyrics,
|
||||||
|
onPressed: _toggleLyrics,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (PlatformDetector.isDesktop(context)) ...[const SizedBox(width: 4), _buildVolumeCluster(service)],
|
if (PlatformDetector.isDesktop(context)) ...[const SizedBox(width: 4), _buildVolumeCluster(service)],
|
||||||
],
|
],
|
||||||
@@ -558,7 +566,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
|||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
||||||
),
|
),
|
||||||
child: Slider(
|
child: FocusableSlider(
|
||||||
value: volume.clamp(0.0, 100.0),
|
value: volume.clamp(0.0, 100.0),
|
||||||
max: 100,
|
max: 100,
|
||||||
onChanged: (value) => unawaited(service.setVolume(value, persist: false)),
|
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.
|
/// entry. On TV ([focusable]) it joins the d-pad chain above the seek bar.
|
||||||
Widget _buildOverflowButton(MediaItem track, {bool focusable = false}) {
|
Widget _buildOverflowButton(MediaItem track, {bool focusable = false}) {
|
||||||
final tk = tokens(context);
|
final tk = tokens(context);
|
||||||
final button = IconButton(
|
void showMenu() => contextMenuKey.currentState?.showContextMenu(context);
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return MediaContextMenu(
|
return MediaContextMenu(
|
||||||
key: contextMenuKey,
|
key: contextMenuKey,
|
||||||
@@ -616,7 +592,21 @@ class _NowPlayingScreenState extends State<NowPlayingScreen>
|
|||||||
extraEntries: [
|
extraEntries: [
|
||||||
MediaMenuExtraEntry(icon: Symbols.bedtime_rounded, label: t.music.sleepTimer, onSelected: _showSleepTimerSheet),
|
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:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../focus/focusable_action_bar.dart';
|
||||||
import '../../focus/input_mode_tracker.dart';
|
import '../../focus/input_mode_tracker.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../services/music/music_playback_service.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
|
/// [OverlaySheetHost] ancestor (all now-playing layouts do) so TV back
|
||||||
/// handling stays centralized in the host.
|
/// handling stays centralized in the host.
|
||||||
Future<void> showQueueSheet(BuildContext context) {
|
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
|
/// Sheet chrome around [QueueList]: header with track count, shuffle/repeat
|
||||||
@@ -48,30 +49,27 @@ class QueueSheet extends StatelessWidget {
|
|||||||
style: TextStyle(fontSize: 13, color: tk.textMuted),
|
style: TextStyle(fontSize: 13, color: tk.textMuted),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
FocusableActionBar(
|
||||||
icon: AppIcon(
|
actions: [
|
||||||
Symbols.shuffle_rounded,
|
FocusableAction(
|
||||||
fill: 1,
|
icon: Symbols.shuffle_rounded,
|
||||||
size: 20,
|
iconColor: service.shuffled ? colorScheme.primary : tk.textMuted,
|
||||||
color: service.shuffled ? colorScheme.primary : tk.textMuted,
|
tooltip: t.common.shuffle,
|
||||||
),
|
onPressed: service.toggleShuffle,
|
||||||
tooltip: t.common.shuffle,
|
),
|
||||||
onPressed: service.toggleShuffle,
|
FocusableAction(
|
||||||
),
|
icon: repeatModeIcon(service.repeatMode),
|
||||||
IconButton(
|
iconColor: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary,
|
||||||
icon: AppIcon(
|
tooltip: repeatModeLabel(service.repeatMode),
|
||||||
repeatModeIcon(service.repeatMode),
|
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
|
||||||
fill: 1,
|
),
|
||||||
size: 20,
|
FocusableAction(
|
||||||
color: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary,
|
icon: Symbols.clear_all_rounded,
|
||||||
),
|
iconColor: tk.textMuted,
|
||||||
tooltip: repeatModeLabel(service.repeatMode),
|
tooltip: t.music.clearQueue,
|
||||||
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
|
onPressed: service.clearUpcoming,
|
||||||
),
|
),
|
||||||
IconButton(
|
],
|
||||||
icon: AppIcon(Symbols.clear_all_rounded, fill: 1, size: 20, color: tk.textMuted),
|
|
||||||
tooltip: t.music.clearQueue,
|
|
||||||
onPressed: service.clearUpcoming,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../focus/focusable_action_bar.dart';
|
import '../../focus/focusable_action_bar.dart';
|
||||||
|
import '../../focus/focusable_button.dart';
|
||||||
import '../../media/library_query.dart';
|
import '../../media/library_query.dart';
|
||||||
import '../../media/media_item.dart';
|
import '../../media/media_item.dart';
|
||||||
import '../../media/media_kind.dart';
|
import '../../media/media_kind.dart';
|
||||||
@@ -187,6 +188,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
|
|
||||||
// Focus management for regular (non-smart) reorderable lists
|
// Focus management for regular (non-smart) reorderable lists
|
||||||
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
|
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
|
||||||
|
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry');
|
||||||
|
|
||||||
// Navigation state for regular (non-smart) playlists
|
// Navigation state for regular (non-smart) playlists
|
||||||
int _focusedIndex = 0;
|
int _focusedIndex = 0;
|
||||||
@@ -219,6 +221,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_continuation.dispose();
|
_continuation.dispose();
|
||||||
_listFocusNode.dispose();
|
_listFocusNode.dispose();
|
||||||
|
_continuationRetryFocusNode.dispose();
|
||||||
_focusRevision.dispose();
|
_focusRevision.dispose();
|
||||||
disposeFocusResources();
|
disposeFocusResources();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -637,12 +640,18 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
}
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
if (key.isDownKey && _focusedIndex < items.length - 1) {
|
if (key.isDownKey) {
|
||||||
_focusedIndex++;
|
if (_focusedIndex < items.length - 1) {
|
||||||
_focusedColumn = 0;
|
_focusedIndex++;
|
||||||
_notifyFocusChanged();
|
_focusedColumn = 0;
|
||||||
_ensureFocusedVisible();
|
_notifyFocusChanged();
|
||||||
return KeyEventResult.handled;
|
_ensureFocusedVisible();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (_continuation.error != null) {
|
||||||
|
_continuationRetryFocusNode.requestFocus();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (key.isLeftKey) {
|
if (key.isLeftKey) {
|
||||||
// Navigate left within columns
|
// Navigate left within columns
|
||||||
@@ -871,7 +880,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
children: [
|
children: [
|
||||||
Text(error, textAlign: TextAlign.center),
|
Text(error, textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 8),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return InkWell(
|
return InkWell(
|
||||||
|
canRequestFocus: false,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
|||||||
isActive: isActive && !widget.requireSelection,
|
isActive: isActive && !widget.requireSelection,
|
||||||
chips: _chipsFor(profile, view),
|
chips: _chipsFor(profile, view),
|
||||||
onTap: () => _switchTo(profile),
|
onTap: () => _switchTo(profile),
|
||||||
|
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
|
||||||
// Manage available for any profile — adding/removing
|
// Manage available for any profile — adding/removing
|
||||||
// borrowed connections is supported on plex_home too. Delete
|
// borrowed connections is supported on plex_home too. Delete
|
||||||
// stays local-only (Plex Home users are owned by Plex).
|
// stays local-only (Plex Home users are owned by Plex).
|
||||||
@@ -371,6 +372,7 @@ class _ProfileTile extends StatelessWidget {
|
|||||||
final BorderRadius borderRadius;
|
final BorderRadius borderRadius;
|
||||||
final List<_ChipData> chips;
|
final List<_ChipData> chips;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
final VoidCallback? onManage;
|
final VoidCallback? onManage;
|
||||||
final VoidCallback? onDelete;
|
final VoidCallback? onDelete;
|
||||||
final VoidCallback? onSignOut;
|
final VoidCallback? onSignOut;
|
||||||
@@ -384,6 +386,7 @@ class _ProfileTile extends StatelessWidget {
|
|||||||
required this.borderRadius,
|
required this.borderRadius,
|
||||||
required this.chips,
|
required this.chips,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
this.onManage,
|
this.onManage,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onSignOut,
|
this.onSignOut,
|
||||||
@@ -397,7 +400,9 @@ class _ProfileTile extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final hasMenu = onManage != null || onDelete != null || onSignOut != null;
|
final hasMenu = onManage != null || onDelete != null || onSignOut != null;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
|
canRequestFocus: false,
|
||||||
onTap: isActive ? null : onTap,
|
onTap: isActive ? null : onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../focus/focusable_text_field.dart';
|
import '../focus/focusable_text_field.dart';
|
||||||
|
import '../focus/focusable_button.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../mixins/debounced_media_search.dart';
|
import '../mixins/debounced_media_search.dart';
|
||||||
@@ -32,6 +33,7 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
|
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
|
||||||
String? _focusResultsForQuery;
|
String? _focusResultsForQuery;
|
||||||
final _tvKeyboardController = TvKeyboardController();
|
final _tvKeyboardController = TvKeyboardController();
|
||||||
|
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -39,6 +41,17 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_clearFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSearch() {
|
||||||
|
searchController.clear();
|
||||||
|
searchFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchDebugLabel => 'Search';
|
String get searchDebugLabel => 'Search';
|
||||||
|
|
||||||
@@ -192,34 +205,46 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||||
child: FocusableTextField(
|
child: Stack(
|
||||||
controller: searchController,
|
alignment: Alignment.centerRight,
|
||||||
focusNode: searchFocusNode,
|
children: [
|
||||||
tvKeyboardController: _tvKeyboardController,
|
FocusableTextField(
|
||||||
textInputAction: TextInputAction.search,
|
controller: searchController,
|
||||||
onNavigateLeft: _navigateToSidebar,
|
focusNode: searchFocusNode,
|
||||||
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
tvKeyboardController: _tvKeyboardController,
|
||||||
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
textInputAction: TextInputAction.search,
|
||||||
onBack: () {
|
onNavigateLeft: _navigateToSidebar,
|
||||||
if (searchController.text.isNotEmpty) {
|
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
|
||||||
searchController.clear();
|
onNavigateDown: searchResults.isNotEmpty && !isSearching
|
||||||
} else {
|
? firstResultFocusNode.requestFocus
|
||||||
_navigateToSidebar();
|
: null,
|
||||||
}
|
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
||||||
},
|
onBack: () {
|
||||||
decoration: pillInputDecoration(
|
if (searchController.text.isNotEmpty) {
|
||||||
context,
|
searchController.clear();
|
||||||
hintText: t.search.hint,
|
} else {
|
||||||
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
_navigateToSidebar();
|
||||||
suffixIcon: searchController.text.isNotEmpty
|
}
|
||||||
? IconButton(
|
},
|
||||||
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
|
decoration: pillInputDecoration(
|
||||||
onPressed: () {
|
context,
|
||||||
searchController.clear();
|
hintText: t.search.hint,
|
||||||
},
|
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||||
)
|
suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null,
|
||||||
: 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import '../../widgets/focused_scroll_scaffold.dart';
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/settings_section.dart';
|
import '../../widgets/settings_section.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import 'licenses_screen.dart';
|
import 'licenses_screen.dart';
|
||||||
@@ -56,7 +57,7 @@ class AboutScreen extends StatelessWidget {
|
|||||||
SettingsGroup(
|
SettingsGroup(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.description_rounded, fill: 1),
|
leading: const AppIcon(Symbols.description_rounded, fill: 1),
|
||||||
title: Text(t.about.openSourceLicenses),
|
title: Text(t.about.openSourceLicenses),
|
||||||
subtitle: Text(t.about.viewLicensesDescription),
|
subtitle: Text(t.about.viewLicensesDescription),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../../focus/focusable_slider.dart';
|
|||||||
import '../../services/device_performance.dart';
|
import '../../services/device_performance.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/setting_tile.dart';
|
import '../../widgets/setting_tile.dart';
|
||||||
import '../../widgets/settings_page.dart';
|
import '../../widgets/settings_page.dart';
|
||||||
import '../../widgets/settings_builder.dart';
|
import '../../widgets/settings_builder.dart';
|
||||||
@@ -224,7 +225,7 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _languageSelector(BuildContext context) {
|
Widget _languageSelector(BuildContext context) {
|
||||||
return ListTile(
|
return FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.language_rounded, fill: 1),
|
leading: const AppIcon(Symbols.language_rounded, fill: 1),
|
||||||
title: Text(t.settings.language),
|
title: Text(t.settings.language),
|
||||||
subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)),
|
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:material_symbols_icons/symbols.dart';
|
||||||
import 'package:plezy/widgets/app_icon.dart';
|
import 'package:plezy/widgets/app_icon.dart';
|
||||||
|
|
||||||
|
import '../../widgets/dialog_action_button.dart';
|
||||||
import '../../focus/focusable_button.dart';
|
import '../../focus/focusable_button.dart';
|
||||||
import '../../focus/focusable_text_field.dart';
|
import '../../focus/focusable_text_field.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
@@ -12,6 +13,7 @@ import '../../models/external_player_models.dart';
|
|||||||
import '../../services/settings_service.dart';
|
import '../../services/settings_service.dart';
|
||||||
import '../../utils/dialogs.dart';
|
import '../../utils/dialogs.dart';
|
||||||
import '../../widgets/expressive_button_group.dart';
|
import '../../widgets/expressive_button_group.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/setting_tile.dart';
|
import '../../widgets/setting_tile.dart';
|
||||||
import '../../widgets/settings_builder.dart';
|
import '../../widgets/settings_builder.dart';
|
||||||
import '../../widgets/settings_page.dart';
|
import '../../widgets/settings_page.dart';
|
||||||
@@ -57,7 +59,7 @@ class ExternalPlayerScreen extends StatelessWidget {
|
|||||||
title: t.externalPlayer.customPlayers,
|
title: t.externalPlayer.customPlayers,
|
||||||
children: [
|
children: [
|
||||||
for (final p in custom) _PlayerTile(player: p, selectedId: selected.id, isCustom: true),
|
for (final p in custom) _PlayerTile(player: p, selectedId: selected.id, isCustom: true),
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
leading: const AppIcon(Symbols.add_rounded, fill: 1),
|
||||||
title: Text(t.externalPlayer.addCustomPlayer),
|
title: Text(t.externalPlayer.addCustomPlayer),
|
||||||
onTap: () => _showAddCustomPlayerDialog(context),
|
onTap: () => _showAddCustomPlayerDialog(context),
|
||||||
@@ -105,17 +107,20 @@ class _PlayerTile extends StatelessWidget {
|
|||||||
leading = const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
|
leading = const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListTile(
|
return FocusableListTile(
|
||||||
leading: leading,
|
leading: leading,
|
||||||
title: Text(player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name),
|
title: Text(player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
children: [
|
children: [
|
||||||
if (isCustom)
|
FocusableButton(
|
||||||
IconButton(
|
onPressed: () => svc.removeCustomExternalPlayer(player.id),
|
||||||
|
autoScroll: false,
|
||||||
|
child: IconButton(
|
||||||
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
|
icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
|
||||||
onPressed: () => svc.removeCustomExternalPlayer(player.id),
|
onPressed: () => svc.removeCustomExternalPlayer(player.id),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
AppIcon(
|
AppIcon(
|
||||||
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
|
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
|
||||||
fill: 1,
|
fill: 1,
|
||||||
@@ -231,15 +236,8 @@ class _AddCustomPlayerDialogState extends State<_AddCustomPlayerDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
FocusableButton(
|
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
||||||
onPressed: () => Navigator.pop(context),
|
DialogActionButton(focusNode: _saveFocusNode, onPressed: _submit, label: t.common.save, isPrimary: true),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import '../../models/hotkey_model.dart';
|
import '../../models/hotkey_model.dart';
|
||||||
import '../../widgets/hotkey_recorder.dart';
|
import '../../widgets/hotkey_recorder.dart';
|
||||||
import '../../i18n/strings.g.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 {
|
class HotKeyRecorderWidget extends StatefulWidget {
|
||||||
final String actionName;
|
final String actionName;
|
||||||
@@ -25,6 +28,11 @@ class HotKeyRecorderWidget extends StatefulWidget {
|
|||||||
|
|
||||||
class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||||
HotKey? _recordedHotKey;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -32,8 +40,43 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
|||||||
_recordedHotKey = widget.currentHotKey;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final hasShortcut = _recordedHotKey != null;
|
||||||
|
final recordLabel = _isCapturing ? t.hotkeys.recordingShortcut : t.hotkeys.pressToRecord;
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(t.hotkeys.setShortcutFor(actionName: widget.actionName)),
|
title: Text(t.hotkeys.setShortcutFor(actionName: widget.actionName)),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
@@ -48,43 +91,62 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
|||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .bold),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Container(
|
Row(
|
||||||
width: double.infinity,
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
Expanded(
|
||||||
decoration: BoxDecoration(
|
child: FocusableWrapper(
|
||||||
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)),
|
focusNode: _recorderFocusNode,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
autofocus: true,
|
||||||
),
|
onSelect: _startCapturing,
|
||||||
child: Row(
|
onBack: widget.onCancel,
|
||||||
children: [
|
onNavigateRight: hasShortcut ? _clearFocusNode.requestFocus : null,
|
||||||
Expanded(
|
onNavigateDown: (hasShortcut ? _saveFocusNode : _cancelFocusNode).requestFocus,
|
||||||
child: HotKeyRecorder(
|
semanticLabel: recordLabel,
|
||||||
initalHotKey: _recordedHotKey,
|
descendantsAreFocusable: false,
|
||||||
onHotKeyRecorded: (hotKey) {
|
useBackgroundFocus: true,
|
||||||
setState(() {
|
child: GestureDetector(
|
||||||
_recordedHotKey = hotKey;
|
onTap: _startCapturing,
|
||||||
});
|
child: Container(
|
||||||
},
|
width: double.infinity,
|
||||||
|
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: hasShortcut
|
||||||
|
? HotKeyRecorder(
|
||||||
|
initalHotKey: _recordedHotKey,
|
||||||
|
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),
|
icon: const AppIcon(Symbols.backspace_rounded, fill: 1, size: 18),
|
||||||
onPressed: () {
|
onPressed: _clearShortcut,
|
||||||
setState(() {
|
|
||||||
_recordedHotKey = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
padding: .zero,
|
padding: .zero,
|
||||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
||||||
tooltip: t.hotkeys.clearShortcut,
|
tooltip: t.hotkeys.clearShortcut,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Press any key combination to set a new shortcut',
|
recordLabel,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)),
|
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
@@ -95,10 +157,22 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: widget.onCancel, child: Text(t.common.cancel)),
|
DialogActionButton(
|
||||||
TextButton(
|
focusNode: _cancelFocusNode,
|
||||||
onPressed: _recordedHotKey != null ? () => widget.onHotKeyRecorded(_recordedHotKey!) : null,
|
onPressed: widget.onCancel,
|
||||||
child: Text(t.common.save),
|
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 '../../focus/focusable_button.dart';
|
||||||
import '../../theme/mono_tokens.dart';
|
import '../../theme/mono_tokens.dart';
|
||||||
import '../../widgets/focused_scroll_scaffold.dart';
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/settings_section.dart';
|
import '../../widgets/settings_section.dart';
|
||||||
import 'hotkey_recorder_widget.dart';
|
import 'hotkey_recorder_widget.dart';
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class KeyboardShortcutsScreen extends StatelessWidget {
|
|||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
for (final action in actions)
|
for (final action in actions)
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
title: Text(keyboardService.getActionDisplayName(action)),
|
title: Text(keyboardService.getActionDisplayName(action)),
|
||||||
subtitle: Text(action),
|
subtitle: Text(action),
|
||||||
trailing: Container(
|
trailing: Container(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import '../../focus/focusable_action_bar.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 '../../focus/key_event_utils.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../mixins/mounted_set_state_mixin.dart';
|
import '../../mixins/mounted_set_state_mixin.dart';
|
||||||
@@ -216,11 +216,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
FocusableButton(
|
DialogActionButton(autofocus: true, onPressed: () => Navigator.of(ctx).pop(), label: t.common.close),
|
||||||
autofocus: true,
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
|
||||||
child: TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../../services/settings_service.dart';
|
|||||||
import '../../widgets/app_menu.dart';
|
import '../../widgets/app_menu.dart';
|
||||||
import '../../widgets/focused_scroll_scaffold.dart';
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
import '../../widgets/focusable_popup_menu_button.dart';
|
import '../../widgets/focusable_popup_menu_button.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/settings_builder.dart';
|
import '../../widgets/settings_builder.dart';
|
||||||
import '../../widgets/settings_section.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.
|
// The page already pads its slivers by 16.
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _savePresetFocusNode,
|
focusNode: _savePresetFocusNode,
|
||||||
leading: const AppIcon(Symbols.save_rounded, fill: 1),
|
leading: const AppIcon(Symbols.save_rounded, fill: 1),
|
||||||
title: Text(t.mpvConfig.saveAsPreset),
|
title: Text(t.mpvConfig.saveAsPreset),
|
||||||
@@ -208,7 +209,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
|
|||||||
),
|
),
|
||||||
if (presets.isNotEmpty)
|
if (presets.isNotEmpty)
|
||||||
...presets.map(
|
...presets.map(
|
||||||
(preset) => ListTile(
|
(preset) => FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
|
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
|
||||||
title: Text(preset.name),
|
title: Text(preset.name),
|
||||||
trailing: FocusablePopupMenuButton<String>(
|
trailing: FocusablePopupMenuButton<String>(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../models/seerr/seerr_session.dart';
|
|||||||
import '../../providers/seerr_account_provider.dart';
|
import '../../providers/seerr_account_provider.dart';
|
||||||
import '../../utils/dialogs.dart';
|
import '../../utils/dialogs.dart';
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/settings_page.dart';
|
import '../../widgets/settings_page.dart';
|
||||||
import '../../widgets/settings_section.dart';
|
import '../../widgets/settings_section.dart';
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ class SeerrSettingsScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SettingsGroup(
|
SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error),
|
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)),
|
title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
onTap: () => unawaited(_disconnect(context, account)),
|
onTap: () => unawaited(_disconnect(context, account)),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../providers/trakt_account_provider.dart';
|
|||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
import '../../widgets/catalog_source_logo.dart';
|
import '../../widgets/catalog_source_logo.dart';
|
||||||
import '../../widgets/focused_scroll_scaffold.dart';
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/settings_section.dart';
|
import '../../widgets/settings_section.dart';
|
||||||
import 'seerr_connect_screen.dart';
|
import 'seerr_connect_screen.dart';
|
||||||
import 'seerr_settings_screen.dart';
|
import 'seerr_settings_screen.dart';
|
||||||
@@ -144,7 +145,7 @@ class _ServiceHubRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return FocusableListTile(
|
||||||
leading: leading,
|
leading: leading,
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
subtitle: Text(username != null ? t.services.connectedAs(username: username!) : t.services.notConnected),
|
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 '../../utils/update_dialog.dart';
|
||||||
import '../../widgets/desktop_app_bar.dart';
|
import '../../widgets/desktop_app_bar.dart';
|
||||||
import '../../widgets/dialog_action_button.dart';
|
import '../../widgets/dialog_action_button.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/library_management_sheet.dart';
|
import '../../widgets/library_management_sheet.dart';
|
||||||
import '../../widgets/setting_tile.dart';
|
import '../../widgets/setting_tile.dart';
|
||||||
import '../../widgets/settings_builder.dart';
|
import '../../widgets/settings_builder.dart';
|
||||||
@@ -190,7 +191,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDonateTile() {
|
Widget _buildDonateTile() {
|
||||||
return ListTile(
|
return FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kDonate),
|
focusNode: _focusTracker.get(_kDonate),
|
||||||
leading: const AppIcon(Symbols.favorite_rounded, fill: 1),
|
leading: const AppIcon(Symbols.favorite_rounded, fill: 1),
|
||||||
title: Text(t.settings.supportDeveloper),
|
title: Text(t.settings.supportDeveloper),
|
||||||
@@ -328,7 +329,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
future: storageService.getCurrentDownloadPathDisplay(),
|
future: storageService.getCurrentDownloadPathDisplay(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final currentPath = snapshot.data ?? '...';
|
final currentPath = snapshot.data ?? '...';
|
||||||
return ListTile(
|
return FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kDownloadLocation),
|
focusNode: _focusTracker.get(_kDownloadLocation),
|
||||||
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
|
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
|
||||||
title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault),
|
title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault),
|
||||||
@@ -389,7 +390,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
return SettingsGroup(
|
return SettingsGroup(
|
||||||
title: t.settings.advanced,
|
title: t.settings.advanced,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kWatchTogetherRelay),
|
focusNode: _focusTracker.get(_kWatchTogetherRelay),
|
||||||
leading: const AppIcon(Symbols.dns_rounded, fill: 1),
|
leading: const AppIcon(Symbols.dns_rounded, fill: 1),
|
||||||
title: Text(t.settings.watchTogetherRelay),
|
title: Text(t.settings.watchTogetherRelay),
|
||||||
@@ -418,7 +419,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
subtitle: t.settings.viewLogsDescription,
|
subtitle: t.settings.viewLogsDescription,
|
||||||
destinationBuilder: (context) => const LogsScreen(),
|
destinationBuilder: (context) => const LogsScreen(),
|
||||||
),
|
),
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kClearCache),
|
focusNode: _focusTracker.get(_kClearCache),
|
||||||
leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1),
|
leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1),
|
||||||
title: Text(t.settings.clearCache),
|
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),
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||||
onTap: () => _showClearCacheDialog(),
|
onTap: () => _showClearCacheDialog(),
|
||||||
),
|
),
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kResetSettings),
|
focusNode: _focusTracker.get(_kResetSettings),
|
||||||
leading: const AppIcon(Symbols.restore_rounded, fill: 1),
|
leading: const AppIcon(Symbols.restore_rounded, fill: 1),
|
||||||
title: Text(t.settings.resetSettings),
|
title: Text(t.settings.resetSettings),
|
||||||
@@ -435,7 +436,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
onTap: () => _showResetSettingsDialog(),
|
onTap: () => _showResetSettingsDialog(),
|
||||||
),
|
),
|
||||||
if (kDebugMode)
|
if (kDebugMode)
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.error_rounded, fill: 1),
|
leading: const AppIcon(Symbols.error_rounded, fill: 1),
|
||||||
title: const Text('Test Sentry'),
|
title: const Text('Test Sentry'),
|
||||||
subtitle: const Text('Send a test error'),
|
subtitle: const Text('Send a test error'),
|
||||||
@@ -445,7 +446,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (kDebugMode)
|
if (kDebugMode)
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.timer_rounded, fill: 1),
|
leading: const AppIcon(Symbols.timer_rounded, fill: 1),
|
||||||
title: const Text('Test ANR'),
|
title: const Text('Test ANR'),
|
||||||
subtitle: const Text('Block the main thread for 10 seconds'),
|
subtitle: const Text('Block the main thread for 10 seconds'),
|
||||||
@@ -464,7 +465,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
return SettingsGroup(
|
return SettingsGroup(
|
||||||
title: t.settings.backup,
|
title: t.settings.backup,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kExportSettings),
|
focusNode: _focusTracker.get(_kExportSettings),
|
||||||
leading: const AppIcon(Symbols.upload_rounded, fill: 1),
|
leading: const AppIcon(Symbols.upload_rounded, fill: 1),
|
||||||
title: Text(t.settings.exportSettings),
|
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),
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||||
onTap: _handleExportSettings,
|
onTap: _handleExportSettings,
|
||||||
),
|
),
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kImportSettings),
|
focusNode: _focusTracker.get(_kImportSettings),
|
||||||
leading: const AppIcon(Symbols.download_rounded, fill: 1),
|
leading: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||||
title: Text(t.settings.importSettings),
|
title: Text(t.settings.importSettings),
|
||||||
@@ -497,7 +498,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
return SettingsGroup(
|
return SettingsGroup(
|
||||||
title: t.settings.updates,
|
title: t.settings.updates,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kCheckForUpdates),
|
focusNode: _focusTracker.get(_kCheckForUpdates),
|
||||||
leading: const AppIcon(Symbols.system_update_rounded, fill: 1),
|
leading: const AppIcon(Symbols.system_update_rounded, fill: 1),
|
||||||
title: Text(t.settings.checkForUpdates),
|
title: Text(t.settings.checkForUpdates),
|
||||||
@@ -514,7 +515,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
return SettingsGroup(
|
return SettingsGroup(
|
||||||
title: t.settings.updates,
|
title: t.settings.updates,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
focusNode: _focusTracker.get(_kCheckForUpdates),
|
focusNode: _focusTracker.get(_kCheckForUpdates),
|
||||||
leading: AppIcon(
|
leading: AppIcon(
|
||||||
hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded,
|
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/settings_service.dart';
|
||||||
import '../../services/trackers/tracker_constants.dart';
|
import '../../services/trackers/tracker_constants.dart';
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/setting_tile.dart';
|
import '../../widgets/setting_tile.dart';
|
||||||
import '../../widgets/settings_builder.dart';
|
import '../../widgets/settings_builder.dart';
|
||||||
import '../../widgets/settings_page.dart';
|
import '../../widgets/settings_page.dart';
|
||||||
@@ -76,7 +77,7 @@ class TrackerAccountSettingsBody extends StatelessWidget {
|
|||||||
prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)],
|
prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)],
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final settings = SettingsService.instance;
|
final settings = SettingsService.instance;
|
||||||
return ListTile(
|
return FocusableListTile(
|
||||||
leading: const AppIcon(Symbols.filter_list_rounded, fill: 1),
|
leading: const AppIcon(Symbols.filter_list_rounded, fill: 1),
|
||||||
title: Text(t.services.libraryFilter.title),
|
title: Text(t.services.libraryFilter.title),
|
||||||
subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, service)),
|
subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, service)),
|
||||||
@@ -92,7 +93,7 @@ class TrackerAccountSettingsBody extends StatelessWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SettingsGroup(
|
SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
FocusableListTile(
|
||||||
leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error),
|
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)),
|
title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
onTap: () => unawaited(Future<void>.sync(onDisconnect)),
|
onTap: () => unawaited(Future<void>.sync(onDisconnect)),
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
|
|||||||
onNavigateLeft: () => cancelFocusNode.requestFocus(),
|
onNavigateLeft: () => cancelFocusNode.requestFocus(),
|
||||||
onNavigateUp: () {},
|
onNavigateUp: () {},
|
||||||
onNavigateDown: () {},
|
onNavigateDown: () {},
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: onPlayNext,
|
onPressed: onPlayNext,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -401,6 +402,7 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
|
|||||||
onNavigateLeft: () => pauseFocusNode.requestFocus(),
|
onNavigateLeft: () => pauseFocusNode.requestFocus(),
|
||||||
onNavigateUp: () {},
|
onNavigateUp: () {},
|
||||||
onNavigateDown: () {},
|
onNavigateDown: () {},
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: onContinue,
|
onPressed: onContinue,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
|
|||||||
+18
-21
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../focus/focusable_button.dart';
|
|
||||||
import '../focus/focusable_text_field.dart';
|
import '../focus/focusable_text_field.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../focus/input_mode_tracker.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
@@ -47,24 +46,24 @@ Future<bool> showConfirmDialog(
|
|||||||
title: Text(title),
|
title: Text(title),
|
||||||
content: Text(message),
|
content: Text(message),
|
||||||
actions: [
|
actions: [
|
||||||
FocusableButton(
|
DialogActionButton(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
child: TextButton(
|
label: cancelText ?? t.common.cancel,
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
||||||
style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
|
||||||
child: Text(cancelText ?? t.common.cancel),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
FocusableButton(
|
DialogActionButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: FilledButton(
|
label: confirmText,
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
isPrimary: true,
|
||||||
style: isDestructive
|
style: isDestructive
|
||||||
? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError)
|
? FilledButton.styleFrom(
|
||||||
: null,
|
padding: _buttonPadding,
|
||||||
child: Text(confirmText),
|
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),
|
title: Text(t.messages.serverLimitTitle),
|
||||||
content: Text(t.messages.serverLimitBody),
|
content: Text(t.messages.serverLimitBody),
|
||||||
actions: [
|
actions: [
|
||||||
FocusableButton(
|
DialogActionButton(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
child: FilledButton(
|
label: t.common.close,
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
isPrimary: true,
|
||||||
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
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(
|
child: FocusableButton(
|
||||||
autofocus: _recentRooms.isEmpty,
|
autofocus: _recentRooms.isEmpty,
|
||||||
onPressed: _isBusy ? null : _createSession,
|
onPressed: _isBusy ? null : _createSession,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _isBusy ? null : _createSession,
|
onPressed: _isBusy ? null : _createSession,
|
||||||
icon: _isCreating ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.add_rounded),
|
icon: _isCreating ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.add_rounded),
|
||||||
@@ -431,6 +432,7 @@ class _RecentRoomTile extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
trailing: IconButton(icon: const Icon(Symbols.more_vert_rounded), onPressed: () => _showActions(context)),
|
trailing: IconButton(icon: const Icon(Symbols.more_vert_rounded), onPressed: () => _showActions(context)),
|
||||||
onTap: isBusy ? null : onTap,
|
onTap: isBusy ? null : onTap,
|
||||||
|
onLongPress: () => _showActions(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -438,8 +440,8 @@ class _RecentRoomTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showActions(BuildContext context) {
|
void _showActions(BuildContext context) {
|
||||||
OverlaySheetController.showAdaptive(
|
OverlaySheetController.of(context).show(
|
||||||
context,
|
showDragHandle: true,
|
||||||
builder: (context) => AppMenuSheet<String>(
|
builder: (context) => AppMenuSheet<String>(
|
||||||
entries: [
|
entries: [
|
||||||
AppMenuItem(value: 'rename', icon: Symbols.edit_rounded, label: t.watchTogether.renameRoom),
|
AppMenuItem(value: 'rename', icon: Symbols.edit_rounded, label: t.watchTogether.renameRoom),
|
||||||
@@ -714,6 +716,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> {
|
|||||||
child: FocusableButton(
|
child: FocusableButton(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onPressed: _isJoining ? null : _joinCurrentPlayback,
|
onPressed: _isJoining ? null : _joinCurrentPlayback,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _isJoining ? null : _joinCurrentPlayback,
|
onPressed: _isJoining ? null : _joinCurrentPlayback,
|
||||||
icon: _isJoining ? const LoadingIndicatorBox() : const Icon(Symbols.play_arrow_rounded),
|
icon: _isJoining ? const LoadingIndicatorBox() : const Icon(Symbols.play_arrow_rounded),
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
|
|||||||
focusNode: _joinFocusNode,
|
focusNode: _joinFocusNode,
|
||||||
onPressed: _join,
|
onPressed: _join,
|
||||||
onNavigateUp: _sessionIdFocusNode.requestFocus,
|
onNavigateUp: _sessionIdFocusNode.requestFocus,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _join,
|
onPressed: _join,
|
||||||
icon: const Icon(Symbols.group_add),
|
icon: const Icon(Symbols.group_add),
|
||||||
|
|||||||
@@ -5,19 +5,24 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../focus/focusable_wrapper.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../utils/dialogs.dart';
|
import '../../utils/dialogs.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
import '../../utils/snackbar_helper.dart';
|
import '../../utils/snackbar_helper.dart';
|
||||||
|
import '../../widgets/bottom_sheet_header.dart';
|
||||||
|
import '../../widgets/focusable_list_tile.dart';
|
||||||
import '../../widgets/overlay_sheet.dart';
|
import '../../widgets/overlay_sheet.dart';
|
||||||
import '../models/watch_session.dart';
|
import '../models/watch_session.dart';
|
||||||
import '../providers/watch_together_provider.dart';
|
import '../providers/watch_together_provider.dart';
|
||||||
|
|
||||||
class WatchTogetherSessionIndicator extends StatelessWidget {
|
class WatchTogetherSessionIndicator extends StatelessWidget {
|
||||||
final VoidCallback? onLeaveSession;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -36,8 +41,14 @@ class WatchTogetherSessionIndicator extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) {
|
void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) {
|
||||||
OverlaySheetController.of(context).show(
|
onCancelAutoHide?.call();
|
||||||
builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession),
|
unawaited(
|
||||||
|
OverlaySheetController.of(context)
|
||||||
|
.show(
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession),
|
||||||
|
)
|
||||||
|
.whenComplete(() => onStartAutoHide?.call()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,53 +72,55 @@ class _SessionIndicator extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return FocusableWrapper(
|
||||||
color: Colors.black54,
|
onSelect: onTap,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
semanticLabel: t.watchTogether.openSessionControls,
|
||||||
child: InkWell(
|
descendantsAreFocusable: false,
|
||||||
onTap: onTap,
|
borderRadius: 20,
|
||||||
|
useBackgroundFocus: true,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.black54,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
canRequestFocus: false,
|
||||||
child: Row(
|
onTap: onTap,
|
||||||
mainAxisSize: .min,
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
children: [
|
child: Padding(
|
||||||
// Sync indicator or group icon
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
if (isSyncing)
|
child: Row(
|
||||||
SizedBox(
|
mainAxisSize: .min,
|
||||||
width: 16,
|
children: [
|
||||||
height: 16,
|
if (isSyncing)
|
||||||
child: PlatformDetector.isTV()
|
SizedBox(
|
||||||
? const Icon(Symbols.sync_rounded, size: 16, color: Colors.white)
|
width: 16,
|
||||||
: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
height: 16,
|
||||||
)
|
child: PlatformDetector.isTV()
|
||||||
else
|
? const Icon(Symbols.sync_rounded, size: 16, color: Colors.white)
|
||||||
Icon(Symbols.group, size: 18, color: isHost ? Colors.amber : Colors.white),
|
: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
const SizedBox(width: 6),
|
else
|
||||||
|
Icon(Symbols.group, size: 18, color: isHost ? Colors.amber : Colors.white),
|
||||||
// Participant count
|
|
||||||
Text(
|
|
||||||
'$participantCount',
|
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 14),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Host badge
|
|
||||||
if (isHost) ...[
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Container(
|
Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
'$participantCount',
|
||||||
decoration: const BoxDecoration(
|
style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 14),
|
||||||
color: Colors.amber,
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
t.watchTogether.hostBadge,
|
|
||||||
style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: .bold),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
if (isHost) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.amber,
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
t.watchTogether.hostBadge,
|
||||||
|
style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: .bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -125,131 +138,119 @@ class _SessionMenuSheet extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return SafeArea(
|
return Column(
|
||||||
child: Padding(
|
mainAxisSize: .min,
|
||||||
padding: const EdgeInsets.all(16),
|
children: [
|
||||||
child: Column(
|
BottomSheetHeader(
|
||||||
mainAxisSize: .min,
|
title: t.watchTogether.title,
|
||||||
crossAxisAlignment: .stretch,
|
icon: Symbols.group,
|
||||||
children: [
|
iconColor: theme.colorScheme.primary,
|
||||||
// Header
|
action: Container(
|
||||||
Row(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Icon(Symbols.group, color: theme.colorScheme.primary),
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
const SizedBox(width: 12),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
Expanded(
|
),
|
||||||
child: Column(
|
child: Text(
|
||||||
crossAxisAlignment: .start,
|
provider.controlMode == ControlMode.hostOnly
|
||||||
children: [
|
? t.watchTogether.hostControls
|
||||||
Text(t.watchTogether.title, style: theme.textTheme.titleMedium),
|
: t.watchTogether.anyoneControls,
|
||||||
Text(
|
style: theme.textTheme.labelSmall,
|
||||||
provider.isHost ? t.watchTogether.youAreHost : t.watchTogether.watchingWithOthers,
|
),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
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(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
),
|
),
|
||||||
],
|
child: Row(
|
||||||
),
|
mainAxisSize: .min,
|
||||||
),
|
children: [
|
||||||
// Control mode badge
|
Text(
|
||||||
Container(
|
'${t.watchTogether.sessionCode}: ',
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
Text(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
provider.sessionId!,
|
||||||
),
|
style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontWeight: .bold),
|
||||||
child: Text(
|
),
|
||||||
provider.controlMode == ControlMode.hostOnly
|
const SizedBox(width: 8),
|
||||||
? t.watchTogether.hostControls
|
Icon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||||
: t.watchTogether.anyoneControls,
|
],
|
||||||
style: theme.textTheme.labelSmall,
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
// Session code with copy button
|
const SizedBox(height: 8),
|
||||||
if (provider.sessionId != null) ...[
|
Text(t.watchTogether.participants, style: theme.textTheme.titleSmall),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
InkWell(
|
for (final participant in provider.participants)
|
||||||
onTap: () => _copySessionCode(context, provider.sessionId!),
|
ListTile(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
leading: CircleAvatar(
|
||||||
child: Container(
|
backgroundColor: participant.isHost
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
? theme.colorScheme.primary
|
||||||
decoration: BoxDecoration(
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
child: Icon(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
participant.isHost ? Symbols.star : Symbols.person,
|
||||||
),
|
color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
||||||
child: Row(
|
size: 20,
|
||||||
mainAxisSize: .min,
|
),
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${t.watchTogether.sessionCode}: ',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
provider.sessionId!,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontWeight: .bold),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
title: Text(participant.displayName),
|
||||||
|
subtitle: participant.isHost ? Text(t.watchTogether.host) : null,
|
||||||
|
trailing: participant.isBuffering
|
||||||
|
? SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: PlatformDetector.isTV()
|
||||||
|
? const Icon(Symbols.hourglass_empty_rounded, size: 16)
|
||||||
|
: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: .zero,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
],
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 16),
|
FocusableListTile(
|
||||||
const Divider(),
|
leading: Icon(Symbols.logout, color: theme.colorScheme.error),
|
||||||
const SizedBox(height: 8),
|
title: Text(
|
||||||
|
provider.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession,
|
||||||
// Participants list
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
Text(t.watchTogether.participants, style: theme.textTheme.titleSmall),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...provider.participants.map(
|
|
||||||
(p) => ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: p.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(
|
|
||||||
p.isHost ? Symbols.star : Symbols.person,
|
|
||||||
color: p.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Text(p.displayName),
|
onTap: () => unawaited(_confirmLeave(context)),
|
||||||
subtitle: p.isHost ? Text(t.watchTogether.host) : null,
|
|
||||||
trailing: p.isBuffering
|
|
||||||
? SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: PlatformDetector.isTV()
|
|
||||||
? const Icon(Symbols.hourglass_empty_rounded, size: 16)
|
|
||||||
: const CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
dense: true,
|
|
||||||
contentPadding: .zero,
|
contentPadding: .zero,
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
ListTile(
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
contentPadding: .zero,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +259,7 @@ class _SessionMenuSheet extends StatelessWidget {
|
|||||||
showSuccessSnackBar(context, t.watchTogether.sessionCodeCopied);
|
showSuccessSnackBar(context, t.watchTogether.sessionCodeCopied);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmLeave(BuildContext context) async {
|
Future<void> _confirmLeave(BuildContext context) async {
|
||||||
final confirmed = await showConfirmDialog(
|
final confirmed = await showConfirmDialog(
|
||||||
context,
|
context,
|
||||||
title: provider.isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion,
|
title: provider.isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion,
|
||||||
@@ -267,10 +268,10 @@ class _SessionMenuSheet extends StatelessWidget {
|
|||||||
isDestructive: true,
|
isDestructive: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed) {
|
if (!confirmed || !context.mounted) return;
|
||||||
unawaited(provider.leaveSession());
|
OverlaySheetController.closeAdaptive(context);
|
||||||
onLeaveSession?.call();
|
unawaited(provider.leaveSession());
|
||||||
}
|
onLeaveSession?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableT
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final enabled = item.enabled && widget.onPressed != null;
|
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 foreground = _foregroundColor(context, active: active);
|
||||||
final subtitleColor = foreground.withValues(alpha: active && item.stateLayerColor != null ? 0.86 : 0.68);
|
final subtitleColor = foreground.withValues(alpha: active && item.stateLayerColor != null ? 0.86 : 0.68);
|
||||||
final background = _backgroundColor(context, active: active);
|
final background = _backgroundColor(context, active: active);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class BottomSheetHeader extends StatelessWidget {
|
|||||||
final Widget? action;
|
final Widget? action;
|
||||||
|
|
||||||
/// Optional callback when close button is pressed
|
/// 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;
|
final VoidCallback? onClose;
|
||||||
|
|
||||||
/// Optional icon to display as leading widget
|
/// 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/card_focus_scope.dart';
|
||||||
import '../focus/dpad_navigator.dart';
|
import '../focus/dpad_navigator.dart';
|
||||||
|
import '../focus/key_event_utils.dart';
|
||||||
import '../media/media_server_client.dart';
|
import '../media/media_server_client.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../theme/mono_tokens.dart';
|
import '../theme/mono_tokens.dart';
|
||||||
@@ -132,8 +133,14 @@ class CastMemberStripState extends State<CastMemberStrip> {
|
|||||||
|
|
||||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||||
final key = event.logicalKey;
|
final key = event.logicalKey;
|
||||||
if (key.isBackKey || !event.isActionable) return KeyEventResult.ignored;
|
if (key.isBackKey || widget.members.isEmpty) return KeyEventResult.ignored;
|
||||||
if (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) {
|
if (key.isLeftKey) {
|
||||||
_moveFocus(-1);
|
_moveFocus(-1);
|
||||||
@@ -151,10 +158,6 @@ class CastMemberStripState extends State<CastMemberStrip> {
|
|||||||
widget.onNavigateDown!();
|
widget.onNavigateDown!();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
if (key.isSelectKey && widget.onMemberTap != null) {
|
|
||||||
widget.onMemberTap!(_focusedIndex);
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../focus/dpad_navigator.dart';
|
import '../focus/focusable_wrapper.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../focus/key_event_utils.dart';
|
|
||||||
import 'clickable_cursor.dart';
|
import 'clickable_cursor.dart';
|
||||||
|
|
||||||
class CollapsibleText extends StatefulWidget {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final style = widget.style ?? DefaultTextStyle.of(context).style;
|
final style = widget.style ?? DefaultTextStyle.of(context).style;
|
||||||
@@ -123,31 +95,22 @@ class _CollapsibleTextState extends State<CollapsibleText> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final focusNode = widget.focusNode;
|
result = FocusableWrapper(
|
||||||
if (focusNode != null) {
|
focusNode: widget.focusNode,
|
||||||
result = Focus(
|
onSelect: _toggleExpanded,
|
||||||
focusNode: focusNode,
|
onNavigateUp: widget.onNavigateUp,
|
||||||
skipTraversal: widget.skipTraversal,
|
onNavigateDown: widget.onNavigateDown,
|
||||||
onKeyEvent: _handleKeyEvent,
|
onNavigateLeft: widget.onNavigateLeft,
|
||||||
child: ListenableBuilder(
|
onNavigateRight: widget.onNavigateRight,
|
||||||
listenable: focusNode,
|
semanticLabel: _expanded ? t.accessibility.collapseText : t.accessibility.expandText,
|
||||||
builder: (context, child) {
|
descendantsAreFocusable: false,
|
||||||
final showFocus = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
disableScale: true,
|
||||||
return AnimatedContainer(
|
useBackgroundFocus: true,
|
||||||
duration: const Duration(milliseconds: 150),
|
borderRadius: 8,
|
||||||
padding: const EdgeInsets.all(4),
|
child: result,
|
||||||
decoration: BoxDecoration(
|
);
|
||||||
color: showFocus
|
if (widget.skipTraversal) {
|
||||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
|
result = ExcludeFocusTraversal(child: result);
|
||||||
: Colors.transparent,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
||||||
),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: result,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ClickableCursor(
|
return ClickableCursor(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../services/settings_service.dart';
|
|||||||
import '../../utils/dialogs.dart';
|
import '../../utils/dialogs.dart';
|
||||||
import '../../focus/focusable_button.dart';
|
import '../../focus/focusable_button.dart';
|
||||||
import '../../focus/key_event_utils.dart';
|
import '../../focus/key_event_utils.dart';
|
||||||
|
import '../dialog_action_button.dart';
|
||||||
|
|
||||||
class RemoteSessionDialog extends StatefulWidget {
|
class RemoteSessionDialog extends StatefulWidget {
|
||||||
const RemoteSessionDialog({super.key});
|
const RemoteSessionDialog({super.key});
|
||||||
@@ -115,22 +116,22 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> with MountedS
|
|||||||
title: Text(t.common.error),
|
title: Text(t.common.error),
|
||||||
content: Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')),
|
content: Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')),
|
||||||
actions: [
|
actions: [
|
||||||
FocusableButton(
|
DialogActionButton(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
focusNode: _errorCloseFocusNode,
|
focusNode: _errorCloseFocusNode,
|
||||||
onPressed: _close,
|
onPressed: _close,
|
||||||
onBack: _close,
|
onBack: _close,
|
||||||
onNavigateRight: () => _errorRetryFocusNode.requestFocus(),
|
onNavigateRight: () => _errorRetryFocusNode.requestFocus(),
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
child: TextButton(onPressed: _close, child: Text(t.common.close)),
|
label: t.common.close,
|
||||||
),
|
),
|
||||||
FocusableButton(
|
DialogActionButton(
|
||||||
focusNode: _errorRetryFocusNode,
|
focusNode: _errorRetryFocusNode,
|
||||||
onPressed: _startServer,
|
onPressed: _startServer,
|
||||||
onBack: _close,
|
onBack: _close,
|
||||||
onNavigateLeft: () => _errorCloseFocusNode.requestFocus(),
|
onNavigateLeft: () => _errorCloseFocusNode.requestFocus(),
|
||||||
useBackgroundFocus: true,
|
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 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../focus/focusable_button.dart';
|
import '../focus/focusable_button.dart';
|
||||||
|
import '../focus/focusable_wrapper.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../models/trackers/device_code.dart';
|
import '../models/trackers/device_code.dart';
|
||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
@@ -45,17 +46,25 @@ class DeviceCodeDialog extends StatelessWidget {
|
|||||||
Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium),
|
Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
child: InkWell(
|
child: FocusableWrapper(
|
||||||
onTap: () => _copy(context),
|
onSelect: () => _copy(context),
|
||||||
borderRadius: BorderRadius.circular(8),
|
semanticLabel: t.services.deviceCode.copyCode,
|
||||||
child: Padding(
|
descendantsAreFocusable: false,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
useBackgroundFocus: true,
|
||||||
child: Text(
|
borderRadius: 8,
|
||||||
code.userCode,
|
child: InkWell(
|
||||||
style: theme.textTheme.displaySmall?.copyWith(
|
canRequestFocus: false,
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
onTap: () => _copy(context),
|
||||||
letterSpacing: 4,
|
borderRadius: BorderRadius.circular(8),
|
||||||
fontWeight: .w600,
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
child: Text(
|
||||||
|
code.userCode,
|
||||||
|
style: theme.textTheme.displaySmall?.copyWith(
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
letterSpacing: 4,
|
||||||
|
fontWeight: .w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -66,6 +75,7 @@ class DeviceCodeDialog extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FocusableButton(
|
child: FocusableButton(
|
||||||
onPressed: _open,
|
onPressed: _open,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
label: Text(t.services.deviceCode.openToActivate(service: serviceName)),
|
label: Text(t.services.deviceCode.openToActivate(service: serviceName)),
|
||||||
|
|||||||
@@ -9,40 +9,61 @@ import '../focus/focusable_button.dart';
|
|||||||
/// `FocusableButton(onPressed: ..., child: TextButton(onPressed: ..., ...))`
|
/// `FocusableButton(onPressed: ..., child: TextButton(onPressed: ..., ...))`
|
||||||
/// boilerplate with a single call.
|
/// boilerplate with a single call.
|
||||||
class DialogActionButton extends StatelessWidget {
|
class DialogActionButton extends StatelessWidget {
|
||||||
final VoidCallback onPressed;
|
final VoidCallback? onPressed;
|
||||||
final String label;
|
final String label;
|
||||||
final FocusNode? focusNode;
|
final FocusNode? focusNode;
|
||||||
|
final bool autofocus;
|
||||||
final bool isPrimary;
|
final bool isPrimary;
|
||||||
|
final bool? useBackgroundFocus;
|
||||||
|
final VoidCallback? onBack;
|
||||||
final VoidCallback? onNavigateUp;
|
final VoidCallback? onNavigateUp;
|
||||||
final VoidCallback? onNavigateDown;
|
final VoidCallback? onNavigateDown;
|
||||||
final VoidCallback? onNavigateLeft;
|
final VoidCallback? onNavigateLeft;
|
||||||
final VoidCallback? onNavigateRight;
|
final VoidCallback? onNavigateRight;
|
||||||
|
final ButtonStyle? style;
|
||||||
|
final Widget? icon;
|
||||||
const DialogActionButton({
|
const DialogActionButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
required this.label,
|
required this.label,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
|
this.autofocus = false,
|
||||||
this.isPrimary = false,
|
this.isPrimary = false,
|
||||||
|
this.useBackgroundFocus,
|
||||||
|
this.onBack,
|
||||||
this.onNavigateUp,
|
this.onNavigateUp,
|
||||||
this.onNavigateDown,
|
this.onNavigateDown,
|
||||||
this.onNavigateLeft,
|
this.onNavigateLeft,
|
||||||
this.onNavigateRight,
|
this.onNavigateRight,
|
||||||
|
this.style,
|
||||||
|
this.icon,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return FocusableButton(
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
|
autofocus: autofocus,
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
useBackgroundFocus: isPrimary,
|
useBackgroundFocus: useBackgroundFocus ?? isPrimary,
|
||||||
|
onBack: onBack,
|
||||||
onNavigateUp: onNavigateUp,
|
onNavigateUp: onNavigateUp,
|
||||||
onNavigateDown: onNavigateDown,
|
onNavigateDown: onNavigateDown,
|
||||||
onNavigateLeft: onNavigateLeft,
|
onNavigateLeft: onNavigateLeft,
|
||||||
onNavigateRight: onNavigateRight,
|
onNavigateRight: onNavigateRight,
|
||||||
child: isPrimary
|
child: button,
|
||||||
? FilledButton(onPressed: onPressed, child: Text(label))
|
|
||||||
: TextButton(onPressed: onPressed, child: Text(label)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// An [AppMenuButton] that can be focused and opened with D-pad select.
|
||||||
class FocusablePopupMenuButton<T> extends StatefulWidget {
|
class FocusablePopupMenuButton<T> extends StatefulWidget {
|
||||||
final Widget? icon;
|
final Widget? icon;
|
||||||
|
final Widget? child;
|
||||||
final String? tooltip;
|
final String? tooltip;
|
||||||
|
final bool enabled;
|
||||||
final AppMenuEntryBuilder<T> itemBuilder;
|
final AppMenuEntryBuilder<T> itemBuilder;
|
||||||
final ValueChanged<T>? onSelected;
|
final ValueChanged<T>? onSelected;
|
||||||
final GlobalKey<AppMenuButtonState<T>>? menuKey;
|
final GlobalKey<AppMenuButtonState<T>>? menuKey;
|
||||||
|
final AppMenuAnchorAlignment anchorAlignment;
|
||||||
|
final Offset alignmentOffset;
|
||||||
|
final double minWidth;
|
||||||
|
final double? maxWidth;
|
||||||
|
final EdgeInsetsGeometry? childPadding;
|
||||||
final FocusNode? focusNode;
|
final FocusNode? focusNode;
|
||||||
final VoidCallback? onNavigateUp;
|
final VoidCallback? onNavigateUp;
|
||||||
final VoidCallback? onNavigateDown;
|
final VoidCallback? onNavigateDown;
|
||||||
@@ -23,10 +30,17 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
|
|||||||
const FocusablePopupMenuButton({
|
const FocusablePopupMenuButton({
|
||||||
super.key,
|
super.key,
|
||||||
this.icon,
|
this.icon,
|
||||||
|
this.child,
|
||||||
this.tooltip,
|
this.tooltip,
|
||||||
|
this.enabled = true,
|
||||||
required this.itemBuilder,
|
required this.itemBuilder,
|
||||||
this.onSelected,
|
this.onSelected,
|
||||||
this.menuKey,
|
this.menuKey,
|
||||||
|
this.anchorAlignment = AppMenuAnchorAlignment.start,
|
||||||
|
this.alignmentOffset = Offset.zero,
|
||||||
|
this.minWidth = 220,
|
||||||
|
this.maxWidth,
|
||||||
|
this.childPadding,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
this.onNavigateUp,
|
this.onNavigateUp,
|
||||||
this.onNavigateDown,
|
this.onNavigateDown,
|
||||||
@@ -36,7 +50,7 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
|
|||||||
this.borderRadius = 100,
|
this.borderRadius = 100,
|
||||||
this.useBackgroundFocus = true,
|
this.useBackgroundFocus = true,
|
||||||
this.enableLongPress = true,
|
this.enableLongPress = true,
|
||||||
});
|
}) : assert(icon != null || child != null, 'FocusablePopupMenuButton requires icon or child');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FocusablePopupMenuButton<T>> createState() => _FocusablePopupMenuButtonState<T>();
|
State<FocusablePopupMenuButton<T>> createState() => _FocusablePopupMenuButtonState<T>();
|
||||||
@@ -53,6 +67,7 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FocusableWrapper(
|
return FocusableWrapper(
|
||||||
focusNode: widget.focusNode,
|
focusNode: widget.focusNode,
|
||||||
|
canRequestFocus: widget.enabled,
|
||||||
disableScale: true,
|
disableScale: true,
|
||||||
borderRadius: widget.borderRadius,
|
borderRadius: widget.borderRadius,
|
||||||
useBackgroundFocus: widget.useBackgroundFocus,
|
useBackgroundFocus: widget.useBackgroundFocus,
|
||||||
@@ -63,14 +78,21 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
|
|||||||
onNavigateDown: widget.onNavigateDown,
|
onNavigateDown: widget.onNavigateDown,
|
||||||
onNavigateLeft: widget.onNavigateLeft,
|
onNavigateLeft: widget.onNavigateLeft,
|
||||||
onNavigateRight: widget.onNavigateRight,
|
onNavigateRight: widget.onNavigateRight,
|
||||||
onSelect: _showMenu,
|
onSelect: widget.enabled ? _showMenu : null,
|
||||||
onLongPress: widget.enableLongPress ? _showMenu : null,
|
onLongPress: widget.enabled && widget.enableLongPress ? _showMenu : null,
|
||||||
child: AppMenuButton<T>(
|
child: AppMenuButton<T>(
|
||||||
key: _menuKey,
|
key: _menuKey,
|
||||||
icon: widget.icon,
|
icon: widget.icon,
|
||||||
tooltip: widget.tooltip,
|
tooltip: widget.tooltip,
|
||||||
|
enabled: widget.enabled,
|
||||||
onSelected: widget.onSelected,
|
onSelected: widget.onSelected,
|
||||||
entriesBuilder: widget.itemBuilder,
|
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).
|
/// Optional actions to display in the app bar (e.g., IconButton widgets).
|
||||||
final List<Widget>? actions;
|
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.
|
/// Whether the app bar should remain visible when scrolling.
|
||||||
/// Defaults to true.
|
/// Defaults to true.
|
||||||
final bool pinned;
|
final bool pinned;
|
||||||
@@ -44,6 +50,7 @@ class FocusedScrollScaffold extends StatefulWidget {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.slivers,
|
required this.slivers,
|
||||||
this.actions,
|
this.actions,
|
||||||
|
this.focusableAppBarActions = false,
|
||||||
this.pinned = true,
|
this.pinned = true,
|
||||||
this.automaticallyImplyLeading = true,
|
this.automaticallyImplyLeading = true,
|
||||||
this.onBackPressed,
|
this.onBackPressed,
|
||||||
@@ -56,6 +63,7 @@ class FocusedScrollScaffold extends StatefulWidget {
|
|||||||
class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
||||||
final _scopeNode = FocusScopeNode();
|
final _scopeNode = FocusScopeNode();
|
||||||
bool _focusRequested = false;
|
bool _focusRequested = false;
|
||||||
|
bool _appBarFocusEnabled = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -71,6 +79,9 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
|||||||
if (_scopeNode.focusedChild != null) return;
|
if (_scopeNode.focusedChild != null) return;
|
||||||
_scopeNode.requestFocus();
|
_scopeNode.requestFocus();
|
||||||
_scopeNode.nextFocus();
|
_scopeNode.nextFocus();
|
||||||
|
if (widget.focusableAppBarActions && !_appBarFocusEnabled) {
|
||||||
|
setState(() => _appBarFocusEnabled = true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,14 +105,22 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
ExcludeFocus(
|
if (!widget.focusableAppBarActions || !_appBarFocusEnabled)
|
||||||
child: CustomAppBar(
|
ExcludeFocus(
|
||||||
|
child: CustomAppBar(
|
||||||
|
title: widget.title,
|
||||||
|
pinned: widget.pinned,
|
||||||
|
actions: widget.actions,
|
||||||
|
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
CustomAppBar(
|
||||||
title: widget.title,
|
title: widget.title,
|
||||||
pinned: widget.pinned,
|
pinned: widget.pinned,
|
||||||
actions: widget.actions,
|
actions: widget.actions,
|
||||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
...widget.slivers,
|
...widget.slivers,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import '../models/hotkey_model.dart';
|
|||||||
|
|
||||||
/// Captures a key combination from the user and calls [onHotKeyRecorded].
|
/// Captures a key combination from the user and calls [onHotKeyRecorded].
|
||||||
class HotKeyRecorder extends StatefulWidget {
|
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 HotKey? initalHotKey;
|
||||||
final ValueChanged<HotKey> onHotKeyRecorded;
|
final ValueChanged<HotKey> onHotKeyRecorded;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HotKeyRecorder> createState() => _HotKeyRecorderState();
|
State<HotKeyRecorder> createState() => _HotKeyRecorderState();
|
||||||
@@ -24,6 +25,14 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
|
|||||||
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(HotKeyRecorder oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.initalHotKey != oldWidget.initalHotKey) {
|
||||||
|
_hotKey = widget.initalHotKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||||
@@ -31,6 +40,7 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _handleKeyEvent(KeyEvent keyEvent) {
|
bool _handleKeyEvent(KeyEvent keyEvent) {
|
||||||
|
if (!widget.enabled) return false;
|
||||||
if (keyEvent is KeyUpEvent) return false;
|
if (keyEvent is KeyUpEvent) return false;
|
||||||
|
|
||||||
final physicalKeysPressed = HardwareKeyboard.instance.physicalKeysPressed;
|
final physicalKeysPressed = HardwareKeyboard.instance.physicalKeysPressed;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '../utils/provider_extensions.dart';
|
|||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import 'app_icon.dart';
|
import 'app_icon.dart';
|
||||||
import 'app_menu.dart';
|
import 'app_menu.dart';
|
||||||
|
import 'bottom_sheet_header.dart';
|
||||||
import 'overlay_sheet.dart';
|
import 'overlay_sheet.dart';
|
||||||
|
|
||||||
/// A menu action item for context menus
|
/// 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)
|
List<MediaLibrary>? _originalOrder; // Original order before move (for cancel)
|
||||||
final FocusNode _listFocusNode = FocusNode();
|
final FocusNode _listFocusNode = FocusNode();
|
||||||
final ScrollController _dialogScrollController = ScrollController();
|
final ScrollController _dialogScrollController = ScrollController();
|
||||||
|
final ScrollController _sheetScrollController = ScrollController();
|
||||||
bool _backKeyDownSeen = false;
|
bool _backKeyDownSeen = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -332,6 +334,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_listFocusNode.dispose();
|
_listFocusNode.dispose();
|
||||||
_dialogScrollController.dispose();
|
_dialogScrollController.dispose();
|
||||||
|
_sheetScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +508,11 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
|||||||
for (final item in menuItems)
|
for (final item in menuItems)
|
||||||
AppMenuItem<String>(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive),
|
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(
|
body: Focus(
|
||||||
focusNode: _listFocusNode,
|
focusNode: _listFocusNode,
|
||||||
|
descendantsAreFocusable: false,
|
||||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||||
onKeyEvent: _handleKeyEvent,
|
onKeyEvent: _handleKeyEvent,
|
||||||
child: _buildFlatLibraryListDialog(hiddenLibraryKeys),
|
child: _buildFlatLibraryListDialog(hiddenLibraryKeys),
|
||||||
@@ -556,47 +564,19 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DraggableScrollableSheet(
|
return Column(
|
||||||
initialChildSize: 0.7,
|
children: [
|
||||||
minChildSize: 0.5,
|
BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded),
|
||||||
maxChildSize: 0.95,
|
Flexible(
|
||||||
expand: false,
|
child: Focus(
|
||||||
builder: (context, scrollController) {
|
focusNode: _listFocusNode,
|
||||||
return Column(
|
descendantsAreFocusable: false,
|
||||||
children: [
|
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||||
// Header
|
onKeyEvent: _handleKeyEvent,
|
||||||
Container(
|
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys),
|
||||||
padding: const EdgeInsets.all(16),
|
),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)),
|
],
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const AppIcon(Symbols.edit_rounded, fill: 1),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(t.libraries.manageLibraries, style: const TextStyle(fontSize: 20, fontWeight: .bold)),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
|
||||||
onPressed: () => OverlaySheetController.popAdaptive(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Library list (grouped by server if multiple servers)
|
|
||||||
Expanded(
|
|
||||||
child: Focus(
|
|
||||||
focusNode: _listFocusNode,
|
|
||||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
|
||||||
onKeyEvent: _handleKeyEvent,
|
|
||||||
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import '../utils/platform_detector.dart';
|
|||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
import '../services/external_player_service.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/focusable_text_field.dart';
|
||||||
import '../focus/key_event_utils.dart';
|
import '../focus/key_event_utils.dart';
|
||||||
import '../screens/plex_match_screen.dart';
|
import '../screens/plex_match_screen.dart';
|
||||||
@@ -588,6 +588,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
selected = await OverlaySheetController.showAdaptive<String>(
|
selected = await OverlaySheetController.showAdaptive<String>(
|
||||||
this.context,
|
this.context,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
builder: (context) => AppMenuSheet<String>(
|
builder: (context) => AppMenuSheet<String>(
|
||||||
title: _itemDisplayTitle(),
|
title: _itemDisplayTitle(),
|
||||||
entries: _menuEntries(menuActions),
|
entries: _menuEntries(menuActions),
|
||||||
@@ -1320,6 +1321,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
await OverlaySheetController.showAdaptive(
|
await OverlaySheetController.showAdaptive(
|
||||||
this.context,
|
this.context,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
builder: (context) => RatingBottomSheet(
|
builder: (context) => RatingBottomSheet(
|
||||||
item: item,
|
item: item,
|
||||||
serverClient: client,
|
serverClient: client,
|
||||||
@@ -1959,12 +1961,7 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)],
|
||||||
FocusableButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../focus/focusable_action_bar.dart';
|
||||||
|
import '../../focus/focusable_wrapper.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../media/ids.dart';
|
import '../../media/ids.dart';
|
||||||
import '../../media/media_item.dart';
|
import '../../media/media_item.dart';
|
||||||
@@ -16,7 +18,6 @@ import '../../utils/music_navigation.dart';
|
|||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
import '../../utils/provider_extensions.dart';
|
import '../../utils/provider_extensions.dart';
|
||||||
import '../../utils/video_player_navigation.dart';
|
import '../../utils/video_player_navigation.dart';
|
||||||
import '../app_icon.dart';
|
|
||||||
import '../media_context_menu.dart';
|
import '../media_context_menu.dart';
|
||||||
import '../optimized_media_image.dart';
|
import '../optimized_media_image.dart';
|
||||||
import '../overlay_sheet.dart';
|
import '../overlay_sheet.dart';
|
||||||
@@ -252,7 +253,14 @@ class _MiniPlayerCard extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMixin<_MiniPlayerCard> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -266,96 +274,111 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
|
|||||||
color: tk.surface,
|
color: tk.surface,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
borderRadius: BorderRadius.circular(tk.radiusLg),
|
borderRadius: BorderRadius.circular(tk.radiusLg),
|
||||||
child: InkWell(
|
child: SizedBox(
|
||||||
mouseCursor: SystemMouseCursors.click,
|
height: _MusicMiniPlayerOverlayState._cardHeight,
|
||||||
onTap: () => unawaited(openNowPlaying(context)),
|
child: Stack(
|
||||||
onTapDown: storeTapPosition,
|
children: [
|
||||||
onLongPress: showContextMenuFromTap,
|
const Positioned.fill(child: _MiniPlayerProgress()),
|
||||||
onSecondaryTapDown: storeTapPosition,
|
Padding(
|
||||||
onSecondaryTap: showContextMenuFromTap,
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: SizedBox(
|
child: Row(
|
||||||
height: _MusicMiniPlayerOverlayState._cardHeight,
|
children: [
|
||||||
child: Stack(
|
Expanded(
|
||||||
children: [
|
child: FocusableWrapper(
|
||||||
// Played fraction tints the card background itself — the card
|
focusNode: _detailsFocusNode,
|
||||||
// fills up as the track progresses (clipped by the Material's
|
onSelect: () => unawaited(openNowPlaying(context)),
|
||||||
// rounded corners above).
|
enableLongPress: true,
|
||||||
const Positioned.fill(child: _MiniPlayerProgress()),
|
onLongPress: showContextMenuFromTap,
|
||||||
Padding(
|
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
semanticLabel: widget.track.title,
|
||||||
child: Row(
|
descendantsAreFocusable: false,
|
||||||
children: [
|
disableScale: true,
|
||||||
ClipRRect(
|
useBackgroundFocus: true,
|
||||||
borderRadius: BorderRadius.circular(tk.radiusSm),
|
borderRadius: tk.radiusLg,
|
||||||
child: OptimizedMediaImage(
|
child: InkWell(
|
||||||
client: client,
|
canRequestFocus: false,
|
||||||
imagePath: widget.track.thumbPath,
|
mouseCursor: SystemMouseCursors.click,
|
||||||
imageType: ImageType.square,
|
onTap: () => unawaited(openNowPlaying(context)),
|
||||||
width: 48,
|
onTapDown: storeTapPosition,
|
||||||
height: 48,
|
onLongPress: showContextMenuFromTap,
|
||||||
fallbackIcon: Symbols.music_note_rounded,
|
onSecondaryTapDown: storeTapPosition,
|
||||||
),
|
onSecondaryTap: showContextMenuFromTap,
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(width: 10),
|
children: [
|
||||||
Expanded(
|
ClipRRect(
|
||||||
child: Column(
|
borderRadius: BorderRadius.circular(tk.radiusSm),
|
||||||
mainAxisAlignment: .center,
|
child: OptimizedMediaImage(
|
||||||
crossAxisAlignment: .start,
|
client: client,
|
||||||
children: [
|
imagePath: widget.track.thumbPath,
|
||||||
Text(
|
imageType: ImageType.square,
|
||||||
widget.track.title ?? '',
|
width: 48,
|
||||||
maxLines: 1,
|
height: 48,
|
||||||
overflow: .ellipsis,
|
fallbackIcon: Symbols.music_note_rounded,
|
||||||
style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text),
|
),
|
||||||
),
|
|
||||||
if (artist != null && artist.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
artist,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: .ellipsis,
|
|
||||||
style: TextStyle(fontSize: 12, color: tk.textMuted),
|
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(width: 10),
|
||||||
),
|
Expanded(
|
||||||
),
|
child: Column(
|
||||||
if (widget.desktop)
|
mainAxisAlignment: .center,
|
||||||
IconButton(
|
crossAxisAlignment: .start,
|
||||||
icon: AppIcon(Symbols.skip_previous_rounded, fill: 1, color: tk.text),
|
children: [
|
||||||
tooltip: t.music.previousTrack,
|
Text(
|
||||||
onPressed: () => unawaited(service.previous()),
|
widget.track.title ?? '',
|
||||||
),
|
maxLines: 1,
|
||||||
IconButton(
|
overflow: .ellipsis,
|
||||||
icon: AppIcon(
|
style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text),
|
||||||
isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
),
|
||||||
fill: 1,
|
if (artist != null && artist.isNotEmpty)
|
||||||
color: tk.text,
|
Text(
|
||||||
),
|
artist,
|
||||||
tooltip: isPlaying ? t.common.pause : t.common.play,
|
maxLines: 1,
|
||||||
onPressed: () => unawaited(service.togglePlayPause()),
|
overflow: .ellipsis,
|
||||||
),
|
style: TextStyle(fontSize: 12, color: tk.textMuted),
|
||||||
IconButton(
|
),
|
||||||
icon: AppIcon(Symbols.skip_next_rounded, fill: 1, color: tk.text),
|
],
|
||||||
tooltip: t.music.nextTrack,
|
),
|
||||||
onPressed: () => unawaited(service.next()),
|
),
|
||||||
),
|
],
|
||||||
if (widget.desktop)
|
|
||||||
AnimatedOpacity(
|
|
||||||
opacity: _hovered ? 1 : 0,
|
|
||||||
duration: tk.fast,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: !_hovered,
|
|
||||||
child: IconButton(
|
|
||||||
icon: AppIcon(Symbols.close_rounded, fill: 1, size: 20, color: tk.textMuted),
|
|
||||||
tooltip: t.music.stopPlayback,
|
|
||||||
onPressed: widget.onDismissed,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
|
FocusableActionBar(
|
||||||
|
key: _transportKey,
|
||||||
|
onNavigateLeft: _detailsFocusNode.requestFocus,
|
||||||
|
actions: [
|
||||||
|
if (widget.desktop)
|
||||||
|
FocusableAction(
|
||||||
|
icon: Symbols.skip_previous_rounded,
|
||||||
|
iconColor: tk.text,
|
||||||
|
tooltip: t.music.previousTrack,
|
||||||
|
onPressed: () => unawaited(service.previous()),
|
||||||
|
),
|
||||||
|
FocusableAction(
|
||||||
|
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||||
|
iconColor: tk.text,
|
||||||
|
tooltip: isPlaying ? t.common.pause : t.common.play,
|
||||||
|
onPressed: () => unawaited(service.togglePlayPause()),
|
||||||
|
),
|
||||||
|
FocusableAction(
|
||||||
|
icon: Symbols.skip_next_rounded,
|
||||||
|
iconColor: tk.text,
|
||||||
|
tooltip: t.music.nextTrack,
|
||||||
|
onPressed: () => unawaited(service.next()),
|
||||||
|
),
|
||||||
|
if (widget.desktop)
|
||||||
|
FocusableAction(
|
||||||
|
icon: Symbols.close_rounded,
|
||||||
|
iconColor: tk.textMuted,
|
||||||
|
tooltip: t.music.stopPlayback,
|
||||||
|
onPressed: widget.onDismissed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -377,12 +400,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
|
|||||||
child: card,
|
child: card,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!widget.desktop) return card;
|
return card;
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
|
||||||
child: card,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../focus/focusable_button.dart';
|
import '../focus/focusable_button.dart';
|
||||||
|
import '../focus/focusable_wrapper.dart';
|
||||||
import '../services/trackers/oauth_proxy_client.dart';
|
import '../services/trackers/oauth_proxy_client.dart';
|
||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import 'dialog_action_button.dart';
|
import 'dialog_action_button.dart';
|
||||||
@@ -55,18 +56,26 @@ class OAuthProxyDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
InkWell(
|
FocusableWrapper(
|
||||||
onTap: () => _copyUrl(context),
|
onSelect: () => _copyUrl(context),
|
||||||
borderRadius: BorderRadius.circular(8),
|
semanticLabel: t.services.oauthProxy.copyUrl,
|
||||||
child: Padding(
|
descendantsAreFocusable: false,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
borderRadius: 8,
|
||||||
child: Text(
|
useBackgroundFocus: true,
|
||||||
start.url,
|
child: InkWell(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
canRequestFocus: false,
|
||||||
fontFamily: 'monospace',
|
onTap: () => _copyUrl(context),
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
start.url,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -75,6 +84,7 @@ class OAuthProxyDialog extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FocusableButton(
|
child: FocusableButton(
|
||||||
onPressed: _open,
|
onPressed: _open,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)),
|
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
|
/// Push a sub-page using the overlay system if available, otherwise fall
|
||||||
/// back to [showModalBottomSheet]. Returns the result from the page.
|
/// 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>(
|
static Future<T?> pushAdaptive<T>(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required WidgetBuilder builder,
|
required WidgetBuilder builder,
|
||||||
FocusNode? initialFocusNode,
|
FocusNode? initialFocusNode,
|
||||||
|
BoxConstraints? constraints,
|
||||||
|
Color? backgroundColor,
|
||||||
|
bool barrierDismissible = true,
|
||||||
|
bool isScrollControlled = false,
|
||||||
|
bool showDragHandle = false,
|
||||||
}) async {
|
}) async {
|
||||||
final controller = maybeOf(context);
|
final controller = maybeOf(context);
|
||||||
if (controller != null) {
|
if (controller != null) {
|
||||||
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
|
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++;
|
openSheetCount.value++;
|
||||||
try {
|
try {
|
||||||
return await showModalBottomSheet<T>(
|
return await showModalBottomSheet<T>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => SafeArea(top: false, child: builder(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 {
|
} finally {
|
||||||
openSheetCount.value--;
|
openSheetCount.value--;
|
||||||
@@ -310,6 +331,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
|||||||
Alignment alignment = Alignment.bottomCenter,
|
Alignment alignment = Alignment.bottomCenter,
|
||||||
bool showDragHandle = false,
|
bool showDragHandle = false,
|
||||||
}) {
|
}) {
|
||||||
|
BackKeyCoordinator.clear();
|
||||||
// If already open, close first (instant)
|
// If already open, close first (instant)
|
||||||
final wasOpen = _isOpen;
|
final wasOpen = _isOpen;
|
||||||
if (_isOpen) {
|
if (_isOpen) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '../utils/snackbar_helper.dart';
|
|||||||
import 'app_icon.dart';
|
import 'app_icon.dart';
|
||||||
import 'app_menu.dart';
|
import 'app_menu.dart';
|
||||||
import 'loading_indicator_box.dart';
|
import 'loading_indicator_box.dart';
|
||||||
|
import 'focusable_list_tile.dart';
|
||||||
import 'overlay_sheet.dart';
|
import 'overlay_sheet.dart';
|
||||||
import 'stat_chip.dart';
|
import 'stat_chip.dart';
|
||||||
|
|
||||||
@@ -396,7 +397,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
|||||||
else ...[
|
else ...[
|
||||||
if (!_isMovie && _partialSeasons) ..._buildSeasonSection(theme),
|
if (!_isMovie && _partialSeasons) ..._buildSeasonSection(theme),
|
||||||
if (_can4k)
|
if (_can4k)
|
||||||
SwitchListTile(
|
FocusableSwitchListTile(
|
||||||
value: _is4k,
|
value: _is4k,
|
||||||
onChanged: _submitting ? null : _toggle4k,
|
onChanged: _submitting ? null : _toggle4k,
|
||||||
title: Text(t.seerr.request4k),
|
title: Text(t.seerr.request4k),
|
||||||
@@ -432,7 +433,11 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
|||||||
children: [
|
children: [
|
||||||
Text(t.seerr.requestsLoadFailed, style: theme.textTheme.bodyMedium),
|
Text(t.seerr.requestsLoadFailed, style: theme.textTheme.bodyMedium),
|
||||||
const SizedBox(height: 12),
|
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 number = season.seasonNumber;
|
||||||
final blockedLabel = _seasonBlockedLabel(number);
|
final blockedLabel = _seasonBlockedLabel(number);
|
||||||
final episodeCount = season.episodeCount;
|
final episodeCount = season.episodeCount;
|
||||||
return CheckboxListTile(
|
return FocusableCheckboxListTile(
|
||||||
focusNode: _seasonFocusNodes[index],
|
focusNode: _seasonFocusNodes[index],
|
||||||
value: blockedLabel != null || _selectedSeasons.contains(number),
|
value: blockedLabel != null || _selectedSeasons.contains(number),
|
||||||
onChanged: blockedLabel != null || _submitting
|
onChanged: blockedLabel != null || _submitting
|
||||||
@@ -604,7 +609,7 @@ class _PickerTile<T> extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return FocusableListTile(
|
||||||
leading: AppIcon(icon, fill: 1),
|
leading: AppIcon(icon, fill: 1),
|
||||||
title: Text(label),
|
title: Text(label),
|
||||||
subtitle: value.isEmpty ? null : Text(value, maxLines: 1, overflow: TextOverflow.ellipsis),
|
subtitle: value.isEmpty ? null : Text(value, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
|||||||
+104
-105
@@ -6,7 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import '../screens/settings/settings_utils.dart';
|
import '../screens/settings/settings_utils.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import 'app_icon.dart';
|
import 'app_icon.dart';
|
||||||
import 'clickable_cursor.dart';
|
import 'focusable_list_tile.dart';
|
||||||
import 'settings_section.dart';
|
import 'settings_section.dart';
|
||||||
|
|
||||||
/// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable].
|
/// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable].
|
||||||
@@ -43,22 +43,21 @@ class SettingSwitchTile extends StatelessWidget {
|
|||||||
final svc = _TileBase._svc;
|
final svc = _TileBase._svc;
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: svc.listenable(pref),
|
valueListenable: svc.listenable(pref),
|
||||||
builder: (_, value, _) => ClickableCursor(
|
builder: (_, value, _) => FocusableSwitchListTile(
|
||||||
enabled: enabled,
|
focusNode: focusNode,
|
||||||
child: SwitchListTile(
|
secondary: AppIcon(icon, fill: 1),
|
||||||
focusNode: focusNode,
|
title: Text(title),
|
||||||
secondary: AppIcon(icon, fill: 1),
|
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||||
title: Text(title),
|
value: value,
|
||||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
dense: false,
|
||||||
value: value,
|
visualDensity: VisualDensity.standard,
|
||||||
onChanged: enabled
|
onChanged: enabled
|
||||||
? (v) async {
|
? (v) async {
|
||||||
await svc.write(pref, v);
|
await svc.write(pref, v);
|
||||||
final callback = onAfterWrite;
|
final callback = onAfterWrite;
|
||||||
if (callback != null) await callback(v);
|
if (callback != null) await callback(v);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,15 +86,15 @@ class SettingNavigationTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClickableCursor(
|
return FocusableListTile(
|
||||||
child: ListTile(
|
focusNode: focusNode,
|
||||||
focusNode: focusNode,
|
leading: AppIcon(icon, fill: 1),
|
||||||
leading: AppIcon(icon, fill: 1),
|
title: Text(title),
|
||||||
title: Text(title),
|
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
trailing: AppIcon(trailingIcon, fill: 1),
|
||||||
trailing: AppIcon(trailingIcon, fill: 1),
|
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)),
|
||||||
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)),
|
dense: false,
|
||||||
),
|
visualDensity: VisualDensity.standard,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,26 +129,26 @@ class SettingNumberTile extends StatelessWidget {
|
|||||||
final svc = _TileBase._svc;
|
final svc = _TileBase._svc;
|
||||||
return ValueListenableBuilder<int>(
|
return ValueListenableBuilder<int>(
|
||||||
valueListenable: svc.listenable(pref),
|
valueListenable: svc.listenable(pref),
|
||||||
builder: (_, value, _) => ClickableCursor(
|
builder: (_, value, _) => FocusableListTile(
|
||||||
child: ListTile(
|
leading: AppIcon(icon, fill: 1),
|
||||||
leading: AppIcon(icon, fill: 1),
|
title: Text(title),
|
||||||
title: Text(title),
|
subtitle: Text(subtitleBuilder(value)),
|
||||||
subtitle: Text(subtitleBuilder(value)),
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
dense: false,
|
||||||
onTap: () => showNumericInputDialog(
|
visualDensity: VisualDensity.standard,
|
||||||
context: context,
|
onTap: () => showNumericInputDialog(
|
||||||
title: title,
|
context: context,
|
||||||
labelText: labelText,
|
title: title,
|
||||||
suffixText: suffixText,
|
labelText: labelText,
|
||||||
min: min,
|
suffixText: suffixText,
|
||||||
max: max,
|
min: min,
|
||||||
currentValue: value,
|
max: max,
|
||||||
onSave: (v) async {
|
currentValue: value,
|
||||||
await svc.write(pref, v);
|
onSave: (v) async {
|
||||||
final callback = onAfterWrite;
|
await svc.write(pref, v);
|
||||||
if (callback != null) await callback(v);
|
final callback = onAfterWrite;
|
||||||
},
|
if (callback != null) await callback(v);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -188,25 +187,25 @@ class SettingSelectionTile<T, S> extends StatelessWidget {
|
|||||||
valueListenable: svc.listenable(pref),
|
valueListenable: svc.listenable(pref),
|
||||||
builder: (_, raw, _) {
|
builder: (_, raw, _) {
|
||||||
final value = decode(raw);
|
final value = decode(raw);
|
||||||
return ClickableCursor(
|
return FocusableListTile(
|
||||||
child: ListTile(
|
leading: AppIcon(icon, fill: 1),
|
||||||
leading: AppIcon(icon, fill: 1),
|
title: Text(title),
|
||||||
title: Text(title),
|
subtitle: Text(subtitleBuilder(value)),
|
||||||
subtitle: Text(subtitleBuilder(value)),
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
dense: false,
|
||||||
onTap: () async {
|
visualDensity: VisualDensity.standard,
|
||||||
final picked = await showSelectionDialog<T>(
|
onTap: () async {
|
||||||
context: context,
|
final picked = await showSelectionDialog<T>(
|
||||||
title: title,
|
context: context,
|
||||||
options: options,
|
title: title,
|
||||||
currentValue: value,
|
options: options,
|
||||||
);
|
currentValue: value,
|
||||||
if (picked == null) return;
|
);
|
||||||
await svc.write(pref, encode(picked));
|
if (picked == null) return;
|
||||||
final callback = onAfterWrite;
|
await svc.write(pref, encode(picked));
|
||||||
if (callback != null) await callback(picked);
|
final callback = onAfterWrite;
|
||||||
},
|
if (callback != null) await callback(picked);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -237,23 +236,23 @@ class SettingRegexTile extends StatelessWidget {
|
|||||||
final svc = _TileBase._svc;
|
final svc = _TileBase._svc;
|
||||||
return ValueListenableBuilder<String>(
|
return ValueListenableBuilder<String>(
|
||||||
valueListenable: svc.listenable(pref),
|
valueListenable: svc.listenable(pref),
|
||||||
builder: (_, value, _) => ClickableCursor(
|
builder: (_, value, _) => FocusableListTile(
|
||||||
child: ListTile(
|
leading: AppIcon(icon, fill: 1),
|
||||||
leading: AppIcon(icon, fill: 1),
|
title: Text(title),
|
||||||
title: Text(title),
|
subtitle: Text(subtitle),
|
||||||
subtitle: Text(subtitle),
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
dense: false,
|
||||||
onTap: () => showRegexInputDialog(
|
visualDensity: VisualDensity.standard,
|
||||||
context: context,
|
onTap: () => showRegexInputDialog(
|
||||||
title: title,
|
context: context,
|
||||||
currentValue: value,
|
title: title,
|
||||||
defaultValue: defaultValue,
|
currentValue: value,
|
||||||
onSave: (v) async {
|
defaultValue: defaultValue,
|
||||||
await svc.write(pref, v);
|
onSave: (v) async {
|
||||||
final callback = onAfterWrite;
|
await svc.write(pref, v);
|
||||||
if (callback != null) await callback(v);
|
final callback = onAfterWrite;
|
||||||
},
|
if (callback != null) await callback(v);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -329,31 +328,31 @@ class SettingColorTile extends StatelessWidget {
|
|||||||
final svc = _TileBase._svc;
|
final svc = _TileBase._svc;
|
||||||
return ValueListenableBuilder<String>(
|
return ValueListenableBuilder<String>(
|
||||||
valueListenable: svc.listenable(pref),
|
valueListenable: svc.listenable(pref),
|
||||||
builder: (_, hex, _) => ClickableCursor(
|
builder: (_, hex, _) => FocusableListTile(
|
||||||
child: ListTile(
|
leading: AppIcon(icon, fill: 1),
|
||||||
leading: AppIcon(icon, fill: 1),
|
title: Text(title),
|
||||||
title: Text(title),
|
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
trailing: Container(
|
||||||
trailing: Container(
|
width: 28,
|
||||||
width: 28,
|
height: 28,
|
||||||
height: 28,
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: hexToColor(hex),
|
||||||
color: hexToColor(hex),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderRadius: BorderRadius.circular(6),
|
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () => showColorInputDialog(
|
|
||||||
context: context,
|
|
||||||
title: title,
|
|
||||||
currentHex: hex,
|
|
||||||
onSave: (v) async {
|
|
||||||
await svc.write(pref, v);
|
|
||||||
final callback = onAfterWrite;
|
|
||||||
if (callback != null) await callback(v);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
dense: false,
|
||||||
|
visualDensity: VisualDensity.standard,
|
||||||
|
onTap: () => showColorInputDialog(
|
||||||
|
context: context,
|
||||||
|
title: title,
|
||||||
|
currentHex: hex,
|
||||||
|
onSave: (v) async {
|
||||||
|
await svc.write(pref, v);
|
||||||
|
final callback = onAfterWrite;
|
||||||
|
if (callback != null) await callback(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../focus/dpad_navigator.dart';
|
import '../focus/dpad_navigator.dart';
|
||||||
import '../focus/focus_memory_tracker.dart';
|
import '../focus/focus_memory_tracker.dart';
|
||||||
|
import '../focus/input_mode_tracker.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_library.dart';
|
import '../media/media_library.dart';
|
||||||
import '../mixins/mounted_set_state_mixin.dart';
|
import '../mixins/mounted_set_state_mixin.dart';
|
||||||
@@ -103,7 +104,7 @@ class NavigationRailItem extends StatelessWidget {
|
|||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: focusNode,
|
listenable: focusNode,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final focused = focusNode.hasFocus;
|
final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
||||||
return Focus(
|
return Focus(
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
@@ -1014,9 +1015,10 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: () {
|
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 (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;
|
return null;
|
||||||
}(),
|
}(),
|
||||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||||
@@ -1240,7 +1242,9 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
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,
|
borderRadius: radius,
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper
|
|||||||
return Focus(
|
return Focus(
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
|
descendantsAreFocusable: false,
|
||||||
onFocusChange: (hasFocus) {
|
onFocusChange: (hasFocus) {
|
||||||
setState(() => _isFocused = hasFocus);
|
setState(() => _isFocused = hasFocus);
|
||||||
if (!hasFocus) stopRepeat();
|
if (!hasFocus) stopRepeat();
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
|
|||||||
return Focus(
|
return Focus(
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
|
descendantsAreFocusable: false,
|
||||||
onFocusChange: (hasFocus) {
|
onFocusChange: (hasFocus) {
|
||||||
setState(() => _isFocused = hasFocus);
|
setState(() => _isFocused = hasFocus);
|
||||||
if (!hasFocus) stopRepeat();
|
if (!hasFocus) stopRepeat();
|
||||||
|
|||||||
@@ -693,6 +693,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
metadata: widget.metadata,
|
metadata: widget.metadata,
|
||||||
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
||||||
onBack: widget.onBack,
|
onBack: widget.onBack,
|
||||||
|
onCancelAutoHide: widget.onCancelAutoHide,
|
||||||
|
onStartAutoHide: widget.onStartAutoHide,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[
|
if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[
|
||||||
|
|||||||
@@ -344,6 +344,8 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
|||||||
child: VideoControlsHeader(
|
child: VideoControlsHeader(
|
||||||
metadata: widget.metadata,
|
metadata: widget.metadata,
|
||||||
style: VideoHeaderStyle.multiLine,
|
style: VideoHeaderStyle.multiLine,
|
||||||
|
onCancelAutoHide: widget.onCancelAutoHide,
|
||||||
|
onStartAutoHide: widget.onStartAutoHide,
|
||||||
trailing: widget.trackChapterControls,
|
trailing: widget.trackChapterControls,
|
||||||
onBack: widget.onBack,
|
onBack: widget.onBack,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -139,6 +139,13 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
|||||||
OverlaySheetController.of(context).refocus();
|
OverlaySheetController.of(context).refocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _hideLanguagePickerView() {
|
||||||
|
setState(() => _showLanguagePicker = false);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _languageFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _focusFirstResult() {
|
void _focusFirstResult() {
|
||||||
if (_results != null && _results!.isNotEmpty && !_isSearching && _error == null) {
|
if (_results != null && _results!.isNotEmpty && !_isSearching && _error == null) {
|
||||||
_firstResultFocusNode.requestFocus();
|
_firstResultFocusNode.requestFocus();
|
||||||
@@ -219,7 +226,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
|||||||
return _LanguagePickerView(
|
return _LanguagePickerView(
|
||||||
currentCode: _languageCode,
|
currentCode: _languageCode,
|
||||||
onSelected: _onLanguageSelected,
|
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 propertyName = isSubtitle ? 'sub-delay' : 'audio-delay';
|
||||||
final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset;
|
final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset;
|
||||||
|
|
||||||
// Created here so we can pass it as initialFocusNode to the overlay sheet,
|
// Created here so it can be passed as the overlay's initial focus target.
|
||||||
// ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar.
|
// The creator disposes it after the overlay's lifecycle completes.
|
||||||
final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider');
|
final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider');
|
||||||
|
|
||||||
// show() with new alignment replaces the current sheet (completing the
|
// show() with new alignment replaces the current sheet (completing the
|
||||||
@@ -299,6 +299,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.whenComplete(() {
|
.whenComplete(() {
|
||||||
|
sliderFocusNode.dispose();
|
||||||
widget.onStartAutoHide?.call();
|
widget.onStartAutoHide?.call();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1073,7 +1074,6 @@ class _CompactSyncBarState extends State<_CompactSyncBar> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.sliderFocusNode.dispose();
|
|
||||||
_resetFocusNode.dispose();
|
_resetFocusNode.dispose();
|
||||||
_closeFocusNode.dispose();
|
_closeFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class SleepTimerActiveStatus extends StatelessWidget {
|
|||||||
sleepTimer.cancelTimer();
|
sleepTimer.cancelTimer();
|
||||||
onCancel?.call();
|
onCancel?.call();
|
||||||
},
|
},
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
||||||
label: Text(t.common.cancel),
|
label: Text(t.common.cancel),
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:plezy/widgets/app_icon.dart';
|
import 'package:plezy/widgets/app_icon.dart';
|
||||||
import 'package:material_symbols_icons/symbols.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_button.dart';
|
||||||
import '../../../focus/focusable_wrapper.dart';
|
import '../../../focus/focusable_wrapper.dart';
|
||||||
import '../../../mpv/mpv.dart';
|
import '../../../mpv/mpv.dart';
|
||||||
@@ -233,35 +232,19 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
|||||||
onLongPressStart: _startLongPressDecrement,
|
onLongPressStart: _startLongPressDecrement,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Focus(
|
child: SliderTheme(
|
||||||
onKeyEvent: (node, event) {
|
data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark),
|
||||||
// Select/enter on the slider jumps focus to the close button
|
child: FocusableSlider(
|
||||||
if (event.logicalKey.isSelectKey && event is KeyDownEvent) {
|
focusNode: widget.sliderFocusNode,
|
||||||
widget.closeFocusNode?.requestFocus();
|
value: sliderValue,
|
||||||
return KeyEventResult.handled;
|
min: _sliderMin,
|
||||||
}
|
max: _sliderMax,
|
||||||
return KeyEventResult.ignored;
|
divisions: _sliderDivisions,
|
||||||
},
|
activeColor: Theme.of(context).colorScheme.primary,
|
||||||
canRequestFocus: false,
|
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
|
||||||
child: SliderTheme(
|
onSelect: widget.closeFocusNode?.requestFocus,
|
||||||
data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark),
|
onChanged: (value) => setState(() => _currentOffset = value),
|
||||||
child: Slider(
|
onChangeEnd: _applyOffset,
|
||||||
focusNode: widget.sliderFocusNode,
|
|
||||||
value: sliderValue,
|
|
||||||
min: _sliderMin,
|
|
||||||
max: _sliderMax,
|
|
||||||
divisions: _sliderDivisions,
|
|
||||||
activeColor: Theme.of(context).colorScheme.primary,
|
|
||||||
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_currentOffset = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onChangeEnd: (value) {
|
|
||||||
_applyOffset(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -373,6 +356,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
|||||||
// Reset button
|
// Reset button
|
||||||
FocusableButton(
|
FocusableButton(
|
||||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||||
|
useBackgroundFocus: true,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||||
icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1),
|
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).
|
/// Optional callback for back button. If null, defaults to Navigator.pop(true).
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
|
final VoidCallback? onCancelAutoHide;
|
||||||
|
final VoidCallback? onStartAutoHide;
|
||||||
|
|
||||||
const VideoControlsHeader({
|
const VideoControlsHeader({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -37,6 +39,8 @@ class VideoControlsHeader extends StatelessWidget {
|
|||||||
this.style = VideoHeaderStyle.multiLine,
|
this.style = VideoHeaderStyle.multiLine,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
this.onBack,
|
this.onBack,
|
||||||
|
this.onCancelAutoHide,
|
||||||
|
this.onStartAutoHide,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,7 +58,13 @@ class VideoControlsHeader extends StatelessWidget {
|
|||||||
selector: (_, p) => p.isInSession,
|
selector: (_, p) => p.isInSession,
|
||||||
builder: (context, inSession, child) {
|
builder: (context, inSession, child) {
|
||||||
if (!inSession) return const SizedBox.shrink();
|
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,
|
?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(() {
|
tearDown(() {
|
||||||
TvDetectionService.debugSetAppleTVOverride(null);
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
BackKeyUpSuppressor.clearSuppression();
|
BackKeyUpSuppressor.clearSuppression();
|
||||||
|
BackKeyCoordinator.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tvOS physical keyboard back runs on key down and suppresses key up', (tester) async {
|
testWidgets('tvOS physical keyboard back runs on key down and suppresses key up', (tester) async {
|
||||||
@@ -74,6 +75,31 @@ void main() {
|
|||||||
await tester.pump();
|
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', () {
|
group('dpadKeyHandler trapHorizontalEdges', () {
|
||||||
testWidgets('consumes edge LEFT/RIGHT so focus cannot escape the group', (tester) async {
|
testWidgets('consumes edge LEFT/RIGHT so focus cannot escape the group', (tester) async {
|
||||||
final trapped = FocusNode(debugLabel: 'trapped');
|
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();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('SHEET'), findsNothing);
|
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/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:material_symbols_icons/symbols.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/i18n/strings.g.dart';
|
||||||
import 'package:plezy/media/media_backend.dart';
|
import 'package:plezy/media/media_backend.dart';
|
||||||
import 'package:plezy/media/media_kind.dart';
|
import 'package:plezy/media/media_kind.dart';
|
||||||
@@ -524,20 +525,22 @@ void main() {
|
|||||||
var parentBuilds = 0;
|
var parentBuilds = 0;
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
InputModeTracker(
|
||||||
theme: ThemeData(extensions: const [_testTokens]),
|
child: MaterialApp(
|
||||||
home: Scaffold(
|
theme: ThemeData(extensions: const [_testTokens]),
|
||||||
body: Builder(
|
home: Scaffold(
|
||||||
builder: (context) {
|
body: Builder(
|
||||||
parentBuilds++;
|
builder: (context) {
|
||||||
return NavigationRailItem(
|
parentBuilds++;
|
||||||
icon: Symbols.home_rounded,
|
return NavigationRailItem(
|
||||||
label: const Text('Home'),
|
icon: Symbols.home_rounded,
|
||||||
isSelected: false,
|
label: const Text('Home'),
|
||||||
onTap: () {},
|
isSelected: false,
|
||||||
focusNode: focusNode,
|
onTap: () {},
|
||||||
);
|
focusNode: focusNode,
|
||||||
},
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -547,6 +550,9 @@ void main() {
|
|||||||
expect(_railItemDecoration(tester, item)?.color, isNull);
|
expect(_railItemDecoration(tester, item)?.color, isNull);
|
||||||
expect(parentBuilds, 1);
|
expect(parentBuilds, 1);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
focusNode.requestFocus();
|
focusNode.requestFocus();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user