import 'dart:io'; 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 '../../../model/song_model.dart'; import '../../../providers/user_provider.dart'; import '../../../service/song_service.dart'; import '../../../service/user_service.dart'; import '../../../widgets/score_progress_chart.dart'; import '../../tool/gradientText.dart'; class SongDetailPage extends StatefulWidget { final SongModel song; final Map> userScoreCache; const SongDetailPage({ super.key, required this.song, required this.userScoreCache, }); @override State createState() => _SongDetailPageState(); } class _SongDetailPageState extends State { String? _selectedType; bool _isAliasExpanded = false; // 用于跟踪下载状态,key为 "type_levelId" final Map _isDownloading = {}; final Map>> _chartDataCache = {}; final Map _isLoadingChart = {}; @override void initState() { super.initState(); _initSelectedType(); } void _initSelectedType() { if (widget.song.sd != null && widget.song.sd!.isNotEmpty) { _selectedType = 'SD'; } else if (widget.song.dx != null && widget.song.dx!.isNotEmpty) { _selectedType = 'DX'; } else if (widget.song.ut != null && widget.song.ut!.isNotEmpty) { _selectedType = 'UT'; } } List _getAvailableTypes() { List types = []; if (widget.song.sd != null && widget.song.sd!.isNotEmpty) types.add('SD'); if (widget.song.dx != null && widget.song.dx!.isNotEmpty) types.add('DX'); if (widget.song.ut != null && widget.song.ut!.isNotEmpty) types.add('UT'); return types; } List> _getCurrentDifficulties() { List> all = []; if (_selectedType == 'SD' && widget.song.sd != null) { for (var d in widget.song.sd!.values) { all.add({'type': 'SD', 'diff': d}); } } else if (_selectedType == 'DX' && widget.song.dx != null) { for (var d in widget.song.dx!.values) { all.add({'type': 'DX', 'diff': d}); } } else if (_selectedType == 'UT' && widget.song.ut != null) { for (var d in widget.song.ut!.values) { all.add({'type': 'UT', 'diff': d}); } } all.sort((a, b) { int idA = a['diff']['level_id'] ?? 0; int idB = b['diff']['level_id'] ?? 0; return idA.compareTo(idB); }); return all; } // ===================== 【新增】下载并打开 ADX 谱面功能 ===================== Future _downloadAndOpenAdx(String type, Map diff) async { int levelId = diff['level_id'] ?? 0; String downloadKey = "${type}_$levelId"; // 防止重复点击 if (_isDownloading[downloadKey] == true) return; setState(() { _isDownloading[downloadKey] = 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; } if (type == 'SD') { // SD: cdn.godserver.cn/resource/static/adx/00000/{songId}.adx String idStr = songId.toString().padLeft(5, '0'); 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'); 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"; // 确保文件名不冲突,可以加时间戳或者随机数,这里简单处理 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},请确保已安装谱面编辑器")), ); } } catch (e) { print("Download error: $e"); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("下载出错: $e")), ); } finally { setState(() { _isDownloading[downloadKey] = false; }); } } Future _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; } else if (type == 'DX') { apiMusicId = 10000 + widget.song.id; } else { 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, 'scoreRank': log.segaChartNew?.scoreRank ?? 0, 'comboStatus': log.segaChartNew?.comboStatus ?? 0, 'syncStatus': log.segaChartNew?.syncStatus ?? 0, 'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0, 'playCount': log.segaChartNew?.playCount ?? 0, 'level': log.segaChartNew?.level ?? 0, }).toList(); setState(() { _chartDataCache[type] = chartList; _isLoadingChart[type] = false; }); } catch (e) { print("Load chart error: $e"); setState(() => _isLoadingChart[type] = false); } } Future _loadUtChartData(String cacheKey, int realId) async { if (_chartDataCache.containsKey(cacheKey) || _isLoadingChart[cacheKey] == true) { return; } setState(() => _isLoadingChart[cacheKey] = true); try { final userProvider = UserProvider.instance; final token = userProvider.token; if (token == null || token.isEmpty) { 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, 'scoreRank': log.segaChartNew?.scoreRank ?? 0, 'comboStatus': log.segaChartNew?.comboStatus ?? 0, 'syncStatus': log.segaChartNew?.syncStatus ?? 0, 'deluxscoreMax': log.segaChartNew?.deluxscoreMax ?? 0, 'playCount': log.segaChartNew?.playCount ?? 0, }).toList(); setState(() { _chartDataCache[cacheKey] = chartList; _isLoadingChart[cacheKey] = false; }); } catch (e) { print("Load UT chart error: $e"); setState(() => _isLoadingChart[cacheKey] = false); } } Widget _buildAliasSection(List rawAliases) { final uniqueAliases = rawAliases .where((e) => e != null && e.trim().isNotEmpty) .map((e) => e.trim()) .toSet() .toList(); if (uniqueAliases.isEmpty) return const SizedBox.shrink(); final displayLimit = _isAliasExpanded ? uniqueAliases.length : 3; final displayedAliases = uniqueAliases.take(displayLimit).toList(); final hasMore = uniqueAliases.length > 3; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 6.0, runSpacing: 4.0, children: [ ...displayedAliases.map((alias) => Container( decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.grey.shade300, width: 0.5), ), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), child: Text( alias, style: TextStyle( fontSize: 11, color: Colors.grey[700], fontStyle: FontStyle.italic, ), ), )).toList(), ], ), if (hasMore) InkWell( onTap: () { setState(() { _isAliasExpanded = !_isAliasExpanded; }); }, child: Padding( padding: const EdgeInsets.only(top: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _isAliasExpanded ? "收起" : "查看更多 (${uniqueAliases.length - 3})", style: TextStyle(fontSize: 11), ), Icon( _isAliasExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 14, color: Theme.of(context).primaryColor, ), ], ), ), ), ], ); } @override Widget build(BuildContext context) { final coverUrl = _getCoverUrl(widget.song.id); final diffs = _getCurrentDifficulties(); final availableTypes = _getAvailableTypes(); return Scaffold( appBar: AppBar( title: Text(widget.song.title ?? "歌曲详情"), elevation: 2, ), body: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Card( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 4, shadowColor: Colors.purpleAccent.withOpacity(0.2), child: Padding( padding: const EdgeInsets.all(14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.network( coverUrl, width: 90, height: 90, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: 90, height: 90, color: Colors.grey[200], child: const Icon(Icons.music_note, size: 36), ), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "[${widget.song.id}] ${widget.song.title}", style: const TextStyle( fontSize: 17, fontWeight: FontWeight.bold, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), Text( "艺术家:${widget.song.artist ?? '未知'}", style: const TextStyle(fontSize: 13), ), const SizedBox(height: 4), if (widget.song.albums?.isNotEmpty == true) _buildAliasSection(widget.song.albums!), const SizedBox(height: 6), Text( "${widget.song.genre ?? ''} | ${widget.song.from ?? ''}", style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 2), Text( "BPM:${widget.song.bpm ?? '未知'}", style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), ), ], ), ), ), 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( 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); }, ), ), ], ), ), 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), ], ), ), ); } Widget _diffItem({required String type, required Map diff}) { int levelId = diff['level_id'] ?? 0; final double lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0; final designer = diff['note_designer'] ?? "-"; final notes = diff['notes'] ?? {}; final total = notes['total'] ?? 0; final tap = notes['tap'] ?? 0; final hold = notes['hold'] ?? 0; final slide = notes['slide'] ?? 0; final brk = notes['break_'] ?? 0; int realId; String? utTitleName; String cacheKey = ""; if (type == 'UT') { realId = (diff['id'] as num?)?.toInt() ?? 0; final utTitleMap = widget.song.utTitle as Map?; if (utTitleMap != null) { final key = diff['id'].toString(); utTitleName = utTitleMap[key]?.toString(); } cacheKey = "UT_$realId"; WidgetsBinding.instance.addPostFrameCallback((_) { _loadUtChartData(cacheKey, realId); }); } else { realId = _getRealMusicId(type); cacheKey = type; WidgetsBinding.instance.addPostFrameCallback((_) { _loadTypeChartData(type); }); } List> chartHistory = []; if (_chartDataCache.containsKey(cacheKey)) { if (type == 'UT') { chartHistory = _chartDataCache[cacheKey]!; } else { chartHistory = _chartDataCache[cacheKey]! .where((e) => e['level'] == levelId) .toList(); } } bool hasScoreData = chartHistory.isNotEmpty; final score = widget.userScoreCache[realId]?[levelId]; bool hasUserScore = score != null; String name = ""; Color color = Colors.grey; bool isBanquet = type == 'UT'; if (isBanquet) { color = Colors.pinkAccent; name = utTitleName ?? "UT 宴会谱"; } else { switch (levelId) { case 0: name = "$type Basic"; color = Colors.green; break; case 1: name = "$type Advanced"; color = Colors.yellow.shade700; break; case 2: name = "$type Expert"; color = Colors.red; break; case 3: name = "$type Master"; color = Colors.purple; break; case 4: name = "$type Re:Master"; color = Colors.purpleAccent.shade100; break; default: name = type; } } final rating990 = _calculateRating(lvValue, 99.0); final rating995 = _calculateRating(lvValue, 99.5); final rating1000 = _calculateRating(lvValue, 100.0); 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)), elevation: 5, shadowColor: color.withOpacity(0.2), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withOpacity(0.4), width: 1.2), ), child: Text( isBanquet ? name : "$name (${lvValue.toStringAsFixed(1)})", style: TextStyle( color: color, fontWeight: FontWeight.bold, fontSize: 15, ), ), ), Row( 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); }, 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), ), ) : Icon( Icons.download_rounded, size: 18, color: type == 'UT' ? Colors.grey : color, ), ), ), ], ), ], ), const SizedBox(height: 10), Text("谱师:$designer", style: const TextStyle(fontSize: 14)), const SizedBox(height: 8), Table( columnWidths: const { 0: FixedColumnWidth(40), 1: FixedColumnWidth(40), 2: FixedColumnWidth(40), 3: FixedColumnWidth(40), 4: FixedColumnWidth(40), }, children: [ TableRow( children: [ _tableCell("总物量", color), _tableCell("TAP", color), _tableCell("HOLD", color), _tableCell("SLIDE", color), _tableCell("BRK", color), ], ), TableRow( children: [ _tableCell(total.toString(), color, isHeader: false), _tableCell(tap.toString(), color, isHeader: false), _tableCell(hold.toString(), color, isHeader: false), _tableCell(slide.toString(), color, isHeader: false), _tableCell(brk.toString(), color, isHeader: false), ], ), ], ), const SizedBox(height: 10), Table( columnWidths: const { 0: FixedColumnWidth(50), 1: FixedColumnWidth(50), 2: FixedColumnWidth(50), 3: FixedColumnWidth(50), 4: FixedColumnWidth(50), 5: FixedColumnWidth(50), }, children: [ TableRow( children: [ _tableCell("完成度", color), _tableCell("99.0%", color), _tableCell("99.5%", color), _tableCell("100.0%", color), _tableCell("100.3%", color), _tableCell("100.5%", color), _tableCell("", color), ], ), TableRow( children: [ _tableCell("Rating", color, isHeader: false), _tableCell(rating990.toStringAsFixed(2), color, isHeader: false), _tableCell(rating995.toStringAsFixed(2), color, isHeader: false), _tableCell(rating1000.toStringAsFixed(2), color, isHeader: false), _tableCell(rating1003.toStringAsFixed(2), color, isHeader: false), _tableCell(rating1005.toStringAsFixed(2), color, isHeader: false), _tableCell("", color, isHeader: false), ], ), ], ), const SizedBox(height: 14), if (hasScoreData) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("推分进程", style: TextStyle(fontSize: 13, color: Colors.grey)), Text( "最近: ${(chartHistory.last['achievement'] / 10000.0).toStringAsFixed(4)}%", style: TextStyle(fontSize: 13, color: color, fontWeight: FontWeight.w600), ), ], ), const SizedBox(height: 8), ScoreProgressChart( historyScores: chartHistory, lineColor: color, fillColor: color, ), ], ) else const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Text("暂无历史记录", style: TextStyle(fontSize: 13, color: Colors.grey)), ), if (hasUserScore) ...[ const SizedBox(height: 12), Divider(height: 1, color: Colors.grey[300]), const SizedBox(height: 12), _buildScoreInfo(score!,diff), ], ], ), ), ); } Widget _tableCell(String text, Color color, {bool isHeader = true}) { return Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( text, style: TextStyle( fontSize: isHeader ? 11 : 12, color: isHeader ? color : color.withOpacity(0.99), fontWeight: isHeader ? FontWeight.bold : FontWeight.w500, ), ), ), ); } static int _calculateRating(double diff, double achievementPercent) { double sys = 22.4; double ach = achievementPercent; if (ach >= 100.5000) { return (diff * 22.512).floor(); } if (ach == 100.4999) { sys = 22.2; } else if (ach >= 100.0000) { sys = 21.6; } else if (ach == 99.9999) { sys = 21.4; } else if (ach >= 99.5000) { sys = 21.1; } else if (ach >= 99.0000) { sys = 20.8; } else if (ach >= 98.0000) { sys = 20.3; } else if (ach >= 97.0000) { sys = 20.0; } else if (ach >= 94.0000) { sys = 16.8; } else if (ach >= 90.0000) { sys = 15.2; } else if (ach >= 80.0000) { sys = 13.6; } else if (ach >= 75.0000) { sys = 12.0; } else if (ach >= 70.0000) { sys = 11.2; } else if (ach >= 60.0000) { sys = 9.6; } else if (ach >= 50.0000) { sys = 8.0; } else { sys = 0.0; } if (sys == 0.0) return 0; return (diff * sys * ach / 100).floor(); } Widget _buildScoreInfo(Map score, Map diff) { final ach = (score['achievement'] ?? 0) / 10000; final rank = _getRankText(score['scoreRank'] ?? 0); final dxScore = score['deluxscoreMax'] ?? 0; final playCount = score['playCount'] ?? 0; final rating = score['rating'] ?? 0; int comboStatus = score['comboStatus'] ?? 0; int syncStatus = score['syncStatus'] ?? 0; Color achColor = _getColorByAchievement(ach); Color rankColor = _getColorByRank(rank); int totalNotes = diff['notes']['total'] ?? 0; int allDx = totalNotes * 3; double perc = allDx > 0 ? dxScore / allDx : 0.0; String? comboIconPath; switch (comboStatus) { case 1: comboIconPath = "images/UI_MSS_MBase_Icon_FC.png"; break; case 2: comboIconPath = "images/UI_MSS_MBase_Icon_FCp.png"; break; case 3: comboIconPath = "images/UI_MSS_MBase_Icon_AP.png"; break; case 4: comboIconPath = "images/UI_MSS_MBase_Icon_APp.png"; break; } String? syncIconPath; switch (syncStatus) { case 1: syncIconPath = "images/UI_MSS_MBase_Icon_FS.png"; break; case 2: syncIconPath = "images/UI_MSS_MBase_Icon_FSp.png"; break; case 3: syncIconPath = "images/UI_MSS_MBase_Icon_FSD.png"; break; case 4: syncIconPath = "images/UI_MSS_MBase_Icon_FSDp.png"; break; case 5: syncIconPath = "images/UI_MSS_MBase_Icon_Sync.png"; break; } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ Icon(Icons.emoji_events_rounded, color: Colors.amber, size: 18), SizedBox(width: 4), Text( "你的成绩", style: TextStyle( fontWeight: FontWeight.bold, fontSize: 15, height: 1.1, ), ), ], ), const SizedBox(height: 10), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ GradientText( data: "${ach.toStringAsFixed(4)}%", style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold), gradientLayers: [ GradientLayer( gradient: const LinearGradient( colors: [Colors.pinkAccent, Colors.blue], ), blendMode: BlendMode.srcIn, ), ], ), const SizedBox(width: 8), _buildTag(rank, rankColor), if (comboIconPath != null) Padding( padding: const EdgeInsets.only(left: 2), child: Image.asset(comboIconPath, width: 40, height: 40), ), if (syncIconPath != null) Padding( padding: const EdgeInsets.only(left: 4), child: Image.asset(syncIconPath, width: 40, height: 40), ), ], ), const SizedBox(height: 12), Row( children: [ const Icon(Icons.score_rounded, size: 14, color: Colors.blueGrey), const SizedBox(width: 4), Text("DX:$dxScore / $allDx", style: TextStyle(fontSize: 13, color: Colors.grey[700])), const SizedBox(width: 10), if (perc >= 0.85) Padding( padding: const EdgeInsets.only(right: 4), child: _getDxStarIcon(perc), ), const Spacer(), Icon(Icons.star_rate_rounded, size: 14, color: Colors.amber), const SizedBox(width: 3), Text("Rating:$rating", style: TextStyle(fontSize: 12, color: Colors.grey[700], fontWeight: FontWeight.w500)), const SizedBox(width: 10), Icon(Icons.play_circle_outline_rounded, size: 14, color: Colors.grey[600]), const SizedBox(width: 3), Text("$playCount 次", style: TextStyle(fontSize: 12, color: Colors.grey[600])), ], ), ], ), ), ], ); } Widget _getDxStarIcon(double perc) { String assetPath; if (perc >= 0.97) { assetPath = "images/UI_GAM_Gauge_DXScoreIcon_05.png"; } else if (perc >= 0.95) { assetPath = "images/UI_GAM_Gauge_DXScoreIcon_04.png"; } else if (perc >= 0.93) { assetPath = "images/UI_GAM_Gauge_DXScoreIcon_03.png"; } else if (perc >= 0.90) { assetPath = "images/UI_GAM_Gauge_DXScoreIcon_02.png"; } else if (perc >= 0.85) { assetPath = "images/UI_GAM_Gauge_DXScoreIcon_01.png"; } else { return const SizedBox(); } return Image.asset(assetPath, width: 30, height: 30, fit: BoxFit.contain); } Widget _buildTag(String text, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withOpacity(0.5), width: 1.2), ), child: Text( text, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14), ), ); } Color _getColorByAchievement(double ach) { if (ach >= 100.5) return const Color(0xFFD4AF37); if (ach >= 100.0) return Colors.purple; if (ach >= 99.5) return Colors.purpleAccent; if (ach >= 99.0) return Colors.deepPurple; if (ach >= 98.0) return Colors.lightBlue; if (ach >= 97.0) return Colors.blue; return Colors.green; } Color _getColorByRank(String rank) { if (rank.contains("SSS+")) return const Color(0xFFD4AF37); if (rank.contains("SSS")) return Colors.purple; if (rank.contains("SS+") || rank.contains("SS")) return Colors.deepPurple; if (rank.contains("S+") || rank.contains("S")) return Colors.blue; return Colors.green; } String _getCoverUrl(int musicId) { int displayId = musicId % 10000; if (musicId >= 16000 && musicId <= 20000) { String idStr = displayId.toString().padLeft(6, '0'); return "https://u.mai2.link/jacket/UI_Jacket_$idStr.jpg"; } else { return "https://cdn.godserver.cn/resource/static/mai/cover/$displayId.png"; } } String _getRankText(int rank) { switch (rank) { case 13: return "SSS+"; case 12: return "SSS"; case 11: return "SS+"; case 10: return "SS"; case 9: return "S+"; case 8: return "S"; case 7: return "AAA"; case 6: return "AA"; case 5: return "A"; case 4: return "BBB"; case 3: return "BB"; case 2: return "B"; case 1: return "C"; default: return "D"; } } int _getRealMusicId(String type) { if (type == "SD") return widget.song.id; if (type == "DX") return 10000 + widget.song.id; return 100000 + widget.song.id; } }