From a136357508811f7b86f135db303ea96b93e6c375 Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:38:03 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(music):=20=E6=96=B0=E5=A2=9E=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E9=9F=B3=E4=B9=90=E6=92=AD=E6=94=BE=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=93=E8=BE=91=E6=B5=8F=E8=A7=88=E5=92=8C?= =?UTF-8?q?=E6=AD=8C=E6=9B=B2=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.MD | 5 + lib/api/music/MusicApi.dart | 59 ++++++ lib/api/music/SubsonicApi.dart | 47 +++++ lib/layout.dart | 17 +- lib/music/music_library.dart | 354 +++++++++++++++++++++++++++++++++ 5 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 lib/api/music/MusicApi.dart create mode 100644 lib/api/music/SubsonicApi.dart create mode 100644 lib/music/music_library.dart diff --git a/CHANGELOG.MD b/CHANGELOG.MD index d9804d0..35afedb 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -2,6 +2,11 @@ 更新日志文档,版本顺序从新到旧,最新版本在最前(上)面。 +# 1.8.0 + +- 新增桌面音乐播放器,支持专辑浏览、歌曲列表和播放 +- 新增音乐库导航页面,移动端和桌面端均支持 + # 1.7.2 - VIP/转码流自动设置字幕延迟6秒,seek/reload 后重新应用 diff --git a/lib/api/music/MusicApi.dart b/lib/api/music/MusicApi.dart new file mode 100644 index 0000000..6185c26 --- /dev/null +++ b/lib/api/music/MusicApi.dart @@ -0,0 +1,59 @@ +import 'package:dio/dio.dart'; +import 'package:ikaros/api/dio_client.dart'; + +/// 音乐模块 API. +/// 对接服务端 music 模块的 REST 接口. +class MusicApi { + /// 查询专辑列表 + Future> listAlbums( + {int page = 1, int size = 20}) async { + String apiUrl = "/api/v1/music/album/list"; + try { + Dio dio = await DioClient.getDio(); + var response = + await dio.get(apiUrl, queryParameters: {"page": page, "size": size}); + if (response.statusCode != 200) { + return {"items": [], "total": 0}; + } + return response.data is Map ? response.data as Map : {}; + } catch (e) { + return {"items": [], "total": 0}; + } + } + + /// 查询专辑下的歌曲 + Future>> listSongs(String albumId) async { + String apiUrl = "/api/v1/music/album/$albumId/songs"; + try { + Dio dio = await DioClient.getDio(); + var response = await dio.get(apiUrl); + if (response.statusCode != 200) { + return []; + } + List list = response.data is List ? response.data as List : []; + return list.cast>(); + } catch (e) { + return []; + } + } + + /// 搜索专辑 + Future> searchAlbums( + String keyword, int page, int size) async { + String apiUrl = "/api/v1/music/album/search"; + try { + Dio dio = await DioClient.getDio(); + var response = await dio.get(apiUrl, queryParameters: { + "keyword": keyword, + "page": page, + "size": size, + }); + if (response.statusCode != 200) { + return {"items": [], "total": 0}; + } + return response.data is Map ? response.data as Map : {}; + } catch (e) { + return {"items": [], "total": 0}; + } + } +} diff --git a/lib/api/music/SubsonicApi.dart b/lib/api/music/SubsonicApi.dart new file mode 100644 index 0000000..bc37ab8 --- /dev/null +++ b/lib/api/music/SubsonicApi.dart @@ -0,0 +1,47 @@ +import 'package:ikaros/api/auth/AuthApi.dart'; +import 'package:ikaros/api/auth/AuthParams.dart'; + +/// Subsonic API 客户端. +/// 用于音频流播放和音乐库浏览. +class SubsonicApi { + /// 获取歌曲的音频流 URL + static Future getStreamUrl(String songId) async { + AuthParams? authParams = await AuthApi().getAuthParams(); + if (authParams == null || authParams.baseUrl.isEmpty) { + return ""; + } + String baseUrl = authParams.baseUrl; + // Subsonic streaming: /rest/stream?id={songId}&u={user}&p={pass}&f=json + return "$baseUrl/rest/stream?id=$songId&u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app&f=json"; + } + + /// 获取专辑封面图 URL + static Future getCoverArtUrl(String albumId) async { + AuthParams? authParams = await AuthApi().getAuthParams(); + if (authParams == null || authParams.baseUrl.isEmpty) { + return ""; + } + String baseUrl = authParams.baseUrl; + return "$baseUrl/rest/getCoverArt?id=al-$albumId&u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app"; + } + + /// 获取播放列表 + static Future getPlaylistUrl() async { + AuthParams? authParams = await AuthApi().getAuthParams(); + if (authParams == null || authParams.baseUrl.isEmpty) { + return ""; + } + String baseUrl = authParams.baseUrl; + return "$baseUrl/rest/getPlaylists?u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app&f=json"; + } + + /// 发送 Scrobble 记录 + static Future getScrobbleUrl(String songId, {int time = 0}) async { + AuthParams? authParams = await AuthApi().getAuthParams(); + if (authParams == null || authParams.baseUrl.isEmpty) { + return ""; + } + String baseUrl = authParams.baseUrl; + return "$baseUrl/rest/scrobble?id=$songId&time=$time&u=${authParams.username}&p=enc:${authParams.token}&c=ikaros_app&f=json"; + } +} diff --git a/lib/layout.dart b/lib/layout.dart index fc2d59a..d98c82c 100644 --- a/lib/layout.dart +++ b/lib/layout.dart @@ -1,6 +1,7 @@ import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:ikaros/collection/collections.dart'; +import 'package:ikaros/music/music_library.dart'; import 'package:ikaros/subject/subject.dart'; import 'package:ikaros/subject/subjects.dart'; import 'package:ikaros/user/user.dart'; @@ -81,7 +82,12 @@ class _MobileLayoutState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: [const CollectionPage(), const SubjectsPage(), const UserPage()][_pageIndex], + body: [ + const CollectionPage(), + const SubjectsPage(), + const MusicLibraryPage(), + const UserPage() + ][_pageIndex], bottomNavigationBar: NavigationBar( onDestinationSelected: (int index) { setState(() { @@ -98,6 +104,10 @@ class _MobileLayoutState extends State { icon: Icon(Icons.tv), label: '条目', ), + NavigationDestination( + icon: Icon(Icons.library_music), + label: '音乐', + ), NavigationDestination( icon: Icon(Icons.account_circle), label: '我的', @@ -124,6 +134,7 @@ class _DesktopLayoutState extends State { final List _pages = [ const CollectionPage(), const SubjectsPage(), + const MusicLibraryPage(), const UserPage(), ]; @@ -158,6 +169,10 @@ class _DesktopLayoutState extends State { icon: Icon(Icons.tv), label: Text('条目'), ), + NavigationRailDestination( + icon: Icon(Icons.library_music), + label: Text('音乐'), + ), NavigationRailDestination( icon: Icon(Icons.account_circle), label: Text('我的'), diff --git a/lib/music/music_library.dart b/lib/music/music_library.dart new file mode 100644 index 0000000..d09120d --- /dev/null +++ b/lib/music/music_library.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:ikaros/api/music/MusicApi.dart'; +import 'package:ikaros/api/subject/SubjectApi.dart'; +import 'package:ikaros/api/subject/model/Subject.dart'; +import 'package:ikaros/utils/screen_utils.dart'; +import 'package:ikaros/subject/subject.dart'; + +/// 音乐库页面. +/// 展示专辑列表,点击进入专辑详情(歌曲列表 + 播放). +class MusicLibraryPage extends StatefulWidget { + const MusicLibraryPage({super.key}); + + @override + State createState() => _MusicLibraryPageState(); +} + +class _MusicLibraryPageState extends State { + final MusicApi _musicApi = MusicApi(); + List> _albums = []; + bool _isLoading = true; + bool _hasMore = true; + int _page = 1; + final int _size = 20; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _loadAlbums(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200 && + !_isLoading && + _hasMore) { + _loadAlbums(); + } + } + + Future _loadAlbums() async { + if (_isLoading) return; + setState(() => _isLoading = true); + try { + var result = await _musicApi.listAlbums(page: _page, size: _size); + List items = result["items"] ?? []; + int total = result["total"] ?? 0; + setState(() { + _albums.addAll(items.cast>()); + _hasMore = _albums.length < total; + _page++; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("音乐库")), + body: _albums.isEmpty && !_isLoading + ? const Center(child: Text("暂无专辑")) + : GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(8), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: ScreenUtils.screenWidth(context) > 800 ? 4 : 2, + childAspectRatio: 0.75, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: _albums.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _albums.length) { + return const Center(child: CircularProgressIndicator()); + } + final album = _albums[index]; + return _AlbumCard( + album: album, + onTap: () => _openAlbum(album), + ); + }, + ), + ); + } + + void _openAlbum(Map album) { + final albumId = album["id"] as String? ?? ""; + final name = album["nameCn"] as String? ?? album["name"] as String? ?? ""; + final cover = album["cover"] as String? ?? ""; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MusicAlbumDetailPage( + albumId: albumId, + albumName: name, + albumCover: cover, + ), + ), + ); + } +} + +class _AlbumCard extends StatelessWidget { + final Map album; + final VoidCallback onTap; + + const _AlbumCard({required this.album, required this.onTap}); + + @override + Widget build(BuildContext context) { + final name = album["nameCn"] as String? ?? album["name"] as String? ?? ""; + final cover = album["cover"] as String? ?? ""; + final artist = album["name"] as String? ?? ""; + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: cover.isNotEmpty + ? Image.network(cover, fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (_, __, ___) => Container( + color: Colors.grey[900], + child: const Icon(Icons.music_note, + size: 48, color: Colors.white54), + )) + : Container( + color: Colors.grey[900], + child: const Center( + child: Icon(Icons.album, + size: 48, color: Colors.white54), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold)), + if (artist.isNotEmpty) + Text(artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.grey[500], fontSize: 12)), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 专辑详情页(歌曲列表 + 播放). +class MusicAlbumDetailPage extends StatefulWidget { + final String albumId; + final String albumName; + final String albumCover; + + const MusicAlbumDetailPage({ + super.key, + required this.albumId, + required this.albumName, + required this.albumCover, + }); + + @override + State createState() => _MusicAlbumDetailPageState(); +} + +class _MusicAlbumDetailPageState extends State { + final MusicApi _musicApi = MusicApi(); + List> _songs = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSongs(); + } + + Future _loadSongs() async { + setState(() => _isLoading = true); + try { + var songs = await _musicApi.listSongs(widget.albumId); + setState(() { + _songs = songs; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.albumName)), + body: Column( + children: [ + // 专辑头部 + _buildAlbumHeader(), + // 歌曲列表 + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _songs.isEmpty + ? const Center(child: Text("暂无歌曲")) + : ListView.builder( + itemCount: _songs.length, + itemBuilder: (context, index) { + final song = _songs[index]; + return _SongTile( + song: song, + index: index, + onPlay: () => _playSong(index), + ); + }), + ), + ], + ), + ); + } + + Widget _buildAlbumHeader() { + return Container( + padding: const EdgeInsets.all(16), + color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 100, + height: 100, + child: widget.albumCover.isNotEmpty + ? Image.network(widget.albumCover, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: Colors.grey[800], + child: const Icon(Icons.album, + size: 40, color: Colors.white54)) + ) + : Container( + color: Colors.grey[800], + child: const Icon(Icons.album, + size: 40, color: Colors.white54), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.albumName, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text("${_songs.length} 首歌曲", + style: TextStyle(color: Colors.grey[500])), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: _songs.isNotEmpty ? () => _playAll() : null, + icon: const Icon(Icons.play_arrow), + label: const Text("播放全部"), + ), + ], + ), + ), + ], + ), + ); + } + + void _playSong(int index) { + final song = _songs[index]; + final songId = song["id"] as String? ?? ""; + final name = song["nameCn"] as String? ?? song["name"] as String? ?? ""; + + // 跳转到条目详情页,使用已有播放器 + final subjectId = song["subjectId"] as String? ?? ""; + if (subjectId.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SubjectPage(id: subjectId), + ), + ); + } + } + + void _playAll() { + if (_songs.isEmpty) return; + _playSong(0); + } +} + +class _SongTile extends StatelessWidget { + final Map song; + final int index; + final VoidCallback onPlay; + + const _SongTile({ + required this.song, + required this.index, + required this.onPlay, + }); + + @override + Widget build(BuildContext context) { + final name = song["nameCn"] as String? ?? song["name"] as String? ?? ""; + final duration = song["duration"] as int? ?? 0; + final sequence = song["sequence"] as int? ?? 0; + + String durationStr = "0:00"; + if (duration > 0) { + final m = (duration ~/ 60).toString(); + final s = (duration % 60).toString().padLeft(2, '0'); + durationStr = "$m:$s"; + } + + return ListTile( + leading: CircleAvatar( + child: Text("${index + 1}", style: const TextStyle(fontSize: 14)), + ), + title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: durationStr != "0:00" ? Text(durationStr) : null, + trailing: IconButton( + icon: const Icon(Icons.play_circle_outline), + onPressed: onPlay, + ), + onTap: onPlay, + ); + } +} From 95ce7b7ae990efd70ea5a0c3501bd6f7fcb976f5 Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:41:50 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(music):=20=E5=AE=8C=E5=96=84=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E6=92=AD=E6=94=BE=E5=99=A8=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E3=80=81=E7=8E=B0=E5=9C=A8=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E6=8C=87=E7=A4=BA=E6=9D=A1=E5=92=8C=E9=9A=8F=E6=9C=BA=E6=92=AD?= =?UTF-8?q?=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/music/music_library.dart | 292 ++++++++++++++++++++++++++++------- 1 file changed, 235 insertions(+), 57 deletions(-) diff --git a/lib/music/music_library.dart b/lib/music/music_library.dart index d09120d..654ad5c 100644 --- a/lib/music/music_library.dart +++ b/lib/music/music_library.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:ikaros/api/music/MusicApi.dart'; -import 'package:ikaros/api/subject/SubjectApi.dart'; -import 'package:ikaros/api/subject/model/Subject.dart'; -import 'package:ikaros/utils/screen_utils.dart'; +import 'package:ikaros/api/subject/EpisodeApi.dart'; +import 'package:ikaros/api/subject/model/Episode.dart'; +import 'package:ikaros/api/subject/model/EpisodeResource.dart'; import 'package:ikaros/subject/subject.dart'; +import 'package:ikaros/utils/message_utils.dart'; +import 'package:ikaros/utils/screen_utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// 音乐库页面. -/// 展示专辑列表,点击进入专辑详情(歌曲列表 + 播放). +/// 展示专辑列表,支持搜索和上下滑动加载更多. class MusicLibraryPage extends StatefulWidget { const MusicLibraryPage({super.key}); @@ -22,20 +25,49 @@ class _MusicLibraryPageState extends State { int _page = 1; final int _size = 20; final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + bool _isSearching = false; + + // 当前播放信息(通过 SubjectPage 播放时保存) + String? _currentSongId; + String? _currentSongName; + String? _currentAlbumName; + String? _currentAlbumId; + String? _currentAlbumCover; @override void initState() { super.initState(); _loadAlbums(); _scrollController.addListener(_onScroll); + _restoreNowPlaying(); } @override void dispose() { _scrollController.dispose(); + _searchController.dispose(); super.dispose(); } + Future _restoreNowPlaying() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _currentSongName = prefs.getString("now_playing_song"); + _currentAlbumName = prefs.getString("now_playing_album"); + _currentAlbumId = prefs.getString("now_playing_album_id"); + _currentAlbumCover = prefs.getString("now_playing_cover"); + }); + } + + Future _saveNowPlaying(Map song) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString("now_playing_song", song["nameCn"] ?? song["name"] ?? ""); + await prefs.setString("now_playing_album", "正在播放"); + await prefs.setString("now_playing_album_id", song["subjectId"] ?? ""); + await prefs.setString("now_playing_cover", song["cover"] ?? song["albumCover"] ?? ""); + } + void _onScroll() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200 && @@ -45,10 +77,14 @@ class _MusicLibraryPageState extends State { } } - Future _loadAlbums() async { + Future _loadAlbums({bool refresh = false}) async { if (_isLoading) return; setState(() => _isLoading = true); try { + if (refresh) { + _page = 1; + _albums.clear(); + } var result = await _musicApi.listAlbums(page: _page, size: _size); List items = result["items"] ?? []; int total = result["total"] ?? 0; @@ -58,6 +94,26 @@ class _MusicLibraryPageState extends State { _page++; _isLoading = false; }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) Toast.show(context, "加载专辑失败: $e"); + } + } + + Future _search(String keyword) async { + if (keyword.isEmpty) { + _loadAlbums(refresh: true); + return; + } + setState(() => _isLoading = true); + try { + var result = await _musicApi.searchAlbums(keyword, 1, 50); + List items = result["items"] ?? []; + setState(() { + _albums = items.cast>(); + _hasMore = false; + _isLoading = false; + }); } catch (e) { setState(() => _isLoading = false); } @@ -66,30 +122,67 @@ class _MusicLibraryPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("音乐库")), - body: _albums.isEmpty && !_isLoading - ? const Center(child: Text("暂无专辑")) - : GridView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(8), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: ScreenUtils.screenWidth(context) > 800 ? 4 : 2, - childAspectRatio: 0.75, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: _albums.length + (_hasMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _albums.length) { - return const Center(child: CircularProgressIndicator()); + appBar: AppBar( + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: "搜索专辑...", + border: InputBorder.none, + ), + onSubmitted: (v) => _search(v), + ) + : const Text("音乐库"), + actions: [ + IconButton( + icon: Icon(_isSearching ? Icons.close : Icons.search), + onPressed: () { + setState(() { + _isSearching = !_isSearching; + if (!_isSearching) { + _searchController.clear(); + _loadAlbums(refresh: true); } - final album = _albums[index]; - return _AlbumCard( - album: album, - onTap: () => _openAlbum(album), - ); - }, + }); + }, + ), + if (_albums.isNotEmpty) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _loadAlbums(refresh: true), ), + ], + ), + body: RefreshIndicator( + onRefresh: () => _loadAlbums(refresh: true), + child: _albums.isEmpty && !_isLoading + ? const Center(child: Text("暂无专辑")) + : GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 80), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + ScreenUtils.isDesktop(context) ? 5 : 2, + childAspectRatio: 0.72, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: _albums.length + (_isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _albums.length) { + return const Center(child: CircularProgressIndicator()); + } + final album = _albums[index]; + return _AlbumCard( + album: album, + onTap: () => _openAlbum(album), + ); + }, + ), + ), + // 底部现在播放指示条 + bottomSheet: _currentSongName != null ? _buildNowPlayingBar() : null, ); } @@ -108,6 +201,53 @@ class _MusicLibraryPageState extends State { ), ); } + + Widget _buildNowPlayingBar() { + return Container( + height: 56, + color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.9), + child: ListTile( + leading: _currentAlbumCover != null && _currentAlbumCover!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network(_currentAlbumCover!, + width: 40, height: 40, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.music_note)), + ) + : const Icon(Icons.music_note, size: 40), + title: Text(_currentSongName ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + subtitle: Text(_currentAlbumName ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () { + if (_currentAlbumId != null && _currentAlbumId!.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SubjectPage(id: _currentAlbumId!), + ), + ); + } + }, + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => setState(() => _currentSongName = null), + ), + ], + ), + ), + ); + } } class _AlbumCard extends StatelessWidget { @@ -118,11 +258,13 @@ class _AlbumCard extends StatelessWidget { @override Widget build(BuildContext context) { - final name = album["nameCn"] as String? ?? album["name"] as String? ?? ""; + final name = + album["nameCn"] as String? ?? album["name"] as String? ?? ""; final cover = album["cover"] as String? ?? ""; final artist = album["name"] as String? ?? ""; return Card( clipBehavior: Clip.antiAlias, + elevation: 2, child: InkWell( onTap: onTap, child: Column( @@ -130,18 +272,18 @@ class _AlbumCard extends StatelessWidget { children: [ Expanded( child: cover.isNotEmpty - ? Image.network(cover, fit: BoxFit.cover, + ? Image.network(cover, + fit: BoxFit.cover, width: double.infinity, errorBuilder: (_, __, ___) => Container( - color: Colors.grey[900], - child: const Icon(Icons.music_note, - size: 48, color: Colors.white54), - )) + color: Colors.grey[900], + child: const Icon(Icons.music_note, + size: 48, color: Colors.white54))) : Container( color: Colors.grey[900], child: const Center( - child: Icon(Icons.album, - size: 48, color: Colors.white54), + child: + Icon(Icons.album, size: 48, color: Colors.white54), ), ), ), @@ -158,8 +300,8 @@ class _AlbumCard extends StatelessWidget { Text(artist, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.grey[500], fontSize: 12)), + style: + TextStyle(color: Colors.grey[500], fontSize: 12)), ], ), ), @@ -217,22 +359,22 @@ class _MusicAlbumDetailPageState extends State { appBar: AppBar(title: Text(widget.albumName)), body: Column( children: [ - // 专辑头部 _buildAlbumHeader(), - // 歌曲列表 Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) : _songs.isEmpty ? const Center(child: Text("暂无歌曲")) : ListView.builder( + padding: const EdgeInsets.only(bottom: 20), itemCount: _songs.length, itemBuilder: (context, index) { final song = _songs[index]; return _SongTile( song: song, index: index, - onPlay: () => _playSong(index), + albumName: widget.albumName, + albumCover: widget.albumCover, ); }), ), @@ -253,12 +395,12 @@ class _MusicAlbumDetailPageState extends State { width: 100, height: 100, child: widget.albumCover.isNotEmpty - ? Image.network(widget.albumCover, fit: BoxFit.cover, + ? Image.network(widget.albumCover, + fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( - color: Colors.grey[800], - child: const Icon(Icons.album, - size: 40, color: Colors.white54)) - ) + color: Colors.grey[800], + child: const Icon( + Icons.album, size: 40, color: Colors.white54))) : Container( color: Colors.grey[800], child: const Icon(Icons.album, @@ -278,10 +420,22 @@ class _MusicAlbumDetailPageState extends State { Text("${_songs.length} 首歌曲", style: TextStyle(color: Colors.grey[500])), const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: _songs.isNotEmpty ? () => _playAll() : null, - icon: const Icon(Icons.play_arrow), - label: const Text("播放全部"), + Row( + children: [ + ElevatedButton.icon( + onPressed: + _songs.isNotEmpty ? () => _playAll() : null, + icon: const Icon(Icons.play_arrow), + label: const Text("播放全部"), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: + _songs.isNotEmpty ? () => _playAll() : null, + icon: const Icon(Icons.shuffle), + label: const Text("随机播放"), + ), + ], ), ], ), @@ -292,12 +446,21 @@ class _MusicAlbumDetailPageState extends State { } void _playSong(int index) { + if (index >= _songs.length) return; final song = _songs[index]; final songId = song["id"] as String? ?? ""; final name = song["nameCn"] as String? ?? song["name"] as String? ?? ""; - - // 跳转到条目详情页,使用已有播放器 final subjectId = song["subjectId"] as String? ?? ""; + + // 保存播放信息 + final prefsKey = "now_playing_song"; + SharedPreferences.getInstance().then((prefs) { + prefs.setString(prefsKey, name); + prefs.setString("now_playing_album", widget.albumName); + prefs.setString("now_playing_album_id", subjectId); + prefs.setString("now_playing_cover", widget.albumCover); + }); + if (subjectId.isNotEmpty) { Navigator.push( context, @@ -305,6 +468,8 @@ class _MusicAlbumDetailPageState extends State { builder: (_) => SubjectPage(id: subjectId), ), ); + } else { + Toast.show(context, "播放: $name"); } } @@ -317,19 +482,20 @@ class _MusicAlbumDetailPageState extends State { class _SongTile extends StatelessWidget { final Map song; final int index; - final VoidCallback onPlay; + final String albumName; + final String albumCover; const _SongTile({ required this.song, required this.index, - required this.onPlay, + required this.albumName, + required this.albumCover, }); @override Widget build(BuildContext context) { final name = song["nameCn"] as String? ?? song["name"] as String? ?? ""; final duration = song["duration"] as int? ?? 0; - final sequence = song["sequence"] as int? ?? 0; String durationStr = "0:00"; if (duration > 0) { @@ -340,15 +506,27 @@ class _SongTile extends StatelessWidget { return ListTile( leading: CircleAvatar( - child: Text("${index + 1}", style: const TextStyle(fontSize: 14)), + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Text("${index + 1}", + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onPrimaryContainer)), ), title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: durationStr != "0:00" ? Text(durationStr) : null, trailing: IconButton( icon: const Icon(Icons.play_circle_outline), - onPressed: onPlay, + color: Theme.of(context).colorScheme.primary, + onPressed: () { + // 直接调用父级方法 + final parent = context.findAncestorStateOfType<_MusicAlbumDetailPageState>(); + parent?._playSong(index); + }, ), - onTap: onPlay, + onTap: () { + final parent = context.findAncestorStateOfType<_MusicAlbumDetailPageState>(); + parent?._playSong(index); + }, ); } } From e8376130594955d6e03b9ef8c14542ad9e708b7c Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:44:15 +0800 Subject: [PATCH 3/7] =?UTF-8?q?test(music):=20=E6=B7=BB=E5=8A=A0=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E6=A8=A1=E5=9D=97=20API=20=E5=93=8D=E5=BA=94=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=92=8C=E6=97=B6=E9=95=BF=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/api/music/MusicApiTest.dart | 163 +++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 test/api/music/MusicApiTest.dart diff --git a/test/api/music/MusicApiTest.dart b/test/api/music/MusicApiTest.dart new file mode 100644 index 0000000..ce3944d --- /dev/null +++ b/test/api/music/MusicApiTest.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// 音乐模块 API 单元测试 +/// 测试 JSON 响应解析逻辑和数据类型验证 +void main() { + group('MusicApi - 专辑列表响应解析', () { + test('正常响应应正确解析分页数据', () { + final responseData = { + "page": 1, + "size": 20, + "total": 2, + "items": [ + { + "id": "album-001", + "name": "OST Collection", + "nameCn": "原声合集", + "cover": "https://example.com/cover1.jpg", + "airTime": "2025-01-01", + "songCount": 12, + "subjectId": "sub-001", + }, + { + "id": "album-002", + "name": "Character Song", + "nameCn": "角色歌", + "cover": "https://example.com/cover2.jpg", + "airTime": "2025-02-01", + "songCount": null, + "subjectId": "sub-002", + }, + ] + }; + + final items = responseData["items"] as List; + expect(items.length, 2); + expect(responseData["total"], 2); + + // 验证第一张专辑 + final album1 = items[0] as Map; + expect(album1["id"], "album-001"); + expect(album1["nameCn"], "原声合集"); + expect(album1["songCount"], 12); + + // 验证第二张专辑(songCount 为 null) + final album2 = items[1] as Map; + expect(album2["id"], "album-002"); + expect(album2["songCount"], isNull); + }); + + test('空列表应返回空数组', () { + final responseData = { + "page": 1, + "size": 20, + "total": 0, + "items": [] + }; + + final items = responseData["items"] as List; + expect(items, isEmpty); + expect(responseData["total"], 0); + }); + }); + + group('MusicApi - 歌曲列表响应解析', () { + test('歌曲列表应正确解析', () { + final responseData = [ + { + "id": "song-001", + "name": "OP Theme", + "nameCn": "片头曲", + "duration": 240, + "sequence": 1, + "subjectId": "sub-001", + }, + { + "id": "song-002", + "name": "ED Theme", + "nameCn": "片尾曲", + "duration": 180, + "sequence": 2, + "subjectId": "sub-001", + }, + ]; + + expect(responseData.length, 2); + + final first = responseData[0]; + expect(first["duration"], 240); + expect(first["sequence"], 1); + + final second = responseData[1]; + expect(second["duration"], 180); + expect(second["nameCn"], "片尾曲"); + }); + + test('空歌曲列表', () { + final songs = >[]; + expect(songs, isEmpty); + }); + + test('歌曲时长格式化', () { + // 测试音乐库中的时长格式化逻辑 + String formatDuration(int duration) { + if (duration <= 0) return "0:00"; + final m = (duration ~/ 60).toString(); + final s = (duration % 60).toString().padLeft(2, '0'); + return "$m:$s"; + } + + expect(formatDuration(240), "4:00"); + expect(formatDuration(180), "3:00"); + expect(formatDuration(65), "1:05"); + expect(formatDuration(0), "0:00"); + expect(formatDuration(-1), "0:00"); + }); + }); + + group('MusicApi - 搜索响应解析', () { + test('搜索结果', () { + final responseData = { + "page": 1, + "size": 20, + "total": 1, + "items": [ + { + "id": "album-001", + "name": "OST Collection", + "nameCn": "原声合集", + "cover": "https://example.com/cover.jpg", + } + ] + }; + + final items = responseData["items"] as List; + expect(items.length, 1); + expect(responseData["total"], 1); + }); + }); + + group('SubsonicApi - 流媒体 URL', () { + test('stream URL 格式', () { + final baseUrl = "https://ikaros.example.com"; + final songId = "song-001"; + final username = "admin"; + final token = "mytoken"; + + final url = + "$baseUrl/rest/stream?id=$songId&u=$username&p=enc:$token&c=ikaros_app&f=json"; + expect(url, contains("song-001")); + expect(url, contains("enc:mytoken")); + expect(url, contains("rest/stream")); + }); + + test('cover art URL 格式', () { + final baseUrl = "https://ikaros.example.com"; + final albumId = "al-album-001"; + + final url = "$baseUrl/rest/getCoverArt?id=$albumId&u=admin&p=enc:token&c=ikaros_app"; + expect(url, contains("getCoverArt")); + expect(url, contains("al-album-001")); + }); + }); +} From 65b5d4a013f3db20dffebf3967407cc0d1a8d0b1 Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:54:28 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(music):=20=E6=96=B0=E5=A2=9E=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E9=9F=B3=E4=B9=90=E6=92=AD=E6=94=BE=E5=99=A8=20NowPla?= =?UTF-8?q?yingPage=EF=BC=8C=E6=94=AF=E6=8C=81=E5=86=85=E8=81=94=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=9C=A8=E7=BA=BF=E9=9F=B3=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 dart_vlc 实现真正的桌面内联音频播放 - 播放队列管理:顺序/随机/单曲循环/列表循环 - 全屏播放界面:封面、标题、进度条、上一首/下一首/播放暂停 - 从专辑详情页直接推送歌曲到播放器,无需跳转到条目页 - 播放队列弹窗:可查看和跳转到队列中的任意歌曲 - 随机播放按钮使用随机起点 --- lib/music/music_library.dart | 60 ++-- lib/music/music_now_playing.dart | 514 +++++++++++++++++++++++++++++++ 2 files changed, 537 insertions(+), 37 deletions(-) create mode 100644 lib/music/music_now_playing.dart diff --git a/lib/music/music_library.dart b/lib/music/music_library.dart index 654ad5c..58fa939 100644 --- a/lib/music/music_library.dart +++ b/lib/music/music_library.dart @@ -1,9 +1,6 @@ import 'package:flutter/material.dart'; import 'package:ikaros/api/music/MusicApi.dart'; -import 'package:ikaros/api/subject/EpisodeApi.dart'; -import 'package:ikaros/api/subject/model/Episode.dart'; -import 'package:ikaros/api/subject/model/EpisodeResource.dart'; -import 'package:ikaros/subject/subject.dart'; +import 'package:ikaros/music/music_now_playing.dart'; import 'package:ikaros/utils/message_utils.dart'; import 'package:ikaros/utils/screen_utils.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -229,14 +226,8 @@ class _MusicLibraryPageState extends State { IconButton( icon: const Icon(Icons.play_arrow), onPressed: () { - if (_currentAlbumId != null && _currentAlbumId!.isNotEmpty) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SubjectPage(id: _currentAlbumId!), - ), - ); - } + // 打开正在播放页面(假设已有队列) + Toast.show(context, "$_currentSongName - 请在音乐库中选择专辑播放"); }, ), IconButton( @@ -431,7 +422,7 @@ class _MusicAlbumDetailPageState extends State { const SizedBox(width: 8), OutlinedButton.icon( onPressed: - _songs.isNotEmpty ? () => _playAll() : null, + _songs.isNotEmpty ? () => _playShuffled() : null, icon: const Icon(Icons.shuffle), label: const Text("随机播放"), ), @@ -447,36 +438,31 @@ class _MusicAlbumDetailPageState extends State { void _playSong(int index) { if (index >= _songs.length) return; - final song = _songs[index]; - final songId = song["id"] as String? ?? ""; - final name = song["nameCn"] as String? ?? song["name"] as String? ?? ""; - final subjectId = song["subjectId"] as String? ?? ""; - - // 保存播放信息 - final prefsKey = "now_playing_song"; - SharedPreferences.getInstance().then((prefs) { - prefs.setString(prefsKey, name); - prefs.setString("now_playing_album", widget.albumName); - prefs.setString("now_playing_album_id", subjectId); - prefs.setString("now_playing_cover", widget.albumCover); - }); - - if (subjectId.isNotEmpty) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SubjectPage(id: subjectId), - ), - ); - } else { - Toast.show(context, "播放: $name"); - } + pushNowPlaying( + context, + _songs, + startIndex: index, + albumName: widget.albumName, + albumCover: widget.albumCover, + ); } void _playAll() { if (_songs.isEmpty) return; _playSong(0); } + + void _playShuffled() { + if (_songs.isEmpty) return; + final randomIndex = DateTime.now().millisecondsSinceEpoch % _songs.length; + pushNowPlaying( + context, + _songs, + startIndex: randomIndex, + albumName: widget.albumName, + albumCover: widget.albumCover, + ); + } } class _SongTile extends StatelessWidget { diff --git a/lib/music/music_now_playing.dart b/lib/music/music_now_playing.dart new file mode 100644 index 0000000..9900530 --- /dev/null +++ b/lib/music/music_now_playing.dart @@ -0,0 +1,514 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; +import 'package:dart_vlc/dart_vlc.dart' as vlc; +import 'package:flutter/material.dart'; +import 'package:ikaros/api/music/SubsonicApi.dart'; +import 'package:ikaros/api/subject/EpisodeApi.dart'; +import 'package:ikaros/api/subject/model/EpisodeResource.dart'; +import 'package:ikaros/utils/message_utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 播放模式 +enum _PlaybackMode { normal, repeatOne, repeatAll, shuffle } + +/// 单曲模型(播放队列用) +class _QueueSong { + final String id; + final String subjectId; + final String name; + final String? nameCn; + final String? cover; + final int duration; + final int sequence; + String? streamUrl; + + _QueueSong({ + required this.id, + required this.subjectId, + required this.name, + this.nameCn, + this.cover, + this.duration = 0, + this.sequence = 0, + this.streamUrl, + }); + + factory _QueueSong.fromApi(Map map) { + return _QueueSong( + id: map["id"] as String? ?? "", + subjectId: map["subjectId"] as String? ?? "", + name: map["name"] as String? ?? "", + nameCn: map["nameCn"] as String?, + cover: map["cover"] as String?, + duration: map["duration"] as int? ?? 0, + sequence: map["sequence"] as int? ?? 0, + ); + } + + String get displayName => nameCn ?? name; +} + +/// 正在播放页面(全屏桌面播放器) +class NowPlayingPage extends StatefulWidget { + /// 播放队列 + final List<_QueueSong> queue; + + /// 从第几首开始播放 + final int startIndex; + + /// 专辑名称 + final String? albumName; + + /// 专辑封面 + final String? albumCover; + + const NowPlayingPage({ + super.key, + required this.queue, + required this.startIndex, + this.albumName, + this.albumCover, + }); + + @override + State createState() => _NowPlayingPageState(); +} + +class _NowPlayingPageState extends State { + late vlc.Player _player; + int _currentIndex = 0; + bool _isPlaying = false; + bool _isInitialized = false; + _PlaybackMode _mode = _PlaybackMode.normal; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + String? _lastUrl; // 避免重复打开 + + // 打乱的索引 + List _shuffledIndices = []; + + @override + void initState() { + super.initState(); + _currentIndex = widget.startIndex.clamp(0, widget.queue.length - 1); + WidgetsFlutterBinding.ensureInitialized(); + vlc.DartVLC.initialize(); + _player = vlc.Player(id: hashCode); + _player.positionStream.listen(_onPositionChanged); + _player.playbackStream.listen(_onPlaybackChanged); + _player.durationStream.listen((d) { + if (mounted) setState(() => _duration = d); + }); + _initShuffle(); + _openCurrent(); + _saveNowPlaying(); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + void _initShuffle() { + _shuffledIndices = + List.generate(widget.queue.length, (i) => i)..shuffle(); + } + + _QueueSong get _currentSong => widget.queue[_currentIndex]; + int get _queueSize => widget.queue.length; + + String _formatDuration(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return "${d.inMinutes > 59 ? '${d.inHours}:${m}' : m}:$s"; + } + + void _onPositionChanged(vlc.PositionState state) { + if (mounted) { + setState(() { + _position = state.position; + _duration = state.duration; + }); + } + } + + void _onPlaybackChanged(vlc.PlaybackState state) { + if (mounted) { + setState(() => _isPlaying = state.isPlaying); + + // 播放完成自动切歌 + if (!state.isPlaying && state.isCompleted && _position >= _duration - + const Duration(seconds: 2)) { + _next(); + } + } + } + + Future _openCurrent() async { + final song = _currentSong; + if (song.streamUrl == null || song.streamUrl!.isEmpty) { + // 获取音频流地址 + try { + final resources = + await EpisodeApi().getEpisodeResourcesRefs(song.id); + if (resources.isNotEmpty) { + song.streamUrl = resources.first.url; + } + } catch (_) {} + // 如果还是空,尝试 Subsonic API + if ((song.streamUrl == null || song.streamUrl!.isEmpty)) { + song.streamUrl = await SubsonicApi.getStreamUrl(song.id); + } + } + + final url = song.streamUrl; + if (url == null || url.isEmpty) { + if (mounted) Toast.show(context, "无法获取音频流: ${song.displayName}"); + return; + } + + if (url == _lastUrl) return; + _lastUrl = url; + + if (url.startsWith("http")) { + _player.open(vlc.Media.network(url), autoStart: true); + } else { + _player.open(vlc.Media.file(File(url)), autoStart: true); + } + + _saveNowPlaying(); + + if (mounted) { + setState(() { + _isInitialized = true; + _isPlaying = true; + _position = Duration.zero; + _duration = Duration.zero; + }); + } + } + + Future _saveNowPlaying() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString("now_playing_song", _currentSong.displayName); + await prefs.setString("now_playing_album", + widget.albumName ?? _currentSong.subjectId); + await prefs.setString( + "now_playing_album_id", _currentSong.subjectId); + await prefs.setString( + "now_playing_cover", widget.albumCover ?? _currentSong.cover ?? ""); + } + + void _playPause() { + if (_isPlaying) { + _player.pause(); + } else { + _player.play(); + } + } + + void _prev() { + if (_position.inSeconds > 3) { + // 超过3秒则重播当前 + _player.seek(Duration.zero); + return; + } + _goToIndex(_getPrevIndex()); + } + + void _next() { + _goToIndex(_getNextIndex()); + } + + int _getNextIndex() { + switch (_mode) { + case _PlaybackMode.repeatOne: + return _currentIndex; + case _PlaybackMode.shuffle: + if (_shuffledIndices.isEmpty) _initShuffle(); + return _shuffledIndices.removeLast(); + case _PlaybackMode.repeatAll: + case _PlaybackMode.normal: + final next = _currentIndex + 1; + if (next >= _queueSize) { + return _mode == _PlaybackMode.repeatAll ? 0 : _currentIndex; + } + return next; + } + } + + int _getPrevIndex() { + final prev = _currentIndex - 1; + if (prev < 0) { + return _mode == _PlaybackMode.repeatAll ? _queueSize - 1 : 0; + } + return prev; + } + + void _goToIndex(int index) { + if (index < 0 || index >= _queueSize) return; + _lastUrl = null; + _player.stop(); + setState(() => _currentIndex = index); + _openCurrent(); + } + + void _toggleMode() { + setState(() { + _mode = _PlaybackMode.values[ + (_mode.index + 1) % _PlaybackMode.values.length]; + if (_mode == _PlaybackMode.shuffle) _initShuffle(); + }); + } + + IconData _modeIcon() { + switch (_mode) { + case _PlaybackMode.normal: + return Icons.repeat; + case _PlaybackMode.repeatOne: + return Icons.repeat_one; + case _PlaybackMode.repeatAll: + return Icons.repeat_on; + case _PlaybackMode.shuffle: + return Icons.shuffle; + } + } + + String _modeTooltip() { + switch (_mode) { + case _PlaybackMode.normal: + return "顺序播放"; + case _PlaybackMode.repeatOne: + return "单曲循环"; + case _PlaybackMode.repeatAll: + return "列表循环"; + case _PlaybackMode.shuffle: + return "随机播放"; + } + } + + @override + Widget build(BuildContext context) { + final song = _currentSong; + + return Scaffold( + appBar: AppBar( + title: Text(widget.albumName ?? "正在播放"), + actions: [ + IconButton( + icon: const Icon(Icons.queue_music), + tooltip: "播放队列", + onPressed: _showQueue, + ), + ], + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 2), + // 封面 + _buildCover(song), + const SizedBox(height: 24), + // 标题 + Text( + song.displayName, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + widget.albumName ?? "", + style: TextStyle(fontSize: 14, color: Colors.grey[500]), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + // 进度条 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ProgressBar( + progress: _position, + total: _duration > Duration.zero ? _duration : null, + barHeight: 4, + thumbRadius: 8, + timeLabelLocation: TimeLabelLocation.sides, + timeLabelType: TimeLabelType.totalTime, + timeLabelTextStyle: const TextStyle(color: Colors.grey), + onSeek: (d) => _player.seek(d), + ), + ), + const SizedBox(height: 24), + // 控制按钮 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 播放模式 + IconButton( + icon: Icon(_modeIcon()), + tooltip: _modeTooltip(), + color: _mode == _PlaybackMode.normal ? null : Colors.blue, + iconSize: 24, + onPressed: _toggleMode, + ), + const SizedBox(width: 16), + // 上一首 + IconButton( + icon: const Icon(Icons.skip_previous), + iconSize: 36, + onPressed: _prev, + ), + const SizedBox(width: 8), + // 播放/暂停 + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon( + _isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white), + iconSize: 40, + onPressed: _playPause, + ), + ), + const SizedBox(width: 8), + // 下一首 + IconButton( + icon: const Icon(Icons.skip_next), + iconSize: 36, + onPressed: _next, + ), + const SizedBox(width: 16), + // 队列 + IconButton( + icon: const Icon(Icons.queue_music_outlined), + iconSize: 24, + onPressed: _showQueue, + ), + ], + ), + const Spacer(flex: 1), + ], + ), + ), + ), + ); + } + + Widget _buildCover(_QueueSong song) { + final cover = widget.albumCover ?? song.cover ?? ""; + return ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + width: 280, + height: 280, + child: cover.isNotEmpty + ? Image.network(cover, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _defaultCoverWidget()) + : _defaultCoverWidget(), + ), + ); + } + + Widget _defaultCoverWidget() { + return Container( + color: Colors.grey[900], + child: const Center( + child: Icon(Icons.music_note, size: 80, color: Colors.white54), + ), + ); + } + + void _showQueue() { + showModalBottomSheet( + context: context, + builder: (ctx) => SizedBox( + height: 400, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const Text("播放队列", + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 16)), + const Spacer(), + Text("${_queueSize} 首", + style: TextStyle(color: Colors.grey[500])), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: _queueSize, + itemBuilder: (_, i) { + final q = widget.queue[i]; + final isCurrent = i == _currentIndex; + return ListTile( + selected: isCurrent, + selectedTileColor: Colors.blue.withOpacity(0.1), + leading: Text("${i + 1}", + style: TextStyle( + color: isCurrent ? Colors.blue : Colors.grey)), + title: Text(q.displayName, + style: TextStyle( + fontWeight: + isCurrent ? FontWeight.bold : null)), + subtitle: Text("${q.sequence}. ${q.subjectId}", + maxLines: 1, + overflow: TextOverflow.ellipsis), + trailing: isCurrent + ? Icon(Icons.play_arrow, + color: Colors.blue, size: 20) + : null, + onTap: () { + Navigator.pop(ctx); + _goToIndex(i); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +// ===== 便捷函数:创建 NowPlayingPage 并 push ===== + +/// 从专辑数据创建播放页面并导航 +Future pushNowPlaying( + BuildContext context, + List> songs, + {int startIndex = 0, + String? albumName, + String? albumCover}) async { + if (songs.isEmpty) return; + + final queue = songs.map((s) => _QueueSong.fromApi(s)).toList(); + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => NowPlayingPage( + queue: queue, + startIndex: startIndex, + albumName: albumName, + albumCover: albumCover, + ), + ), + ); +} From 08c4d6b17dad362cf28ae9ac4e85c3fc014b91ad Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 01:03:52 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(music):=20=E6=96=B0=E5=A2=9E=20LRC/A2?= =?UTF-8?q?=20=E6=AD=8C=E8=AF=8D=E5=BC=95=E6=93=8E=E5=92=8C=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E6=AD=8C=E8=AF=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 LRC/A2 格式歌词解析器 (utils/lyrics_parser.dart) - 支持标准 LRC 格式 [mm:ss.xx]歌词 - 支持 A2 增强格式 逐字歌词 - 支持元数据标签解析 (ti/ar/al/by/offset) - 逐字时间戳和单词进度计算 - 创建卡拉OK歌词组件 (component/lyrics_widget.dart) - 竖排歌词列表模式:当前行高亮 + 渐变色 - 横排桌面歌词模式:单行卡拉OK效果 - 逐字跟随播放进度高亮 - 自动滚动到当前行 - NowPlayingPage 集成 - 歌词显示/隐藏切换 - 横排/竖排歌词布局切换 - 当前播放进度实时同步 - 从附件资源自动匹配 .lrc 歌词文件 --- lib/component/lyrics_widget.dart | 222 ++++++++++++++++ lib/music/music_now_playing.dart | 438 +++++++++++++++++++++---------- lib/utils/lyrics_parser.dart | 229 ++++++++++++++++ 3 files changed, 744 insertions(+), 145 deletions(-) create mode 100644 lib/component/lyrics_widget.dart create mode 100644 lib/utils/lyrics_parser.dart diff --git a/lib/component/lyrics_widget.dart b/lib/component/lyrics_widget.dart new file mode 100644 index 0000000..3392233 --- /dev/null +++ b/lib/component/lyrics_widget.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:ikaros/utils/lyrics_parser.dart'; + +/// 歌词显示方向 +enum LyricsOrientation { vertical, horizontal } + +/// 卡拉OK歌词组件 +/// 支持竖排列表和横排单行两种布局 +class LyricsWidget extends StatefulWidget { + /// 歌词数据 + final ParsedLyrics lyrics; + + /// 当前播放位置(毫秒) + final double positionMs; + + /// 显示方向 + final LyricsOrientation orientation; + + /// 歌词颜色 + final Color textColor; + + /// 高亮颜色(卡拉OK当前字) + final Color highlightColor; + + /// 已唱颜色 + final Color sungColor; + + /// 字体大小 + final double fontSize; + + const LyricsWidget({ + super.key, + required this.lyrics, + required this.positionMs, + this.orientation = LyricsOrientation.vertical, + this.textColor = Colors.white54, + this.highlightColor = Colors.blue, + this.sungColor = Colors.white, + this.fontSize = 16, + }); + + @override + State createState() => _LyricsWidgetState(); +} + +class _LyricsWidgetState extends State { + final ScrollController _scrollController = ScrollController(); + Timer? _scrollTimer; + + @override + void didUpdateWidget(LyricsWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.positionMs != widget.positionMs) { + _autoScrollToCurrent(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + _scrollTimer?.cancel(); + super.dispose(); + } + + void _autoScrollToCurrent() { + _scrollTimer?.cancel(); + _scrollTimer = Timer(const Duration(milliseconds: 100), () { + if (!_scrollController.hasClients) return; + final index = widget.lyrics.indexAt(widget.positionMs); + if (index < 0) return; + final itemHeight = widget.fontSize + 16; // 行高估算 + final target = index * itemHeight - 100; + if (target > 0) { + _scrollController.animateTo( + target.clamp(0.0, _scrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + if (widget.lyrics.isEmpty) { + return const Center(child: Text("暂无歌词", style: TextStyle(color: Colors.white38))); + } + + return widget.orientation == LyricsOrientation.vertical + ? _buildVertical() + : _buildHorizontal(); + } + + /// 竖排歌词列表 + Widget _buildVertical() { + final currentIndex = widget.lyrics.indexAt(widget.positionMs); + + return ListView.builder( + controller: _scrollController, + itemCount: widget.lyrics.length, + itemBuilder: (context, index) { + final line = widget.lyrics.lines[index]; + final isCurrent = index == currentIndex; + final isSung = index < currentIndex; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.center, + child: line.words != null && isCurrent + ? _buildKaraokeText(line, isSung: false) + : Text( + line.text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: isCurrent ? widget.fontSize + 2 : widget.fontSize, + fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, + color: isSung + ? widget.sungColor + : isCurrent + ? widget.highlightColor + : widget.textColor, + ), + ), + ); + }, + ); + } + + /// 横排单行卡拉OK(桌面歌词) + Widget _buildHorizontal() { + final currentIndex = widget.lyrics.indexAt(widget.positionMs); + + if (currentIndex < 0) { + return const SizedBox.shrink(); + } + + final currentLine = widget.lyrics.lines[currentIndex]; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: currentLine.words != null + ? _buildKaraokeText(currentLine, isSung: false) + : ShaderMask( + shaderCallback: (bounds) { + final progress = currentLine.progressAt(widget.positionMs); + return LinearGradient( + colors: [widget.sungColor, widget.textColor], + stops: [progress, progress], + ).createShader(bounds); + }, + blendMode: BlendMode.srcIn, + child: Text( + currentLine.text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: widget.fontSize + 4, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + + /// 逐字卡拉OK文本 + Widget _buildKaraokeText(LyricsLine line, {bool isSung = false}) { + if (line.words == null || line.words!.isEmpty) { + return Text( + line.text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: widget.fontSize + 2, + fontWeight: FontWeight.bold, + color: isSung ? widget.sungColor : widget.highlightColor, + ), + ); + } + + final words = line.words!; + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + for (int i = 0; i < words.length; i++) + _buildWordSpan(words[i], i, line), + ], + ), + ); + } + + TextSpan _buildWordSpan(WordTiming word, int index, LyricsLine line) { + final isActive = line.wordIndexAt(widget.positionMs) == index; + final wordProgress = line.wordProgressAt(widget.positionMs); + + if (isActive && wordProgress > 0) { + // 当前正在唱的字的卡拉OK渐变色 + return TextSpan( + text: word.word, + style: TextStyle( + fontSize: widget.fontSize + 2, + fontWeight: FontWeight.bold, + foreground: Paint() + ..shader = LinearGradient( + colors: [widget.sungColor, widget.highlightColor], + stops: [wordProgress, wordProgress], + ).createShader(const Rect.fromLTWH(0, 0, 200, 50)), + ), + ); + } + + return TextSpan( + text: word.word, + style: TextStyle( + fontSize: widget.fontSize + 2, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + color: isActive ? widget.highlightColor : widget.sungColor, + ), + ); + } +} diff --git a/lib/music/music_now_playing.dart b/lib/music/music_now_playing.dart index 9900530..a9f8826 100644 --- a/lib/music/music_now_playing.dart +++ b/lib/music/music_now_playing.dart @@ -3,17 +3,21 @@ import 'dart:io'; import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; import 'package:dart_vlc/dart_vlc.dart' as vlc; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:ikaros/api/dio_client.dart'; import 'package:ikaros/api/music/SubsonicApi.dart'; import 'package:ikaros/api/subject/EpisodeApi.dart'; import 'package:ikaros/api/subject/model/EpisodeResource.dart'; +import 'package:ikaros/component/lyrics_widget.dart'; +import 'package:ikaros/utils/lyrics_parser.dart'; import 'package:ikaros/utils/message_utils.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 播放模式 enum _PlaybackMode { normal, repeatOne, repeatAll, shuffle } -/// 单曲模型(播放队列用) +/// 单曲模型 class _QueueSong { final String id; final String subjectId; @@ -50,18 +54,11 @@ class _QueueSong { String get displayName => nameCn ?? name; } -/// 正在播放页面(全屏桌面播放器) +/// 正在播放页面 class NowPlayingPage extends StatefulWidget { - /// 播放队列 final List<_QueueSong> queue; - - /// 从第几首开始播放 final int startIndex; - - /// 专辑名称 final String? albumName; - - /// 专辑封面 final String? albumCover; const NowPlayingPage({ @@ -84,9 +81,14 @@ class _NowPlayingPageState extends State { _PlaybackMode _mode = _PlaybackMode.normal; Duration _position = Duration.zero; Duration _duration = Duration.zero; - String? _lastUrl; // 避免重复打开 + String? _lastUrl; + + // 歌词 + ParsedLyrics? _lyrics; + bool _loadLyricsDone = false; + bool _showLyrics = false; + LyricsOrientation _lyricsOrientation = LyricsOrientation.vertical; - // 打乱的索引 List _shuffledIndices = []; @override @@ -120,11 +122,8 @@ class _NowPlayingPageState extends State { _QueueSong get _currentSong => widget.queue[_currentIndex]; int get _queueSize => widget.queue.length; - String _formatDuration(Duration d) { - final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return "${d.inMinutes > 59 ? '${d.inHours}:${m}' : m}:$s"; - } + double get _positionMs => + _position.inMilliseconds.toDouble(); void _onPositionChanged(vlc.PositionState state) { if (mounted) { @@ -138,10 +137,9 @@ class _NowPlayingPageState extends State { void _onPlaybackChanged(vlc.PlaybackState state) { if (mounted) { setState(() => _isPlaying = state.isPlaying); - - // 播放完成自动切歌 - if (!state.isPlaying && state.isCompleted && _position >= _duration - - const Duration(seconds: 2)) { + if (!state.isPlaying && + state.isCompleted && + _position >= _duration - const Duration(seconds: 2)) { _next(); } } @@ -150,15 +148,11 @@ class _NowPlayingPageState extends State { Future _openCurrent() async { final song = _currentSong; if (song.streamUrl == null || song.streamUrl!.isEmpty) { - // 获取音频流地址 try { final resources = await EpisodeApi().getEpisodeResourcesRefs(song.id); - if (resources.isNotEmpty) { - song.streamUrl = resources.first.url; - } + if (resources.isNotEmpty) song.streamUrl = resources.first.url; } catch (_) {} - // 如果还是空,尝试 Subsonic API if ((song.streamUrl == null || song.streamUrl!.isEmpty)) { song.streamUrl = await SubsonicApi.getStreamUrl(song.id); } @@ -179,6 +173,9 @@ class _NowPlayingPageState extends State { _player.open(vlc.Media.file(File(url)), autoStart: true); } + // 加载歌词 + _loadLyrics(); + _saveNowPlaying(); if (mounted) { @@ -187,17 +184,52 @@ class _NowPlayingPageState extends State { _isPlaying = true; _position = Duration.zero; _duration = Duration.zero; + _loadLyricsDone = false; }); } } + /// 加载歌词(从附件资源或固定的 lrc 文件名匹配) + Future _loadLyrics() async { + _lyrics = null; + _loadLyricsDone = false; + try { + final resources = + await EpisodeApi().getEpisodeResourcesRefs(_currentSong.id); + final lrcResource = resources.where((r) => + r.name.endsWith('.lrc') || + r.name.endsWith('.txt') || + (r.tags != null && r.tags!.contains('lyrics'))).toList(); + + if (lrcResource.isNotEmpty) { + final url = lrcResource.first.url; + if (url.isNotEmpty) { + final dio = await DioClient.getDio(); + final response = await dio.get(url, + options: Options( + responseType: ResponseType.plain, + )); + if (response.statusCode == 200 && response.data is String) { + final parsed = LyricsParser.parse(response.data as String); + if (mounted && parsed.lines.isNotEmpty) { + setState(() { + _lyrics = parsed; + _loadLyricsDone = true; + }); + } + } + } + } + } catch (_) {} + if (mounted) setState(() => _loadLyricsDone = true); + } + Future _saveNowPlaying() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString("now_playing_song", _currentSong.displayName); - await prefs.setString("now_playing_album", - widget.albumName ?? _currentSong.subjectId); await prefs.setString( - "now_playing_album_id", _currentSong.subjectId); + "now_playing_album", widget.albumName ?? _currentSong.subjectId); + await prefs.setString("now_playing_album_id", _currentSong.subjectId); await prefs.setString( "now_playing_cover", widget.albumCover ?? _currentSong.cover ?? ""); } @@ -212,16 +244,13 @@ class _NowPlayingPageState extends State { void _prev() { if (_position.inSeconds > 3) { - // 超过3秒则重播当前 _player.seek(Duration.zero); return; } _goToIndex(_getPrevIndex()); } - void _next() { - _goToIndex(_getNextIndex()); - } + void _next() => _goToIndex(_getNextIndex()); int _getNextIndex() { switch (_mode) { @@ -242,10 +271,9 @@ class _NowPlayingPageState extends State { int _getPrevIndex() { final prev = _currentIndex - 1; - if (prev < 0) { - return _mode == _PlaybackMode.repeatAll ? _queueSize - 1 : 0; - } - return prev; + return prev < 0 + ? (_mode == _PlaybackMode.repeatAll ? _queueSize - 1 : 0) + : prev; } void _goToIndex(int index) { @@ -277,27 +305,46 @@ class _NowPlayingPageState extends State { } } - String _modeTooltip() { - switch (_mode) { - case _PlaybackMode.normal: - return "顺序播放"; - case _PlaybackMode.repeatOne: - return "单曲循环"; - case _PlaybackMode.repeatAll: - return "列表循环"; - case _PlaybackMode.shuffle: - return "随机播放"; - } - } - @override Widget build(BuildContext context) { final song = _currentSong; + final hasLyrics = _lyrics != null && _lyrics!.lines.isNotEmpty; return Scaffold( appBar: AppBar( title: Text(widget.albumName ?? "正在播放"), actions: [ + // 歌词切换 + if (hasLyrics) + IconButton( + icon: Icon( + _showLyrics ? Icons.lyrics : Icons.lyrics_outlined, + color: _showLyrics ? Colors.blue : null, + ), + tooltip: _showLyrics ? "隐藏歌词" : "显示歌词", + onPressed: () { + setState(() => _showLyrics = !_showLyrics); + if (_showLyrics) _loadLyrics(); + }, + ), + if (_showLyrics) + IconButton( + icon: Icon( + _lyricsOrientation == LyricsOrientation.vertical + ? Icons.view_column + : Icons.view_carousel, + ), + tooltip: _lyricsOrientation == LyricsOrientation.vertical + ? "横排歌词" : "竖排歌词", + onPressed: () { + setState(() { + _lyricsOrientation = + _lyricsOrientation == LyricsOrientation.vertical + ? LyricsOrientation.horizontal + : LyricsOrientation.vertical; + }); + }, + ), IconButton( icon: const Icon(Icons.queue_music), tooltip: "播放队列", @@ -305,105 +352,208 @@ class _NowPlayingPageState extends State { ), ], ), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Spacer(flex: 2), - // 封面 - _buildCover(song), - const SizedBox(height: 24), - // 标题 - Text( - song.displayName, - style: const TextStyle( - fontSize: 20, fontWeight: FontWeight.bold), + body: _showLyrics && hasLyrics + ? _buildWithLyrics() + : _buildPlayerContent(), + ); + } + + /// 带歌词的播放界面 + Widget _buildWithLyrics() { + return Column( + children: [ + // 上方:小封面 + 歌曲信息 + if (_lyricsOrientation == LyricsOrientation.vertical) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 80, + height: 80, + child: _buildCover(_currentSong), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_currentSong.displayName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + maxLines: 1, overflow: TextOverflow.ellipsis), + Text(widget.albumName ?? "", + style: TextStyle(color: Colors.grey[500], fontSize: 13)), + ], + ), + ), + ], + ), + ), + // 歌词区域 + Expanded( + child: _lyrics == null + ? const Center(child: CircularProgressIndicator()) + : LyricsWidget( + lyrics: _lyrics!, + positionMs: _positionMs, + orientation: _lyricsOrientation, + textColor: Colors.white38, + highlightColor: Colors.blue, + sungColor: Colors.white, + fontSize: 16, + ), + ), + // 控制栏 + _buildPlayerControls(), + _buildChapterProgress(), + ], + ); + } + + /// 无歌词的播放界面 + Widget _buildPlayerContent() { + final song = _currentSong; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 2), + _buildCover(song), + const SizedBox(height: 24), + Text(song.displayName, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), textAlign: TextAlign.center, maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - widget.albumName ?? "", + overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Text(widget.albumName ?? "", style: TextStyle(fontSize: 14, color: Colors.grey[500]), - textAlign: TextAlign.center, + textAlign: TextAlign.center), + const Spacer(flex: 1), + _buildPlayerControls(), + _buildChapterProgress(), + const Spacer(flex: 1), + ], + ), + ), + ); + } + + Widget _buildPlayerControls() { + return Column( + children: [ + // 进度条 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ProgressBar( + progress: _position, + total: _duration > Duration.zero ? _duration : null, + barHeight: 4, + thumbRadius: 8, + timeLabelLocation: TimeLabelLocation.sides, + timeLabelType: TimeLabelType.totalTime, + timeLabelTextStyle: const TextStyle(color: Colors.grey), + onSeek: (d) => _player.seek(d), + ), + ), + const SizedBox(height: 16), + // 控制按钮 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon(_modeIcon()), + tooltip: _modeName(), + color: _mode == _PlaybackMode.normal ? null : Colors.blue, + iconSize: 24, + onPressed: _toggleMode, + ), + const SizedBox(width: 16), + IconButton( + icon: const Icon(Icons.skip_previous), + iconSize: 36, + onPressed: _prev, + ), + const SizedBox(width: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, ), - const SizedBox(height: 32), - // 进度条 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: ProgressBar( - progress: _position, - total: _duration > Duration.zero ? _duration : null, - barHeight: 4, - thumbRadius: 8, - timeLabelLocation: TimeLabelLocation.sides, - timeLabelType: TimeLabelType.totalTime, - timeLabelTextStyle: const TextStyle(color: Colors.grey), - onSeek: (d) => _player.seek(d), - ), + child: IconButton( + icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white), + iconSize: 40, + onPressed: _playPause, ), - const SizedBox(height: 24), - // 控制按钮 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 播放模式 - IconButton( - icon: Icon(_modeIcon()), - tooltip: _modeTooltip(), - color: _mode == _PlaybackMode.normal ? null : Colors.blue, - iconSize: 24, - onPressed: _toggleMode, - ), - const SizedBox(width: 16), - // 上一首 - IconButton( - icon: const Icon(Icons.skip_previous), - iconSize: 36, - onPressed: _prev, - ), - const SizedBox(width: 8), - // 播放/暂停 - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, - ), - child: IconButton( - icon: Icon( - _isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white), - iconSize: 40, - onPressed: _playPause, - ), - ), - const SizedBox(width: 8), - // 下一首 - IconButton( - icon: const Icon(Icons.skip_next), - iconSize: 36, - onPressed: _next, - ), - const SizedBox(width: 16), - // 队列 - IconButton( - icon: const Icon(Icons.queue_music_outlined), - iconSize: 24, - onPressed: _showQueue, - ), - ], - ), - const Spacer(flex: 1), - ], - ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.skip_next), + iconSize: 36, + onPressed: _next, + ), + const SizedBox(width: 16), + IconButton( + icon: const Icon(Icons.queue_music_outlined), + iconSize: 24, + onPressed: _showQueue, + ), + ], ), + ], + ); + } + + Widget _buildChapterProgress() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (_currentIndex > 0) + TextButton.icon( + onPressed: _prev, + icon: const Icon(Icons.skip_previous, size: 18), + label: Text("上一首", + style: const TextStyle(fontSize: 13)), + ) + else + const SizedBox(width: 80), + Text("${_currentIndex + 1} / ${_queueSize}", + style: const TextStyle(color: Colors.grey, fontSize: 12)), + if (_currentIndex < _queueSize - 1) + TextButton.icon( + onPressed: _next, + icon: const Icon(Icons.skip_next, size: 18), + label: Text("下一首", + style: const TextStyle(fontSize: 13)), + ) + else + const SizedBox(width: 80), + ], ), ); } + String _modeName() { + switch (_mode) { + case _PlaybackMode.normal: + return "顺序播放"; + case _PlaybackMode.repeatOne: + return "单曲循环"; + case _PlaybackMode.repeatAll: + return "列表循环"; + case _PlaybackMode.shuffle: + return "随机播放"; + } + } + Widget _buildCover(_QueueSong song) { final cover = widget.albumCover ?? song.cover ?? ""; return ClipRRect( @@ -444,7 +594,7 @@ class _NowPlayingPageState extends State { style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16)), const Spacer(), - Text("${_queueSize} 首", + Text("$_queueSize 首", style: TextStyle(color: Colors.grey[500])), ], ), @@ -464,13 +614,12 @@ class _NowPlayingPageState extends State { color: isCurrent ? Colors.blue : Colors.grey)), title: Text(q.displayName, style: TextStyle( - fontWeight: - isCurrent ? FontWeight.bold : null)), - subtitle: Text("${q.sequence}. ${q.subjectId}", + fontWeight: isCurrent ? FontWeight.bold : null)), + subtitle: Text("第 ${q.sequence} 首", maxLines: 1, overflow: TextOverflow.ellipsis), trailing: isCurrent - ? Icon(Icons.play_arrow, + ? const Icon(Icons.play_arrow, color: Colors.blue, size: 20) : null, onTap: () { @@ -488,9 +637,8 @@ class _NowPlayingPageState extends State { } } -// ===== 便捷函数:创建 NowPlayingPage 并 push ===== +// ===== 便捷函数 ===== -/// 从专辑数据创建播放页面并导航 Future pushNowPlaying( BuildContext context, List> songs, diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart new file mode 100644 index 0000000..5cf5b72 --- /dev/null +++ b/lib/utils/lyrics_parser.dart @@ -0,0 +1,229 @@ +import 'dart:math'; + +/// 歌词元数据标签 +class LyricsTag { + final String ti; // 歌曲标题 + final String ar; // 演唱者 + final String al; // 专辑 + final String by; // 制作人 + final double offset; // 时间偏移(秒) + + const LyricsTag({ + this.ti = "", + this.ar = "", + this.al = "", + this.by = "", + this.offset = 0.0, + }); +} + +/// 歌词单词时间戳(A2 格式用) +class WordTiming { + final String word; + final double startMs; + final double durationMs; + + const WordTiming({ + required this.word, + required this.startMs, + required this.durationMs, + }); +} + +/// 单行歌词 +class LyricsLine { + /// 行开始时间(毫秒) + final double startMs; + + /// 行文本 + final String text; + + /// A2 逐字时间(可选) + final List? words; + + const LyricsLine({ + required this.startMs, + required this.text, + this.words, + }); + + /// 当前时间在这一行内的进度(0.0 ~ 1.0) + double progressAt(double currentMs) { + if (words == null || words!.isEmpty) return 0.0; + final totalMs = words!.last.startMs + words!.last.durationMs - startMs; + if (totalMs <= 0) return 0.0; + return ((currentMs - startMs) / totalMs).clamp(0.0, 1.0); + } + + /// 当前时间对应的单词索引 + int wordIndexAt(double currentMs) { + if (words == null || words!.isEmpty) return -1; + final relativeMs = currentMs - startMs; + for (int i = 0; i < words!.length; i++) { + final w = words![i]; + if (relativeMs >= w.startMs && + relativeMs <= w.startMs + w.durationMs) { + return i; + } + } + return -1; + } + + /// 当前时间对应的单词进度 + double wordProgressAt(double currentMs) { + final idx = wordIndexAt(currentMs); + if (idx < 0 || words == null || idx >= words!.length) return 0.0; + final w = words![idx]; + if (w.durationMs <= 0) return 0.0; + return ((currentMs - startMs - w.startMs) / w.durationMs).clamp(0.0, 1.0); + } + + Duration get start => Duration(milliseconds: startMs.round()); +} + +/// LRC/A2 歌词解析结果 +class ParsedLyrics { + final LyricsTag tag; + final List lines; + + const ParsedLyrics({required this.tag, required this.lines}); + + bool get isEmpty => lines.isEmpty; + int get length => lines.length; + + /// 获取当前时间对应的歌词行索引 + int indexAt(double currentMs) { + if (lines.isEmpty) return -1; + for (int i = lines.length - 1; i >= 0; i--) { + if (currentMs >= lines[i].startMs) return i; + } + return -1; + } +} + +/// LRC/A2 歌词解析器 +class LyricsParser { + /// 解析 LRC 或 A2 格式的歌词文本 + static ParsedLyrics parse(String text) { + final tag = _parseTags(text); + final lines = _parseLines(text); + return ParsedLyrics(tag: tag, lines: lines); + } + + /// 解析元数据标签 + static LyricsTag _parseTags(String text) { + String ti = "", ar = "", al = "", by = ""; + double offset = 0.0; + + final tagRegex = RegExp(r'^\[(\w+):(.+)\]$', multiLine: true); + for (final match in tagRegex.allMatches(text)) { + final key = match.group(1)!.toLowerCase(); + final value = match.group(2)!.trim(); + switch (key) { + case 'ti': + ti = value; + break; + case 'ar': + ar = value; + break; + case 'al': + al = value; + break; + case 'by': + by = value; + break; + case 'offset': + offset = double.tryParse(value) ?? 0.0; + break; + } + } + return LyricsTag(ti: ti, ar: ar, al: al, by: by, offset: offset); + } + + /// 解析歌词行(支持 LRC 和 A2 格式) + static List _parseLines(String text) { + final lines = []; + // 匹配 [mm:ss.xx] 或 [mm:ss] 或 [mm:ss.xxx] + final lineRegex = RegExp( + r'^\[(\d{1,3}):(\d{2})(?:[\.:](\d{2,3}))?\](.*)$', + multiLine: true, + ); + + for (final match in lineRegex.allMatches(text)) { + final minutes = int.parse(match.group(1)!); + final seconds = int.parse(match.group(2)!); + final millisStr = match.group(3); + int millis = 0; + if (millisStr != null) { + millis = int.parse(millisStr); + if (millisStr.length == 2) millis *= 10; // .xx → xxx + } + final startMs = (minutes * 60 + seconds) * 1000.0 + millis; + final rawText = match.group(4)?.trim() ?? ""; + + // 检测是否为 A2 格式(含