Initial commit
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user