import 'dart:async'; 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 '../models/plex_library.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; /// Reusable navigation rail item widget that handles focus, selection, and interaction class NavigationRailItem extends StatelessWidget { final IconData icon; final IconData? selectedIcon; final Widget label; final bool isSelected; final bool isFocused; final bool isCollapsed; final bool useSimpleLayout; final VoidCallback onTap; final FocusNode focusNode; final bool autofocus; final BorderRadius borderRadius; final double iconSize; /// Called when RIGHT arrow is pressed to navigate to content area. final VoidCallback? onNavigateRight; const NavigationRailItem({ super.key, required this.icon, this.selectedIcon, required this.label, required this.isSelected, required this.isFocused, 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.onNavigateRight, }); @override Widget build(BuildContext context) { final t = tokens(context); return Focus( focusNode: focusNode, autofocus: autofocus, onKeyEvent: (node, event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey.isSelectKey) { onTap(); return KeyEventResult.handled; } // RIGHT arrow navigates to content area 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 (isSelected && isFocused) return t.text.withValues(alpha: 0.15); if (isSelected) return t.text.withValues(alpha: 0.1); if (isFocused) return t.text.withValues(alpha: 0.12); return null; }(), borderRadius: borderRadius, ), clipBehavior: Clip.hardEdge, child: UnconstrainedBox( alignment: Alignment.centerLeft, constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, child: SizedBox( width: SideNavigationRailState.expandedWidth - 24, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17), child: Row( children: [ 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); }(), ), ], ), ), ), ), ), ), ), ); } } /// 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; 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.onReconnect, }); @override State createState() => SideNavigationRailState(); } class SideNavigationRailState extends State { bool _librariesExpanded = true; // Collapsed/expanded state bool _isHovered = false; bool _isTouchExpanded = false; Timer? _collapseTimer; static const double collapsedWidth = 80.0; static const double expandedWidth = 220.0; static const Duration _collapseDelay = Duration(milliseconds: 150); // Focus keys for main nav items static const _kHome = 'home'; static const _kLibraries = 'libraries'; static const _kSearch = 'search'; static const _kDownloads = 'downloads'; static const _kSettings = 'settings'; static const _kReconnect = 'reconnect'; static const _kFullscreen = 'fullscreen'; // 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; /// 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( onFocusChanged: () { // ignore: no-empty-block - setState triggers rebuild to update focus styling if (mounted) setState(() {}); }, 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) { _isTouchExpanded = false; } } void _onHoverEnter() { _collapseTimer?.cancel(); _isTouchExpanded = false; // Mouse takes over if (!_isHovered) { setState(() => _isHovered = true); } } void _onHoverExit() { _collapseTimer?.cancel(); _collapseTimer = Timer(_collapseDelay, () { if (mounted && _isHovered) { setState(() => _isHovered = false); } }); } /// 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}) { if (targetKey != null) { final node = _focusTracker.nodeFor(targetKey); if (node != null) { node.requestFocus(); return; } } _focusTracker.restoreFocus(fallbackKey: _kHome); } /// Build the set of valid focus keys (main nav + current libraries) Set _buildValidFocusKeys(List libraries) { return { _kHome, _kLibraries, _kSearch, _kDownloads, _kSettings, _kReconnect, if (_showFullscreenToggle) _kFullscreen, 'liveTv', ...libraries.map((lib) => lib.globalKey), }; } /// Ordered list of focusable keys matching visual top-to-bottom order. List _buildFocusOrder(List visibleLibraries, {required bool hasLiveTv}) { return [ if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect, if (!widget.isOfflineMode) ...[ _kHome, _kLibraries, if (_librariesExpanded) ...visibleLibraries.map((lib) => lib.globalKey), if (hasLiveTv) 'liveTv', _kSearch, ], _kDownloads, _kSettings, if (_showFullscreenToggle) _kFullscreen, ]; } /// 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; nextNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final ctx = nextNode.context; if (ctx != null) { Scrollable.ensureVisible(ctx, alignment: 0.5, duration: const Duration(milliseconds: 200)); } }); return KeyEventResult.handled; } /// Collapse the sidebar (resets touch-expand state). void collapse() { if (_isTouchExpanded) { setState(() => _isTouchExpanded = false); } } /// Reload libraries (called when servers change or profile switches) void reloadLibraries() { final librariesProvider = context.read(); librariesProvider.refresh(); } IconData _getLibraryIcon(String type) { switch (type.toLowerCase()) { case 'movie': return Symbols.movie_rounded; case 'show': return Symbols.tv_rounded; case 'artist': return Symbols.music_note_rounded; case 'photo': return Symbols.photo_rounded; case 'mixed': return Symbols.share_rounded; default: return Symbols.folder_rounded; } } /// Calculate top padding for macOS traffic lights double _getTopPadding(BuildContext context) { double basePadding = MediaQuery.of(context).padding.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; // Get libraries from provider and filter visible ones final allLibraries = librariesProvider.libraries; final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); // Prune stale focus nodes when libraries change _focusTracker.pruneExcept(_buildValidFocusKeys(allLibraries)); final isCollapsed = !_shouldExpand; final hasLiveTv = context.watch().hasLiveTv; final focusOrder = _buildFocusOrder(visibleLibraries, hasLiveTv: hasLiveTv); // Listen to fullscreen changes for macOS return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { return TapRegion( onTapOutside: (_) { if (_isTouchExpanded) { setState(() => _isTouchExpanded = false); } }, child: MouseRegion( onEnter: (_) => _onHoverEnter(), onExit: (_) => _onHoverExit(), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: isCollapsed ? () => setState(() => _isTouchExpanded = true) : null, child: AnimatedContainer( duration: t.normal, curve: Curves.easeOutCubic, width: isCollapsed ? collapsedWidth : expandedWidth, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(color: t.surface), child: IgnorePointer( ignoring: isCollapsed, child: Focus( canRequestFocus: false, skipTraversal: true, onKeyEvent: (node, event) => _handleVerticalNavigation(node, event, focusOrder), child: Column( children: [ // Safe area for status bar and macOS traffic lights SizedBox(height: _getTopPadding(context)), // Navigation content Expanded( child: ListView( padding: const EdgeInsets.symmetric(horizontal: 12), clipBehavior: Clip.hardEdge, children: [ // Reconnect button when offline if (widget.isOfflineMode && widget.onReconnect != null) ...[ _buildReconnectItem(isCollapsed: isCollapsed), const SizedBox(height: 8), ], // In online mode, show full navigation if (!widget.isOfflineMode) ...[ // Home _buildNavItem( icon: Symbols.home_rounded, 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, ), const SizedBox(height: 8), // Libraries section _buildLibrariesSection(visibleLibraries, t, isCollapsed: isCollapsed), const SizedBox(height: 8), // Live TV (only if DVR available) 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, isFocused: _focusTracker.isFocused('liveTv'), onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv), focusNode: _focusTracker.get('liveTv'), isCollapsed: isCollapsed, ), const SizedBox(height: 8), ], // Search _buildNavItem( icon: Symbols.search_rounded, 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, ), const SizedBox(height: 8), ], // Downloads _buildNavItem( icon: Symbols.download_rounded, 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, ), const SizedBox(height: 8), // Settings _buildNavItem( icon: Symbols.settings_rounded, 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, ), ], ), ), if (_showFullscreenToggle) Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: _buildFullscreenItem(isCollapsed: isCollapsed), ), ], ), ), ), ), ), ), ); }, ); } Widget _buildNavItem({ required IconData icon, required IconData selectedIcon, required String label, required bool isSelected, required bool isFocused, 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, color: isSelected ? t.text : t.textMuted, ), overflow: TextOverflow.ellipsis, maxLines: 1, ), isSelected: isSelected, isFocused: isFocused, isCollapsed: isCollapsed, onTap: onTap, focusNode: focusNode, autofocus: autofocus, onNavigateRight: widget.onNavigateToContent, ); } Widget _buildReconnectItem({required bool isCollapsed}) { final t = tokens(context); final isFocused = _focusTracker.isFocused(_kReconnect); 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: FontWeight.w400, color: t.textMuted), overflow: TextOverflow.ellipsis, maxLines: 1, ), isSelected: false, isFocused: isFocused, 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 isFocused = _focusTracker.isFocused(_kFullscreen); 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: FontWeight.w400, color: t.textMuted), overflow: TextOverflow.ellipsis, maxLines: 1, ), isSelected: false, isFocused: isFocused, isCollapsed: isCollapsed, onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()), focusNode: _focusTracker.get(_kFullscreen), onNavigateRight: widget.onNavigateToContent, ); } Widget _buildLibrariesSection(List visibleLibraries, dynamic t, {bool isCollapsed = false}) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null; final isLibrariesFocused = _focusTracker.isFocused(_kLibraries); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Libraries header with expand/collapse 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: () { setState(() { _librariesExpanded = !_librariesExpanded; }); }, borderRadius: BorderRadius.circular(tokens(context).radiusMd), child: Container( decoration: BoxDecoration( color: () { if (isLibrariesSelected) 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: Alignment.centerLeft, constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, child: SizedBox( width: expandedWidth - 24, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17), 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, ), ), ], ), ), ), ), ), ), ), ), // Library items with animated height 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: Alignment.topCenter, heightFactor: value, child: child), ); }, child: ExcludeFocus( excluding: !_librariesExpanded || isCollapsed, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: 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 (visibleLibraries.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Text( Translations.of(context).libraries.noLibrariesFound, style: TextStyle(fontSize: 12, color: t.textMuted), ), ) else _buildLibraryItems(visibleLibraries, 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 _buildLibraryItems(List visibleLibraries, dynamic t) { // Find which library names are not unique final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: visibleLibraries.map((library) { final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null; return _buildLibraryItem(library, t, showServerName: showServerName); }).toList(), ); } Widget _buildLibraryItem(PlexLibrary library, dynamic t, {bool showServerName = false}) { final isSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == library.globalKey; final isFocused = _focusTracker.isFocused(library.globalKey); final focusNode = _focusTracker.get(library.globalKey); return Padding( padding: const EdgeInsets.only(left: 12), child: NavigationRailItem( icon: _getLibraryIcon(library.type), selectedIcon: _getLibraryIcon(library.type), label: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( library.title, style: TextStyle( fontSize: 13, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? t.text : t.textMuted, ), overflow: TextOverflow.ellipsis, ), if (showServerName) Text( library.serverName!, style: TextStyle(fontSize: 9, color: t.textMuted.withValues(alpha: 0.4)), overflow: TextOverflow.ellipsis, ), ], ), isSelected: isSelected, isFocused: isFocused, useSimpleLayout: true, onTap: () => widget.onLibrarySelected(library.globalKey), focusNode: focusNode, borderRadius: BorderRadius.circular(tokens(context).radiusSm), iconSize: 18, onNavigateRight: widget.onNavigateToContent, ), ); } }