import 'dart:async'; import 'dart:ui' show lerpDouble; import '../media/ids.dart'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_memory_tracker.dart'; import '../focus/input_mode_tracker.dart'; import '../media/media_item.dart'; import '../media/media_library.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../services/device_performance.dart'; import '../services/music/music_playback_service.dart'; import '../services/settings_service.dart'; import '../utils/music_navigation.dart'; import '../utils/content_utils.dart'; import '../utils/platform_detector.dart'; import '../utils/scroll_utils.dart'; import '../utils/library_grouping.dart'; import 'music/equalizer_icon.dart'; import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../theme/mono_tokens.dart'; import '../widgets/backend_badge.dart'; import '../i18n/strings.g.dart'; enum _LibraryNavSection { visible, hidden } sealed class _LibraryNavRow { final _LibraryNavSection section; const _LibraryNavRow({required this.section}); } final class _LibraryServerHeaderRow extends _LibraryNavRow { final String serverId; final String serverName; const _LibraryServerHeaderRow({required super.section, required this.serverId, required this.serverName}); } final class _LibraryItemRow extends _LibraryNavRow { final MediaLibrary library; final bool showServerName; const _LibraryItemRow({required super.section, required this.library, this.showServerName = false}); } /// SELECT activates the rail row, RIGHT hands off to the content area. KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelect, VoidCallback? onNavigateRight}) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey.isSelectKey) { onSelect(); return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { onNavigateRight(); return KeyEventResult.handled; } return KeyEventResult.ignored; } /// Reusable navigation rail item widget that handles focus, selection, and interaction. /// /// Renders the two M3 Expressive destination layouts and morphs between them: /// collapsed is an icon-only 56x32 pill indicator centered in the strip /// (announced via [collapsedLabel]); expanded is a full-width stadium row /// with leading icon and [label]. Items that never render collapsed /// (library rows) leave [collapsedLabel] null and always use the expanded /// layout. class NavigationRailItem extends StatelessWidget { /// M3E collapsed-rail active indicator geometry. static const double collapsedIndicatorWidth = 56; static const double collapsedIndicatorHeight = 32; /// Fixed collapsed item height so the collapse morph interpolates between /// known extents (32px pill + breathing room). static const double collapsedItemHeight = 40; final IconData icon; final IconData? selectedIcon; /// Custom leading widget rendered instead of the [icon] (e.g. the Now /// Playing item's equalizer). Should be at most [iconSize] tall/wide. final Widget? iconWidget; final Widget label; /// Base color inherited by single-style text labels. During the morph its /// alpha is changed at the leaf, avoiding an opacity layer for the row. final Color? labelColor; /// Builds labels with more than one text color at [opacity]. Non-text /// labels without a builder use one opacity layer only while morphing. final Widget Function(double opacity)? labelBuilder; /// Semantic label for the icon-only collapsed pill. Null means the item /// has no collapsed representation and always lays out expanded. final String? collapsedLabel; /// Widget rendered after the [label] (e.g. a section header's chevron). /// Expanded layout only. final Widget? trailing; /// Builds a trailing leaf at [opacity] when it should fade with the label. final Widget Function(double opacity)? trailingBuilder; final bool isSelected; final bool isCollapsed; final VoidCallback onTap; final FocusNode focusNode; final bool autofocus; final double iconSize; /// Horizontal content padding of the expanded row. final double horizontalPadding; /// Fixed content width of the expanded layout, so the collapse morph and /// the rail width animation stay in lockstep. final double expandedContentWidth; /// Expanded row height: fixed for collapsible items, a minimum for /// expanded-only items (whose labels may add a second line). final double expandedHeight; final bool suppressSelectedBackground; /// Background tint while keyboard-focused, and its stronger variant used /// when the item also shows its selected background. final double focusAlpha; final double selectedFocusAlpha; /// Called when RIGHT arrow is pressed to navigate to content area. final VoidCallback? onNavigateRight; const NavigationRailItem({ super.key, required this.icon, this.selectedIcon, this.iconWidget, required this.label, this.collapsedLabel, this.labelColor, this.labelBuilder, this.trailing, this.trailingBuilder, required this.isSelected, this.isCollapsed = false, required this.onTap, required this.focusNode, this.autofocus = false, this.iconSize = 22, this.horizontalPadding = 17, this.expandedContentWidth = SideNavigationRailState.expandedWidth - 24, this.expandedHeight = 48, this.suppressSelectedBackground = false, this.focusAlpha = 0.12, this.selectedFocusAlpha = 0.15, this.onNavigateRight, }); Color? _indicatorColorForLayout(MonoTokens t, {required bool focused, required bool collapsedLayout}) { // The collapsed TV rail is a transparent overlay strip; a persistent // active pill over artwork is noise there, so it shows focus only. final showSelected = isSelected && !suppressSelectedBackground && !(collapsedLayout && PlatformDetector.isTV()); if (focused) return t.text.withValues(alpha: showSelected ? selectedFocusAlpha : focusAlpha); if (showSelected) return t.text.withValues(alpha: 0.1); return null; } Color? _indicatorColor(MonoTokens t, {required bool focused, required double expansion}) { return Color.lerp( _indicatorColorForLayout(t, focused: focused, collapsedLayout: true), _indicatorColorForLayout(t, focused: focused, collapsedLayout: false), expansion, ); } Widget _leadingIcon(MonoTokens t) => iconWidget ?? AppIcon( isSelected && selectedIcon != null ? selectedIcon! : icon, fill: 1, size: iconSize, color: isSelected ? t.text : t.textMuted, ); static double _collapsedItemWidth(BuildContext context) => SideNavigationRailState.collapsedWidthForContext(context) - 2 * SideNavigationRailState.horizontalPaddingForContext(context, isCollapsed: true); /// M3E collapsed destination: an icon-only pill indicator. Widget _buildCollapsedLayout(BuildContext context, MonoTokens t, {required bool focused}) { final width = _collapsedItemWidth(context); return SizedBox( width: width, height: collapsedItemHeight, child: Center( child: Semantics( label: collapsedLabel, child: Container( width: width < collapsedIndicatorWidth ? width : collapsedIndicatorWidth, height: collapsedIndicatorHeight, alignment: .center, decoration: BoxDecoration( color: _indicatorColor(t, focused: focused, expansion: 0), borderRadius: BorderRadius.circular(MonoTokens.radiusFull), ), child: _leadingIcon(t), ), ), ), ); } /// M3E expanded destination: full-width stadium indicator behind the row. Widget _labelAtOpacity(BuildContext context, double opacity) { final builder = labelBuilder; if (builder != null) return builder(opacity); final textStyle = label is Text ? (label as Text).style : null; final inheritsTextColor = label is Text && (textStyle == null || (textStyle.inherit && textStyle.color == null && textStyle.foreground == null)); final color = inheritsTextColor ? labelColor ?? DefaultTextStyle.of(context).style.color : null; if (color != null) { return DefaultTextStyle.merge( style: TextStyle(color: color.withValues(alpha: color.a * opacity)), child: label, ); } if (opacity >= 1) return label; // The reconnect progress indicator is the only production caller of this // path. It cannot inherit text color, so retain one small opacity layer // rather than a layer for every destination row. return Opacity(opacity: opacity, child: label); } Widget? _trailingAtOpacity(double opacity) { final builder = trailingBuilder; return builder == null ? trailing : builder(opacity); } Widget _buildExpandedContent( BuildContext context, MonoTokens t, { required double labelOpacity, required bool showLeading, }) { return Row( children: [ if (showLeading) _leadingIcon(t) else SizedBox(width: iconSize, height: iconSize), const SizedBox(width: 11), Expanded(child: _labelAtOpacity(context, labelOpacity)), ?_trailingAtOpacity(labelOpacity), ], ); } Widget _buildExpandedLayout(BuildContext context, MonoTokens t, {required bool focused, required bool fixedHeight}) { return Container( constraints: fixedHeight ? null : BoxConstraints(minHeight: expandedHeight), alignment: .centerLeft, padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: fixedHeight ? 0 : 8), decoration: BoxDecoration( color: _indicatorColor(t, focused: focused, expansion: 1), borderRadius: BorderRadius.circular(MonoTokens.radiusFull), ), child: _buildExpandedContent(context, t, labelOpacity: 1, showLeading: true), ); } /// At the endpoints only the active layout exists in the tree. Mid-morph a /// single icon moves with the pill geometry while label and indicator color /// alpha provide the crossfade without subtree opacity layers. Widget _buildMorphingLayouts(BuildContext context, MonoTokens t, {required bool focused, required double expansion}) { if (expansion <= 0) return _buildCollapsedLayout(context, t, focused: focused); if (expansion >= 1) { return SizedBox( width: expandedContentWidth, height: expandedHeight, child: _buildExpandedLayout(context, t, focused: focused, fixedHeight: true), ); } final collapsedWidth = _collapsedItemWidth(context); const collapsedHeight = collapsedItemHeight; final collapsedPillWidth = collapsedWidth < collapsedIndicatorWidth ? collapsedWidth : collapsedIndicatorWidth; final indicatorLeft = lerpDouble((collapsedWidth - collapsedPillWidth) / 2, 0, expansion); final indicatorTop = lerpDouble((collapsedHeight - collapsedIndicatorHeight) / 2, 0, expansion); final iconLeft = lerpDouble((collapsedWidth - iconSize) / 2, horizontalPadding, expansion); final iconTop = lerpDouble((collapsedHeight - iconSize) / 2, (expandedHeight - iconSize) / 2, expansion); return SizedBox( width: lerpDouble(collapsedWidth, expandedContentWidth, expansion), height: lerpDouble(collapsedHeight, expandedHeight, expansion), child: Stack( children: [ Positioned( left: indicatorLeft, top: indicatorTop, child: Container( width: lerpDouble(collapsedPillWidth, expandedContentWidth, expansion), height: lerpDouble(collapsedIndicatorHeight, expandedHeight, expansion), decoration: BoxDecoration( color: _indicatorColor(t, focused: focused, expansion: expansion), borderRadius: BorderRadius.circular(MonoTokens.radiusFull), ), ), ), Positioned( left: 0, top: 0, child: SizedBox( width: expandedContentWidth, height: expandedHeight, child: Padding( padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: _buildExpandedContent(context, t, labelOpacity: expansion, showLeading: false), ), ), ), Positioned( left: iconLeft, top: iconTop, child: SizedBox( width: iconSize, height: iconSize, child: Center(child: _leadingIcon(t)), ), ), ], ), ); } @override Widget build(BuildContext context) { final t = tokens(context); return ListenableBuilder( listenable: focusNode, builder: (context, _) { final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context); final content = collapsedLabel == null ? SizedBox( width: expandedContentWidth, child: _buildExpandedLayout(context, t, focused: focused, fixedHeight: false), ) : TweenAnimationBuilder( tween: Tween(end: isCollapsed ? 0.0 : 1.0), duration: SideNavigationRailState.expandDuration, curve: SideNavigationRailState.expandCurve, builder: (context, expansion, _) => _buildMorphingLayouts(context, t, focused: focused, expansion: expansion), ); return Focus( focusNode: focusNode, autofocus: autofocus, onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onTap, onNavigateRight: onNavigateRight), child: Material( color: Colors.transparent, child: InkWell( canRequestFocus: false, onTap: onTap, borderRadius: BorderRadius.circular(MonoTokens.radiusFull), child: UnconstrainedBox( alignment: .centerLeft, constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, child: content, ), ), ), ); }, ); } } /// Side navigation rail for Desktop and Android TV platforms class SideNavigationRail extends StatefulWidget { final NavigationTabId selectedTab; final String? selectedLibraryKey; final bool isOfflineMode; final bool isSidebarFocused; final bool alwaysExpanded; final bool isReconnecting; final ValueChanged onDestinationSelected; final ValueChanged onLibrarySelected; /// Called when RIGHT arrow is pressed to navigate to content without selecting. final VoidCallback? onNavigateToContent; /// Called when the user taps the reconnect button in offline mode. final VoidCallback? onReconnect; /// Called when hover/touch expansion changes, so the shell can scrim the /// content the modal rail overlays. final ValueChanged? onInteractionExpandedChanged; const SideNavigationRail({ super.key, required this.selectedTab, this.selectedLibraryKey, this.isOfflineMode = false, this.isSidebarFocused = false, this.alwaysExpanded = false, this.isReconnecting = false, required this.onDestinationSelected, required this.onLibrarySelected, this.onNavigateToContent, this.onInteractionExpandedChanged, this.onReconnect, }); @override State createState() => SideNavigationRailState(); } class SideNavigationRailState extends State with MountedSetStateMixin { /// Libraries section expansion, held in settings rather than widget state so /// it survives relaunch, remount and layout switches (#1896). The rail's /// [ListenableBuilder] below listens to the pref, so the toggle just writes. bool get _librariesExpanded => SettingsService.instance.read(SettingsService.librariesSectionExpanded); bool _isHovered = false; bool _isTouchExpanded = false; bool _lastReportedInteractionExpanded = false; Timer? _collapseTimer; static const double collapsedWidth = 80.0; static const double tvCollapsedWidth = 48.0; static const double expandedWidth = 220.0; static const double _horizontalPadding = 12.0; static const double _collapsedHorizontalPadding = 4.0; static const double _itemGap = 4.0; static const Duration _collapseDelay = Duration(milliseconds: 150); /// Trailing corner radius of the floating (hover/touch) overlay panel; the /// docked open rail keeps the regular radiusLg card rounding. static const double overlayCornerRadius = 32.0; /// Collapse/expand morph timing, shared by the rail width, every item's /// layout morph, and the shell's content translate + bleed counter /// animations (MainScreen and SideNavigationBleedBuilder) so they all /// track tick for tick. /// Reduced-tier devices resolve this to zero; keep every consumer on this /// getter so the shell and rail continue to move in lockstep. static Duration get expandDuration => DevicePerformance.reducedDuration(const Duration(milliseconds: 250)); static const Curve expandCurve = Curves.easeInOutCubicEmphasized; static double collapsedWidthForContext(BuildContext _) => PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth; /// Edge padding around the item column; collapsed rails center their items /// (72px in the 80px desktop strip, 40px in the 48px TV strip). static double horizontalPaddingForContext(BuildContext _, {required bool isCollapsed}) { return isCollapsed ? _collapsedHorizontalPadding : _horizontalPadding; } static const _kHome = 'home'; static const _kExplore = 'explore'; static const _kNowPlaying = 'nowPlaying'; static const _kLibraries = 'libraries'; static const _kSearch = 'search'; static const _kDownloads = 'downloads'; static const _kSettings = 'settings'; static const _kReconnect = 'reconnect'; static const _kFullscreen = 'fullscreen'; static const _kHiddenLibraries = 'hiddenLibraries'; static const _kServerHeaderPrefix = 'serverHeader'; static const _kLibraryItemPrefix = 'library'; bool _hiddenLibrariesExpanded = false; final Set _collapsedServerGroupKeys = {}; // Unified focus state tracker for all nav items (main + libraries) late final FocusMemoryTracker _focusTracker; /// Whether the sidebar should be expanded (always, hover, or focus) bool get _shouldExpand => widget.alwaysExpanded || _isHovered || _isTouchExpanded || widget.isSidebarFocused; bool get _interactionExpanded => _isHovered || _isTouchExpanded; /// True while the rail floats over the content as an M3E modal panel. /// /// Hover/touch expansion leaves the shell at its collapsed content offset, /// so a floating panel covers content and owns its own opaque surface and /// edge shadow. Every docked shape — collapsed, always-open, or D-pad /// focus-expanded — displaces content instead and stays transparent on TV. bool get _isFloatingPanel => _interactionExpanded && !widget.alwaysExpanded; bool get _showDownloads => !PlatformDetector.isAppleTV(); /// macOS has the system green button; mobile/TV have no OS fullscreen toggle. bool get _showFullscreenToggle => Platform.isWindows || Platform.isLinux; @override void initState() { super.initState(); _focusTracker = FocusMemoryTracker(debugLabelPrefix: 'nav'); } @override void dispose() { _collapseTimer?.cancel(); _focusTracker.dispose(); super.dispose(); } @override void didUpdateWidget(covariant SideNavigationRail oldWidget) { super.didUpdateWidget(oldWidget); // Auto-collapse after navigation (selection changed) if (oldWidget.selectedTab != widget.selectedTab || oldWidget.selectedLibraryKey != widget.selectedLibraryKey) { final wasInteractionExpanded = _interactionExpanded; _isTouchExpanded = false; if (wasInteractionExpanded != _interactionExpanded) { _scheduleInteractionExpandedNotification(); } } } /// didUpdateWidget runs during build, where notifying the parent would /// setState mid-build; defer that report to the next frame. void _scheduleInteractionExpandedNotification() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _notifyInteractionExpandedIfNeeded(); }); } void _notifyInteractionExpandedIfNeeded() { final expanded = _interactionExpanded; if (_lastReportedInteractionExpanded == expanded) return; _lastReportedInteractionExpanded = expanded; widget.onInteractionExpandedChanged?.call(expanded); } void _onHoverEnter() { _collapseTimer?.cancel(); if (_isHovered && !_isTouchExpanded) return; setState(() { _isTouchExpanded = false; // Mouse takes over _isHovered = true; }); _notifyInteractionExpandedIfNeeded(); } void _onHoverExit() { _collapseTimer?.cancel(); _collapseTimer = Timer(_collapseDelay, () { if (mounted && _isHovered) { setState(() => _isHovered = false); _notifyInteractionExpandedIfNeeded(); } }); } void _expandForTouch() { if (_isTouchExpanded) return; setState(() => _isTouchExpanded = true); _notifyInteractionExpandedIfNeeded(); } /// The key of the last focused sidebar item (for pre-capture before focus shifts). String? get lastFocusedKey => _focusTracker.lastFocusedKey; /// Focus the last focused nav item, or Home as fallback. /// If [targetKey] is provided, try it first (used when the caller captured /// the intended target before a focus-scope switch overwrote it). void focusActiveItem({String? targetKey}) { final node = _resolveFocusNode(targetKey) ?? _mountedFocusNodeFor(_kHome); if (node == null) return; _requestFocusAndReveal(node); } /// Focus the Home nav item (Back returning to the home tab). void focusHomeItem() { final node = _mountedFocusNodeFor(_kHome); if (node == null) return; _requestFocusAndReveal(node); } /// Resolve the best mounted focus node in priority order: /// 1. Explicit [targetKey] (captured before scope switch) /// 2. Last focused key still in the tracker /// 3. Currently selected navigation item (tab / library) /// 4. Home fallback FocusNode? _resolveFocusNode(String? targetKey) { return _mountedFocusNodeFor(targetKey) ?? _mountedFocusNodeFor(_focusTracker.lastFocusedKey) ?? _mountedFocusNodeFor(_resolveSelectedFocusKey()); } FocusNode? _mountedFocusNodeFor(String? key) { if (key == null) return null; final node = _focusTracker.nodeFor(key); return node?.context == null ? null : node; } /// Derive a focus key from the current selection state (tab + library). /// Returns null if no meaningful selected item exists. String? _resolveSelectedFocusKey() { switch (widget.selectedTab) { case NavigationTabId.discover: return _kHome; case NavigationTabId.explore: return _kExplore; case NavigationTabId.libraries: final libKey = widget.selectedLibraryKey; if (libKey != null && _librariesExpanded) { final visibleKey = '$_kLibraryItemPrefix:${_LibraryNavSection.visible.name}:$libKey'; if (_mountedFocusNodeFor(visibleKey) != null) return visibleKey; if (_hiddenLibrariesExpanded) { final hiddenKey = '$_kLibraryItemPrefix:${_LibraryNavSection.hidden.name}:$libKey'; if (_mountedFocusNodeFor(hiddenKey) != null) return hiddenKey; } } return _kLibraries; case NavigationTabId.search: return _kSearch; case NavigationTabId.downloads: return _showDownloads ? _kDownloads : null; case NavigationTabId.settings: return _kSettings; case NavigationTabId.liveTv: return 'liveTv'; } } /// Request focus on [node] and scroll it into view after the next frame. void _requestFocusAndReveal(FocusNode node) { node.requestFocus(); scrollContextToCenter(node.context); } String _serverHeaderFocusKey(_LibraryNavSection section, ServerId serverId) => '$_kServerHeaderPrefix:${section.name}:$serverId'; String _libraryItemFocusKey(_LibraryNavSection section, MediaLibrary library) => '$_kLibraryItemPrefix:${section.name}:${library.globalKey}'; String _serverGroupStateKey(_LibraryNavSection section, ServerId serverId) => '${section.name}:$serverId'; String _focusKeyForLibraryRow(_LibraryNavRow row) => switch (row) { _LibraryServerHeaderRow(:final section, :final serverId) => _serverHeaderFocusKey(section, ServerId(serverId)), _LibraryItemRow(:final section, :final library) => _libraryItemFocusKey(section, library), }; Iterable _focusKeysForLibraryRows(List<_LibraryNavRow> rows) => rows.map(_focusKeyForLibraryRow); /// Build the set of valid focus keys (main nav + currently rendered library rows). Set _buildValidFocusKeys({ required List<_LibraryNavRow> visibleRows, required List<_LibraryNavRow> hiddenRows, required bool hasHiddenLibraries, required bool hasLiveTv, required bool hasNowPlaying, required bool hasExplore, }) { return { _kHome, if (hasNowPlaying) _kNowPlaying, _kLibraries, if (hasExplore) _kExplore, _kSearch, if (_showDownloads) _kDownloads, _kSettings, _kReconnect, if (hasHiddenLibraries) _kHiddenLibraries, if (_showFullscreenToggle) _kFullscreen, if (hasLiveTv) 'liveTv', ..._focusKeysForLibraryRows(visibleRows), if (_hiddenLibrariesExpanded) ..._focusKeysForLibraryRows(hiddenRows), }; } /// Build rendered rows inside one library section. This is the single source /// of truth for both widget rendering and D-pad focus ordering. List<_LibraryNavRow> _buildLibraryRows( List libs, { required _LibraryNavSection section, required bool showServerHeaders, }) { if (!showServerHeaders) { final nonUniqueNames = _getNonUniqueLibraryNames(libs); return libs.map((lib) { return _LibraryItemRow( section: section, library: lib, showServerName: nonUniqueNames.contains(lib.title) && lib.serverName != null, ); }).toList(); } final grouped = groupLibrariesByFirstAppearance(libs); final result = <_LibraryNavRow>[]; for (final serverKey in grouped.serverOrder) { final bucket = grouped.byServer[serverKey]!; if (serverKey.isNotEmpty) { result.add( _LibraryServerHeaderRow( section: section, serverId: serverKey, serverName: bucket.first.serverName ?? serverKey, ), ); } if (serverKey.isEmpty || !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, ServerId(serverKey)))) { for (final lib in bucket) { result.add(_LibraryItemRow(section: section, library: lib)); } } } return result; } Set _buildServerGroupStateKeys( List visibleLibraries, List hiddenLibraries, { required bool showServerHeaders, }) { if (!showServerHeaders) return {}; return { for (final lib in visibleLibraries) if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.visible, ServerId(lib.serverId!)), for (final lib in hiddenLibraries) if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.hidden, ServerId(lib.serverId!)), }; } /// Ordered list of focusable keys matching visual top-to-bottom order. List _buildFocusOrder( List<_LibraryNavRow> visibleRows, List<_LibraryNavRow> hiddenRows, { required bool hasHiddenLibraries, required bool hasLiveTv, required bool hasNowPlaying, required bool hasExplore, required bool isCollapsed, }) { return [ if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect, if (!widget.isOfflineMode) ...[ _kHome, if (hasNowPlaying) _kNowPlaying, _kLibraries, // Library rows render inside ExcludeFocus(excluding: !_librariesExpanded // || isCollapsed); keep the D-pad order in lockstep with that render // condition so a collapsed rail never targets a focus-excluded row. if (_librariesExpanded && !isCollapsed) ...[ ..._focusKeysForLibraryRows(visibleRows), if (hasHiddenLibraries) ...[ _kHiddenLibraries, if (_hiddenLibrariesExpanded) ..._focusKeysForLibraryRows(hiddenRows), ], ], if (hasLiveTv) 'liveTv', if (hasExplore) _kExplore, _kSearch, ], if (_showDownloads) _kDownloads, _kSettings, if (_showFullscreenToggle) _kFullscreen, ]; } void _debugAssertUniqueFocusOrder(List focusOrder) { assert(() { final seen = {}; for (final key in focusOrder) { if (!seen.add(key)) { throw FlutterError('SideNavigationRail focus order contains duplicate key: $key'); } } return true; }()); } /// Handle D-pad UP/DOWN by explicitly moving focus to the next/previous item. KeyEventResult _handleVerticalNavigation(FocusNode _, KeyEvent event, List focusOrder) { if (event is! KeyDownEvent) return KeyEventResult.ignored; final isDown = event.logicalKey == LogicalKeyboardKey.arrowDown; final isUp = event.logicalKey == LogicalKeyboardKey.arrowUp; if (!isDown && !isUp) return KeyEventResult.ignored; final currentKey = _focusTracker.lastFocusedKey; if (currentKey == null) return KeyEventResult.ignored; final currentIndex = focusOrder.indexOf(currentKey); if (currentIndex == -1) return KeyEventResult.ignored; // Scan onward past keys whose node is unmounted or focus-excluded (a row // can outlive its focusability, e.g. under an ExcludeFocus ancestor). // Returning handled without moving focus would swallow the key and also // suppress the framework's own directional traversal, so yield ignored // when no viable candidate remains in the requested direction. final step = isDown ? 1 : -1; for (var index = currentIndex + step; index >= 0 && index < focusOrder.length; index += step) { final node = _focusTracker.nodeFor(focusOrder[index]); if (node == null || node.context == null || !node.canRequestFocus) continue; _requestFocusAndReveal(node); return KeyEventResult.handled; } return KeyEventResult.ignored; } /// Collapse the sidebar (resets touch-expand state). void collapse() { if (_isTouchExpanded) { setState(() => _isTouchExpanded = false); _notifyInteractionExpandedIfNeeded(); } } /// Reload libraries (called when servers change or profile switches) void reloadLibraries() { final librariesProvider = context.read(); librariesProvider.refresh(); } /// Calculate top padding for macOS traffic lights double _getTopPadding(BuildContext context) { double basePadding = MediaQuery.paddingOf(context).top + 16; // On macOS, add extra padding for traffic lights (when not fullscreen) if (Platform.isMacOS) { final isFullscreen = FullscreenStateManager().isFullscreen; if (!isFullscreen) { // Traffic lights area is approximately 52 pixels high basePadding = basePadding < 52 ? 52 : basePadding; } } return basePadding; } @override Widget build(BuildContext context) { final t = tokens(context); final librariesProvider = context.watch(); final hiddenLibrariesProvider = context.watch(); final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; final allLibraries = librariesProvider.libraries; final visibleLibraries = []; final hiddenLibraries = []; final serverIds = {}; for (final lib in allLibraries) { if (lib.serverId != null) serverIds.add(lib.serverId!); if (hiddenKeys.contains(lib.globalKey)) { hiddenLibraries.add(lib); } else { visibleLibraries.add(lib); } } final isCollapsed = !_shouldExpand; final effectiveCollapsedWidth = collapsedWidthForContext(context); final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed); final hasLiveTv = context.watch().hasLiveTv; // Nullable watch: rail tests (and any host without the profile session // scope) simply never show the Explore item. final hasExploreSource = context.watch()?.hasAnySource ?? false; // Nullable watch: rail tests (and any host without the profile session // scope) simply never show the Now Playing item. TV-only — it is the // way back into the now-playing screen there; desktop already has the // mini-player for that. final musicService = context.watch(); final nowPlayingTrack = widget.isOfflineMode || !PlatformDetector.isTV() ? null : musicService?.currentTrack; // Listen to fullscreen + the groupLibrariesByServer / showExploreTab // settings so the rail rebuilds when they are toggled in Appearance, and to // librariesSectionExpanded so the Libraries header toggle repaints. return ListenableBuilder( listenable: Listenable.merge([ FullscreenStateManager(), SettingsService.instance.listenable(SettingsService.groupLibrariesByServer), SettingsService.instance.listenable(SettingsService.showExploreTab), SettingsService.instance.listenable(SettingsService.librariesSectionExpanded), ]), builder: (context, _) { final hasExplore = hasExploreSource && SettingsService.instance.read(SettingsService.showExploreTab); // Server grouping: only when multi-server AND the user-facing toggle is on. final groupByServerSetting = SettingsService.instance.read(SettingsService.groupLibrariesByServer); final showServerHeaders = serverIds.length > 1 && groupByServerSetting; _collapsedServerGroupKeys.retainAll( _buildServerGroupStateKeys(visibleLibraries, hiddenLibraries, showServerHeaders: showServerHeaders), ); final visibleRows = _buildLibraryRows( visibleLibraries, section: _LibraryNavSection.visible, showServerHeaders: showServerHeaders, ); final hiddenRows = _buildLibraryRows( hiddenLibraries, section: _LibraryNavSection.hidden, showServerHeaders: showServerHeaders, ); _focusTracker.pruneExcept( _buildValidFocusKeys( visibleRows: visibleRows, hiddenRows: hiddenRows, hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasLiveTv: hasLiveTv, hasNowPlaying: nowPlayingTrack != null, hasExplore: hasExplore, ), ); final focusOrder = _buildFocusOrder( visibleRows, hiddenRows, hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasLiveTv: hasLiveTv, hasNowPlaying: nowPlayingTrack != null, hasExplore: hasExplore, isCollapsed: isCollapsed, ); _debugAssertUniqueFocusOrder(focusOrder); return TapRegion( onTapOutside: (_) { if (_isTouchExpanded) { setState(() => _isTouchExpanded = false); _notifyInteractionExpandedIfNeeded(); } }, child: MouseRegion( cursor: isCollapsed ? SystemMouseCursors.click : MouseCursor.defer, onEnter: (_) => _onHoverEnter(), onExit: (_) => _onHoverExit(), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: isCollapsed ? _expandForTouch : null, child: AnimatedContainer( duration: expandDuration, curve: expandCurve, width: isCollapsed ? effectiveCollapsedWidth : expandedWidth, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(), child: Stack( children: [ // Rail surface. A docked rail is transparent on TV so the // full-bleed backdrop continues behind it; content is // pushed clear of it, never covered. A floating panel // covers content, so it paints its surface on every // platform (#2079). Positioned.fill( child: AnimatedOpacity( opacity: PlatformDetector.isTV() && !_isFloatingPanel ? 0.0 : 1.0, // Shares the width morph's duration/curve: a shorter // fade strands a fully grown, fully transparent panel // over the content part-way through the collapse. duration: expandDuration, curve: expandCurve, child: AnimatedContainer( duration: expandDuration, curve: expandCurve, decoration: BoxDecoration( color: t.surface, // Open rails read as an M3E panel: rounded // trailing corners, extra-rounded plus an edge // shadow while floating over content (modal) // instead of pushing it. borderRadius: isCollapsed ? BorderRadius.zero : BorderRadius.horizontal( right: Radius.circular(_isFloatingPanel ? overlayCornerRadius : t.radiusLg), ), boxShadow: _isFloatingPanel ? [BoxShadow(color: Colors.black.withValues(alpha: 0.35), blurRadius: 24)] : const [], ), ), ), ), IgnorePointer( ignoring: isCollapsed, child: Focus( canRequestFocus: false, skipTraversal: true, onKeyEvent: (node, event) => _handleVerticalNavigation(node, event, focusOrder), child: Column( children: [ SizedBox(height: _getTopPadding(context)), Expanded( child: AnimatedPadding( padding: .symmetric(horizontal: horizontalPadding), duration: expandDuration, curve: expandCurve, child: ListView( padding: EdgeInsets.zero, clipBehavior: Clip.hardEdge, children: [ if (widget.isOfflineMode && widget.onReconnect != null) ...[ _buildReconnectItem(isCollapsed: isCollapsed), const SizedBox(height: _itemGap), ], if (!widget.isOfflineMode) ...[ _buildNavItem( icon: Symbols.home_rounded, selectedIcon: Symbols.home_rounded, label: Translations.of(context).common.home, isSelected: widget.selectedTab == NavigationTabId.discover, onTap: () => widget.onDestinationSelected(NavigationTabId.discover), focusNode: _focusTracker.get(_kHome), isCollapsed: isCollapsed, ), const SizedBox(height: _itemGap), // Now Playing — only while a music session is live. if (nowPlayingTrack != null && musicService != null) ...[ _buildNowPlayingItem(nowPlayingTrack, musicService, isCollapsed: isCollapsed), const SizedBox(height: _itemGap), ], _buildLibrariesSection( visibleRows, hiddenRows, hiddenLibraries.length, t, isCollapsed: isCollapsed, ), const SizedBox(height: _itemGap), if (context.watch().hasLiveTv) ...[ _buildNavItem( icon: Symbols.live_tv_rounded, selectedIcon: Symbols.live_tv_rounded, label: Translations.of(context).navigation.liveTv, isSelected: widget.selectedTab == NavigationTabId.liveTv, onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv), focusNode: _focusTracker.get('liveTv'), isCollapsed: isCollapsed, ), const SizedBox(height: _itemGap), ], if (hasExplore) ...[ _buildNavItem( icon: Symbols.explore_rounded, selectedIcon: Symbols.explore_rounded, label: Translations.of(context).navigation.explore, isSelected: widget.selectedTab == NavigationTabId.explore, onTap: () => widget.onDestinationSelected(NavigationTabId.explore), focusNode: _focusTracker.get(_kExplore), isCollapsed: isCollapsed, ), const SizedBox(height: _itemGap), ], _buildNavItem( icon: Symbols.search_rounded, selectedIcon: Symbols.search_rounded, label: Translations.of(context).common.search, isSelected: widget.selectedTab == NavigationTabId.search, onTap: () => widget.onDestinationSelected(NavigationTabId.search), focusNode: _focusTracker.get(_kSearch), isCollapsed: isCollapsed, ), const SizedBox(height: _itemGap), ], // Downloads (hidden on Apple TV — no user // file storage) if (_showDownloads) ...[ _buildNavItem( icon: Symbols.download_rounded, selectedIcon: Symbols.download_rounded, label: Translations.of(context).navigation.downloads, isSelected: widget.selectedTab == NavigationTabId.downloads, onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), focusNode: _focusTracker.get(_kDownloads), isCollapsed: isCollapsed, ), const SizedBox(height: _itemGap), ], _buildNavItem( icon: Symbols.settings_rounded, selectedIcon: Symbols.settings_rounded, label: Translations.of(context).common.settings, isSelected: widget.selectedTab == NavigationTabId.settings, onTap: () => widget.onDestinationSelected(NavigationTabId.settings), focusNode: _focusTracker.get(_kSettings), isCollapsed: isCollapsed, ), ], ), ), ), if (_showFullscreenToggle) AnimatedPadding( padding: .fromLTRB(horizontalPadding, 0, horizontalPadding, 12), duration: expandDuration, curve: expandCurve, child: _buildFullscreenItem(isCollapsed: isCollapsed), ), ], ), ), ), ], ), ), ), ), ); }, ); } Widget _buildNavItem({ required IconData icon, required IconData selectedIcon, required String label, required bool isSelected, required VoidCallback onTap, required FocusNode focusNode, required bool isCollapsed, bool autofocus = false, }) { final t = tokens(context); return NavigationRailItem( icon: icon, selectedIcon: selectedIcon, label: Text( label, style: TextStyle(fontSize: 14, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400), overflow: .ellipsis, maxLines: 1, ), labelColor: isSelected ? t.text : t.textMuted, collapsedLabel: label, isSelected: isSelected, isCollapsed: isCollapsed, onTap: onTap, focusNode: focusNode, autofocus: autofocus, suppressSelectedBackground: widget.isSidebarFocused, onNavigateRight: widget.onNavigateToContent, ); } /// "Now Playing" rail item — the shared equalizer as its icon (animating /// while audio plays), the current track's title as its label. SELECT/tap /// opens the now-playing screen. Widget _buildNowPlayingItem(MediaItem track, MusicPlaybackService musicService, {required bool isCollapsed}) { final t = tokens(context); final nowPlayingLabel = Translations.of(context).music.nowPlaying; final trackTitle = track.title ?? ''; Widget buildLabel(double opacity) { return Column( crossAxisAlignment: .start, mainAxisSize: .min, children: [ Text( nowPlayingLabel, style: TextStyle( fontSize: 14, fontWeight: .w600, color: t.text.withValues(alpha: t.text.a * opacity), ), overflow: .ellipsis, maxLines: 1, ), Text( trackTitle, style: TextStyle(fontSize: 11, color: t.textMuted.withValues(alpha: t.textMuted.a * opacity)), overflow: .ellipsis, maxLines: 1, ), ], ); } return NavigationRailItem( icon: Symbols.music_note_rounded, iconWidget: SizedBox( width: 22, child: Center( child: EqualizerIcon(animate: musicService.isPlaying, color: t.text), ), ), label: buildLabel(1), labelBuilder: buildLabel, collapsedLabel: Translations.of(context).music.nowPlaying, isSelected: false, isCollapsed: isCollapsed, onTap: () => unawaited(openNowPlaying(context)), focusNode: _focusTracker.get(_kNowPlaying), onNavigateRight: widget.onNavigateToContent, ); } Widget _buildReconnectItem({required bool isCollapsed}) { final t = tokens(context); return NavigationRailItem( icon: widget.isReconnecting ? Symbols.sync_rounded : Symbols.wifi_rounded, label: widget.isReconnecting ? SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: t.text)) : Text( Translations.of(context).common.reconnect, style: const TextStyle(fontSize: 14, fontWeight: .w400), overflow: .ellipsis, maxLines: 1, ), labelColor: widget.isReconnecting ? null : t.textMuted, collapsedLabel: Translations.of(context).common.reconnect, isSelected: false, isCollapsed: isCollapsed, // ignore: no-empty-block - no-op tap handler while reconnecting onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(), focusNode: _focusTracker.get(_kReconnect), onNavigateRight: widget.onNavigateToContent, ); } Widget _buildFullscreenItem({required bool isCollapsed}) { final t = tokens(context); final isFullscreen = FullscreenStateManager().isFullscreen; final label = isFullscreen ? Translations.of(context).common.exitFullscreen : Translations.of(context).common.fullscreen; return NavigationRailItem( icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, label: Text( label, style: const TextStyle(fontSize: 14, fontWeight: .w400), overflow: .ellipsis, maxLines: 1, ), labelColor: t.textMuted, collapsedLabel: label, isSelected: false, isCollapsed: isCollapsed, onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()), focusNode: _focusTracker.get(_kFullscreen), onNavigateRight: widget.onNavigateToContent, ); } Widget _buildLibrariesSection( List<_LibraryNavRow> visibleRows, List<_LibraryNavRow> hiddenRows, int hiddenLibraryCount, dynamic t, { bool isCollapsed = false, }) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries; final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0; final headerLabelColor = isLibrariesTabSelected ? t.text : t.textMuted; Widget buildChevron(double opacity) { return AppIcon( _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, fill: 1, size: 18, color: t.textMuted.withValues(alpha: t.textMuted.a * opacity), ); } return Column( crossAxisAlignment: .start, children: [ // A destination-sized row like Home/Search — same height, icon and // label metrics — with the chevron as the expand affordance. The // smaller header type is reserved for the nested sub-headers. NavigationRailItem( icon: Symbols.video_library_rounded, label: Text( Translations.of(context).navigation.libraries, style: TextStyle(fontSize: 14, fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400), overflow: .ellipsis, maxLines: 1, ), labelColor: headerLabelColor, collapsedLabel: Translations.of(context).navigation.libraries, trailing: buildChevron(1), trailingBuilder: buildChevron, isSelected: isLibrariesTabSelected, isCollapsed: isCollapsed, onTap: () => unawaited(SettingsService.instance.write(SettingsService.librariesSectionExpanded, !_librariesExpanded)), focusNode: _focusTracker.get(_kLibraries), // A selected library owns the highlight; the header only shows it // for the bare Libraries tab. suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null, focusAlpha: 0.08, selectedFocusAlpha: 0.1, onNavigateRight: widget.onNavigateToContent, ), TweenAnimationBuilder( tween: Tween(end: (_librariesExpanded && !isCollapsed) ? 1.0 : 0.0), duration: expandDuration, curve: expandCurve, builder: (context, value, child) { return ClipRect( child: Align(alignment: .topCenter, heightFactor: value, child: child), ); }, child: ExcludeFocus( excluding: !_librariesExpanded || isCollapsed, child: Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ const SizedBox(height: 4), if (isLoading) Padding( padding: const EdgeInsets.all(16), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: t.textMuted), ), ), ) else if (allEmpty) Padding( padding: const EdgeInsets.all(16), child: Text( Translations.of(context).libraries.noLibrariesFound, style: TextStyle(fontSize: 12, color: t.textMuted), ), ) else ...[ if (visibleRows.isNotEmpty) _buildLibraryGroupedColumn(visibleRows, t), if (hiddenLibraryCount > 0) ...[ _buildHiddenLibrariesHeader(hiddenLibraryCount, t), if (_hiddenLibrariesExpanded) _buildLibraryGroupedColumn(hiddenRows, t), ], ], ], ), ), ), ], ); } /// Get set of library names that appear more than once (not globally unique) Set _getNonUniqueLibraryNames(List libraries) { final nameCounts = {}; for (final lib in libraries) { nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1; } return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } Widget _buildLibraryGroupedColumn(List<_LibraryNavRow> rows, dynamic t) { return Column( crossAxisAlignment: .start, children: rows.map((row) { return switch (row) { _LibraryServerHeaderRow(:final section, :final serverId, :final serverName) => _buildServerHeader( section, ServerId(serverId), serverName, t, ), _LibraryItemRow(:final section, :final library, :final showServerName) => _buildLibraryItem( section, library, t, showServerName: showServerName, ), }; }).toList(), ); } Widget _buildServerHeader(_LibraryNavSection section, ServerId serverId, String serverName, dynamic t) { // Resolve backend per server so the badge matches the brand. Falls back // to the generic `dns` icon if the client isn't registered yet (rare — // can happen during a profile switch before the manager rehydrates). final backend = context.read().serverManager.getClient(serverId)?.backend; return _buildCollapsibleHeader( focusKey: _serverHeaderFocusKey(section, serverId), icon: Symbols.dns_rounded, iconSize: 14, leading: backend == null ? null : BackendBadge(backend: backend, size: 14, color: t.textMuted), label: serverName, labelStyle: TextStyle(fontSize: 11, fontWeight: .w600, letterSpacing: 0.4, color: t.textMuted), verticalPadding: 6, isExpanded: !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, serverId)), onToggle: () => _toggleServerCollapse(section, serverId), t: t, ); } void _toggleServerCollapse(_LibraryNavSection section, ServerId serverId) { final groupKey = _serverGroupStateKey(section, serverId); setState(() { if (!_collapsedServerGroupKeys.add(groupKey)) { _collapsedServerGroupKeys.remove(groupKey); } }); } Widget _buildHiddenLibrariesHeader(int count, dynamic t) { return _buildCollapsibleHeader( focusKey: _kHiddenLibraries, icon: Symbols.visibility_off_rounded, iconSize: 16, label: Translations.of(context).libraries.hiddenLibrariesCount(count: count), labelStyle: TextStyle(fontSize: 12, fontWeight: .w500, color: t.textMuted), verticalPadding: 8, isExpanded: _hiddenLibrariesExpanded, onToggle: () => setState(() => _hiddenLibrariesExpanded = !_hiddenLibrariesExpanded), t: t, ); } Widget _buildCollapsibleHeader({ required String focusKey, required IconData icon, required double iconSize, Widget? leading, required String label, required TextStyle labelStyle, required double verticalPadding, required bool isExpanded, required VoidCallback onToggle, required dynamic t, }) { final focusNode = _focusTracker.get(focusKey); final radius = BorderRadius.circular(MonoTokens.radiusFull); // Sub-section header at full rail width; the smaller type alone carries // the hierarchy, matching M3E expanded-rail sections. return ListenableBuilder( listenable: focusNode, builder: (context, _) => Focus( focusNode: focusNode, onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent), child: Material( color: Colors.transparent, child: InkWell( canRequestFocus: false, onTap: onToggle, borderRadius: radius, child: Container( decoration: BoxDecoration( color: focusNode.hasFocus && InputModeTracker.isKeyboardMode(context) ? t.text.withValues(alpha: 0.08) : null, borderRadius: radius, ), clipBehavior: Clip.hardEdge, 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, ), ], ), ), ), ), ), ), ), ), ); } Widget _buildLibraryItem(_LibraryNavSection section, MediaLibrary library, dynamic t, {bool showServerName = false}) { final isSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == library.globalKey; final focusKey = _libraryItemFocusKey(section, library); final focusNode = _focusTracker.get(focusKey); return NavigationRailItem( icon: ContentTypeHelper.getLibraryIcon(library.kind.id), selectedIcon: ContentTypeHelper.getLibraryIcon(library.kind.id), label: Column( crossAxisAlignment: .start, mainAxisSize: .min, children: [ Text( library.title, style: TextStyle( fontSize: 13, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? t.text : t.textMuted, ), overflow: .ellipsis, ), if (showServerName) Text( library.serverName!, style: TextStyle(fontSize: 9, color: t.textMuted.withValues(alpha: 0.4)), overflow: .ellipsis, ), ], ), isSelected: isSelected, onTap: () => widget.onLibrarySelected(library.globalKey), focusNode: focusNode, iconSize: 18, expandedHeight: 40, suppressSelectedBackground: widget.isSidebarFocused, onNavigateRight: widget.onNavigateToContent, ); } }