import 'dart:io' show Platform; import 'package:flutter/material.dart'; import '../../core/contracts/library.dart'; import '../utils/format_utils.dart'; import 'app_loading_ring.dart'; import 'app_snack_bar.dart'; import 'frosted_panel.dart'; @immutable class VersionSourceCardData { final String topLabel; final int? sizeBytes; final int? bitrate; final String serverName; final String serverKey; final String sourceKey; final List mergedServerNames; final bool isCurrent; final bool selected; final Future Function()? onTap; final String? tooltip; final String resolutionLabel; const VersionSourceCardData({ required this.topLabel, required this.serverName, this.serverKey = '', this.sourceKey = '', this.mergedServerNames = const [], this.sizeBytes, this.bitrate, this.isCurrent = false, this.selected = false, this.onTap, this.tooltip, this.resolutionLabel = '', }); factory VersionSourceCardData.fromCrossServer( CrossServerSourceCard card, { bool isCurrent = false, bool selected = false, Future Function()? onTap, }) { final src = card.mediaSource; return VersionSourceCardData( topLabel: _topLabelFromQuality(card.quality), serverName: card.serverName, serverKey: card.serverId, sourceKey: '${card.serverId}|${card.mediaSourceId}', sizeBytes: src.Size, bitrate: src.Bitrate, isCurrent: isCurrent, selected: selected, onTap: onTap, tooltip: '切换到 ${card.serverName}', resolutionLabel: card.quality?.resolutionLabel ?? '', ); } factory VersionSourceCardData.fromLocalSource( EmbyRawMediaSource source, { required String serverName, String serverKey = '', bool isCurrent = false, Future Function()? onTap, String? tooltip, }) { final quality = MediaQualityInfo.fromMediaSource(source); return VersionSourceCardData( topLabel: _topLabelFromQuality(quality), serverName: serverName, serverKey: serverKey, sourceKey: '$serverKey|${source.Id ?? ''}', sizeBytes: source.Size, bitrate: source.Bitrate, isCurrent: isCurrent, onTap: onTap, tooltip: tooltip, resolutionLabel: quality.resolutionLabel, ); } } String _abbrevRange(String label) { if (label.isEmpty) return 'SDR'; if (label == 'Dolby Vision') return 'Dolby'; return label; } String _topLabelFromQuality(MediaQualityInfo? quality) { final resolutionLabel = quality?.resolutionLabel ?? ''; final rangeLabel = _abbrevRange(quality?.dynamicRangeLabel ?? ''); final parts = []; if (resolutionLabel.isNotEmpty) parts.add(resolutionLabel); parts.add(rangeLabel); return parts.join(' '); } class VersionSourceCard extends StatefulWidget { final VersionSourceCardData data; final bool compact; final VoidCallback? onMergedServersTap; const VersionSourceCard({ super.key, required this.data, this.compact = false, this.onMergedServersTap, }); @override State createState() => _VersionSourceCardState(); } class _VersionSourceCardState extends State { bool _loading = false; Future _open() async { final data = widget.data; if (_loading || data.isCurrent) return; final tap = data.onTap; if (tap == null) return; setState(() => _loading = true); try { await tap(); } catch (_) { if (!mounted) return; showAppSnackBar(context, '切换服务器失败', tone: AppSnackTone.error); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { final data = widget.data; final sizeBytes = data.sizeBytes; final bitrate = data.bitrate; final hasSize = sizeBytes != null && sizeBytes > 0; final hasBitrate = bitrate != null && bitrate > 0; final showInfoRow = hasSize || hasBitrate; final isCurrent = data.isCurrent; final highlighted = isCurrent || data.selected; final representedServerCount = data.mergedServerNames.length; final hasMergedServers = representedServerCount > 1; final isAndroid = Platform.isAndroid; final compact = widget.compact; final cardWidth = compact ? 130.0 : (isAndroid ? 170.0 : 220.0); final cardHeight = compact ? 52.0 : (isAndroid ? 100.0 : 120.0); final radius = compact ? 10.0 : 16.0; final textColor = Colors.white.withValues(alpha: 0.85); final card = SizedBox( width: cardWidth, height: cardHeight, child: Stack( fit: StackFit.expand, children: [ FrostedPanel( radius: radius, padding: compact ? const EdgeInsets.symmetric(horizontal: 10, vertical: 6) : const EdgeInsets.symmetric(horizontal: 12, vertical: 10), blurSigma: 15, border: Border.all(color: Colors.white.withValues(alpha: 0.08)), color: highlighted ? const Color(0xFF4F8DFF).withValues(alpha: 0.18) : Colors.white.withValues(alpha: 0.10), child: compact ? _buildCompactContent(data, hasMergedServers) : Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Expanded( child: Text( data.topLabel, style: TextStyle( fontSize: isAndroid ? 16 : 18, fontWeight: FontWeight.w700, color: Colors.white, height: 1.1, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (isCurrent) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: const Color(0xFF4F8DFF), borderRadius: BorderRadius.circular(999), ), child: const Text( '当前', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white, ), ), ), ], ), if (showInfoRow) Row( children: [ if (hasSize) ...[ Icon( Icons.movie_outlined, size: isAndroid ? 13 : 14, color: textColor, ), SizedBox(width: isAndroid ? 3 : 4), Flexible( child: Text( formatFileSize(sizeBytes), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: isAndroid ? 12 : 13, color: textColor, ), ), ), ], if (hasSize && hasBitrate) SizedBox(width: isAndroid ? 6 : 12), if (hasBitrate) ...[ Icon( Icons.equalizer, size: isAndroid ? 13 : 14, color: textColor, ), SizedBox(width: isAndroid ? 3 : 4), Flexible( child: Text( '${(bitrate / 1e6).toStringAsFixed(2)}Mbps', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: isAndroid ? 12 : 13, color: textColor, ), ), ), ], ], ), Row( children: [ Container( width: 8, height: 8, decoration: const BoxDecoration( color: Color(0xFF4F8DFF), shape: BoxShape.circle, ), ), const SizedBox(width: 6), Expanded( child: Text( data.serverName, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: Colors.white, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (hasMergedServers) ...[ const SizedBox(width: 6), _MergedServersButton( serverCount: representedServerCount, onTap: widget.onMergedServersTap, ), ], const SizedBox(width: 6), Icon( Icons.bolt, size: 14, color: Colors.white.withValues(alpha: 0.85), ), ], ), ], ), ), if (highlighted) Positioned.fill( child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(radius), border: Border.all( color: const Color(0xFF4F8DFF), width: 1.5, ), ), ), ), ), if (_loading) Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(radius), child: Container( color: Colors.black.withValues(alpha: 0.3), child: Center( child: AppLoadingRing( size: compact ? 14.0 : 18.0, color: Colors.white, ), ), ), ), ), ], ), ); final tappable = GestureDetector(onTap: _open, child: card); final tooltip = data.tooltip; if (tooltip == null) return tappable; return Tooltip(message: tooltip, child: tappable); } Widget _buildCompactContent( VersionSourceCardData data, bool hasMergedServers, ) { final sizeBytes = data.sizeBytes; final secondaryColor = Colors.white.withValues(alpha: 0.65); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ Expanded( child: Text( data.topLabel, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: Colors.white, height: 1.1, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (data.isCurrent) Container( margin: const EdgeInsets.only(left: 4), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: const Color(0xFF4F8DFF), borderRadius: BorderRadius.circular(999), ), child: const Text( '当前', style: TextStyle( fontSize: 9, fontWeight: FontWeight.w600, color: Colors.white, ), ), ), if (hasMergedServers) ...[ const SizedBox(width: 4), _MergedServersButton( serverCount: data.mergedServerNames.length, onTap: widget.onMergedServersTap, compact: true, ), ], ], ), const SizedBox(height: 3), Text( [ if (sizeBytes != null && sizeBytes > 0) formatFileSize(sizeBytes), data.serverName, ].join(' · '), style: TextStyle(fontSize: 11, color: secondaryColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ); } } class _MergedServersButton extends StatelessWidget { final int serverCount; final VoidCallback? onTap; final bool compact; const _MergedServersButton({ required this.serverCount, required this.onTap, this.compact = false, }); @override Widget build(BuildContext context) { final content = Container( padding: EdgeInsets.symmetric( horizontal: compact ? 4 : 6, vertical: compact ? 1 : 2, ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( '$serverCount服', style: TextStyle( fontSize: compact ? 9 : 10, fontWeight: FontWeight.w600, color: Colors.white70, ), ), const SizedBox(width: 2), Icon( Icons.keyboard_arrow_down_rounded, size: compact ? 11 : 13, color: Colors.white70, ), ], ), ); final callback = onTap; if (callback == null) return content; return Tooltip( message: '选择其他服务器', child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: callback, child: content, ), ), ); } }