321 lines
9.4 KiB
Dart
321 lines
9.4 KiB
Dart
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../utils/format_utils.dart';
|
|
import 'adaptive_modal.dart';
|
|
import 'app_loading_ring.dart';
|
|
import 'app_snack_bar.dart';
|
|
import 'version_filter.dart';
|
|
import 'version_grouping.dart';
|
|
import 'version_source_card.dart';
|
|
|
|
const int kCompactInlineVersionLimit = 6;
|
|
const int kDesktopInlineVersionLimit = 8;
|
|
|
|
int inlineVersionLimit() {
|
|
return Platform.isAndroid
|
|
? kCompactInlineVersionLimit
|
|
: kDesktopInlineVersionLimit;
|
|
}
|
|
|
|
Future<void> showAllVersionsSheet({
|
|
required BuildContext context,
|
|
required List<VersionSourceGroup> groups,
|
|
String title = '全部版本',
|
|
}) async {
|
|
final isSingleVersionPicker = groups.length == 1;
|
|
await showAdaptiveModal<void>(
|
|
context: context,
|
|
maxWidth: isSingleVersionPicker ? 520 : 720,
|
|
maxHeight: isSingleVersionPicker ? 300 : 640,
|
|
compactMaxHeightFactor: isSingleVersionPicker ? 0.55 : 0.92,
|
|
compactHeightFactor: isSingleVersionPicker ? null : 0.82,
|
|
builder: (modalContext) => AllVersionsSheet(groups: groups, title: title),
|
|
);
|
|
}
|
|
|
|
|
|
class AllVersionsSheet extends StatelessWidget {
|
|
final List<VersionSourceGroup> groups;
|
|
final String title;
|
|
|
|
const AllVersionsSheet({
|
|
super.key,
|
|
required this.groups,
|
|
this.title = '全部版本',
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final groupsByBucket = <String, List<VersionSourceGroup>>{};
|
|
for (final group in groups) {
|
|
final bucket = versionBucketOf(group.representative.resolutionLabel);
|
|
groupsByBucket.putIfAbsent(bucket, () => []).add(group);
|
|
}
|
|
|
|
final concreteSourceCount = groups.fold<int>(
|
|
0,
|
|
(count, group) => count + group.members.length,
|
|
);
|
|
|
|
return Material(
|
|
color: const Color(0xFF171A20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 8, 10),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.video_library_outlined,
|
|
color: Colors.white70,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'$concreteSourceCount 个来源',
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.55),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.close, color: Colors.white60),
|
|
tooltip: '关闭',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 1, color: Colors.white.withValues(alpha: 0.08)),
|
|
Flexible(
|
|
fit: FlexFit.loose,
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
|
children: [
|
|
for (final bucket in kVersionBucketOrder)
|
|
if (groupsByBucket[bucket] case final bucketGroups?) ...[
|
|
_BucketHeader(label: bucket, count: bucketGroups.length),
|
|
const SizedBox(height: 8),
|
|
for (
|
|
var index = 0;
|
|
index < bucketGroups.length;
|
|
index++
|
|
) ...[
|
|
_VersionGroupRow(group: bucketGroups[index]),
|
|
if (index < bucketGroups.length - 1)
|
|
const SizedBox(height: 10),
|
|
],
|
|
const SizedBox(height: 20),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BucketHeader extends StatelessWidget {
|
|
final String label;
|
|
final int count;
|
|
|
|
const _BucketHeader({required this.label, required this.count});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'$count 个版本',
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.5),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VersionGroupRow extends StatelessWidget {
|
|
final VersionSourceGroup group;
|
|
|
|
const _VersionGroupRow({required this.group});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final representative = group.representative;
|
|
final sizeBytes = representative.sizeBytes;
|
|
final bitrate = representative.bitrate;
|
|
final details = <String>[
|
|
if (sizeBytes != null && sizeBytes > 0) formatFileSize(sizeBytes),
|
|
if (bitrate != null && bitrate > 0)
|
|
'${(bitrate / 1e6).toStringAsFixed(2)}Mbps',
|
|
];
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.06),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
representative.topLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
if (details.isNotEmpty)
|
|
Text(
|
|
details.join(' · '),
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.58),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final member in group.members)
|
|
_ServerSourceChip(data: member),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ServerSourceChip extends StatefulWidget {
|
|
final VersionSourceCardData data;
|
|
|
|
const _ServerSourceChip({required this.data});
|
|
|
|
@override
|
|
State<_ServerSourceChip> createState() => _ServerSourceChipState();
|
|
}
|
|
|
|
class _ServerSourceChipState extends State<_ServerSourceChip> {
|
|
bool _loading = false;
|
|
|
|
Future<void> _selectSource() async {
|
|
final data = widget.data;
|
|
if (_loading || data.isCurrent || data.selected || data.onTap == null) {
|
|
return;
|
|
}
|
|
|
|
setState(() => _loading = true);
|
|
try {
|
|
await data.onTap!();
|
|
if (mounted) Navigator.of(context).pop();
|
|
} catch (_) {
|
|
if (mounted) {
|
|
setState(() => _loading = false);
|
|
showAppSnackBar(context, '切换服务器失败', tone: AppSnackTone.error);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = widget.data;
|
|
final active = data.isCurrent || data.selected;
|
|
|
|
return Tooltip(
|
|
message: active ? '${data.serverName}(当前选择)' : '切换到 ${data.serverName}',
|
|
child: InkWell(
|
|
onTap: active ? null : _selectSource,
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
|
decoration: BoxDecoration(
|
|
color: active
|
|
? const Color(0xFF4F8DFF).withValues(alpha: 0.24)
|
|
: Colors.white.withValues(alpha: 0.07),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(
|
|
color: active
|
|
? const Color(0xFF4F8DFF)
|
|
: Colors.white.withValues(alpha: 0.12),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_loading)
|
|
const AppLoadingRing(size: 12, color: Colors.white)
|
|
else
|
|
Icon(
|
|
active ? Icons.check_circle : Icons.dns_outlined,
|
|
size: 14,
|
|
color: active ? const Color(0xFF77A7FF) : Colors.white60,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
data.serverName,
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: active ? 1 : 0.78),
|
|
fontSize: 12,
|
|
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
|
|
),
|
|
),
|
|
if (data.isCurrent) ...[
|
|
const SizedBox(width: 6),
|
|
const Text(
|
|
'当前',
|
|
style: TextStyle(
|
|
color: Color(0xFF9FC0FF),
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|