ver1.00.00

bindQR
This commit is contained in:
spasolreisa
2026-04-19 22:22:04 +08:00
parent 74e47971ca
commit c5c3f7c8f5
14 changed files with 920 additions and 161 deletions

View File

@@ -3,7 +3,8 @@ import 'package:flutter/cupertino.dart';
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 'package:open_file/open_file.dart';
import 'package:webview_flutter/webview_flutter.dart'; // 【新增】导入 webview
import '../../../model/song_model.dart';
import '../../../providers/user_provider.dart';
import '../../../service/song_service.dart';
@@ -28,17 +29,34 @@ class SongDetailPage extends StatefulWidget {
class _SongDetailPageState extends State<SongDetailPage> {
String? _selectedType;
bool _isAliasExpanded = false;
// 用于跟踪下载状态key为 "type_levelId"
final Map<String, bool> _isDownloading = {};
bool _isDownloading = false;
final Map<String, List<Map<String, dynamic>>> _chartDataCache = {};
final Map<String, bool> _isLoadingChart = {};
// 【新增】用于控制 WebView 显示和加载 URL
String? _previewUrl;
late WebViewController _webViewController;
@override
void initState() {
super.initState();
_initSelectedType();
// 【新增】初始化 WebViewController
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
// 可选:更新加载进度
},
onPageStarted: (String url) {},
onPageFinished: (String url) {},
onHttpError: (HttpResponseError error) {},
onWebResourceError: (WebResourceError error) {},
),
);
}
void _initSelectedType() {
@@ -82,74 +100,50 @@ class _SongDetailPageState extends State<SongDetailPage> {
return all;
}
// ===================== 【新增】下载并打开 ADX 谱面功能 =====================
Future<void> _downloadAndOpenAdx(String type, Map diff) async {
int levelId = diff['level_id'] ?? 0;
String downloadKey = "${type}_$levelId";
// 防止重复点击
if (_isDownloading[downloadKey] == true) return;
Future<void> _downloadCurrentTypeAdx() async {
if (_isDownloading) return;
if (_selectedType == null || _selectedType == 'UT') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("仅支持下载 SD 或 DX 谱面")),
);
return;
}
setState(() {
_isDownloading[downloadKey] = true;
_isDownloading = true;
});
try {
// 1. 构建 URL
int songId = widget.song.id;
String url;
// UT 谱面通常没有标准的 CDN adx 下载地址,这里只处理 SD 和 DX
if (type == 'UT') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("UT 宴会谱暂不支持直接下载 ADX 文件")),
);
setState(() => _isDownloading[downloadKey] = false);
return;
}
String type = _selectedType!;
if (type == 'SD') {
// SD: cdn.godserver.cn/resource/static/adx/00000/{songId}.adx
String idStr = songId.toString().padLeft(5, '0');
String idStr = songId.toString();
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
} else {
// DX: cdn.godserver.cn/resource/static/adx/00000/{songId+10000}.adx
int dxId = songId + 10000;
String idStr = dxId.toString().padLeft(5, '0');
String idStr = dxId.toString();
url = "https://cdn.godserver.cn/resource/static/adx/$idStr.adx";
}
// 2. 下载文件
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception("下载失败: HTTP ${response.statusCode}");
}
// 3. 获取临时目录并保存文件
final directory = await getApplicationDocumentsDirectory();
// 注意iOS 上 getApplicationDocumentsDirectory 是持久化的。
// 如果希望每次下载都清理,可以使用 getTemporaryDirectory()。
// 这里为了稳定性,使用 DocumentsDirectory但文件名保持唯一性。
// 生成文件名: SongTitle_Type_Level.adx
String safeTitle = widget.song.title?.replaceAll(RegExp(r'[^\w\s\u4e00-\u9fa5]'), '_') ?? "song";
String fileName = "${safeTitle}_${type}_Lv${levelId}.adx";
// 确保文件名不冲突,可以加时间戳或者随机数,这里简单处理
String fileName = "${safeTitle}_$type.adx";
final filePath = "${directory.path}/$fileName";
final file = File(filePath);
await file.writeAsBytes(response.bodyBytes);
// 4. 调用系统原生打开
// open_file 会尝试寻找能打开 .adx 的应用
final result = await OpenFile.open(filePath);
if (result.type != ResultType.done) {
// 如果没有安装能打开 adx 的应用,提示用户
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("无法打开文件: ${result.message},请确保已安装谱面编辑器")),
SnackBar(content: Text("无法打开文件: ${result.message}")),
);
}
@@ -160,29 +154,26 @@ class _SongDetailPageState extends State<SongDetailPage> {
);
} finally {
setState(() {
_isDownloading[downloadKey] = false;
_isDownloading = false;
});
}
}
// 【新增】加载图表数据逻辑保持不变...
Future<void> _loadTypeChartData(String type) async {
if (_chartDataCache.containsKey(type) || _isLoadingChart[type] == true) {
return;
}
setState(() {
_isLoadingChart[type] = true;
});
try {
final userProvider = UserProvider.instance;
final token = userProvider.token;
if (token == null || token.isEmpty) {
setState(() => _isLoadingChart[type] = false);
return;
}
int apiMusicId;
if (type == 'SD') {
apiMusicId = widget.song.id;
@@ -192,10 +183,8 @@ class _SongDetailPageState extends State<SongDetailPage> {
setState(() => _isLoadingChart[type] = false);
return;
}
final logs = await UserService.getChartLog(token, [apiMusicId]);
logs.sort((a, b) => a.time.compareTo(b.time));
final chartList = logs.map((log) => {
'achievement': log.segaChartNew?.achievement ?? 0,
'time': log.time,
@@ -206,7 +195,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
'playCount': log.segaChartNew?.playCount ?? 0,
'level': log.segaChartNew?.level ?? 0,
}).toList();
setState(() {
_chartDataCache[type] = chartList;
_isLoadingChart[type] = false;
@@ -221,9 +209,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) {
return;
}
setState(() => _isLoadingChart[cacheKey] = true);
try {
final userProvider = UserProvider.instance;
final token = userProvider.token;
@@ -231,10 +217,8 @@ class _SongDetailPageState extends State<SongDetailPage> {
setState(() => _isLoadingChart[cacheKey] = false);
return;
}
final logs = await UserService.getChartLog(token, [realId]);
logs.sort((a, b) => a.time.compareTo(b.time));
final chartList = logs.map((log) => {
'achievement': log.segaChartNew?.achievement ?? 0,
'time': log.time,
@@ -244,7 +228,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0,
'playCount': log.segaChartNew?.playCount ?? 0,
}).toList();
setState(() {
_chartDataCache[cacheKey] = chartList;
_isLoadingChart[cacheKey] = false;
@@ -324,6 +307,27 @@ class _SongDetailPageState extends State<SongDetailPage> {
@override
Widget build(BuildContext context) {
// 【新增】如果存在预览 URL则显示 WebView 页面
if (_previewUrl != null) {
return Scaffold(
appBar: AppBar(
title: const Text("谱面预览"),
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
_previewUrl = null;
});
},
),
],
),
body: WebViewWidget(controller: _webViewController),
);
}
// 否则显示原有的详情页面
final coverUrl = _getCoverUrl(widget.song.id);
final diffs = _getCurrentDifficulties();
final availableTypes = _getAvailableTypes();
@@ -332,13 +336,24 @@ class _SongDetailPageState extends State<SongDetailPage> {
appBar: AppBar(
title: Text(widget.song.title ?? "歌曲详情"),
elevation: 2,
actions: [
if (_selectedType != 'UT' && _selectedType != null)
IconButton(
icon: _isDownloading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.download_rounded),
tooltip: "下载 ${_selectedType} 谱面",
onPressed: _isDownloading ? null : _downloadCurrentTypeAdx,
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
// 【修改】使用 Column 布局,将固定内容和滚动内容分离
body: Column(
children: [
// 1. 固定顶部:歌曲信息卡片
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 4,
shadowColor: Colors.purpleAccent.withOpacity(0.2),
@@ -401,69 +416,79 @@ class _SongDetailPageState extends State<SongDetailPage> {
),
),
),
),
const SizedBox(height: 20),
if (availableTypes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
const Text(
"难度详情",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(width: 12),
if (availableTypes.length > 1)
Expanded(
child: CupertinoSlidingSegmentedControl<String>(
groupValue: _selectedType,
backgroundColor: Colors.grey.shade200,
thumbColor: Theme.of(context).primaryColor,
children: {
for (var type in availableTypes)
type: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Text(
type,
style: TextStyle(
color: _selectedType == type ? Colors.white : Colors.black87,
fontWeight: FontWeight.w600,
),
),
// 2. 滚动区域:难度详情
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12), // 注意vertical padding 可以减小或移除,因为顶部已有 padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (availableTypes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
const Text(
"难度详情",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(width: 12),
if (availableTypes.length > 1)
Expanded(
child: CupertinoSlidingSegmentedControl<String>(
groupValue: _selectedType,
backgroundColor: Colors.grey.shade200,
thumbColor: Theme.of(context).primaryColor,
children: {
for (var type in availableTypes)
type: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Text(
type,
style: TextStyle(
color: _selectedType == type ? Colors.white : Colors.black87,
fontWeight: FontWeight.w600,
),
),
),
},
onValueChanged: (val) {
if (val != null) setState(() => _selectedType = val);
},
),
},
onValueChanged: (val) {
if (val != null) setState(() => _selectedType = val);
},
),
),
],
),
),
const SizedBox(height: 12),
],
),
if (diffs.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(30),
child: Text("暂无谱面数据", style: TextStyle(color: Colors.grey)),
),
)
else
...diffs.map((item) => _diffItem(
type: item['type'],
diff: item['diff'],
)),
const SizedBox(height: 30),
],
),
const SizedBox(height: 12),
],
if (diffs.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(30),
child: Text("暂无谱面数据", style: TextStyle(color: Colors.grey)),
),
)
else
...diffs.map((item) => _diffItem(
type: item['type'],
diff: item['diff'],
)),
const SizedBox(height: 30),
],
),
),
),
],
),
);
}
// 【修改】_diffItem 方法,增加预览按钮
Widget _diffItem({required String type, required Map diff}) {
int levelId = diff['level_id'] ?? 0;
final double lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0;
@@ -536,10 +561,6 @@ class _SongDetailPageState extends State<SongDetailPage> {
final rating1003 = _calculateRating(lvValue, 100.3);
final rating1005 = _calculateRating(lvValue, 100.5);
// 下载状态的 Key
String downloadKey = "${type}_$levelId";
bool isDownloading = _isDownloading[downloadKey] ?? false;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
@@ -570,38 +591,38 @@ class _SongDetailPageState extends State<SongDetailPage> {
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasUserScore)
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
// ===================== 【新增】下载/预览按钮 =====================
const SizedBox(width: 8),
InkWell(
onTap: isDownloading || type == 'UT' ? null : () {
_downloadAndOpenAdx(type, diff);
// 【新增】谱面预览按钮
IconButton(
icon: const Icon(Icons.preview_rounded, size: 22),
tooltip: "查看谱面预览",
onPressed: () {
// 构造 URL
// 规则: https://maimai.lxns.net/chart?chart_id={id}&difficulty={level}
int chartId;
if (type == 'SD') {
chartId = widget.song.id;
} else if (type == 'DX') {
chartId = widget.song.id + 10000;
} else {
// UT 情况,通常 UT 的 ID 在 diff['id'] 中,或者需要特殊处理
// 这里假设 UT 也使用类似的 ID 逻辑,如果 lxns 支持 UT 预览的话
// 如果 lxns 不支持 UT可以禁用此按钮或提示
chartId = (diff['id'] as num?)?.toInt() ?? 0;
}
final url = "https://maimai.lxns.net/chart?chart_id=$chartId&difficulty=$levelId";
// 加载 URL 并切换视图
_webViewController.loadRequest(Uri.parse(url));
setState(() {
_previewUrl = url;
});
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: type == 'UT' ? Colors.grey.shade300 : Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: isDownloading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
: Icon(
Icons.download_rounded,
size: 18,
color: type == 'UT' ? Colors.grey : color,
),
),
),
],
),
@@ -838,7 +859,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
GradientText(
data: "${ach.toStringAsFixed(4)}%",
data:"${ach.toStringAsFixed(4)}%",
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
gradientLayers: [
GradientLayer(