From 2101f1d7f0a48c66c9cd951bd0f049a070da2d74 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:56:53 +0200 Subject: [PATCH] perf: isolate focus and media rebuilds --- lib/focus/focusable_text_field.dart | 75 ++-- lib/focus/focusable_wrapper.dart | 19 +- lib/focus/input_mode_tracker.dart | 14 +- lib/navigation/profile_session_screen.dart | 6 +- lib/screens/discover_screen.dart | 102 ++--- lib/screens/downloads/sync_rules_screen.dart | 18 +- .../focusable_detail_screen_mixin.dart | 2 +- .../libraries/alpha_scroll_handle.dart | 1 + .../libraries/tabs/library_browse_tab.dart | 3 +- lib/screens/media_detail_screen.dart | 43 +- lib/screens/music/queue_sheet.dart | 2 +- .../playlist/playlist_detail_screen.dart | 90 ++-- lib/screens/settings/settings_screen.dart | 2 +- lib/screens/settings/settings_utils.dart | 6 +- lib/utils/dialogs.dart | 2 +- lib/widgets/app_menu.dart | 2 +- lib/widgets/focused_scroll_scaffold.dart | 2 +- lib/widgets/hub_section.dart | 14 +- lib/widgets/optimized_media_image.dart | 57 ++- lib/widgets/overlay_sheet.dart | 6 +- lib/widgets/side_navigation_rail.dart | 394 +++++++++--------- .../video_controls/parts/visibility.dart | 4 +- .../video_controls/sheets/chapter_sheet.dart | 35 +- .../video_controls/video_controls.dart | 229 +++++----- .../video_controls/widgets/content_strip.dart | 30 +- test/focus/input_mode_tracker_test.dart | 54 +++ .../downloads/sync_rules_screen_test.dart | 20 +- test/widgets/side_navigation_rail_test.dart | 38 ++ 28 files changed, 771 insertions(+), 499 deletions(-) create mode 100644 test/focus/input_mode_tracker_test.dart diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 30d8dd03..c35e930b 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -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; diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index e1db7313..9d358dd2 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -347,17 +347,22 @@ class _FocusableWrapperState extends State 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) { diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart index d77c6802..9324dcd8 100644 --- a/lib/focus/input_mode_tracker.dart +++ b/lib/focus/input_mode_tracker.dart @@ -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 diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index cf876795..bd998d73 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -103,9 +103,9 @@ class _ProfileSessionScreenState extends State { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, activeProfile, _) { - final activeId = activeProfile.activeId; + return Selector( + selector: (_, activeProfile) => activeProfile.activeId, + builder: (context, activeId, _) { _onSessionProfileChanged(activeId); final initialPromptHandled = widget.initialPromptHandled || _hasBuiltSession; return KeyedSubtree( diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 84be71d2..d271e50d 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -95,6 +95,7 @@ class _DiscoverScreenState extends State final PageController _heroController = PageController(); final ScrollController _scrollController = ScrollController(); int _currentHeroIndex = 0; + final ValueNotifier _heroIndex = ValueNotifier(0); Timer? _autoScrollTimer; Timer? _indicatorTimer; final ValueNotifier _indicatorProgress = ValueNotifier(0.0); @@ -357,6 +358,7 @@ class _DiscoverScreenState extends State setState(() { if (isNewLoad || heroOutOfBounds) { _currentHeroIndex = 0; + _heroIndex.value = 0; } _updateHubKeys(); }); @@ -443,6 +445,7 @@ class _DiscoverScreenState extends State _indicatorTimer?.cancel(); _spotlight.dispose(); _indicatorProgress.dispose(); + _heroIndex.dispose(); _heroController.dispose(); _scrollController.dispose(); _heroFocusNode.removeListener(_onHeroFocusChanged); @@ -481,6 +484,7 @@ class _DiscoverScreenState extends State // 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 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 ), ), 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( + 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( - 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( + 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), - ), - ); - }); - }(), + }), + ); + }, + ), ], ), ), diff --git a/lib/screens/downloads/sync_rules_screen.dart b/lib/screens/downloads/sync_rules_screen.dart index a7a6420e..097f6dfa 100644 --- a/lib/screens/downloads/sync_rules_screen.dart +++ b/lib/screens/downloads/sync_rules_screen.dart @@ -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 createState() => _SyncRulesScreenState(); +} + +class _SyncRulesScreenState extends State { + late final Stream> _connections; + + @override + void initState() { + super.initState(); + _connections = context.read().watchConnections(); + } + @override Widget build(BuildContext context) { return Consumer( builder: (context, downloadProvider, _) { final syncRules = downloadProvider.syncRules; final multiServerProvider = context.watch(); - final connectionRegistry = context.read(); return StreamBuilder>( - stream: connectionRegistry.watchConnections(), + stream: _connections, initialData: const [], builder: (context, snapshot) { final connections = snapshot.data ?? const []; diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index ff0d7ed6..8d8716be 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -149,7 +149,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu if (mounted && hasItems) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (InputModeTracker.isKeyboardMode(context)) { + if (InputModeTracker.isKeyboardMode(context, listen: false)) { setState(() { isAppBarFocused = false; }); diff --git a/lib/screens/libraries/alpha_scroll_handle.dart b/lib/screens/libraries/alpha_scroll_handle.dart index c2bde8b1..cac1ba73 100644 --- a/lib/screens/libraries/alpha_scroll_handle.dart +++ b/lib/screens/libraries/alpha_scroll_handle.dart @@ -126,6 +126,7 @@ class _AlphaScrollHandleState extends State with SingleTicker _dragFraction = newFraction; final letter = _helper.letterAtFraction(newFraction); + if (letter == _dragLetter) return; setState(() => _dragLetter = letter); widget.onJump(_helper.indexForLetter(letter) ?? 0); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index c123a7cc..1ff22b53 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1434,8 +1434,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState // Locked focus pattern for extras int _focusedExtraIndex = 0; + final ValueNotifier _focusedExtraIndexNotifier = ValueNotifier(0); late final FocusNode _extrasFocusNode; final Map> _extraCardKeys = {}; final _extrasSectionKey = GlobalKey(); @@ -368,6 +370,7 @@ class _MediaDetailScreenState extends State // Locked focus pattern for cast int _focusedCastIndex = 0; + final ValueNotifier _focusedCastIndexNotifier = ValueNotifier(0); late final FocusNode _castFocusNode; final ScrollController _castScrollController = ScrollController(); final _castSectionKey = GlobalKey(); @@ -853,10 +856,12 @@ class _MediaDetailScreenState extends State _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 // 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 // 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 // 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 // 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 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 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 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 focusNode: _extrasFocusNode, onKeyEvent: _handleExtrasKeyEvent, child: ListenableBuilder( - listenable: _extrasFocusNode, + listenable: Listenable.merge([_extrasFocusNode, _focusedExtraIndexNotifier]), builder: (context, _) { final hasFocus = _extrasFocusNode.hasFocus; diff --git a/lib/screens/music/queue_sheet.dart b/lib/screens/music/queue_sheet.dart index 36159d89..16287f5f 100644 --- a/lib/screens/music/queue_sheet.dart +++ b/lib/screens/music/queue_sheet.dart @@ -126,7 +126,7 @@ class _QueueListState extends State { 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((_) { diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index f2ed9deb..99cd1d95 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -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 _focusRevision = ValueNotifier(0); + + void _notifyFocusChanged() => _focusRevision.value++; // Move mode state int? _movingIndex; @@ -215,6 +219,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen= 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= 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 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 _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 _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 '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, + ), + ); + }, ); }, ); diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 32b54f6a..e9f859ab 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -111,7 +111,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun @override void focusActiveTabIfReady() { - if (InputModeTracker.isKeyboardMode(context)) { + if (InputModeTracker.isKeyboardMode(context, listen: false)) { _focusTracker.restoreFocus(fallbackKey: DonationService.isEnabled ? _kDonate : _kAppearance); } } diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 29b52ad4..7412f7cd 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -105,7 +105,7 @@ Future showSelectionDialog({ required List> options, required T currentValue, }) { - final focusFirstItem = InputModeTracker.isKeyboardMode(context); + final focusFirstItem = InputModeTracker.isKeyboardMode(context, listen: false); return showScopedDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -148,7 +148,7 @@ void showNumericInputDialog({ required int currentValue, required Future 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 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); diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 1a268f51..7cb26b33 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -290,7 +290,7 @@ Future showOptionPickerDialog( Future Function(T value)? onBeforeClose, OptionPickerToggle? toggle, }) { - final focusFirstItem = InputModeTracker.isKeyboardMode(context); + final focusFirstItem = InputModeTracker.isKeyboardMode(context, listen: false); return showScopedDialog( context: context, builder: (context) => _OptionPickerDialog( diff --git a/lib/widgets/app_menu.dart b/lib/widgets/app_menu.dart index 502258e1..f129671a 100644 --- a/lib/widgets/app_menu.dart +++ b/lib/widgets/app_menu.dart @@ -184,7 +184,7 @@ class AppMenuButtonState extends State> { } Future _handlePressed() async { - await showButtonMenu(focusFirstItem: InputModeTracker.isKeyboardMode(context)); + await showButtonMenu(focusFirstItem: InputModeTracker.isKeyboardMode(context, listen: false)); } @override diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index b5afdf2b..629ddeae 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -64,7 +64,7 @@ class _FocusedScrollScaffoldState extends State { } 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; diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 11b1e57f..5956fadf 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -112,6 +112,10 @@ class HubSectionState extends State 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 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 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 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 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 with MountedSetStateMixin, Skele setState(() { _focusedIndex = clamped; }); - HubFocusMemory.setForHub(widget.hub.id, clamped); + HubFocusMemory.setForHub(_focusMemoryKey, clamped); _notifyFocusedItemChanged(); _scrollToIndex(clamped); _hubFocusNode.requestFocus(); diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 3cdf4ab8..24de561f 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -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); +} diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 567e50e0..2d4156da 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -415,13 +415,13 @@ class _OverlaySheetHostState extends State 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 with SingleTickerPr } void _refocus() { - final focusDescendant = InputModeTracker.isKeyboardMode(context); + final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_isOpen) return; diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index adb83c49..35fb9c51 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -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 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 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 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 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 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 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 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 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 with MountedSetS maxLines: 1, ), isSelected: isSelected, - isFocused: isFocused, isCollapsed: isCollapsed, onTap: onTap, focusNode: focusNode, @@ -919,7 +909,6 @@ class SideNavigationRailState extends State with MountedSetS ], ), isSelected: false, - isFocused: _focusTracker.isFocused(_kNowPlaying), isCollapsed: isCollapsed, useSimpleLayout: true, onTap: () => unawaited(openNowPlaying(context)), @@ -931,7 +920,6 @@ class SideNavigationRailState extends State 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 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 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 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 with MountedSetS final librariesProvider = context.watch(); 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 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 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 with MountedSetS ], ), isSelected: isSelected, - isFocused: isFocused, useSimpleLayout: true, onTap: () => widget.onLibrarySelected(library.globalKey), focusNode: focusNode, diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index b1ca588a..54dd5313 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -252,7 +252,9 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { }); _reclaimFocusAfterControlsHide(); } else { - _setControlsState(() {}); + _setControlsState(() { + if (controlsVisible) _controlsMounted = true; + }); } if (visibilityChanged && Platform.isMacOS) { diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index a6512996..6ef540d2 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -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 { final _initialScroll = InitialItemScrollController(); + late Stream _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 { @override Widget build(BuildContext context) { - return StreamBuilder( - 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( + 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()); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index c9b16715..54c889be 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -540,6 +540,7 @@ class _PlexVideoControlsState extends State 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 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 // 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( - 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(); - 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( + 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(); + 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) diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 5d94228f..622f040f 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -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 { int? _lastAutoScrolledQueueIndex; final Map _chapterItemKeys = {}; final Map _queueItemKeys = {}; + late Stream _chapterIndexStream; // Focus nodes for focus navigation mode final List _chapterFocusNodes = []; @@ -92,6 +93,21 @@ class ContentStripState extends State { 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 { final thumbWidth = isTablet ? 200.0 : 120.0; final thumbHeight = isTablet ? 112.0 : 68.0; - return StreamBuilder( - 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( + 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) { diff --git a/test/focus/input_mode_tracker_test.dart b/test/focus/input_mode_tracker_test.dart new file mode 100644 index 00000000..5a0e81c8 --- /dev/null +++ b/test/focus/input_mode_tracker_test.dart @@ -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); + }); +} diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart index c2c7b5ed..2660cc8b 100644 --- a/test/screens/downloads/sync_rules_screen_test.dart +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -89,9 +89,13 @@ class _FakeConnectionRegistry extends ConnectionRegistry { _FakeConnectionRegistry(super.db, this.connections); final List connections; + int watchCalls = 0; @override - Stream> watchConnections() => Stream.value(connections); + Stream> 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 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'); diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 5f0128c6..eec30391 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -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); + }); }