494 lines
15 KiB
Dart
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,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|