import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import 'package:go_router/go_router.dart'; import 'package:reorderable_grid/reorderable_grid.dart'; import '../../core/contracts/auth.dart'; import '../../core/contracts/server.dart'; import '../../providers/di_providers.dart'; import '../../providers/item_counts_provider.dart' show serverItemCountsProvider; import '../../providers/server_connectivity_provider.dart'; import '../../providers/server_provider.dart'; import '../../providers/session_provider.dart'; import '../../shared/constants/breakpoints.dart'; import '../../shared/layout/adaptive_card_grid.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/widgets/add_server_modal.dart'; import '../../shared/widgets/app_card.dart'; import '../../shared/widgets/auto_dismiss_menu.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/app_skeleton.dart'; import '../../shared/widgets/app_sliver_header.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/metadata_chip.dart'; import '../../shared/widgets/page_background.dart'; import '../../shared/widgets/server_action_menu.dart'; import '../../shared/widgets/server_avatar.dart'; import '../../shared/widgets/status_dot.dart'; import '../../shared/utils/user_error_formatter.dart'; import '../modules/modules_home_page.dart'; class ServersPage extends ConsumerWidget { const ServersPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final servers = ref.watch(serverListProvider); final sessions = ref.watch(sessionProvider); final enabledScriptWidgets = (ref.watch(installedScriptWidgetsProvider).value ?? const []) .where((installedWidget) => installedWidget.enabled) .toList(growable: false); final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact; final headerActions = [ if (isCompact) ...[ IconButton( icon: const Icon(Icons.refresh_outlined, size: 20), onPressed: () => Future.wait([ ref.read(serverListProvider.notifier).refresh(), ref.read(sessionProvider.notifier).refresh(), ]), tooltip: '刷新', ), IconButton( icon: const Icon(Icons.add, size: 20), onPressed: () => showAddServerModal(context), tooltip: '添加服务器', ), ] else ...[ FButton( variant: FButtonVariant.outline, onPress: () => showAddServerModal(context), prefix: const Icon(Icons.add, size: 16), child: const Text('添加服务器'), ), ], ]; Widget withBackground(List slivers) { return Stack( children: [ const Positioned.fill(child: PageBackground()), RefreshIndicator( onRefresh: () => Future.wait([ ref.read(serverListProvider.notifier).refresh(), ref.read(sessionProvider.notifier).refresh(), ]), child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), slivers: slivers, ), ), ], ); } List buildModuleSlivers() { if (enabledScriptWidgets.isEmpty) return const []; final horizontalPadding = isCompact ? 16.0 : 24.0; return [ SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB(horizontalPadding, 16, 12, 8), child: Row( children: [ Expanded( child: Text( '模块', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), TextButton.icon( onPressed: () => showModuleCenterSheet(context), icon: const Icon(Icons.settings_outlined, size: 16), label: const Text('模块中心'), ), ], ), ), ), SliverPadding( padding: EdgeInsets.fromLTRB( horizontalPadding, 0, horizontalPadding, isCompact ? 100 : 24, ), sliver: SliverGrid( gridDelegate: isCompact ? const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 1.05, crossAxisSpacing: 12, mainAxisSpacing: 12, ) : const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: kRichCardMinWidth, childAspectRatio: 1.55, crossAxisSpacing: kCardGap, mainAxisSpacing: kCardGap, ), delegate: SliverChildBuilderDelegate((context, moduleIndex) { final installedWidget = enabledScriptWidgets[moduleIndex]; final manifest = installedWidget.manifest; return _ModuleCard( title: manifest.title, description: manifest.description, onTap: () => context.go('/modules/${Uri.encodeComponent(manifest.id)}'), ); }, childCount: enabledScriptWidgets.length), ), ), ]; } return Scaffold( backgroundColor: Colors.transparent, extendBodyBehindAppBar: true, body: servers.when( data: (serverList) { if (serverList.isEmpty) { return withBackground([ AppSliverHeader(title: '服务器', actions: headerActions), SliverFillRemaining( hasScrollBody: false, child: EmptyState( icon: Icons.cloud_outlined, title: '尚未添加 Emby 服务器', message: '点击下方按钮添加第一个服务器,开始构建你的沉浸式影库。', action: FButton( variant: FButtonVariant.primary, onPress: () => showAddServerModal(context), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add, size: 16), SizedBox(width: 6), Text('添加服务器'), ], ), ), ), ), ...buildModuleSlivers(), ]); } final sessionList = sessions.value?.sessions ?? []; final sessionMap = {for (final s in sessionList) s.serverId: s}; return withBackground([ AppSliverHeader( title: isCompact ? '媒体服务器' : '服务器', actions: headerActions, ), SliverPadding( padding: EdgeInsets.fromLTRB( isCompact ? 16 : 24, 8, isCompact ? 16 : 24, enabledScriptWidgets.isEmpty ? (isCompact ? 100 : 24) : 8, ), sliver: SliverReorderableGrid( gridDelegate: isCompact ? const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 1.05, crossAxisSpacing: 12, mainAxisSpacing: 12, ) : const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: kRichCardMinWidth, childAspectRatio: 1.55, crossAxisSpacing: kCardGap, mainAxisSpacing: kCardGap, ), itemCount: serverList.length, onReorder: (oldIndex, newIndex) => ref .read(serverListProvider.notifier) .reorder(oldIndex, newIndex), itemBuilder: (context, serverIndex) { final server = serverList[serverIndex]; final session = sessionMap[server.id]; final actions = buildServerActionCallbacks( context: context, ref: ref, server: server, session: session, ); return _ServerCardReorderListener( key: ValueKey(server.id), index: serverIndex, child: _ServerCard( server: server, session: session, onTap: () async { if (session != null) { await ref .read(sessionProvider.notifier) .switchSession(server.id); if (!context.mounted) return; context.go('/'); } }, onRefresh: actions.onRefresh!, onChangeIcon: actions.onChangeIcon!, onEdit: actions.onEdit!, onTogglePause: actions.onTogglePause!, onDelete: actions.onDelete!, onChangePassword: actions.onChangePassword, ), ); }, ), ), ...buildModuleSlivers(), ]); }, loading: () => withBackground([ AppSliverHeader(title: '服务器', actions: headerActions), const SliverToBoxAdapter( child: SkeletonGrid( itemCount: 6, itemHeight: 194, minCardWidth: kRichCardMinWidth, padding: EdgeInsets.fromLTRB(24, 8, 24, 0), ), ), ]), error: (e, _) => withBackground([ AppSliverHeader(title: '服务器', actions: headerActions), SliverFillRemaining( hasScrollBody: false, child: AppErrorState( message: formatUserError(e, fallback: '服务器加载失败'), onRetry: () => ref.invalidate(serverListProvider), ), ), ]), ), ); } } class _ModuleCard extends StatelessWidget { final String title; final String description; final VoidCallback onTap; const _ModuleCard({ required this.title, required this.description, required this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final normalizedDescription = description.trim(); return AppCard( onTap: onTap, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.extension_rounded, size: 28, color: theme.colorScheme.primary, ), const Spacer(), Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), if (normalizedDescription.isNotEmpty) ...[ const SizedBox(height: 4), Text( normalizedDescription, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ); } } class _ServerCardReorderListener extends StatelessWidget { final int index; final Widget child; const _ServerCardReorderListener({ super.key, required this.index, required this.child, }); @override Widget build(BuildContext context) { const desktop = { TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, }; if (desktop.contains(Theme.of(context).platform)) { return ReorderableGridDragStartListener(index: index, child: child); } return ReorderableGridDelayedDragStartListener(index: index, child: child); } } class _ServerCard extends ConsumerStatefulWidget { final EmbyServer server; final AuthedSession? session; final VoidCallback onTap; final VoidCallback onRefresh; final VoidCallback onChangeIcon; final VoidCallback onEdit; final VoidCallback onDelete; final VoidCallback onTogglePause; final VoidCallback? onChangePassword; const _ServerCard({ required this.server, required this.session, required this.onTap, required this.onRefresh, required this.onChangeIcon, required this.onEdit, required this.onDelete, required this.onTogglePause, this.onChangePassword, }); @override ConsumerState<_ServerCard> createState() => _ServerCardState(); } class _ServerCardState extends ConsumerState<_ServerCard> { String _relativeTime() { final dateStr = widget.server.lastConnectedAt ?? widget.server.updatedAt; final dt = DateTime.tryParse(dateStr); if (dt == null) return '未观看'; final diff = DateTime.now().difference(dt); if (diff.inDays >= 1) return '${diff.inDays}天前'; if (diff.inHours >= 1) return '${diff.inHours}小时前'; if (diff.inMinutes >= 1) return '${diff.inMinutes}分钟前'; return '刚刚'; } ConnectivityStatus _mapStatus(AsyncValue conn) => conn.when( data: (c) => c.reachable ? ConnectivityStatus.online : ConnectivityStatus.offline, loading: () => ConnectivityStatus.unknown, error: (_, _) => ConnectivityStatus.offline, ); List _cardMenuItems() => buildServerActionMenuItems( onRefresh: widget.onRefresh, onChangeIcon: widget.onChangeIcon, onEdit: widget.onEdit, onChangePassword: widget.session == null ? null : widget.onChangePassword, paused: widget.server.paused, onTogglePause: widget.onTogglePause, onDelete: widget.onDelete, ); @override Widget build(BuildContext context) { final isPaused = widget.server.paused; final counts = widget.session != null && !isPaused ? ref.watch(serverItemCountsProvider(widget.server.id)).value : null; final connectivity = isPaused ? null : ref.watch(serverConnectivityProvider(widget.server.baseUrl)); final theme = Theme.of(context); final tokens = context.appTokens; final hasSession = widget.session != null; final seedColor = ServerAvatar.seedColorFor(widget.server.name); final Widget latencyWidget = isPaused ? MetadataChip( icon: Icons.pause_circle_outline, label: '已暂停', foreground: theme.colorScheme.onSurfaceVariant, ) : connectivity!.when( data: (c) { if (c.reachable && c.latencyMs != null) { final Color color; if (c.latencyMs! < 100) { color = tokens.statusOnline; } else if (c.latencyMs! < 300) { color = tokens.statusWarn; } else { color = tokens.statusOffline; } return MetadataChip( label: '${c.latencyMs}ms', foreground: color, ); } return MetadataChip( label: '不可达', foreground: tokens.statusOffline, ); }, loading: () => MetadataChip( label: '检测中...', foreground: theme.colorScheme.onSurfaceVariant, ), error: (_, _) => MetadataChip(label: '检测失败', foreground: tokens.statusOffline), ); return Opacity( opacity: isPaused ? 0.5 : 1.0, child: AppCard( onTap: hasSession && !isPaused ? widget.onTap : null, contextMenuItems: _cardMenuItems(), enableLongPressMenu: false, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [seedColor.withValues(alpha: 0.06), Colors.transparent], ), ), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ServerAvatar( name: widget.server.name, iconUrl: widget.server.iconUrl, size: 34, ), const SizedBox(width: 8), Expanded( child: SizedBox( height: 34, child: Row( children: [ Flexible( child: Text( widget.server.name, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, fontSize: 13, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (hasSession && !isPaused) ...[ const SizedBox(width: 4), StatusDot( status: _mapStatus(connectivity!), size: 7, ), ], ], ), ), ), const SizedBox(width: 4), SizedBox( width: 24, height: 24, child: FPopoverMenu( menuBuilder: autoDismissMenuBuilder, menuAnchor: Alignment.topRight, childAnchor: Alignment.bottomRight, menu: [FItemGroup(children: _cardMenuItems())], builder: (context, controller, child) => GestureDetector( onTap: controller.toggle, child: child, ), child: MouseRegion( cursor: SystemMouseCursors.click, child: Icon( Icons.more_vert, size: 16, color: theme.colorScheme.onSurfaceVariant, ), ), ), ), ], ), const Spacer(), Row( children: [ MetadataChip( icon: Icons.photo_library_outlined, label: counts?.movieCount != null ? '${counts!.movieCount}' : '—', ), const SizedBox(width: 8), MetadataChip( icon: Icons.play_circle_outline, label: counts?.episodeCount != null ? '${counts!.episodeCount}' : '—', ), ], ), const SizedBox(height: 6), Row( children: [ latencyWidget, if (!isPaused) ...[ Text( ' · ', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 11, ), ), Flexible( child: Text( _relativeTime(), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 11, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ], ), ], ), ), ), ), ); } }