diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 3c50e3a..2d91f5f 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -4,6 +4,8 @@ # 1.8.0 +- 新增桌面音乐播放器,支持专辑浏览、歌曲列表和播放 +- 新增音乐库导航页面,移动端和桌面端均支持 - 新增漫画阅读器,支持章节列表和分页/列表两种阅读模式 # 1.7.2 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/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/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..58fa939 --- /dev/null +++ b/lib/music/music_library.dart @@ -0,0 +1,518 @@ +import 'package:flutter/material.dart'; +import 'package:ikaros/api/music/MusicApi.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'; + +/// 音乐库页面. +/// 展示专辑列表,支持搜索和上下滑动加载更多. +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(); + 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 && + !_isLoading && + _hasMore) { + _loadAlbums(); + } + } + + 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; + setState(() { + _albums.addAll(items.cast>()); + _hasMore = _albums.length < total; + _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); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + 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); + } + }); + }, + ), + 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, + ); + } + + 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, + ), + ), + ); + } + + 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: () { + // 打开正在播放页面(假设已有队列) + Toast.show(context, "$_currentSongName - 请在音乐库中选择专辑播放"); + }, + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => setState(() => _currentSongName = null), + ), + ], + ), + ), + ); + } +} + +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, + elevation: 2, + 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( + padding: const EdgeInsets.only(bottom: 20), + itemCount: _songs.length, + itemBuilder: (context, index) { + final song = _songs[index]; + return _SongTile( + song: song, + index: index, + albumName: widget.albumName, + albumCover: widget.albumCover, + ); + }), + ), + ], + ), + ); + } + + 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), + 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 ? () => _playShuffled() : null, + icon: const Icon(Icons.shuffle), + label: const Text("随机播放"), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + void _playSong(int index) { + if (index >= _songs.length) return; + 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 { + final Map song; + final int index; + final String albumName; + final String albumCover; + + const _SongTile({ + required this.song, + required this.index, + 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; + + 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( + 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), + color: Theme.of(context).colorScheme.primary, + onPressed: () { + // 直接调用父级方法 + final parent = context.findAncestorStateOfType<_MusicAlbumDetailPageState>(); + parent?._playSong(index); + }, + ), + onTap: () { + final parent = context.findAncestorStateOfType<_MusicAlbumDetailPageState>(); + parent?._playSong(index); + }, + ); + } +} diff --git a/lib/music/music_now_playing.dart b/lib/music/music_now_playing.dart new file mode 100644 index 0000000..c211038 --- /dev/null +++ b/lib/music/music_now_playing.dart @@ -0,0 +1,659 @@ +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: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; + 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; + + // 歌词 + ParsedLyrics? _lyrics; + bool _loadLyricsDone = false; + bool _showLyrics = false; + LyricsOrientation _lyricsOrientation = LyricsOrientation.vertical; + + 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); + _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; + + double get _positionMs => + _position.inMilliseconds.toDouble(); + + void _onPositionChanged(vlc.PositionState state) { + if (mounted) { + setState(() { + _position = state.position ?? Duration.zero; + _duration = state.duration ?? Duration.zero; + }); + } + } + + 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 (_) {} + 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); + } + + // 加载歌词 + _loadLyrics(); + + _saveNowPlaying(); + + if (mounted) { + setState(() { + _isInitialized = true; + _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); + 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) { + _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; + return prev < 0 + ? (_mode == _PlaybackMode.repeatAll ? _queueSize - 1 : 0) + : 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; + } + } + + @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: "播放队列", + onPressed: _showQueue, + ), + ], + ), + 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 ?? "", + style: TextStyle(fontSize: 14, color: Colors.grey[500]), + 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, + 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, + ), + 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, + ), + ], + ), + ], + ); + } + + 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( + 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} 首", + maxLines: 1, + overflow: TextOverflow.ellipsis), + trailing: isCurrent + ? const Icon(Icons.play_arrow, + color: Colors.blue, size: 20) + : null, + onTap: () { + Navigator.pop(ctx); + _goToIndex(i); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +// ===== 便捷函数 ===== + +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, + ), + ), + ); +} 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 格式(含