ver1.00.00
update
This commit is contained in:
755
lib/pages/music/adx.dart
Normal file
755
lib/pages/music/adx.dart
Normal file
@@ -0,0 +1,755 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'dart:io';
|
||||
import '../../../model/song_model.dart';
|
||||
import '../../../service/song_service.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../tool/cacheImage.dart';
|
||||
|
||||
class AdxDownloadGridPage extends StatefulWidget {
|
||||
const AdxDownloadGridPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdxDownloadGridPage> createState() => _AdxDownloadGridPageState();
|
||||
}
|
||||
|
||||
class _AdxDownloadGridPageState extends State<AdxDownloadGridPage> with SingleTickerProviderStateMixin {
|
||||
bool _isLoading = true;
|
||||
String _error = '';
|
||||
List<SongModel> _songs = [];
|
||||
List<SongModel> _displaySongs = [];
|
||||
|
||||
// 记录正在下载的歌曲ID与类型,避免重复点击
|
||||
final Map<String, bool> _downloading = {};
|
||||
|
||||
// 筛选相关
|
||||
String _searchQuery = '';
|
||||
int? _filterLevelType;
|
||||
Set<String> _filterVersions = {};
|
||||
Set<String> _filterGenres = {};
|
||||
double? _selectedMinLevel;
|
||||
double? _selectedMaxLevel;
|
||||
bool _isAdvancedFilterExpanded = false;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
static const List<double> _levelOptions = [
|
||||
1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5,
|
||||
6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 10.5,
|
||||
11.0, 11.5, 12.0, 12.5,
|
||||
13.0, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7, 13.8, 13.9,
|
||||
14.0, 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7, 14.8, 14.9,
|
||||
15.0,
|
||||
];
|
||||
|
||||
late FixedExtentScrollController _minLevelScrollController;
|
||||
late FixedExtentScrollController _maxLevelScrollController;
|
||||
Set<String> _availableVersions = {};
|
||||
Set<String> _availableGenres = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
_minLevelScrollController = FixedExtentScrollController(initialItem: 0);
|
||||
_maxLevelScrollController = FixedExtentScrollController(initialItem: _levelOptions.length - 1);
|
||||
_loadSongs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_minLevelScrollController.dispose();
|
||||
_maxLevelScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSongs() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final songs = await SongService.getAllSongs();
|
||||
// 去重
|
||||
final map = <int, SongModel>{};
|
||||
for (var s in songs) {
|
||||
map.putIfAbsent(s.id, () => s);
|
||||
}
|
||||
_songs = map.values.toList();
|
||||
_displaySongs = List.from(_songs);
|
||||
|
||||
// 初始化筛选选项
|
||||
_availableVersions = songs.map((s) => s.from).where((v) => v.isNotEmpty).toSet();
|
||||
_availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet();
|
||||
|
||||
setState(() => _error = '');
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// 核心:下载 ADX
|
||||
Future<void> _downloadAdx(SongModel song, String type) async {
|
||||
final key = "${song.id}_$type";
|
||||
if (_downloading[key] == true) return;
|
||||
|
||||
setState(() => _downloading[key] = true);
|
||||
try {
|
||||
int id = type == 'SD' ? song.id : song.id + 10000;
|
||||
final url = "https://cdn.godserver.cn/resource/static/adx/$id.adx";
|
||||
|
||||
final res = await http.get(Uri.parse(url));
|
||||
if (res.statusCode != 200) throw Exception("文件不存在");
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final safeTitle = song.title?.replaceAll(RegExp(r'[^\w\s\u4e00-\u9fa5]'), '_') ?? "song";
|
||||
final file = File("${dir.path}/${safeTitle}_$type.adx");
|
||||
await file.writeAsBytes(res.bodyBytes);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("✅ ${song.title} ($type) 下载完成")),
|
||||
);
|
||||
await OpenFile.open(file.path);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("❌ 下载失败:$e")),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _downloading[key] = false);
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选应用
|
||||
void _applyFilters() {
|
||||
final q = _normalizeSearch(_searchQuery);
|
||||
_displaySongs = _songs.where((song) {
|
||||
// 搜索过滤
|
||||
if (q.isNotEmpty) {
|
||||
bool match = false;
|
||||
if (_normalizeSearch(song.title).contains(q)) match = true;
|
||||
if (_normalizeSearch(song.artist).contains(q)) match = true;
|
||||
if (song.id.toString().contains(q)) match = true;
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
// 版本过滤
|
||||
if (_filterVersions.isNotEmpty && !_filterVersions.contains(song.from)) return false;
|
||||
|
||||
// 流派过滤
|
||||
if (_filterGenres.isNotEmpty && !_filterGenres.contains(song.genre)) return false;
|
||||
|
||||
// 难度类型过滤
|
||||
if (_filterLevelType != null) {
|
||||
bool hasLevel = false;
|
||||
final allDiffs = _getAllDifficultiesWithType(song);
|
||||
for (var d in allDiffs) {
|
||||
if (d['diff']['level_id'] == _filterLevelType) {
|
||||
hasLevel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasLevel) return false;
|
||||
}
|
||||
|
||||
// 定数范围过滤
|
||||
if (_selectedMinLevel != null || _selectedMaxLevel != null) {
|
||||
bool inRange = false;
|
||||
final allDiffs = _getAllDifficultiesWithType(song);
|
||||
for (var d in allDiffs) {
|
||||
final lv = double.tryParse(d['diff']['level_value']?.toString() ?? '');
|
||||
if (lv == null) continue;
|
||||
final minOk = _selectedMinLevel == null || lv >= _selectedMinLevel!;
|
||||
final maxOk = _selectedMaxLevel == null || lv <= _selectedMaxLevel!;
|
||||
if (minOk && maxOk) {
|
||||
inRange = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inRange) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
String _normalizeSearch(String? s) {
|
||||
if (s == null) return '';
|
||||
return s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// 获取所有难度
|
||||
List<Map<String, dynamic>> _getAllDifficultiesWithType(SongModel song) {
|
||||
List<Map<String, dynamic>> all = [];
|
||||
if (song.sd != null && song.sd!.isNotEmpty) {
|
||||
for (var d in song.sd!.values) {
|
||||
all.add({'type': 'SD', 'diff': d});
|
||||
}
|
||||
}
|
||||
if (song.dx != null && song.dx!.isNotEmpty) {
|
||||
for (var d in song.dx!.values) {
|
||||
all.add({'type': 'DX', 'diff': d});
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
void _resetFilters() {
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_filterLevelType = null;
|
||||
_filterVersions.clear();
|
||||
_filterGenres.clear();
|
||||
_selectedMinLevel = null;
|
||||
_selectedMaxLevel = null;
|
||||
});
|
||||
_minLevelScrollController.jumpToItem(0);
|
||||
_maxLevelScrollController.jumpToItem(_levelOptions.length - 1);
|
||||
_applyFilters();
|
||||
}
|
||||
|
||||
// 定数范围文本
|
||||
String _getLevelRangeText() {
|
||||
if (_selectedMinLevel == null && _selectedMaxLevel == null) return "全部";
|
||||
final min = _selectedMinLevel?.toStringAsFixed(1) ?? "1.0";
|
||||
final max = _selectedMaxLevel?.toStringAsFixed(1) ?? "15.0";
|
||||
return "$min ~ $max";
|
||||
}
|
||||
|
||||
// 定数选择器
|
||||
void _showLevelPickerDialog() {
|
||||
int minIdx = _selectedMinLevel != null ? _levelOptions.indexOf(_selectedMinLevel!) : 0;
|
||||
int maxIdx = _selectedMaxLevel != null ? _levelOptions.indexOf(_selectedMaxLevel!) : _levelOptions.length - 1;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_minLevelScrollController.jumpToItem(minIdx);
|
||||
_maxLevelScrollController.jumpToItem(maxIdx);
|
||||
});
|
||||
|
||||
return Container(
|
||||
height: 300,
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedMinLevel = null;
|
||||
_selectedMaxLevel = null;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text("重置", style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
const Text("定数范围", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final min = _levelOptions[_minLevelScrollController.selectedItem];
|
||||
final max = _levelOptions[_maxLevelScrollController.selectedItem];
|
||||
setState(() {
|
||||
_selectedMinLevel = min;
|
||||
_selectedMaxLevel = max;
|
||||
_applyFilters();
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text("确定", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text("最小定数", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
scrollController: _minLevelScrollController,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (_) {},
|
||||
children: _levelOptions.map((e) => Center(child: Text(e.toStringAsFixed(1)))).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text("最大定数", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
scrollController: _maxLevelScrollController,
|
||||
itemExtent: 40,
|
||||
onSelectedItemChanged: (_) {}, children: [],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 多选筛选构建
|
||||
Widget _buildMultiSelectChip({
|
||||
required String label,
|
||||
required Set<String> selectedValues,
|
||||
required Set<String> allOptions,
|
||||
required Function(Set<String>) onSelectionChanged,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
_showMultiSelectDialog(
|
||||
context,
|
||||
title: label,
|
||||
allOptions: allOptions.toList()..sort(),
|
||||
selectedValues: selectedValues,
|
||||
onConfirm: (newSelection) {
|
||||
setState(() {
|
||||
onSelectionChanged(newSelection);
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
selectedValues.isEmpty ? "全部" : "已选 ${selectedValues.length}",
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 多选对话框
|
||||
void _showMultiSelectDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<String> allOptions,
|
||||
required Set<String> selectedValues,
|
||||
required Function(Set<String>) onConfirm,
|
||||
}) {
|
||||
final tempSelection = Set<String>.from(selectedValues);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: allOptions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = allOptions[index];
|
||||
final isSelected = tempSelection.contains(option);
|
||||
return CheckboxListTile(
|
||||
title: Text(option),
|
||||
value: isSelected,
|
||||
onChanged: (bool? value) {
|
||||
setDialogState(() {
|
||||
if (value == true) {
|
||||
tempSelection.add(option);
|
||||
} else {
|
||||
tempSelection.remove(option);
|
||||
}
|
||||
});
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
dense: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setDialogState(() => tempSelection.clear());
|
||||
},
|
||||
child: const Text("清空"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("取消"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
onConfirm(tempSelection);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("确定"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 下拉框
|
||||
Widget _buildDropdown({required String label, required dynamic value, required List<DropdownMenuItem> items, required ValueChanged onChanged}) {
|
||||
return DropdownButtonFormField(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), isDense: false),
|
||||
items: items,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
|
||||
// 是否有筛选条件
|
||||
bool _hasFilters() =>
|
||||
_searchQuery.isNotEmpty ||
|
||||
_filterLevelType != null ||
|
||||
_filterVersions.isNotEmpty ||
|
||||
_filterGenres.isNotEmpty ||
|
||||
_selectedMinLevel != null ||
|
||||
_selectedMaxLevel != null;
|
||||
|
||||
String _coverUrl(int id) {
|
||||
final d = id % 10000;
|
||||
if (id >= 16000 && id <= 20000) {
|
||||
final s = d.toString().padLeft(6, '0');
|
||||
return "https://u.mai2.link/jacket/UI_Jacket_$s.jpg";
|
||||
} else {
|
||||
return "https://cdn.godserver.cn/resource/static/mai/cover/$d.png";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("ADX 谱面下载库"),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadSongs,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildFilterBar(),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 筛选栏
|
||||
Widget _buildFilterBar() {
|
||||
return Container(
|
||||
color: Theme.of(context).cardColor,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "搜索歌名/艺术家/ID",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(25)),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (v) {
|
||||
setState(() => _searchQuery = v);
|
||||
_applyFilters();
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(_isAdvancedFilterExpanded ? Icons.expand_less : Icons.expand_more),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isAdvancedFilterExpanded = !_isAdvancedFilterExpanded;
|
||||
_isAdvancedFilterExpanded ? _animationController.forward() : _animationController.reverse();
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_hasFilters())
|
||||
IconButton(icon: const Icon(Icons.clear, color: Colors.redAccent), onPressed: _resetFilters),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDropdown(
|
||||
label: "难度类型",
|
||||
value: _filterLevelType,
|
||||
items: const [
|
||||
DropdownMenuItem(value: null, child: Text("全部")),
|
||||
DropdownMenuItem(value: 0, child: Text("Basic")),
|
||||
DropdownMenuItem(value: 1, child: Text("Advanced")),
|
||||
DropdownMenuItem(value: 2, child: Text("Expert")),
|
||||
DropdownMenuItem(value: 3, child: Text("Master")),
|
||||
DropdownMenuItem(value: 4, child: Text("Re:Master")),
|
||||
],
|
||||
onChanged: (v) {
|
||||
setState(() => _filterLevelType = v);
|
||||
_applyFilters();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizeTransition(
|
||||
sizeFactor: _animation,
|
||||
axisAlignment: -1,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.grey.withAlpha(20), border: Border(top: BorderSide(color: Colors.grey.shade300))),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMultiSelectChip(
|
||||
label: "版本",
|
||||
selectedValues: _filterVersions,
|
||||
allOptions: _availableVersions,
|
||||
onSelectionChanged: (newSet) {
|
||||
_filterVersions = newSet;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildMultiSelectChip(
|
||||
label: "流派",
|
||||
selectedValues: _filterGenres,
|
||||
allOptions: _availableGenres,
|
||||
onSelectionChanged: (newSet) {
|
||||
_filterGenres = newSet;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
onTap: _showLevelPickerDialog,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(4)),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("定数范围", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(_getLevelRangeText(), style: TextStyle(color: Colors.grey[600])),
|
||||
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error.isNotEmpty) {
|
||||
return Center(child: Text(_error, style: const TextStyle(color: Colors.red)));
|
||||
}
|
||||
if (_displaySongs.isEmpty) {
|
||||
return const Center(child: Text("暂无匹配歌曲"));
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
itemCount: _displaySongs.length,
|
||||
itemBuilder: (ctx, i) => _songCard(_displaySongs[i]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _songCard(SongModel song) {
|
||||
final cover = _coverUrl(song.id);
|
||||
final hasSD = song.sd?.isNotEmpty ?? false;
|
||||
final hasDX = song.dx?.isNotEmpty ?? false;
|
||||
final keySD = "${song.id}_SD";
|
||||
final keyDX = "${song.id}_DX";
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
child: CacheImage.network(
|
||||
cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.music_note, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"[${song.id}] ${song.title}",
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
song.artist ?? "Unknown",
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasSD)
|
||||
Expanded(
|
||||
child: _downloadBtn(
|
||||
text: "SD",
|
||||
color: Colors.green,
|
||||
loading: _downloading[keySD] ?? false,
|
||||
onTap: () => _downloadAdx(song, 'SD'),
|
||||
),
|
||||
),
|
||||
if (hasSD && hasDX) const SizedBox(width: 6),
|
||||
if (hasDX)
|
||||
Expanded(
|
||||
child: _downloadBtn(
|
||||
text: "DX",
|
||||
color: Colors.purple,
|
||||
loading: _downloading[keyDX] ?? false,
|
||||
onTap: () => _downloadAdx(song, 'DX'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _downloadBtn({
|
||||
required String text,
|
||||
required Color color,
|
||||
required bool loading,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ElevatedButton(
|
||||
onPressed: loading ? null : onTap,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
text,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user