perf: isolate focus and media rebuilds
This commit is contained in:
@@ -133,20 +133,25 @@ KeyEventResult _handleInputKey({
|
||||
VoidCallback? onNavigateDown,
|
||||
}) {
|
||||
final key = event.logicalKey;
|
||||
final diagnosticsEnabled = TextInputDiagnostics.enabled;
|
||||
KeyEventResult finish(KeyEventResult result, String reason) {
|
||||
_logTvTextInput(
|
||||
'result=$result reason=$reason key=(${_describeTextInputKey(event)}) '
|
||||
'usesTvKeyboard=$usesTvKeyboard enabled=$enabled textLength=${controller.text.length} '
|
||||
'selection=${controller.selection} onNav(up=${onNavigateUp != null},down=${onNavigateDown != null},'
|
||||
'left=${onNavigateLeft != null},right=${onNavigateRight != null}) onSelect=${onSelect != null} onBack=${onBack != null}',
|
||||
);
|
||||
if (diagnosticsEnabled) {
|
||||
_logTvTextInput(
|
||||
'result=$result reason=$reason key=(${_describeTextInputKey(event)}) '
|
||||
'usesTvKeyboard=$usesTvKeyboard enabled=$enabled textLength=${controller.text.length} '
|
||||
'selection=${controller.selection} onNav(up=${onNavigateUp != null},down=${onNavigateDown != null},'
|
||||
'left=${onNavigateLeft != null},right=${onNavigateRight != null}) onSelect=${onSelect != null} onBack=${onBack != null}',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_logTvTextInput(
|
||||
'received key=(${_describeTextInputKey(event)}) usesTvKeyboard=$usesTvKeyboard enabled=$enabled '
|
||||
'textLength=${controller.text.length} selection=${controller.selection}',
|
||||
);
|
||||
if (diagnosticsEnabled) {
|
||||
_logTvTextInput(
|
||||
'received key=(${_describeTextInputKey(event)}) usesTvKeyboard=$usesTvKeyboard enabled=$enabled '
|
||||
'textLength=${controller.text.length} selection=${controller.selection}',
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, enabled: enabled, event: event)) {
|
||||
return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform');
|
||||
@@ -243,10 +248,12 @@ KeyEventResult _handleInputKey({
|
||||
|
||||
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) {
|
||||
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) {
|
||||
_logTvTextInput(
|
||||
'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard '
|
||||
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
|
||||
);
|
||||
if (TextInputDiagnostics.enabled) {
|
||||
_logTvTextInput(
|
||||
'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard '
|
||||
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -256,10 +263,12 @@ bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bo
|
||||
// native TV navigation cannot rely on deviceType.
|
||||
final key = event.logicalKey;
|
||||
final shouldPass = key.isDpadDirection || key.isBackKey || event.isTvSelectEvent;
|
||||
_logTvTextInput(
|
||||
'native-pass=$shouldPass reason=${shouldPass ? "remote-navigation-key" : "not-navigation-key"} '
|
||||
'key=(${_describeTextInputKey(event)})',
|
||||
);
|
||||
if (TextInputDiagnostics.enabled) {
|
||||
_logTvTextInput(
|
||||
'native-pass=$shouldPass reason=${shouldPass ? "remote-navigation-key" : "not-navigation-key"} '
|
||||
'key=(${_describeTextInputKey(event)})',
|
||||
);
|
||||
}
|
||||
return shouldPass;
|
||||
}
|
||||
|
||||
@@ -745,25 +754,29 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
|
||||
void _syncNativeTextInputFocus() {
|
||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
|
||||
_logTvTextInput(
|
||||
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
|
||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}',
|
||||
);
|
||||
if (TextInputDiagnostics.enabled) {
|
||||
_logTvTextInput(
|
||||
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
|
||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}',
|
||||
);
|
||||
}
|
||||
_setNativeTextInputFocused(focused);
|
||||
}
|
||||
|
||||
void _syncTvKeyboardAutoOpen() {
|
||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._hasTvKeyboard;
|
||||
final visible = _canShowTvKeyboard;
|
||||
_logTvTextInput(
|
||||
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
|
||||
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} '
|
||||
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
|
||||
'installed=${_installedFocusNode?.debugLabel} '
|
||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||
'usesTvKeyboard=${widget.input._hasTvKeyboard} visible=$visible',
|
||||
);
|
||||
if (TextInputDiagnostics.enabled) {
|
||||
_logTvTextInput(
|
||||
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
|
||||
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} '
|
||||
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
|
||||
'installed=${_installedFocusNode?.debugLabel} '
|
||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||
'usesTvKeyboard=${widget.input._hasTvKeyboard} visible=$visible',
|
||||
);
|
||||
}
|
||||
|
||||
if (!focused) {
|
||||
_suppressTvKeyboardForCurrentFocus = false;
|
||||
|
||||
@@ -347,17 +347,22 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
final diagnosticsEnabled = TextInputDiagnostics.enabled;
|
||||
KeyEventResult finish(KeyEventResult result, String reason) {
|
||||
_logFocusableWrapper(
|
||||
'node=${node.debugLabel} result=$result reason=$reason key=(${_describeFocusableKey(event)}) '
|
||||
'onNav(up=${widget.onNavigateUp != null},down=${widget.onNavigateDown != null},'
|
||||
'left=${widget.onNavigateLeft != null},right=${widget.onNavigateRight != null}) '
|
||||
'onSelect=${widget.onSelect != null} onBack=${widget.onBack != null}',
|
||||
);
|
||||
if (diagnosticsEnabled) {
|
||||
_logFocusableWrapper(
|
||||
'node=${node.debugLabel} result=$result reason=$reason key=(${_describeFocusableKey(event)}) '
|
||||
'onNav(up=${widget.onNavigateUp != null},down=${widget.onNavigateDown != null},'
|
||||
'left=${widget.onNavigateLeft != null},right=${widget.onNavigateRight != null}) '
|
||||
'onSelect=${widget.onSelect != null} onBack=${widget.onBack != null}',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_logFocusableWrapper('node=${node.debugLabel} received key=(${_describeFocusableKey(event)})');
|
||||
if (diagnosticsEnabled) {
|
||||
_logFocusableWrapper('node=${node.debugLabel} received key=(${_describeFocusableKey(event)})');
|
||||
}
|
||||
|
||||
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
|
||||
if (event is KeyUpEvent && key.isSelectKey) {
|
||||
|
||||
@@ -32,14 +32,20 @@ class InputModeTracker extends StatefulWidget {
|
||||
const InputModeTracker({super.key, required this.child});
|
||||
|
||||
/// Get the current input mode.
|
||||
static InputMode of(BuildContext context) {
|
||||
final provider = context.dependOnInheritedWidgetOfExactType<_InputModeProvider>();
|
||||
///
|
||||
/// Set [listen] to false for event handlers and post-frame callbacks that
|
||||
/// only need a one-shot value. Those reads must not subscribe their owning
|
||||
/// screen to future input-mode changes.
|
||||
static InputMode of(BuildContext context, {bool listen = true}) {
|
||||
final provider = listen
|
||||
? context.dependOnInheritedWidgetOfExactType<_InputModeProvider>()
|
||||
: context.getInheritedWidgetOfExactType<_InputModeProvider>();
|
||||
return provider?.mode ?? InputMode.pointer;
|
||||
}
|
||||
|
||||
/// Convenience method to check if we're in keyboard mode.
|
||||
static bool isKeyboardMode(BuildContext context) {
|
||||
return of(context) == InputMode.keyboard;
|
||||
static bool isKeyboardMode(BuildContext context, {bool listen = true}) {
|
||||
return of(context, listen: listen) == InputMode.keyboard;
|
||||
}
|
||||
|
||||
/// Whether system back must be blocked because the dpad key handler owns
|
||||
|
||||
@@ -103,9 +103,9 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ActiveProfileProvider>(
|
||||
builder: (context, activeProfile, _) {
|
||||
final activeId = activeProfile.activeId;
|
||||
return Selector<ActiveProfileProvider, String?>(
|
||||
selector: (_, activeProfile) => activeProfile.activeId,
|
||||
builder: (context, activeId, _) {
|
||||
_onSessionProfileChanged(activeId);
|
||||
final initialPromptHandled = widget.initialPromptHandled || _hasBuiltSession;
|
||||
return KeyedSubtree(
|
||||
|
||||
@@ -95,6 +95,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final PageController _heroController = PageController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int _currentHeroIndex = 0;
|
||||
final ValueNotifier<int> _heroIndex = ValueNotifier<int>(0);
|
||||
Timer? _autoScrollTimer;
|
||||
Timer? _indicatorTimer;
|
||||
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
||||
@@ -357,6 +358,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
setState(() {
|
||||
if (isNewLoad || heroOutOfBounds) {
|
||||
_currentHeroIndex = 0;
|
||||
_heroIndex.value = 0;
|
||||
}
|
||||
_updateHubKeys();
|
||||
});
|
||||
@@ -443,6 +445,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_indicatorTimer?.cancel();
|
||||
_spotlight.dispose();
|
||||
_indicatorProgress.dispose();
|
||||
_heroIndex.dispose();
|
||||
_heroController.dispose();
|
||||
_scrollController.dispose();
|
||||
_heroFocusNode.removeListener(_onHeroFocusChanged);
|
||||
@@ -481,6 +484,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// Validate current index is within bounds before calculating next page
|
||||
if (_currentHeroIndex >= _onDeck.length) {
|
||||
_currentHeroIndex = 0;
|
||||
_heroIndex.value = 0;
|
||||
}
|
||||
|
||||
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
|
||||
@@ -1258,11 +1262,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
controller: _heroController,
|
||||
itemCount: _onDeck.length,
|
||||
onPageChanged: (index) {
|
||||
// Validate index is within bounds before updating
|
||||
if (index >= 0 && index < _onDeck.length) {
|
||||
setState(() {
|
||||
_currentHeroIndex = index;
|
||||
});
|
||||
_currentHeroIndex = index;
|
||||
_heroIndex.value = index;
|
||||
_resetAutoScrollTimer();
|
||||
}
|
||||
},
|
||||
@@ -1302,57 +1304,61 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
...() {
|
||||
final range = _getVisibleDotRange();
|
||||
return List.generate(range.end - range.start + 1, (i) {
|
||||
final index = range.start + i;
|
||||
final isActive = _currentHeroIndex == index;
|
||||
final dotSize = _getDotSize(index, range.start, range.end);
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _heroIndex,
|
||||
builder: (context, _, _) {
|
||||
final range = _getVisibleDotRange();
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(range.end - range.start + 1, (i) {
|
||||
final index = range.start + i;
|
||||
final isActive = _currentHeroIndex == index;
|
||||
final dotSize = _getDotSize(index, range.start, range.end);
|
||||
|
||||
return isActive
|
||||
// Progress indicator for active page (~5fps via Timer)
|
||||
? ValueListenableBuilder<double>(
|
||||
valueListenable: _indicatorProgress,
|
||||
builder: (context, progress, child) {
|
||||
final maxWidth = dotSize * 3; // 24px for normal, 15px for small
|
||||
final fillWidth = dotSize + ((maxWidth - dotSize) * progress);
|
||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: maxWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: onSurface.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
child: Align(
|
||||
alignment: .centerLeft,
|
||||
child: Container(
|
||||
width: fillWidth,
|
||||
return isActive
|
||||
? ValueListenableBuilder<double>(
|
||||
valueListenable: _indicatorProgress,
|
||||
builder: (context, progress, child) {
|
||||
final maxWidth = dotSize * 3;
|
||||
final fillWidth = dotSize + ((maxWidth - dotSize) * progress);
|
||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: maxWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: onSurface,
|
||||
color: onSurface.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: .centerLeft,
|
||||
child: Container(
|
||||
width: fillWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: onSurface,
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: AnimatedContainer(
|
||||
duration: tokens(context).slow,
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
// Static indicator for inactive pages
|
||||
: AnimatedContainer(
|
||||
duration: tokens(context).slow,
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
);
|
||||
});
|
||||
}(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,19 +17,31 @@ import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../libraries/state_messages.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
|
||||
class SyncRulesScreen extends StatelessWidget {
|
||||
class SyncRulesScreen extends StatefulWidget {
|
||||
const SyncRulesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SyncRulesScreen> createState() => _SyncRulesScreenState();
|
||||
}
|
||||
|
||||
class _SyncRulesScreenState extends State<SyncRulesScreen> {
|
||||
late final Stream<List<Connection>> _connections;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_connections = context.read<ConnectionRegistry>().watchConnections();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<DownloadProvider>(
|
||||
builder: (context, downloadProvider, _) {
|
||||
final syncRules = downloadProvider.syncRules;
|
||||
final multiServerProvider = context.watch<MultiServerProvider>();
|
||||
final connectionRegistry = context.read<ConnectionRegistry>();
|
||||
|
||||
return StreamBuilder<List<Connection>>(
|
||||
stream: connectionRegistry.watchConnections(),
|
||||
stream: _connections,
|
||||
initialData: const [],
|
||||
builder: (context, snapshot) {
|
||||
final connections = snapshot.data ?? const <Connection>[];
|
||||
|
||||
@@ -149,7 +149,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
if (mounted && hasItems) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (InputModeTracker.isKeyboardMode(context)) {
|
||||
if (InputModeTracker.isKeyboardMode(context, listen: false)) {
|
||||
setState(() {
|
||||
isAppBarFocused = false;
|
||||
});
|
||||
|
||||
@@ -126,6 +126,7 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTicker
|
||||
_dragFraction = newFraction;
|
||||
|
||||
final letter = _helper.letterAtFraction(newFraction);
|
||||
if (letter == _dragLetter) return;
|
||||
|
||||
setState(() => _dragLetter = letter);
|
||||
widget.onJump(_helper.indexForLetter(letter) ?? 0);
|
||||
|
||||
@@ -1434,8 +1434,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// MediaQuery (not the absorber handle) avoids rebuilding during layout —
|
||||
// listening to the handle from a builder fires notifyListeners during the
|
||||
// build phase and triggers a setState-in-build assertion.
|
||||
final media = MediaQuery.of(context);
|
||||
final overlayTopPadding = media.padding.top + kToolbarHeight;
|
||||
final overlayTopPadding = MediaQuery.paddingOf(context).top + kToolbarHeight;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||
import '../navigation/profile_navigation_scope.dart';
|
||||
import '../services/device_performance.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
@@ -358,6 +359,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// Locked focus pattern for extras
|
||||
int _focusedExtraIndex = 0;
|
||||
final ValueNotifier<int> _focusedExtraIndexNotifier = ValueNotifier<int>(0);
|
||||
late final FocusNode _extrasFocusNode;
|
||||
final Map<int, GlobalKey<MediaCardState>> _extraCardKeys = {};
|
||||
final _extrasSectionKey = GlobalKey();
|
||||
@@ -368,6 +370,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// Locked focus pattern for cast
|
||||
int _focusedCastIndex = 0;
|
||||
final ValueNotifier<int> _focusedCastIndexNotifier = ValueNotifier<int>(0);
|
||||
late final FocusNode _castFocusNode;
|
||||
final ScrollController _castScrollController = ScrollController();
|
||||
final _castSectionKey = GlobalKey();
|
||||
@@ -853,10 +856,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_extrasScrollController.dispose();
|
||||
_extrasFocusNode.removeListener(_handleExtrasFocusChange);
|
||||
_extrasFocusNode.dispose();
|
||||
_focusedExtraIndexNotifier.dispose();
|
||||
_playButtonFocusNode.dispose();
|
||||
_ratingChipFocusNode.dispose();
|
||||
_overviewFocusNode.dispose();
|
||||
_castFocusNode.dispose();
|
||||
_focusedCastIndexNotifier.dispose();
|
||||
_infoRowsFocusNode.dispose();
|
||||
_castScrollController.dispose();
|
||||
_extrasSelectLongPress.dispose();
|
||||
@@ -2441,7 +2446,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
// LEFT: previous extra
|
||||
if (key.isLeftKey) {
|
||||
if (_focusedExtraIndex > 0) {
|
||||
setState(() => _focusedExtraIndex--);
|
||||
_focusedExtraIndex--;
|
||||
_focusedExtraIndexNotifier.value = _focusedExtraIndex;
|
||||
scrollListToIndex(
|
||||
_extrasScrollController,
|
||||
_focusedExtraIndex,
|
||||
@@ -2455,7 +2461,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
// RIGHT: next extra
|
||||
if (key.isRightKey) {
|
||||
if (_focusedExtraIndex < _extras!.length - 1) {
|
||||
setState(() => _focusedExtraIndex++);
|
||||
_focusedExtraIndex++;
|
||||
_focusedExtraIndexNotifier.value = _focusedExtraIndex;
|
||||
scrollListToIndex(
|
||||
_extrasScrollController,
|
||||
_focusedExtraIndex,
|
||||
@@ -2504,7 +2511,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
// LEFT: previous cast member
|
||||
if (key.isLeftKey) {
|
||||
if (_focusedCastIndex > 0) {
|
||||
setState(() => _focusedCastIndex--);
|
||||
_focusedCastIndex--;
|
||||
_focusedCastIndexNotifier.value = _focusedCastIndex;
|
||||
scrollListToIndex(
|
||||
_castScrollController,
|
||||
_focusedCastIndex,
|
||||
@@ -2518,7 +2526,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
// RIGHT: next cast member
|
||||
if (key.isRightKey) {
|
||||
if (_focusedCastIndex < roleCount - 1) {
|
||||
setState(() => _focusedCastIndex++);
|
||||
_focusedCastIndex++;
|
||||
_focusedCastIndexNotifier.value = _focusedCastIndex;
|
||||
scrollListToIndex(
|
||||
_castScrollController,
|
||||
_focusedCastIndex,
|
||||
@@ -2978,7 +2987,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final render = tailContext.findRenderObject();
|
||||
if (render is! RenderBox || !render.hasSize) return;
|
||||
final tailTop = render.localToGlobal(Offset.zero).dy;
|
||||
final viewportHeight = MediaQuery.of(context).size.height;
|
||||
final viewportHeight = MediaQuery.sizeOf(context).height;
|
||||
// Prefetch once the tail is within ~one viewport of the visible bottom.
|
||||
if (tailTop <= viewportHeight * 2) unawaited(_loadMoreEpisodeList());
|
||||
}
|
||||
@@ -3151,14 +3160,20 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
style: BackButtonStyle.plain,
|
||||
onPressed: () => Navigator.pop(context, _watchStateChanged),
|
||||
);
|
||||
final loading = Focus(
|
||||
onKeyEvent: _handleMediaDetailBackKey,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: DesktopAppBarSections.buildLeadingSection(leading: backButton, context: context),
|
||||
leadingWidth: DesktopAppBarSections.calculateLeadingWidthForSection(leading: backButton, context: context),
|
||||
final loading = ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) => Focus(
|
||||
onKeyEvent: _handleMediaDetailBackKey,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: DesktopAppBarSections.buildLeadingSection(leading: backButton, context: context),
|
||||
leadingWidth: DesktopAppBarSections.calculateLeadingWidthForSection(
|
||||
leading: backButton,
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
final blockSystemBack = InputModeTracker.shouldBlockSystemBack(context);
|
||||
@@ -4488,7 +4503,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
focusNode: _castFocusNode,
|
||||
onKeyEvent: _handleCastKeyEvent,
|
||||
child: ListenableBuilder(
|
||||
listenable: _castFocusNode,
|
||||
listenable: Listenable.merge([_castFocusNode, _focusedCastIndexNotifier]),
|
||||
builder: (context, _) => CastMemberStrip(
|
||||
members: [for (final actor in roles) (name: actor.tag, secondary: actor.role, imagePath: actor.thumbPath)],
|
||||
imageClient: getServerBoundMediaClient(context),
|
||||
@@ -4517,7 +4532,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
focusNode: _extrasFocusNode,
|
||||
onKeyEvent: _handleExtrasKeyEvent,
|
||||
child: ListenableBuilder(
|
||||
listenable: _extrasFocusNode,
|
||||
listenable: Listenable.merge([_extrasFocusNode, _focusedExtraIndexNotifier]),
|
||||
builder: (context, _) {
|
||||
final hasFocus = _extrasFocusNode.hasFocus;
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class _QueueListState extends State<QueueList> {
|
||||
if (widget.autofocusCurrent) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Timer.run(() {
|
||||
if (!mounted || !InputModeTracker.isKeyboardMode(context)) return;
|
||||
if (!mounted || !InputModeTracker.isKeyboardMode(context, listen: false)) return;
|
||||
// Schedule after the overlay host's _autoFocus second callback so
|
||||
// we override its focus-first-descendant default.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../../utils/dialogs.dart';
|
||||
import '../../utils/download_utils.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
import '../../widgets/listenable_selector.dart';
|
||||
import '../base_media_list_detail_screen.dart';
|
||||
import '../focusable_detail_screen_mixin.dart';
|
||||
import '../../mixins/grid_focus_node_mixin.dart';
|
||||
@@ -190,6 +191,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
// Navigation state for regular (non-smart) playlists
|
||||
int _focusedIndex = 0;
|
||||
int _focusedColumn = 0; // 0=content, 1=drag handle, 2=remove button
|
||||
final ValueNotifier<int> _focusRevision = ValueNotifier<int>(0);
|
||||
|
||||
void _notifyFocusChanged() => _focusRevision.value++;
|
||||
|
||||
// Move mode state
|
||||
int? _movingIndex;
|
||||
@@ -215,6 +219,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
void dispose() {
|
||||
_continuation.dispose();
|
||||
_listFocusNode.dispose();
|
||||
_focusRevision.dispose();
|
||||
disposeFocusResources();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -475,6 +480,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
if (!_canEditPlaylist) return;
|
||||
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||
final item = items[index];
|
||||
final previousItem = index > 0 ? items[index - 1] : null;
|
||||
final nextItem = index + 1 < items.length ? items[index + 1] : null;
|
||||
|
||||
appLogger.d('Removing item ${item.title} from playlist');
|
||||
|
||||
@@ -500,11 +507,19 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.playlists.itemRemoved);
|
||||
} else {
|
||||
// Revert on failure
|
||||
// Restore relative to surviving neighbors; concurrent mutations can
|
||||
// make the original numeric index stale.
|
||||
appLogger.e('Failed to remove playlist item, reverting UI');
|
||||
setState(() {
|
||||
items.insert(index, item);
|
||||
_focusedIndex = index;
|
||||
final nextIndex = nextItem == null ? -1 : items.indexOf(nextItem);
|
||||
final previousIndex = previousItem == null ? -1 : items.indexOf(previousItem);
|
||||
final restoreIndex = nextIndex >= 0
|
||||
? nextIndex
|
||||
: previousIndex >= 0
|
||||
? previousIndex + 1
|
||||
: index.clamp(0, items.length);
|
||||
items.insert(restoreIndex, item);
|
||||
_focusedIndex = restoreIndex;
|
||||
});
|
||||
|
||||
showErrorSnackBar(context, t.playlists.errorRemoving);
|
||||
@@ -612,10 +627,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
// Navigation mode
|
||||
if (key.isUpKey) {
|
||||
if (_focusedIndex > 0) {
|
||||
setState(() {
|
||||
_focusedIndex--;
|
||||
_focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
_focusedIndex--;
|
||||
_focusedColumn = 0;
|
||||
_notifyFocusChanged();
|
||||
_ensureFocusedVisible();
|
||||
} else {
|
||||
// First item - navigate to app bar
|
||||
@@ -624,10 +638,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && _focusedIndex < items.length - 1) {
|
||||
setState(() {
|
||||
_focusedIndex++;
|
||||
_focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
_focusedIndex++;
|
||||
_focusedColumn = 0;
|
||||
_notifyFocusChanged();
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -635,11 +648,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
// Navigate left within columns
|
||||
if (_focusedColumn == 0 && _canEditPlaylist) {
|
||||
// Go to drag handle (column 1)
|
||||
setState(() => _focusedColumn = 1);
|
||||
_focusedColumn = 1;
|
||||
_notifyFocusChanged();
|
||||
return KeyEventResult.handled;
|
||||
} else if (_focusedColumn == 2) {
|
||||
// Go back to content
|
||||
setState(() => _focusedColumn = 0);
|
||||
_focusedColumn = 0;
|
||||
_notifyFocusChanged();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
@@ -647,11 +662,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
// Navigate right within columns
|
||||
if (_focusedColumn == 0 && _canEditPlaylist) {
|
||||
// Go to remove button (column 2)
|
||||
setState(() => _focusedColumn = 2);
|
||||
_focusedColumn = 2;
|
||||
_notifyFocusChanged();
|
||||
return KeyEventResult.handled;
|
||||
} else if (_focusedColumn == 1) {
|
||||
// Go to content from drag handle
|
||||
setState(() => _focusedColumn = 0);
|
||||
_focusedColumn = 0;
|
||||
_notifyFocusChanged();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
@@ -806,32 +823,35 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
// Check keyboard mode directly to ensure we get latest value
|
||||
final inKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
final isFocused = inKeyboardMode && index == _focusedIndex && !isAppBarFocused;
|
||||
final isMoving = index == _movingIndex;
|
||||
|
||||
// Both backends populate playlistItemId in playlist responses; the
|
||||
// backend prefix avoids collisions if the same numeric/uuid string
|
||||
// ever shows up across servers in the same key namespace.
|
||||
final keyId = switch (item) {
|
||||
PlexMediaItem(:final playlistItemId?) => 'p:$playlistItemId',
|
||||
JellyfinMediaItem(:final playlistItemId?) => 'j:$playlistItemId',
|
||||
_ => item.id,
|
||||
};
|
||||
return RepaintBoundary(
|
||||
return ListenableSelector<(bool, int?, bool)>(
|
||||
key: ValueKey(keyId),
|
||||
child: PlaylistItemCard(
|
||||
item: item,
|
||||
index: index,
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
onRefresh: updateItem,
|
||||
canReorder: _canEditPlaylist,
|
||||
isFocused: isFocused,
|
||||
focusedColumn: isFocused ? _focusedColumn : null,
|
||||
isMoving: isMoving,
|
||||
),
|
||||
listenable: _focusRevision,
|
||||
selector: () {
|
||||
final focused = index == _focusedIndex && !isAppBarFocused;
|
||||
return (focused, focused ? _focusedColumn : null, index == _movingIndex);
|
||||
},
|
||||
builder: (context, focusState, _) {
|
||||
final inKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
final isFocused = inKeyboardMode && focusState.$1;
|
||||
return RepaintBoundary(
|
||||
child: PlaylistItemCard(
|
||||
item: item,
|
||||
index: index,
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
onRefresh: updateItem,
|
||||
canReorder: _canEditPlaylist,
|
||||
isFocused: isFocused,
|
||||
focusedColumn: isFocused ? focusState.$2 : null,
|
||||
isMoving: focusState.$3,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
||||
|
||||
@override
|
||||
void focusActiveTabIfReady() {
|
||||
if (InputModeTracker.isKeyboardMode(context)) {
|
||||
if (InputModeTracker.isKeyboardMode(context, listen: false)) {
|
||||
_focusTracker.restoreFocus(fallbackKey: DonationService.isEnabled ? _kDonate : _kAppearance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ Future<T?> showSelectionDialog<T>({
|
||||
required List<DialogOption<T>> options,
|
||||
required T currentValue,
|
||||
}) {
|
||||
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
|
||||
final focusFirstItem = InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
return showScopedDialog<T>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -148,7 +148,7 @@ void showNumericInputDialog({
|
||||
required int currentValue,
|
||||
required Future<void> Function(int value) onSave,
|
||||
}) {
|
||||
final useDpadControls = InputModeTracker.isKeyboardMode(context);
|
||||
final useDpadControls = InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
|
||||
if (useDpadControls) {
|
||||
_showNumericInputDialogTV(
|
||||
@@ -300,7 +300,7 @@ void showColorInputDialog({
|
||||
required String currentHex,
|
||||
required Future<void> Function(String hex) onSave,
|
||||
}) {
|
||||
if (InputModeTracker.isKeyboardMode(context)) {
|
||||
if (InputModeTracker.isKeyboardMode(context, listen: false)) {
|
||||
_showColorInputDialogTV(context: context, title: title, currentHex: currentHex, onSave: onSave);
|
||||
} else {
|
||||
_showColorInputDialogStandard(context: context, title: title, currentHex: currentHex, onSave: onSave);
|
||||
|
||||
@@ -290,7 +290,7 @@ Future<T?> showOptionPickerDialog<T>(
|
||||
Future<T?> Function(T value)? onBeforeClose,
|
||||
OptionPickerToggle? toggle,
|
||||
}) {
|
||||
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
|
||||
final focusFirstItem = InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
return showScopedDialog<T>(
|
||||
context: context,
|
||||
builder: (context) => _OptionPickerDialog<T>(
|
||||
|
||||
@@ -184,7 +184,7 @@ class AppMenuButtonState<T> extends State<AppMenuButton<T>> {
|
||||
}
|
||||
|
||||
Future<void> _handlePressed() async {
|
||||
await showButtonMenu(focusFirstItem: InputModeTracker.isKeyboardMode(context));
|
||||
await showButtonMenu(focusFirstItem: InputModeTracker.isKeyboardMode(context, listen: false));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -64,7 +64,7 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
||||
}
|
||||
|
||||
void _requestInitialFocus() {
|
||||
if (_focusRequested || !mounted || !InputModeTracker.isKeyboardMode(context)) return;
|
||||
if (_focusRequested || !mounted || !InputModeTracker.isKeyboardMode(context, listen: false)) return;
|
||||
_focusRequested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -112,6 +112,10 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
? TvLayoutConstants.shelfHorizontalInset
|
||||
: 12.0;
|
||||
double get _leadingPadding => _leadingPaddingFor(PlatformDetector.isTV());
|
||||
String get _focusMemoryKey {
|
||||
final serverId = widget.hub.serverId;
|
||||
return serverId == null ? widget.hub.id : '$serverId:${widget.hub.id}';
|
||||
}
|
||||
|
||||
final _selectLongPress = DpadSelectLongPressController();
|
||||
|
||||
@@ -171,7 +175,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
final clamped = index.clamp(0, _totalItemCount - 1).toInt();
|
||||
_focusedIndex = clamped;
|
||||
// Remember this position for this specific hub
|
||||
HubFocusMemory.setForHub(widget.hub.id, clamped);
|
||||
HubFocusMemory.setForHub(_focusMemoryKey, clamped);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(clamped);
|
||||
_hubFocusNode.requestFocus();
|
||||
@@ -183,7 +187,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
|
||||
/// Request focus using the stored memory for this hub
|
||||
void requestFocusFromMemory() {
|
||||
final index = HubFocusMemory.getForHub(widget.hub.id, _totalItemCount);
|
||||
final index = HubFocusMemory.getForHub(_focusMemoryKey, _totalItemCount);
|
||||
requestFocusAt(index);
|
||||
}
|
||||
|
||||
@@ -258,7 +262,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
setState(() {
|
||||
_focusedIndex--;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.id, _focusedIndex);
|
||||
HubFocusMemory.setForHub(_focusMemoryKey, _focusedIndex);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(_focusedIndex);
|
||||
} else if (widget.onNavigateToSidebar != null) {
|
||||
@@ -275,7 +279,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
setState(() {
|
||||
_focusedIndex++;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.id, _focusedIndex);
|
||||
HubFocusMemory.setForHub(_focusMemoryKey, _focusedIndex);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(_focusedIndex);
|
||||
}
|
||||
@@ -652,7 +656,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
setState(() {
|
||||
_focusedIndex = clamped;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.id, clamped);
|
||||
HubFocusMemory.setForHub(_focusMemoryKey, clamped);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(clamped);
|
||||
_hubFocusNode.requestFocus();
|
||||
|
||||
@@ -201,15 +201,18 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localFile = localFilePath != null ? File(localFilePath!) : null;
|
||||
final hasLocal = localFile != null && localFile.existsSync();
|
||||
final path = localFilePath;
|
||||
if (path == null) return _buildResolved(context, null);
|
||||
return _ResolvedLocalFile(path: path, builder: _buildResolved);
|
||||
}
|
||||
|
||||
Widget _buildResolved(BuildContext context, File? localFile) {
|
||||
final hasLocal = localFile != null;
|
||||
|
||||
// No local file and no network path → fallback
|
||||
if (!hasLocal && (imagePath == null || imagePath!.isEmpty)) {
|
||||
return _buildFallback(context);
|
||||
}
|
||||
|
||||
// Fast path: skip LayoutBuilder when both dimensions are explicitly known
|
||||
if (_hasKnownDimensions) {
|
||||
return blurArtwork(
|
||||
hasLocal
|
||||
@@ -509,3 +512,49 @@ class _FadeInNetworkImageState extends State<_FadeInNetworkImage> with SingleTic
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResolvedLocalFile extends StatefulWidget {
|
||||
const _ResolvedLocalFile({required this.path, required this.builder});
|
||||
|
||||
final String path;
|
||||
final Widget Function(BuildContext context, File? file) builder;
|
||||
|
||||
@override
|
||||
State<_ResolvedLocalFile> createState() => _ResolvedLocalFileState();
|
||||
}
|
||||
|
||||
class _ResolvedLocalFileState extends State<_ResolvedLocalFile> {
|
||||
File? _file;
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_resolve();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_ResolvedLocalFile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.path != widget.path) _resolve();
|
||||
}
|
||||
|
||||
void _resolve() {
|
||||
final generation = ++_generation;
|
||||
_file = null;
|
||||
final candidate = File(widget.path);
|
||||
candidate.exists().then((exists) {
|
||||
if (!mounted || generation != _generation) return;
|
||||
setState(() => _file = exists ? candidate : null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
++_generation;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builder(context, _file);
|
||||
}
|
||||
|
||||
@@ -415,13 +415,13 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
|
||||
double? _resolveSheetHorizontalAnchor(Alignment alignment) {
|
||||
if (!PlatformDetector.isDesktopOS() || PlatformDetector.isTV()) return null;
|
||||
if (InputModeTracker.isKeyboardMode(context)) return null;
|
||||
if (InputModeTracker.isKeyboardMode(context, listen: false)) return null;
|
||||
if (alignment.x != 0 || alignment.y <= 0) return null;
|
||||
return _lastPointerPosition?.dx;
|
||||
}
|
||||
|
||||
void _autoFocus() {
|
||||
final focusDescendant = InputModeTracker.isKeyboardMode(context);
|
||||
final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
|
||||
// First post-frame: the FocusScope is now built and the node is attached.
|
||||
// Always grab scope focus so key events (especially back) are trapped, even
|
||||
@@ -457,7 +457,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
}
|
||||
|
||||
void _refocus() {
|
||||
final focusDescendant = InputModeTracker.isKeyboardMode(context);
|
||||
final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_isOpen) return;
|
||||
|
||||
@@ -63,7 +63,6 @@ class NavigationRailItem extends StatelessWidget {
|
||||
final Widget? iconWidget;
|
||||
final Widget label;
|
||||
final bool isSelected;
|
||||
final bool isFocused;
|
||||
final bool isCollapsed;
|
||||
final bool useSimpleLayout;
|
||||
final VoidCallback onTap;
|
||||
@@ -84,7 +83,6 @@ class NavigationRailItem extends StatelessWidget {
|
||||
this.iconWidget,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.isFocused,
|
||||
this.isCollapsed = false,
|
||||
this.useSimpleLayout = false,
|
||||
required this.onTap,
|
||||
@@ -102,71 +100,77 @@ class NavigationRailItem extends StatelessWidget {
|
||||
final t = tokens(context);
|
||||
final showSelectedBackground = isSelected && !suppressSelectedBackground;
|
||||
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
onTap();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
|
||||
onNavigateRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
if (isCollapsed) return isFocused ? t.text.withValues(alpha: 0.12) : null;
|
||||
if (isFocused) return t.text.withValues(alpha: showSelectedBackground ? 0.15 : 0.12);
|
||||
if (showSelectedBackground) return t.text.withValues(alpha: 0.1);
|
||||
return null;
|
||||
}(),
|
||||
return ListenableBuilder(
|
||||
listenable: focusNode,
|
||||
builder: (context, _) {
|
||||
final focused = focusNode.hasFocus;
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
onTap();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
|
||||
onNavigateRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: UnconstrainedBox(
|
||||
alignment: .centerLeft,
|
||||
constrainedAxis: Axis.vertical,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: SideNavigationRailState.expandedWidth - 24,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 12, horizontal: horizontalPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
iconWidget ??
|
||||
AppIcon(
|
||||
isSelected && selectedIcon != null ? selectedIcon! : icon,
|
||||
fill: 1,
|
||||
size: iconSize,
|
||||
color: isSelected ? t.text : t.textMuted,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
if (isCollapsed) return focused ? t.text.withValues(alpha: 0.12) : null;
|
||||
if (focused) return t.text.withValues(alpha: showSelectedBackground ? 0.15 : 0.12);
|
||||
if (showSelectedBackground) return t.text.withValues(alpha: 0.1);
|
||||
return null;
|
||||
}(),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: UnconstrainedBox(
|
||||
alignment: .centerLeft,
|
||||
constrainedAxis: Axis.vertical,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: SideNavigationRailState.expandedWidth - 24,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 12, horizontal: horizontalPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
iconWidget ??
|
||||
AppIcon(
|
||||
isSelected && selectedIcon != null ? selectedIcon! : icon,
|
||||
fill: 1,
|
||||
size: iconSize,
|
||||
color: isSelected ? t.text : t.textMuted,
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: () {
|
||||
if (useSimpleLayout) return label;
|
||||
final opacity = isCollapsed ? 0.0 : 1.0;
|
||||
return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label);
|
||||
}(),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: () {
|
||||
if (useSimpleLayout) return label;
|
||||
final opacity = isCollapsed ? 0.0 : 1.0;
|
||||
return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label);
|
||||
}(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -274,13 +278,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusTracker = FocusMemoryTracker(
|
||||
onFocusChanged: () {
|
||||
// ignore: no-empty-block - setState triggers rebuild to update focus styling
|
||||
setStateIfMounted(() {});
|
||||
},
|
||||
debugLabelPrefix: 'nav',
|
||||
);
|
||||
_focusTracker = FocusMemoryTracker(debugLabelPrefix: 'nav');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -741,7 +739,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
selectedIcon: Symbols.home_rounded,
|
||||
label: Translations.of(context).common.home,
|
||||
isSelected: widget.selectedTab == NavigationTabId.discover,
|
||||
isFocused: _focusTracker.isFocused(_kHome),
|
||||
onTap: () => widget.onDestinationSelected(NavigationTabId.discover),
|
||||
focusNode: _focusTracker.get(_kHome),
|
||||
isCollapsed: isCollapsed,
|
||||
@@ -767,7 +764,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
selectedIcon: Symbols.live_tv_rounded,
|
||||
label: Translations.of(context).navigation.liveTv,
|
||||
isSelected: widget.selectedTab == NavigationTabId.liveTv,
|
||||
isFocused: _focusTracker.isFocused('liveTv'),
|
||||
onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv),
|
||||
focusNode: _focusTracker.get('liveTv'),
|
||||
isCollapsed: isCollapsed,
|
||||
@@ -780,7 +776,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
selectedIcon: Symbols.explore_rounded,
|
||||
label: Translations.of(context).navigation.explore,
|
||||
isSelected: widget.selectedTab == NavigationTabId.explore,
|
||||
isFocused: _focusTracker.isFocused(_kExplore),
|
||||
onTap: () => widget.onDestinationSelected(NavigationTabId.explore),
|
||||
focusNode: _focusTracker.get(_kExplore),
|
||||
isCollapsed: isCollapsed,
|
||||
@@ -792,7 +787,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
selectedIcon: Symbols.search_rounded,
|
||||
label: Translations.of(context).common.search,
|
||||
isSelected: widget.selectedTab == NavigationTabId.search,
|
||||
isFocused: _focusTracker.isFocused(_kSearch),
|
||||
onTap: () => widget.onDestinationSelected(NavigationTabId.search),
|
||||
focusNode: _focusTracker.get(_kSearch),
|
||||
isCollapsed: isCollapsed,
|
||||
@@ -807,7 +801,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
selectedIcon: Symbols.download_rounded,
|
||||
label: Translations.of(context).navigation.downloads,
|
||||
isSelected: widget.selectedTab == NavigationTabId.downloads,
|
||||
isFocused: _focusTracker.isFocused(_kDownloads),
|
||||
onTap: () => widget.onDestinationSelected(NavigationTabId.downloads),
|
||||
focusNode: _focusTracker.get(_kDownloads),
|
||||
isCollapsed: isCollapsed,
|
||||
@@ -819,7 +812,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
selectedIcon: Symbols.settings_rounded,
|
||||
label: Translations.of(context).common.settings,
|
||||
isSelected: widget.selectedTab == NavigationTabId.settings,
|
||||
isFocused: _focusTracker.isFocused(_kSettings),
|
||||
onTap: () => widget.onDestinationSelected(NavigationTabId.settings),
|
||||
focusNode: _focusTracker.get(_kSettings),
|
||||
isCollapsed: isCollapsed,
|
||||
@@ -851,7 +843,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
required IconData selectedIcon,
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required bool isFocused,
|
||||
required VoidCallback onTap,
|
||||
required FocusNode focusNode,
|
||||
required bool isCollapsed,
|
||||
@@ -874,7 +865,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
maxLines: 1,
|
||||
),
|
||||
isSelected: isSelected,
|
||||
isFocused: isFocused,
|
||||
isCollapsed: isCollapsed,
|
||||
onTap: onTap,
|
||||
focusNode: focusNode,
|
||||
@@ -919,7 +909,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
],
|
||||
),
|
||||
isSelected: false,
|
||||
isFocused: _focusTracker.isFocused(_kNowPlaying),
|
||||
isCollapsed: isCollapsed,
|
||||
useSimpleLayout: true,
|
||||
onTap: () => unawaited(openNowPlaying(context)),
|
||||
@@ -931,7 +920,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
|
||||
Widget _buildReconnectItem({required bool isCollapsed}) {
|
||||
final t = tokens(context);
|
||||
final isFocused = _focusTracker.isFocused(_kReconnect);
|
||||
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
||||
|
||||
return NavigationRailItem(
|
||||
@@ -945,7 +933,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
maxLines: 1,
|
||||
),
|
||||
isSelected: false,
|
||||
isFocused: isFocused,
|
||||
isCollapsed: isCollapsed,
|
||||
// ignore: no-empty-block - no-op tap handler while reconnecting
|
||||
onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(),
|
||||
@@ -958,7 +945,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
Widget _buildFullscreenItem({required bool isCollapsed}) {
|
||||
final t = tokens(context);
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
final isFocused = _focusTracker.isFocused(_kFullscreen);
|
||||
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
||||
|
||||
return NavigationRailItem(
|
||||
@@ -970,7 +956,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
maxLines: 1,
|
||||
),
|
||||
isSelected: false,
|
||||
isFocused: isFocused,
|
||||
isCollapsed: isCollapsed,
|
||||
onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()),
|
||||
focusNode: _focusTracker.get(_kFullscreen),
|
||||
@@ -990,95 +975,98 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
final librariesProvider = context.watch<LibrariesProvider>();
|
||||
final isLoading = librariesProvider.isLoading;
|
||||
final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null;
|
||||
final isLibrariesFocused = _focusTracker.isFocused(_kLibraries);
|
||||
final librariesFocusNode = _focusTracker.get(_kLibraries);
|
||||
final showLibrariesSelectedBackground = isLibrariesSelected && !widget.isSidebarFocused;
|
||||
final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Focus(
|
||||
focusNode: _focusTracker.get(_kLibraries),
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
setState(() {
|
||||
_librariesExpanded = !_librariesExpanded;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// RIGHT arrow navigates to content area
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
|
||||
widget.onNavigateToContent!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () {
|
||||
ListenableBuilder(
|
||||
listenable: librariesFocusNode,
|
||||
builder: (context, _) => Focus(
|
||||
focusNode: librariesFocusNode,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
setState(() {
|
||||
_librariesExpanded = !_librariesExpanded;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
if (isCollapsed) return isLibrariesFocused ? t.text.withValues(alpha: 0.08) : null;
|
||||
if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1);
|
||||
if (isLibrariesFocused) return t.text.withValues(alpha: 0.08);
|
||||
return null;
|
||||
}(),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: UnconstrainedBox(
|
||||
alignment: .centerLeft,
|
||||
constrainedAxis: Axis.vertical,
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// RIGHT arrow navigates to content area
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
|
||||
widget.onNavigateToContent!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_librariesExpanded = !_librariesExpanded;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
if (isCollapsed) return librariesFocusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null;
|
||||
if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1);
|
||||
if (librariesFocusNode.hasFocus) return t.text.withValues(alpha: 0.08);
|
||||
return null;
|
||||
}(),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: expandedWidth - 24,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.video_library_rounded,
|
||||
fill: 1,
|
||||
size: 22,
|
||||
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: AnimatedOpacity(
|
||||
opacity: isCollapsed ? 0.0 : 1.0,
|
||||
duration: tokens(context).fast,
|
||||
child: Text(
|
||||
Translations.of(context).navigation.libraries,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: widget.selectedTab == NavigationTabId.libraries
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
|
||||
child: UnconstrainedBox(
|
||||
alignment: .centerLeft,
|
||||
constrainedAxis: Axis.vertical,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: expandedWidth - 24,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.video_library_rounded,
|
||||
fill: 1,
|
||||
size: 22,
|
||||
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: AnimatedOpacity(
|
||||
opacity: isCollapsed ? 0.0 : 1.0,
|
||||
duration: tokens(context).fast,
|
||||
child: Text(
|
||||
Translations.of(context).navigation.libraries,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: widget.selectedTab == NavigationTabId.libraries
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: isCollapsed ? 0.0 : 1.0,
|
||||
duration: tokens(context).fast,
|
||||
child: AppIcon(
|
||||
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
color: t.textMuted,
|
||||
AnimatedOpacity(
|
||||
opacity: isCollapsed ? 0.0 : 1.0,
|
||||
duration: tokens(context).fast,
|
||||
child: AppIcon(
|
||||
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
||||
fill: 1,
|
||||
size: 20,
|
||||
color: t.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1223,56 +1211,62 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
required VoidCallback onToggle,
|
||||
required dynamic t,
|
||||
}) {
|
||||
final isFocused = _focusTracker.isFocused(focusKey);
|
||||
final focusNode = _focusTracker.get(focusKey);
|
||||
final radius = BorderRadius.circular(tokens(context).radiusSm);
|
||||
// Match library-item indent: outer Padding(left: 12) + inner horizontal 17.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Focus(
|
||||
focusNode: _focusTracker.get(focusKey),
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
onToggle();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
|
||||
widget.onNavigateToContent!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: onToggle,
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: isFocused ? t.text.withValues(alpha: 0.08) : null, borderRadius: radius),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: UnconstrainedBox(
|
||||
alignment: .centerLeft,
|
||||
constrainedAxis: Axis.vertical,
|
||||
child: ListenableBuilder(
|
||||
listenable: focusNode,
|
||||
builder: (context, _) => Focus(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
onToggle();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) {
|
||||
widget.onNavigateToContent!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: onToggle,
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: focusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null,
|
||||
borderRadius: radius,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: expandedWidth - 24,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: verticalPadding, horizontal: 17),
|
||||
child: Row(
|
||||
children: [
|
||||
leading ?? AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(label, style: labelStyle, overflow: .ellipsis),
|
||||
),
|
||||
AppIcon(
|
||||
isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
||||
fill: 1,
|
||||
size: 16,
|
||||
color: t.textMuted,
|
||||
),
|
||||
],
|
||||
child: UnconstrainedBox(
|
||||
alignment: .centerLeft,
|
||||
constrainedAxis: Axis.vertical,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: expandedWidth - 24,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: verticalPadding, horizontal: 17),
|
||||
child: Row(
|
||||
children: [
|
||||
leading ?? AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(label, style: labelStyle, overflow: .ellipsis),
|
||||
),
|
||||
AppIcon(
|
||||
isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
||||
fill: 1,
|
||||
size: 16,
|
||||
color: t.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1288,7 +1282,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
final isSelected =
|
||||
widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == library.globalKey;
|
||||
final focusKey = _libraryItemFocusKey(section, library);
|
||||
final isFocused = _focusTracker.isFocused(focusKey);
|
||||
final focusNode = _focusTracker.get(focusKey);
|
||||
|
||||
return Padding(
|
||||
@@ -1318,7 +1311,6 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
],
|
||||
),
|
||||
isSelected: isSelected,
|
||||
isFocused: isFocused,
|
||||
useSimpleLayout: true,
|
||||
onTap: () => widget.onLibrarySelected(library.globalKey),
|
||||
focusNode: focusNode,
|
||||
|
||||
@@ -252,7 +252,9 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
||||
});
|
||||
_reclaimFocusAfterControlsHide();
|
||||
} else {
|
||||
_setControlsState(() {});
|
||||
_setControlsState(() {
|
||||
if (controlsVisible) _controlsMounted = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (visibilityChanged && Platform.isMacOS) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:async' show unawaited;
|
||||
import 'dart:async' show Stream, unawaited;
|
||||
import '../../../media/ids.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -46,6 +46,27 @@ class ChapterSheet extends StatefulWidget {
|
||||
|
||||
class _ChapterSheetState extends State<ChapterSheet> {
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
late Stream<int?> _chapterIndexStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bindChapterIndexStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ChapterSheet oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.player, widget.player) || !identical(oldWidget.chapters, widget.chapters)) {
|
||||
_bindChapterIndexStream();
|
||||
}
|
||||
}
|
||||
|
||||
void _bindChapterIndexStream() {
|
||||
_chapterIndexStream = widget.player.streams.position
|
||||
.map((position) => MediaChapter.indexAtPosition(position, widget.chapters))
|
||||
.distinct();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -69,13 +90,11 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<Duration>(
|
||||
stream: widget.player.streams.position,
|
||||
initialData: widget.player.state.position,
|
||||
builder: (context, positionSnapshot) {
|
||||
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
||||
final currentChapterIndex = MediaChapter.indexAtPosition(currentPosition, widget.chapters);
|
||||
|
||||
return StreamBuilder<int?>(
|
||||
stream: _chapterIndexStream,
|
||||
initialData: MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters),
|
||||
builder: (context, chapterSnapshot) {
|
||||
final currentChapterIndex = chapterSnapshot.data;
|
||||
Widget content;
|
||||
if (!widget.chaptersLoaded) {
|
||||
content = const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -540,6 +540,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
bool get _hasRenderedFirstFrame => widget.hasFirstFrame?.value ?? true;
|
||||
|
||||
late bool _lastControlsVisible;
|
||||
late bool _controlsMounted;
|
||||
bool _isLoadingExtras = false;
|
||||
// Item key the in-flight extras load belongs to, so a load for a swapped
|
||||
// item can start while a stale one is still in flight (and the stale
|
||||
@@ -656,6 +657,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_lastControlsVisible = widget.chromeController.controlsVisible;
|
||||
_controlsMounted = _lastControlsVisible;
|
||||
_focusNode = FocusNode();
|
||||
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
|
||||
_seekThrottle = throttle(
|
||||
@@ -938,121 +940,126 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
// Custom controls overlay
|
||||
// Positioned AFTER double-tap zones so controls receive taps first
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showControls,
|
||||
child: FocusScope(
|
||||
// Prevent focus from entering controls when hidden
|
||||
canRequestFocus: _showControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showControls ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
onEnd: () {
|
||||
if (!_showControls) widget.chromeController.markControlsHidden();
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return GestureDetector(
|
||||
onTapUp: (details) => _handleControlsOverlayTap(details, _sizeOf(context)),
|
||||
onLongPressStart: (_) => _handleLongPressStart(),
|
||||
onLongPressEnd: (_) => _handleLongPressEnd(),
|
||||
onLongPressCancel: _handleLongPressCancel,
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame,
|
||||
builder: (context, hasFrame, child) {
|
||||
// Solid black while loading, scrim once frames flow.
|
||||
// Both states share one widget type: hasFrame flips
|
||||
// on every in-place episode switch / live-TV zap, and
|
||||
// a runtimeType change here would re-inflate the whole
|
||||
// controls subtree and drop its state.
|
||||
return RasterizedGradient(
|
||||
gradient: hasFrame
|
||||
? LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
],
|
||||
stops: const [0.0, 0.2, 0.8, 1.0],
|
||||
)
|
||||
: const LinearGradient(colors: [Colors.black, Colors.black]),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: isMobile
|
||||
? Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (_) {
|
||||
if (!widget.chromeController.contentStripVisible) {
|
||||
_restartHideTimerForCurrentPlaybackState();
|
||||
}
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final playbackState = context.watch<PlaybackStateProvider>();
|
||||
final hasStripContent =
|
||||
_chapters.isNotEmpty || playbackState.isQueueActive;
|
||||
return MobileVideoControls(
|
||||
player: widget.player,
|
||||
metadata: widget.metadata,
|
||||
chapters: _chapters,
|
||||
chaptersLoaded: _chaptersLoaded,
|
||||
showChapterMarkersOnTimeline: _showChapterMarkersOnTimeline,
|
||||
seekTimeSmall: _seekTimeSmall,
|
||||
trackChapterControls: _buildTrackChapterControlsWidget(
|
||||
hideChaptersAndQueue: hasStripContent,
|
||||
),
|
||||
onSeek: _throttledSeek,
|
||||
onSeekEnd: _finalizeSeek,
|
||||
onScrubStart: _holdTimelineScrub,
|
||||
onScrubEnd: _releaseTimelineScrub,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
// ignore: no-empty-block - play/pause handled by parent VideoControlsState
|
||||
onPlayPause: () {},
|
||||
onCancelAutoHide: widget.chromeController.cancelAutoHide,
|
||||
onStartAutoHide: widget.chromeController.startAutoHide,
|
||||
onBack: widget.onBack,
|
||||
onNext: widget.onNext,
|
||||
onPrevious: widget.onPrevious,
|
||||
canControl: widget.canControl,
|
||||
hasFirstFrame: widget.hasFirstFrame,
|
||||
thumbnailDataBuilder: widget.thumbnailDataBuilder,
|
||||
isLive: widget.isLive,
|
||||
liveChannelName: widget.liveChannelName,
|
||||
captureBuffer: widget.captureBuffer,
|
||||
isAtLiveEdge: widget.isAtLiveEdge,
|
||||
streamStartEpoch: widget.streamStartEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
serverId: widget.metadata.serverId,
|
||||
showQueueTab: playbackState.isQueueActive,
|
||||
onQueueItemSelected: playbackState.isQueueActive
|
||||
? _onQueueItemSelected
|
||||
: null,
|
||||
chromeController: widget.chromeController,
|
||||
onStripVisibilityChanged: (visible) {
|
||||
if (visible) {
|
||||
widget.chromeController.setContentStripVisible(true);
|
||||
} else {
|
||||
widget.chromeController.setContentStripVisible(false);
|
||||
child: !_controlsMounted
|
||||
? const SizedBox.shrink()
|
||||
: IgnorePointer(
|
||||
ignoring: !_showControls,
|
||||
child: FocusScope(
|
||||
// Prevent focus from entering controls when hidden
|
||||
canRequestFocus: _showControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showControls ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
onEnd: () {
|
||||
if (!_showControls) {
|
||||
widget.chromeController.markControlsHidden();
|
||||
if (_controlsMounted) setState(() => _controlsMounted = false);
|
||||
}
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return GestureDetector(
|
||||
onTapUp: (details) => _handleControlsOverlayTap(details, _sizeOf(context)),
|
||||
onLongPressStart: (_) => _handleLongPressStart(),
|
||||
onLongPressEnd: (_) => _handleLongPressEnd(),
|
||||
onLongPressCancel: _handleLongPressCancel,
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame,
|
||||
builder: (context, hasFrame, child) {
|
||||
// Solid black while loading, scrim once frames flow.
|
||||
// Both states share one widget type: hasFrame flips
|
||||
// on every in-place episode switch / live-TV zap, and
|
||||
// a runtimeType change here would re-inflate the whole
|
||||
// controls subtree and drop its state.
|
||||
return RasterizedGradient(
|
||||
gradient: hasFrame
|
||||
? LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
],
|
||||
stops: const [0.0, 0.2, 0.8, 1.0],
|
||||
)
|
||||
: const LinearGradient(colors: [Colors.black, Colors.black]),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: isMobile
|
||||
? Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (_) {
|
||||
if (!widget.chromeController.contentStripVisible) {
|
||||
_restartHideTimerForCurrentPlaybackState();
|
||||
}
|
||||
},
|
||||
isInEdgeAdjustmentZone: _isGlobalPositionInEdgeAdjustmentZone,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: _buildDesktopControlsListener(),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final playbackState = context.watch<PlaybackStateProvider>();
|
||||
final hasStripContent =
|
||||
_chapters.isNotEmpty || playbackState.isQueueActive;
|
||||
return MobileVideoControls(
|
||||
player: widget.player,
|
||||
metadata: widget.metadata,
|
||||
chapters: _chapters,
|
||||
chaptersLoaded: _chaptersLoaded,
|
||||
showChapterMarkersOnTimeline: _showChapterMarkersOnTimeline,
|
||||
seekTimeSmall: _seekTimeSmall,
|
||||
trackChapterControls: _buildTrackChapterControlsWidget(
|
||||
hideChaptersAndQueue: hasStripContent,
|
||||
),
|
||||
onSeek: _throttledSeek,
|
||||
onSeekEnd: _finalizeSeek,
|
||||
onScrubStart: _holdTimelineScrub,
|
||||
onScrubEnd: _releaseTimelineScrub,
|
||||
onSeekRequested: widget.onSeekRequested,
|
||||
onSeekCompleted: widget.onSeekCompleted,
|
||||
// ignore: no-empty-block - play/pause handled by parent VideoControlsState
|
||||
onPlayPause: () {},
|
||||
onCancelAutoHide: widget.chromeController.cancelAutoHide,
|
||||
onStartAutoHide: widget.chromeController.startAutoHide,
|
||||
onBack: widget.onBack,
|
||||
onNext: widget.onNext,
|
||||
onPrevious: widget.onPrevious,
|
||||
canControl: widget.canControl,
|
||||
hasFirstFrame: widget.hasFirstFrame,
|
||||
thumbnailDataBuilder: widget.thumbnailDataBuilder,
|
||||
isLive: widget.isLive,
|
||||
liveChannelName: widget.liveChannelName,
|
||||
captureBuffer: widget.captureBuffer,
|
||||
isAtLiveEdge: widget.isAtLiveEdge,
|
||||
streamStartEpoch: widget.streamStartEpoch,
|
||||
onLiveSeek: widget.onLiveSeek,
|
||||
serverId: widget.metadata.serverId,
|
||||
showQueueTab: playbackState.isQueueActive,
|
||||
onQueueItemSelected: playbackState.isQueueActive
|
||||
? _onQueueItemSelected
|
||||
: null,
|
||||
chromeController: widget.chromeController,
|
||||
onStripVisibilityChanged: (visible) {
|
||||
if (visible) {
|
||||
widget.chromeController.setContentStripVisible(true);
|
||||
} else {
|
||||
widget.chromeController.setContentStripVisible(false);
|
||||
}
|
||||
},
|
||||
isInEdgeAdjustmentZone: _isGlobalPositionInEdgeAdjustmentZone,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: _buildDesktopControlsListener(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Visual feedback overlay for double-tap
|
||||
if (isMobile && _showDoubleTapFeedback)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:async' show unawaited;
|
||||
import 'dart:async' show Stream, unawaited;
|
||||
import '../../../media/ids.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -79,6 +79,7 @@ class ContentStripState extends State<ContentStrip> {
|
||||
int? _lastAutoScrolledQueueIndex;
|
||||
final Map<int, GlobalKey> _chapterItemKeys = {};
|
||||
final Map<int, GlobalKey> _queueItemKeys = {};
|
||||
late Stream<int?> _chapterIndexStream;
|
||||
|
||||
// Focus nodes for focus navigation mode
|
||||
final List<FocusNode> _chapterFocusNodes = [];
|
||||
@@ -92,6 +93,21 @@ class ContentStripState extends State<ContentStrip> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeTab = _hasChapters ? _StripTab.chapters : _StripTab.queue;
|
||||
_bindChapterIndexStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ContentStrip oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.player, widget.player) || !identical(oldWidget.chapters, widget.chapters)) {
|
||||
_bindChapterIndexStream();
|
||||
}
|
||||
}
|
||||
|
||||
void _bindChapterIndexStream() {
|
||||
_chapterIndexStream = widget.player.streams.position
|
||||
.map((position) => MediaChapter.indexAtPosition(position, widget.chapters))
|
||||
.distinct();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -373,13 +389,11 @@ class ContentStripState extends State<ContentStrip> {
|
||||
final thumbWidth = isTablet ? 200.0 : 120.0;
|
||||
final thumbHeight = isTablet ? 112.0 : 68.0;
|
||||
|
||||
return StreamBuilder<Duration>(
|
||||
stream: widget.player.streams.position,
|
||||
initialData: widget.player.state.position,
|
||||
builder: (context, positionSnapshot) {
|
||||
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
||||
final currentChapterIndex = MediaChapter.indexAtPosition(currentPosition, widget.chapters);
|
||||
|
||||
return StreamBuilder<int?>(
|
||||
stream: _chapterIndexStream,
|
||||
initialData: MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters),
|
||||
builder: (context, chapterSnapshot) {
|
||||
final currentChapterIndex = chapterSnapshot.data;
|
||||
_trimItemKeys(_chapterItemKeys, widget.chapters.length);
|
||||
|
||||
if (currentChapterIndex != null && _lastAutoScrolledChapterIndex != currentChapterIndex) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/services/gamepad_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('one-shot reads do not subscribe to input-mode changes', (tester) async {
|
||||
var listeningBuilds = 0;
|
||||
var oneShotBuilds = 0;
|
||||
InputMode? listeningMode;
|
||||
InputMode? oneShotMode;
|
||||
|
||||
await tester.pumpWidget(
|
||||
InputModeTracker(
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Row(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
listeningBuilds++;
|
||||
listeningMode = InputModeTracker.of(context);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
oneShotBuilds++;
|
||||
oneShotMode = InputModeTracker.of(context, listen: false);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(listeningMode, InputMode.pointer);
|
||||
expect(oneShotMode, InputMode.pointer);
|
||||
expect(listeningBuilds, 1);
|
||||
expect(oneShotBuilds, 1);
|
||||
|
||||
GamepadService.onGamepadInput!.call();
|
||||
await tester.pump();
|
||||
|
||||
expect(listeningMode, InputMode.keyboard);
|
||||
expect(listeningBuilds, 2);
|
||||
expect(oneShotMode, InputMode.pointer);
|
||||
expect(oneShotBuilds, 1);
|
||||
});
|
||||
}
|
||||
@@ -89,9 +89,13 @@ class _FakeConnectionRegistry extends ConnectionRegistry {
|
||||
_FakeConnectionRegistry(super.db, this.connections);
|
||||
|
||||
final List<Connection> connections;
|
||||
int watchCalls = 0;
|
||||
|
||||
@override
|
||||
Stream<List<Connection>> watchConnections() => Stream.value(connections);
|
||||
Stream<List<Connection>> watchConnections() {
|
||||
watchCalls++;
|
||||
return Stream.value(connections);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
@@ -102,7 +106,7 @@ void main() {
|
||||
late DownloadManagerService downloadManager;
|
||||
late MultiServerManager serverManager;
|
||||
MultiServerProvider? multiServerProvider;
|
||||
late ConnectionRegistry connectionRegistry;
|
||||
late _FakeConnectionRegistry connectionRegistry;
|
||||
late List<Connection> connections;
|
||||
|
||||
setUp(() async {
|
||||
@@ -251,6 +255,18 @@ void main() {
|
||||
expect(find.text('No sync rules'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('provider rebuilds reuse the connection stream subscription', (tester) async {
|
||||
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
|
||||
await insertRule(ServerId('orphan-srv'), '76672');
|
||||
await pumpScreen(tester);
|
||||
|
||||
expect(connectionRegistry.watchCalls, 1);
|
||||
await downloadProvider.updateSyncRuleCount(downloadProvider.syncRules.keys.single, 6);
|
||||
await tester.pump();
|
||||
|
||||
expect(connectionRegistry.watchCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('does not autofocus the first sync rule in pointer mode', (tester) async {
|
||||
multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
|
||||
await insertRule(ServerId('orphan-srv'), '76672');
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:plezy/media/ids.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
@@ -516,4 +517,41 @@ void main() {
|
||||
|
||||
expect(selectedLibraryKey, hiddenServerALibrary.globalKey);
|
||||
});
|
||||
|
||||
testWidgets('rail item focus repaints locally without rebuilding its parent', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
var parentBuilds = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(extensions: const [_testTokens]),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
parentBuilds++;
|
||||
return NavigationRailItem(
|
||||
icon: Symbols.home_rounded,
|
||||
label: const Text('Home'),
|
||||
isSelected: false,
|
||||
onTap: () {},
|
||||
focusNode: focusNode,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final item = find.byType(NavigationRailItem);
|
||||
expect(_railItemDecoration(tester, item)?.color, isNull);
|
||||
expect(parentBuilds, 1);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
expect(_railItemDecoration(tester, item)?.color, isNotNull);
|
||||
expect(parentBuilds, 1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user