import 'dart:async'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../providers/home_banner_provider.dart'; import '../../providers/di_providers.dart'; import '../../providers/library_overview_provider.dart'; import '../../providers/server_provider.dart'; import '../../providers/session_provider.dart'; import '../../providers/shell_backdrop_provider.dart'; import '../../features/settings/settings_page.dart'; import '../../providers/tmdb_settings_provider.dart'; import '../../providers/sm_account_provider.dart'; import '../../providers/trakt_settings_provider.dart'; import '../theme/app_theme.dart'; import 'server_action_menu.dart'; import 'server_menu_item.dart'; import 'top_drag_area.dart'; const _kSidebarCollapsedKey = 'smplayer.sidebar_collapsed'; const double _kSidebarBlurSigma = 18; class _SidebarPalette { final Color fg; final Color fgMuted; final Color fgFaint; final Color glassTint; final Color hairline; const _SidebarPalette({ required this.fg, required this.fgMuted, required this.fgFaint, required this.glassTint, required this.hairline, }); static const dark = _SidebarPalette( fg: Colors.white, fgMuted: Color(0x99FFFFFF), fgFaint: Color(0x66FFFFFF), glassTint: Color(0x42000000), hairline: Color(0x14FFFFFF), ); static const light = _SidebarPalette( fg: Color(0xFF1A1A1A), fgMuted: Color(0x99000000), fgFaint: Color(0x59000000), glassTint: Color(0x8CFFFFFF), hairline: Color(0x0F000000), ); static const darkSurface = _SidebarPalette( fg: Colors.white, fgMuted: Color(0x99FFFFFF), fgFaint: Color(0x66FFFFFF), glassTint: Color(0x14FFFFFF), hairline: Color(0x1FFFFFFF), ); } final sidebarCollapsedProvider = StateProvider((ref) => false); Future initSidebarState(WidgetRef ref) async { final prefs = await SharedPreferences.getInstance(); final collapsed = prefs.getBool(_kSidebarCollapsedKey) ?? false; ref.read(sidebarCollapsedProvider.notifier).state = collapsed; } Future _persistSidebarState(bool collapsed) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kSidebarCollapsedKey, collapsed); } class Sidebar extends ConsumerStatefulWidget { final int selectedBranchIndex; final void Function(int index, {bool initialLocation}) onNavigateBranch; final bool immersiveRoute; final String location; const Sidebar({ super.key, required this.selectedBranchIndex, required this.onNavigateBranch, required this.location, this.immersiveRoute = false, }); @override ConsumerState createState() => _SidebarState(); } class _SidebarState extends ConsumerState { bool _suppressBlur = false; Timer? _blurTimer; @override void dispose() { _blurTimer?.cancel(); super.dispose(); } void _onCollapseToggled() { setState(() => _suppressBlur = true); _blurTimer?.cancel(); _blurTimer = Timer(const Duration(milliseconds: 300), () { if (mounted) setState(() => _suppressBlur = false); }); } Widget _frostedGlass({required bool suppressBlur, required Widget child}) { return BackdropFilter( enabled: !suppressBlur, filter: ImageFilter.blur( sigmaX: _kSidebarBlurSigma, sigmaY: _kSidebarBlurSigma, ), child: child, ); } @override Widget build(BuildContext context) { ref.listen(sidebarCollapsedProvider, (prev, next) { if (prev != next) _onCollapseToggled(); }); final theme = Theme.of(context); final tokens = context.appTokens; final hasBackdrop = ref.watch( shellBackdropProvider.select((s) => s != null), ); final immersive = widget.immersiveRoute && hasBackdrop; final isDark = theme.brightness == Brightness.dark; final palette = immersive ? _SidebarPalette.dark : (isDark ? _SidebarPalette.darkSurface : _SidebarPalette.light); final collapsed = ref.watch(sidebarCollapsedProvider); final sessionState = ref.watch(sessionProvider); final servers = ref.watch(serverListProvider).value ?? const []; final serverById = {for (final s in servers) s.id: s}; final iconUrlByServer = {for (final s in servers) s.id: s.iconUrl}; final currentIndex = widget.selectedBranchIndex; final locationSegments = Uri.parse(widget.location).pathSegments; final activeModuleId = locationSegments.length > 1 && locationSegments.first == 'modules' ? locationSegments[1] : null; final sessionByServerId = { for (final session in sessionState.value?.sessions ?? const []) session.serverId: session, }; final sessionList = [ for (final server in servers) if (!server.paused && sessionByServerId[server.id] != null) sessionByServerId[server.id]!, ]; final tmdbConfigured = ref.watch(tmdbSettingsProvider).value?.canRequest ?? false; final traktConnected = ref.watch(traktSettingsProvider).value?.isConnected ?? false; final smAccountLoggedIn = ref.watch(smAccountProvider).value?.loggedIn ?? false; final enabledScriptWidgets = (ref.watch(installedScriptWidgetsProvider).value ?? const []) .where((installedWidget) => installedWidget.enabled) .toList(growable: false); final width = collapsed ? tokens.siderWidthCollapsed : tokens.siderWidthExpanded; return RepaintBoundary( child: AnimatedContainer( duration: tokens.siderTransition, curve: Curves.easeOutCubic, width: width, child: ClipRect( child: _frostedGlass( suppressBlur: _suppressBlur, child: AnimatedContainer( duration: const Duration(milliseconds: 350), curve: Curves.easeOut, decoration: BoxDecoration( color: palette.glassTint, border: Border( right: BorderSide(color: palette.hairline, width: 1), ), ), child: OverflowBox( alignment: Alignment.topLeft, minWidth: width, maxWidth: width, child: SizedBox( width: width, child: Padding( padding: EdgeInsets.fromLTRB( collapsed ? 8 : 16, 10, collapsed ? 8 : 16, 10, ), child: LayoutBuilder( builder: (context, constraints) { final effectiveCollapsed = constraints.maxWidth < 100; const settingsNavReserveHeight = 48.0; final reservedBottomHeight = (sessionList.isNotEmpty || enabledScriptWidgets.isNotEmpty ? 16.0 : 0.0) + 4.0 + settingsNavReserveHeight; final availableHeaderHeight = constraints.maxHeight - reservedBottomHeight; final maxHeaderHeight = constraints.maxHeight.isFinite ? (availableHeaderHeight < 0 ? 0.0 : availableHeaderHeight) : double.infinity; return Column( crossAxisAlignment: effectiveCollapsed ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ ConstrainedBox( constraints: BoxConstraints( maxHeight: maxHeaderHeight, ), child: SingleChildScrollView( child: AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, alignment: Alignment.topCenter, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: effectiveCollapsed ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ _SidebarHeader( collapsed: effectiveCollapsed, palette: palette, onToggle: () { final next = !collapsed; ref .read( sidebarCollapsedProvider .notifier, ) .state = next; _persistSidebarState(next); }, ), const SizedBox(height: 16), _buildSearchEntry( context, collapsed: effectiveCollapsed, palette: palette, ), const SizedBox(height: 12), _navItem( context, icon: Icons.dns_outlined, label: '服务器', selected: currentIndex == 1, collapsed: effectiveCollapsed, palette: palette, onTap: () => widget.onNavigateBranch(1), ), _navItem( context, icon: Icons.dashboard_outlined, label: '聚合视界', selected: currentIndex == 2, collapsed: effectiveCollapsed, palette: palette, onTap: () => widget.onNavigateBranch(2), ), if (tmdbConfigured) _navItem( context, icon: Icons.explore_outlined, label: '发现', selected: currentIndex == 3, collapsed: effectiveCollapsed, palette: palette, onTap: () => widget.onNavigateBranch(3), ), if (smAccountLoggedIn && traktConnected) _navItem( context, icon: Icons.calendar_today_outlined, label: '日历', selected: currentIndex == 4, collapsed: effectiveCollapsed, palette: palette, onTap: () => widget.onNavigateBranch(4), ), ], ), ), ), ), if (sessionList.isNotEmpty || enabledScriptWidgets.isNotEmpty) ...[ const SizedBox(height: 16), Expanded( child: SingleChildScrollView( child: Column( children: [ ...sessionList.map( (session) => Padding( padding: const EdgeInsets.only( bottom: 4, ), child: Builder( builder: (context) { final server = serverById[session.serverId]; final actions = buildServerActionCallbacks( context: context, ref: ref, server: server, session: session, ); return ServerMenuItem( serverName: session.serverName, iconUrl: iconUrlByServer[session .serverId] ?? '', isActive: session.isActive && activeModuleId == null, collapsed: effectiveCollapsed, foregroundActive: palette.fg, foregroundInactive: palette.fgMuted, onTap: () async { if (!session.isActive) { await ref .read( sessionProvider .notifier, ) .switchSession( session.serverId, ); widget.onNavigateBranch( 0, initialLocation: true, ); return; } if (widget.location == '/') { await Future.wait([ ref .read( libraryOverviewProvider .notifier, ) .softRefresh(), ref .read( homeBannerProvider .notifier, ) .softRefresh(), ]); } else { widget.onNavigateBranch( 0, initialLocation: true, ); } }, onChangeIcon: actions.onChangeIcon, onEdit: actions.onEdit, onChangePassword: actions.onChangePassword, onTogglePause: actions.onTogglePause, onDelete: actions.onDelete, ); }, ), ), ), for (final installedWidget in enabledScriptWidgets) _navItem( context, icon: Icons.extension_rounded, label: installedWidget.manifest.title, selected: activeModuleId == installedWidget.manifest.id, collapsed: effectiveCollapsed, palette: palette, onTap: () => context.go( '/modules/${Uri.encodeComponent(installedWidget.manifest.id)}', ), ), ], ), ), ), ] else const Spacer(), const SizedBox(height: 4), _navItem( context, icon: Icons.settings_outlined, label: '设置', selected: false, collapsed: effectiveCollapsed, palette: palette, onTap: () => showSettingsDialog(context), ), ], ); }, ), ), ), ), ), ), ), ), ); } Widget _buildSearchEntry( BuildContext context, { required bool collapsed, required _SidebarPalette palette, }) { final theme = Theme.of(context); if (collapsed) { return Tooltip( message: '搜索', preferBelow: false, child: GestureDetector( onTap: () => context.push('/global-search'), child: MouseRegion( cursor: SystemMouseCursors.click, child: SizedBox( width: 44, height: 38, child: Center( child: Icon( Icons.search_outlined, size: 20, color: palette.fgFaint, ), ), ), ), ), ); } return GestureDetector( onTap: () => context.push('/global-search'), child: MouseRegion( cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: Row( children: [ Icon(Icons.search_outlined, size: 18, color: palette.fgMuted), const SizedBox(width: 10), Expanded( child: Text( '搜索', style: theme.textTheme.bodySmall?.copyWith( color: palette.fgMuted, ), ), ), Text( '⌘K', style: theme.textTheme.labelSmall?.copyWith( color: palette.fgFaint, fontSize: 10, ), ), ], ), ), ), ); } Widget _navItem( BuildContext context, { required IconData icon, required String label, required bool selected, required bool collapsed, required _SidebarPalette palette, required VoidCallback onTap, }) { final theme = Theme.of(context); final foreground = selected ? palette.fg : palette.fgMuted; final child = GestureDetector( onTap: onTap, child: MouseRegion( cursor: SystemMouseCursors.click, child: Padding( padding: EdgeInsets.symmetric( horizontal: collapsed ? 0 : 4, vertical: 8, ), child: collapsed ? SizedBox( width: 44, child: Center(child: Icon(icon, size: 20, color: foreground)), ) : Row( children: [ Container( width: 2, height: 16, margin: const EdgeInsets.only(right: 10), decoration: BoxDecoration( color: selected ? palette.fg : Colors.transparent, borderRadius: BorderRadius.circular(1), ), ), Icon(icon, size: 18, color: foreground), const SizedBox(width: 10), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: foreground, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), ], ), ), ), ); if (collapsed) { return Tooltip( message: label, preferBelow: false, child: Padding(padding: const EdgeInsets.only(bottom: 2), child: child), ); } return Padding(padding: const EdgeInsets.only(bottom: 2), child: child); } } class _SidebarHeader extends StatelessWidget { final bool collapsed; final _SidebarPalette palette; final VoidCallback onToggle; const _SidebarHeader({ required this.collapsed, required this.palette, required this.onToggle, }); @override Widget build(BuildContext context) { final tokens = context.appTokens; if (collapsed) { return Column( children: [ TopDragArea( child: SizedBox( width: double.infinity, height: tokens.titleBarHeight, ), ), const SizedBox(height: 4), GestureDetector( onTap: onToggle, child: MouseRegion( cursor: SystemMouseCursors.click, child: Icon(Icons.menu, size: 18, color: palette.fgFaint), ), ), ], ); } return TopDragArea( child: SizedBox( height: tokens.titleBarHeight, child: Row( children: [ const Spacer(), GestureDetector( onTap: onToggle, child: MouseRegion( cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.all(4), child: Icon(Icons.menu, size: 18, color: palette.fgFaint), ), ), ), ], ), ), ); } }