Files
2026-07-14 11:11:36 +08:00

494 lines
15 KiB
Dart

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<String> mergedServerNames;
final bool isCurrent;
final bool selected;
final Future<void> 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<void> 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<void> 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 = <String>[];
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<VersionSourceCard> createState() => _VersionSourceCardState();
}
class _VersionSourceCardState extends State<VersionSourceCard> {
bool _loading = false;
Future<void> _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,
),
),
);
}
}