import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:unionapp/pages/music/score_single.dart'; import '../../../model/song_model.dart'; import '../../../providers/user_provider.dart'; import '../../../service/song_service.dart'; import '../../../service/user_service.dart'; class MusicPage extends StatefulWidget { const MusicPage({super.key}); @override State createState() => _MusicPageState(); } class _MusicPageState extends State with SingleTickerProviderStateMixin { bool _isLoading = true; bool _isRefreshing = false; String _errorMessage = ''; List _allSongs = []; List _displaySongs = []; // 筛选 String _searchQuery = ''; int? _filterLevelType; String? _filterFromVersion; String? _filterGenre; double? _selectedMinLevel; double? _selectedMaxLevel; bool _isAdvancedFilterExpanded = false; late AnimationController _animationController; late Animation _animation; static const List _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 _availableVersions = {}; Set _availableGenres = {}; // 用户成绩缓存:key = 真实完整 musicId Map> _userScoreCache = {}; @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); _loadAllSongs(); _loadUserScores(); UserProvider.instance.addListener(_onUserChanged); } void _onUserChanged() { if (mounted) { _loadUserScores(); } } @override void dispose() { UserProvider.instance.removeListener(_onUserChanged); _animationController.dispose(); _minLevelScrollController.dispose(); _maxLevelScrollController.dispose(); super.dispose(); } // 加载所有歌曲 Future _loadAllSongs() async { setState(() => _isLoading = true); try { final songs = await SongService.getAllSongs(); _allSongs = songs; final uniqueSongs = {}; for (final song in songs) { uniqueSongs.putIfAbsent(song.id, () => song); } _allSongs = uniqueSongs.values.toList(); _allSongs.sort((a, b) => (b.players ?? 0).compareTo(a.players ?? 0)); _displaySongs = List.from(_allSongs); _availableVersions = songs.map((s) => s.from).where((v) => v.isNotEmpty).toSet(); _availableGenres = songs.map((s) => s.genre).where((g) => g.isNotEmpty).toSet(); setState(() => _errorMessage = ''); } catch (e) { setState(() => _errorMessage = e.toString()); } finally { setState(() => _isLoading = false); } } // 加载用户成绩 —— ✅ 修复 1:直接用完整ID存储 Future _loadUserScores() async { try { final userProvider = UserProvider.instance; final token = userProvider.token; if (token == null) return; final userName = userProvider.selectedCnUserName; Map scoreData; if (userProvider.scoreDataSource == 'sega') { final segaId = userProvider.selectedSegaId; if (segaId == null || segaId.isEmpty) return; scoreData = await UserService.getSegaRatingData(token, segaId); } else { scoreData = await SongService.getUserAllScores(token, name: userName); } List userMusicList = []; if (scoreData.containsKey('userScoreAll_')) { userMusicList = scoreData['userScoreAll_']['userMusicList'] ?? []; } else if (scoreData.containsKey('userMusicList')) { userMusicList = scoreData['userMusicList'] ?? []; } else if (scoreData.containsKey('segaId2chartlist')) { final segaId = userProvider.selectedSegaId; final map = scoreData['segaId2chartlist'] ?? {}; userMusicList = (map[segaId] as List?) ?? []; } _userScoreCache.clear(); for (var group in userMusicList) { final details = group['userMusicDetailList'] ?? []; for (var d in details) { int realMusicId = d['musicId']; // 完整ID int level = d['level'] ?? 0; _userScoreCache[realMusicId] ??= {}; _userScoreCache[realMusicId]![level] = d; } } setState(() {}); } catch (e) { print("加载成绩失败: $e"); } } // 搜索 + 过滤 void _applyFilters() { final q = _normalizeSearch(_searchQuery); _displaySongs = _allSongs.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; for (var a in song.albums) { if (_normalizeSearch(a).contains(q)) match = true; } if (!match) return false; } if (_filterFromVersion != null && song.from != _filterFromVersion) return false; if (_filterGenre != null && song.genre != _filterGenre) 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(); } // 带类型的难度(SD/DX/UT) List> _getAllDifficultiesWithType(SongModel song) { List> 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}); } } if (song.ut != null && song.ut!.isNotEmpty) { for (var d in song.ut!.values) { all.add({'type': 'UT', 'diff': d}); } } return all; } // 根据歌曲类型获取真实完整ID int _getRealMusicId(SongModel song, String type) { if (type == "SD") { return song.id; } else if (type == "DX") { return 10000 + song.id; } else { return 100000 + song.id; } } void _resetFilters() { setState(() { _searchQuery = ''; _filterLevelType = null; _filterFromVersion = null; _filterGenre = null; _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: _levelOptions.map((e) => Center(child: Text(e.toStringAsFixed(1)))).toList(), ), ), ], ), ), ], ), ), ], ), ); }, ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("歌曲列表"), actions: [ if (_isRefreshing) const Padding( padding: EdgeInsets.all(8.0), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), ) else IconButton(icon: const Icon(Icons.refresh), onPressed: () async { setState(() => _isRefreshing = true); await _loadAllSongs(); await _loadUserScores(); setState(() => _isRefreshing = false); }) ], ), 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: _buildDropdown( label: "版本", value: _filterFromVersion, items: [ const DropdownMenuItem(value: null, child: Text("全部")), ..._availableVersions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList() ], onChanged: (v) { setState(() => _filterFromVersion = v); _applyFilters(); }, ), ), const SizedBox(width: 8), Expanded( child: _buildDropdown( label: "流派", value: _filterGenre, items: [ const DropdownMenuItem(value: null, child: Text("全部")), ..._availableGenres.map((g) => DropdownMenuItem(value: g, child: Text(g))).toList() ], onChanged: (v) { setState(() => _filterGenre = v); _applyFilters(); }, ), ), ], ), 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), ], ), ), ), ], ), ), ), ], ), ); } String getLeftEllipsisText(String text, int maxLength) { if (text.length <= maxLength) return text; return '...${text.substring(text.length - maxLength + 3)}'; // +3 是因为 "..." 占3个字符 } Widget _buildDropdown({required String label, required dynamic value, required List 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 || _filterFromVersion != null || _filterGenre != null || _selectedMinLevel != null || _selectedMaxLevel != null; Widget _buildBody() { if (_isLoading) return const Center(child: CircularProgressIndicator()); if (_errorMessage.isNotEmpty) return Center(child: Text(_errorMessage, style: const TextStyle(color: Colors.red))); if (_displaySongs.isEmpty) return const Center(child: Text("无匹配歌曲")); return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), itemCount: _displaySongs.length, itemBuilder: (_, idx) { final song = _displaySongs[idx]; return _songItem(song); }, ); } Widget _songItem(SongModel song) { final cover = _getCoverUrl(song.id); bool hasScore = false; // 检查SD/DX/UT任意一个有成绩 if (song.sd != null && _userScoreCache.containsKey(song.id)) hasScore = true; if (song.dx != null && _userScoreCache.containsKey(10000 + song.id)) hasScore = true; if (song.ut != null && _userScoreCache.containsKey(100000 + song.id)) hasScore = true; // --- 新增:处理别名逻辑 --- // 1. 获取原始列表 List rawAliases = song.albums ?? []; // 2. 去重 (保留插入顺序可以使用 LinkedHashSet,或者简单转为 Set 再转 List) // 注意:Set.from 可能会打乱顺序,如果顺序不重要可以直接用。 // 如果希望保持原顺序去重: final seen = {}; final uniqueAliases = rawAliases.where((alias) => seen.add(alias)).toList(); // 3. 过滤掉与标题或艺术家完全相同的重复项(可选,为了更整洁) final filteredAliases = uniqueAliases.where((alias) => alias != song.title && alias != song.artist ).toList(); // 4. 拼接字符串,如果太长则截断 // 我们设定一个最大显示长度或者最大行数,这里采用“尝试放入尽可能多的词,直到超过一定长度”的策略 String aliasDisplayText = ""; if (filteredAliases.isNotEmpty) { // 尝试加入前 N 个别名,总长度控制在合理范围,例如 60-80 个字符,或者固定个数如 5-8 个 // 这里采用固定个数策略,因为每个词长度不一,固定个数更容易预测高度 int maxAliasCount = 6; List displayList = filteredAliases.take(maxAliasCount).toList(); aliasDisplayText = displayList.join(" · "); // 如果还有更多未显示的,加上省略提示 if (filteredAliases.length > maxAliasCount) { aliasDisplayText += " ..."; } } // ------------------------- return Card( margin: const EdgeInsets.only(bottom: 10), elevation: 4, shadowColor: Colors.purpleAccent.withOpacity(0.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: InkWell( onTap: () { Navigator.push( context, MaterialPageRoute(builder: (_) => SongDetailPage( song: song, userScoreCache: _userScoreCache, )), ); }, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 左侧:封面 + ID 区域 Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ ClipRRect( borderRadius: BorderRadius.circular(2), child: Image.network( cover, width: 100, height: 100, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: 60, height: 60, color: Colors.grey[200], child: const Icon(Icons.music_note), ), ), ), const SizedBox(height: 2), // 这一行:id + 左侧标签 Row( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [ // 标签区域(cn / jp / m2l / long) if (song.cn == true) _buildTag( "CN", backgroundColor: Colors.redAccent, shadowColor: Colors.red.withOpacity(0.3), ), if (song.jp == true) _buildTag( "JP", backgroundColor: Colors.blueAccent, shadowColor: Colors.red.withOpacity(0.3), ), if (song.m2l == true) _buildTag( "M2L", gradient: const LinearGradient( colors: [Colors.purple, Colors.blueAccent], begin: Alignment.centerLeft, end: Alignment.centerRight, ), ), if (song.long == true) _buildTag( "LONG", gradient: const LinearGradient( colors: [Colors.black12, Colors.lightBlueAccent], begin: Alignment.topRight, end: Alignment.bottomLeft, ), ), // id 文本 Text( " ${song.id}", style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 11, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), SizedBox( width: 100, child: Text( getLeftEllipsisText("${song.from}", 15), style: const TextStyle( fontSize: 9, color: Colors.grey, ), maxLines: 1, textAlign: TextAlign.right, overflow: TextOverflow.visible, ), ) ], ), const SizedBox(width: 12), // 右侧:详细信息 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, // 关键:让内容在垂直方向上尽量分布,或者使用 MainAxisSize.min 配合 Spacer mainAxisSize: MainAxisSize.min, children: [ Text( song.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( song.artist ?? "Unknown", style: TextStyle(fontSize: 12, color: Colors.grey[600]), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), ..._getDifficultyChipsByType(song), if (aliasDisplayText.isNotEmpty) ...[ const SizedBox(height: 6), LayoutBuilder( builder: (context, constraints) { // 可选:根据剩余宽度动态决定显示多少行,这里简单处理为最多2行 return Text( aliasDisplayText, style: TextStyle( fontSize: 10, color: Colors.grey[500], height: 1.2, // 紧凑行高 ), maxLines: 5, overflow: TextOverflow.ellipsis, ); }, ), ], ], ), ), if (hasScore) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration(color: Colors.greenAccent.withOpacity(0.15), borderRadius: BorderRadius.circular(4)), child: const Text("Score", style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.bold)), ), ], ), ), ), ); } Widget _buildTag( String text, { // 背景:纯色 或 渐变 二选一 Color? backgroundColor, Gradient? gradient, // 阴影配置 Color? shadowColor, double shadowBlurRadius = 2, Offset shadowOffset = const Offset(0, 1), // 可选样式扩展 double borderRadius = 2, double fontSize = 8, Color textColor = Colors.white, }) { return Container( margin: const EdgeInsets.only(right: 3), padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), decoration: BoxDecoration( // 优先使用渐变,没有渐变则使用纯色 color: gradient == null ? (backgroundColor ?? Colors.blueAccent) : null, gradient: gradient, borderRadius: BorderRadius.circular(borderRadius), // 阴影:只有传入 shadowColor 才显示 boxShadow: shadowColor != null ? [ BoxShadow( color: shadowColor, blurRadius: shadowBlurRadius, offset: shadowOffset, ) ] : null, ), child: Text( text, style: TextStyle( color: textColor, fontSize: fontSize, fontWeight: FontWeight.bold, ), ), ); } List _getDifficultyChipsByType(SongModel song) { final diffs = _getAllDifficultiesWithType(song); // 按 type 分组:SD / DX / UT final Map> typeGroups = {}; for (var item in diffs) { final type = item['type'] as String; typeGroups.putIfAbsent(type, () => []); typeGroups[type]!.add(item); } // 固定顺序:SD → DX → UT final order = ['SD', 'DX', 'UT']; List rows = []; for (final type in order) { final items = typeGroups[type]; if (items == null || items.isEmpty) continue; // 每一个类型 = 一行 Wrap final row = Wrap( spacing: 6, runSpacing: 4, children: items.map((item) { final diff = item['diff'] as Map; final lid = diff['level_id'] as int; final lvValue = double.tryParse(diff['level_value']?.toString() ?? '') ?? 0; bool isBanquet = lvValue == 10.0 || song.id > 100000 || type == 'UT'; int realId = _getRealMusicId(song, type); bool hasScore = _userScoreCache[realId]?[lid] != null; Color color = Colors.grey; String label = ""; if (isBanquet) { color = Colors.pinkAccent; // 多UT按ID显示对应名称 if (type == 'UT') { final utTitleMap = song.utTitle as Map?; if (utTitleMap != null && utTitleMap.isNotEmpty) { final key = item["diff"]['id'].toString(); if (utTitleMap.containsKey(key)) { label = utTitleMap[key].toString(); } else { label = "UT"; } } else { label = "UT"; } } else { label = type; } } else { switch (lid) { case 0: color = Colors.green; label = type; break; case 1: color = Colors.yellow.shade700; label = type; break; case 2: color = Colors.red; label = type; break; case 3: color = Colors.purple; label = type; break; case 4: color = Colors.purpleAccent.shade100; label = type; break; default: return const SizedBox(); } } return Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( color: color.withOpacity(hasScore ? 0.25 : 0.1), borderRadius: BorderRadius.circular(4), border: Border.all(color: color.withOpacity(0.5)), ), child: Text( isBanquet ? label : "$label ${lvValue.toStringAsFixed(1)}", style: TextStyle( color: color, fontSize: 10, fontWeight: FontWeight.bold, ), ), ); }).toList(), ); rows.add(row); rows.add(const SizedBox(height: 4)); } return rows; } 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"; } } }