Files
UnionApp/lib/pages/music/adx.dart
spasolreisa f5f62c828d ver1.00.00
update
2026-04-21 00:28:41 +08:00

755 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
);
}
}