import 'dart:async'; 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/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 class NavigationRailItem extends StatelessWidget { 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; /// Widget rendered after the [label] (e.g. a section header's chevron). final Widget? trailing; final bool isSelected; final bool isCollapsed; final bool useSimpleLayout; final VoidCallback onTap; final FocusNode focusNode; final bool autofocus; final BorderRadius borderRadius; final double iconSize; final double horizontalPadding; 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.trailing, required this.isSelected, this.isCollapsed = false, this.useSimpleLayout = false, required this.onTap, required this.focusNode, this.autofocus = false, this.borderRadius = const BorderRadius.all(Radius.circular(12)), this.iconSize = 22, this.horizontalPadding = 17, this.suppressSelectedBackground = false, this.focusAlpha = 0.12, this.selectedFocusAlpha = 0.15, this.onNavigateRight, }); @override Widget build(BuildContext context) { final t = tokens(context); final showSelectedBackground = isSelected && !suppressSelectedBackground; return ListenableBuilder( listenable: focusNode, builder: (context, _) { final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context); 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, child: Container( decoration: BoxDecoration( color: () { if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null; if (focused) { return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha); } 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); }(), ), ?trailing, ], ), ), ), ), ), ), ), ); }, ); } } /// 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 hover/touch expansion changes, so the shell can reserve width. final ValueChanged? onInteractionExpandedChanged; /// Called when the user taps the reconnect button in offline mode. final VoidCallback? onReconnect; 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 { bool _librariesExpanded = true; 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 _itemHorizontalPadding = 17.0; static const double _defaultIconSize = 22.0; static const Duration _collapseDelay = Duration(milliseconds: 150); static double collapsedWidthForContext(BuildContext _) => PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth; static double itemHorizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) { if (isCollapsed && PlatformDetector.isTV()) { return ((tvCollapsedWidth - _defaultIconSize) / 2).clamp(0.0, _itemHorizontalPadding).toDouble(); } return _itemHorizontalPadding; } static double horizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) { if (!isCollapsed) return _horizontalPadding; final centeredPadding = ((collapsedWidthForContext(context) - _defaultIconSize) / 2) - itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return centeredPadding.clamp(0.0, _horizontalPadding).toDouble(); } 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; 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(); } } } 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, }) { return [ if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect, if (!widget.isOfflineMode) ...[ _kHome, if (hasNowPlaying) _kNowPlaying, _kLibraries, if (_librariesExpanded) ...[ ..._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; final nextIndex = isDown ? currentIndex + 1 : currentIndex - 1; if (nextIndex < 0 || nextIndex >= focusOrder.length) return KeyEventResult.handled; final nextNode = _focusTracker.nodeFor(focusOrder[nextIndex]); if (nextNode == null) return KeyEventResult.ignored; _requestFocusAndReveal(nextNode); return KeyEventResult.handled; } /// 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 itemHorizontalPadding = itemHorizontalPaddingForContext(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. return ListenableBuilder( listenable: Listenable.merge([ FullscreenStateManager(), SettingsService.instance.listenable(SettingsService.groupLibrariesByServer), SettingsService.instance.listenable(SettingsService.showExploreTab), ]), 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, ); _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: t.normal, curve: Curves.easeOutCubic, width: isCollapsed ? effectiveCollapsedWidth : expandedWidth, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(), child: Stack( children: [ Positioned.fill( child: AnimatedOpacity( opacity: PlatformDetector.isTV() ? 0.0 : 1.0, duration: t.normal, curve: Curves.easeOutCubic, child: ColoredBox(color: t.surface), ), ), 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: ListView( padding: .symmetric(horizontal: horizontalPadding), clipBehavior: Clip.hardEdge, children: [ if (widget.isOfflineMode && widget.onReconnect != null) ...[ _buildReconnectItem(isCollapsed: isCollapsed), const SizedBox(height: 8), ], 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: 8), // Now Playing — only while a music session is live. if (nowPlayingTrack != null && musicService != null) ...[ _buildNowPlayingItem(nowPlayingTrack, musicService, isCollapsed: isCollapsed), const SizedBox(height: 8), ], _buildLibrariesSection( visibleRows, hiddenRows, hiddenLibraries.length, t, isCollapsed: isCollapsed, itemHorizontalPadding: itemHorizontalPadding, ), const SizedBox(height: 8), 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: 8), ], 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: 8), ], _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: 8), ], // 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: 8), ], _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) Padding( padding: .fromLTRB(horizontalPadding, 0, horizontalPadding, 12), 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); final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: icon, selectedIcon: selectedIcon, label: Text( label, style: TextStyle( fontSize: 14, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? t.text : t.textMuted, ), overflow: .ellipsis, maxLines: 1, ), isSelected: isSelected, isCollapsed: isCollapsed, onTap: onTap, focusNode: focusNode, autofocus: autofocus, horizontalPadding: itemHorizontalPadding, 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 itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: Symbols.music_note_rounded, iconWidget: SizedBox( width: 22, child: Center( child: EqualizerIcon(animate: musicService.isPlaying, color: t.text), ), ), label: Column( crossAxisAlignment: .start, mainAxisSize: .min, children: [ Text( Translations.of(context).music.nowPlaying, style: TextStyle(fontSize: 14, fontWeight: .w600, color: t.text), overflow: .ellipsis, maxLines: 1, ), Text( track.title ?? '', style: TextStyle(fontSize: 11, color: t.textMuted), overflow: .ellipsis, maxLines: 1, ), ], ), isSelected: false, isCollapsed: isCollapsed, useSimpleLayout: true, onTap: () => unawaited(openNowPlaying(context)), focusNode: _focusTracker.get(_kNowPlaying), horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } Widget _buildReconnectItem({required bool isCollapsed}) { final t = tokens(context); final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); 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: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted), overflow: .ellipsis, maxLines: 1, ), isSelected: false, isCollapsed: isCollapsed, // ignore: no-empty-block - no-op tap handler while reconnecting onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(), focusNode: _focusTracker.get(_kReconnect), horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } Widget _buildFullscreenItem({required bool isCollapsed}) { final t = tokens(context); final isFullscreen = FullscreenStateManager().isFullscreen; final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, label: Text( isFullscreen ? Translations.of(context).common.exitFullscreen : Translations.of(context).common.fullscreen, style: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted), overflow: .ellipsis, maxLines: 1, ), isSelected: false, isCollapsed: isCollapsed, onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()), focusNode: _focusTracker.get(_kFullscreen), horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } Widget _buildLibrariesSection( List<_LibraryNavRow> visibleRows, List<_LibraryNavRow> hiddenRows, int hiddenLibraryCount, dynamic t, { bool isCollapsed = false, required double itemHorizontalPadding, }) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries; final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0; return Column( crossAxisAlignment: .start, children: [ NavigationRailItem( icon: Symbols.video_library_rounded, label: Text( Translations.of(context).navigation.libraries, style: TextStyle( fontSize: 14, fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400, color: isLibrariesTabSelected ? t.text : t.textMuted, ), ), trailing: 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, ), ), isSelected: isLibrariesTabSelected, isCollapsed: isCollapsed, onTap: () => setState(() => _librariesExpanded = !_librariesExpanded), focusNode: _focusTracker.get(_kLibraries), borderRadius: BorderRadius.circular(tokens(context).radiusMd), horizontalPadding: itemHorizontalPadding, // 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: tokens(context).normal, curve: Curves.easeOutCubic, 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(tokens(context).radiusSm); // Match library-item indent: outer Padding(left: 12) + inner horizontal 17. return Padding( padding: const EdgeInsets.only(left: 12), child: 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 Padding( padding: const EdgeInsets.only(left: 12), child: 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, useSimpleLayout: true, onTap: () => widget.onLibrarySelected(library.globalKey), focusNode: focusNode, borderRadius: BorderRadius.circular(tokens(context).radiusSm), iconSize: 18, suppressSelectedBackground: widget.isSidebarFocused, onNavigateRight: widget.onNavigateToContent, ), ); } }