ver1.00.00

update
This commit is contained in:
spasolreisa
2026-04-21 00:28:41 +08:00
parent b985cd1f9e
commit f5f62c828d
13 changed files with 1496 additions and 175 deletions

755
lib/pages/music/adx.dart Normal file
View 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),
),
);
}
}