ver1.00.00
bindQR
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user