perf: isolate focus and media rebuilds

This commit is contained in:
edde746
2026-07-12 19:00:11 +02:00
parent a561777456
commit 2101f1d7f0
28 changed files with 771 additions and 499 deletions
+44 -31
View File
@@ -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;
+12 -7
View File
@@ -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) {
+10 -4
View File
@@ -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
+3 -3
View File
@@ -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(
+54 -48
View File
@@ -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),
),
);
});
}(),
}),
);
},
),
],
),
),
+15 -3
View File
@@ -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: [
+29 -14
View File
@@ -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;
+1 -1
View File
@@ -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,
),
);
},
);
},
);
+1 -1
View File
@@ -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);
}
}
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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>(
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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;
+9 -5
View File
@@ -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();
+53 -4
View File
@@ -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);
}
+3 -3
View 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;
+193 -201
View File
@@ -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());
+118 -111
View File
@@ -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) {
+54
View File
@@ -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);
});
}