From e89bd6381af3c1566596c4f0d448d8c74c3ba8b9 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sat, 31 Oct 2026 23:37:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(playlist):=20=E6=AD=8C=E5=8D=95=E9=9B=86?= =?UTF-8?q?=E5=90=88=E9=A1=B5=20+=20=E6=AD=8C=E5=8D=95=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=20+=20=E9=A6=96=E9=A1=B5=E5=85=A5=E5=8F=A3=20style(ui?= =?UTF-8?q?):=20=E6=92=AD=E6=94=BE=E9=A1=B5=E7=BA=A2=E5=BF=83=E4=B8=8E?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=B1=85=E4=B8=AD=E5=AF=B9=E9=BD=90=E3=80=81?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=8D=95=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - playlists_page:歌单列表(封面/名称/歌曲数)、新建、重命名、删除 - playlist_detail_page:歌单内歌曲、播放全部、单曲播放、移除 - 首页"歌单列表"按钮接入 PlaylistsPage - 播放页:标题行改为 center 对齐、标题限制单行, 收藏按钮压到 36x36,红心与标题不再错位 --- lib/pages/home_page.dart | 13 +- lib/pages/player_page.dart | 12 +- lib/pages/playlist_detail_page.dart | 183 +++++++++++++++++++++ lib/pages/playlists_page.dart | 241 ++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 lib/pages/playlist_detail_page.dart create mode 100644 lib/pages/playlists_page.dart diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 837d411..70f4cbb 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -5,6 +5,7 @@ import '../services/webdav_service.dart'; import '../widgets/magnetic_scroll_physics.dart'; import 'webdav_setup_page.dart'; import 'settings_page.dart'; +import 'playlists_page.dart'; import '../database/song_database.dart'; import '../widgets/song_artwork.dart'; import 'webdav_file_list_page.dart'; @@ -664,7 +665,15 @@ class _HomePageState extends BaseState { const SizedBox(width: 16), Expanded( child: _ClickableTile( - onTap: () {}, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PlaylistsPage(), + ), + ); + if (mounted) safeSetState(() {}); + }, child: Padding( padding: const EdgeInsets.symmetric( vertical: 10), @@ -834,4 +843,4 @@ class _HomePageState extends BaseState { ), ); } -} +} \ No newline at end of file diff --git a/lib/pages/player_page.dart b/lib/pages/player_page.dart index d46df71..843bd7d 100644 --- a/lib/pages/player_page.dart +++ b/lib/pages/player_page.dart @@ -337,7 +337,7 @@ class _PlayerPageState extends BaseState { // ---- 标题 / 艺术家(收藏心在标题右侧)---- Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Text( @@ -348,7 +348,7 @@ class _PlayerPageState extends BaseState { color: Colors.white, height: 1.25, ), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, ), ), @@ -356,17 +356,21 @@ class _PlayerPageState extends BaseState { IconButton( onPressed: () => _toggleFavorite(song), tooltip: '收藏', + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 36, minHeight: 36), + iconSize: 24, icon: Icon( _isFavorite ? Icons.favorite : Icons.favorite_border, color: _isFavorite ? const Color(0xFFE05C5C) : Colors.white54, - size: 26, + size: 24, ), ), ], ), - const SizedBox(height: 10), + const SizedBox(height: 2), Text( song.artist, style: TextStyle(fontSize: 16, color: Colors.grey[300]), diff --git a/lib/pages/playlist_detail_page.dart b/lib/pages/playlist_detail_page.dart new file mode 100644 index 0000000..3c7bf13 --- /dev/null +++ b/lib/pages/playlist_detail_page.dart @@ -0,0 +1,183 @@ +// lib/pages/playlist_detail_page.dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../database/song_database.dart'; +import '../repositories/playlist_repository.dart'; +import '../services/audio_service.dart'; +import '../widgets/song_artwork.dart'; + +/// 歌单详情:展示歌单内歌曲,支持播放全部 / 单曲播放 / 移除 +class PlaylistDetailPage extends StatefulWidget { + final String playlistId; + final String playlistName; + + const PlaylistDetailPage({ + super.key, + required this.playlistId, + required this.playlistName, + }); + + @override + State createState() => _PlaylistDetailPageState(); +} + +class _SongRow { + final String path; + final String title; + final String artist; + final String? artworkPath; + + const _SongRow({ + required this.path, + required this.title, + required this.artist, + this.artworkPath, + }); +} + +class _PlaylistDetailPageState extends State { + final PlaylistRepository _repo = PlaylistRepository(); + final SongDatabase _db = SongDatabase(); + + List<_SongRow> _songs = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final ids = await _repo.getPlaylistSongIds(widget.playlistId); + final meta = await _db.getSongsByPaths(ids); + final rows = <_SongRow>[]; + for (final id in ids) { + final row = meta[id]; + final title = (row?['title'] as String?)?.trim() ?? ''; + final artist = (row?['artist'] as String?)?.trim() ?? ''; + String fallbackName = id.split('/').last; + try { + fallbackName = Uri.decodeComponent(fallbackName); + } catch (_) {} + rows.add(_SongRow( + path: id, + title: title.isNotEmpty + ? title + : fallbackName.replaceAll(RegExp(r'\.[^.]*$'), ''), + artist: artist.isNotEmpty ? artist : '未知艺术家', + artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty == + true + ? (row!['artwork_path'] as String).trim() + : null, + )); + } + if (!mounted) return; + setState(() { + _songs = rows; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('读取歌单失败: $e'))); + } + } + + Future _play(int index) async { + await context + .read() + .playPlaylist(widget.playlistId, startIndex: index); + } + + Future _remove(_SongRow song) async { + await _repo.removeSong(widget.playlistId, song.path); + await _load(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0E1211), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text(widget.playlistName), + actions: [ + IconButton( + icon: const Icon(Icons.play_arrow_rounded, color: Color(0xFFB8D4D0)), + tooltip: '播放全部', + onPressed: _songs.isEmpty ? null : () => _play(0), + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _songs.isEmpty + ? _buildEmpty() + : ListView.builder( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: _songs.length, + itemBuilder: (context, index) { + final song = _songs[index]; + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + leading: SongArtwork( + filePath: song.artworkPath, + size: 44, + fallbackIcon: Icons.music_note, + ), + title: Text( + song.title, + style: + const TextStyle(color: Colors.white, fontSize: 16), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + song.artist, + style: const TextStyle(color: Colors.grey, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.close, + color: Colors.white24, size: 18), + tooltip: '从歌单移除', + onPressed: () => _remove(song), + ), + onTap: () => _play(index), + ); + }, + ), + ); + } + + Widget _buildEmpty() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.music_off_outlined, size: 44, color: Colors.grey[700]), + const SizedBox(height: 14), + Text( + '歌单还是空的', + style: TextStyle(color: Colors.grey[500], fontSize: 15), + ), + const SizedBox(height: 6), + Text( + '在文件列表长按歌曲即可加入', + style: TextStyle(color: Colors.grey[700], fontSize: 12), + ), + ], + ), + ); + } +} diff --git a/lib/pages/playlists_page.dart b/lib/pages/playlists_page.dart new file mode 100644 index 0000000..786c18a --- /dev/null +++ b/lib/pages/playlists_page.dart @@ -0,0 +1,241 @@ +// lib/pages/playlists_page.dart +import 'package:flutter/material.dart'; + +import '../models/playlist_summary.dart'; +import '../repositories/playlist_repository.dart'; +import '../widgets/song_artwork.dart'; +import 'playlist_detail_page.dart'; + +/// 歌单集合页:展示所有歌单,支持新建 / 重命名 / 删除 +class PlaylistsPage extends StatefulWidget { + const PlaylistsPage({super.key}); + + @override + State createState() => _PlaylistsPageState(); +} + +class _PlaylistsPageState extends State { + final PlaylistRepository _repo = PlaylistRepository(); + List _playlists = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final list = await _repo.getSummaries(); + if (!mounted) return; + setState(() { + _playlists = list; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('读取歌单失败: $e')), + ); + } + } + + Future _create() async { + final name = await _promptName('新建歌单'); + if (name == null || name.isEmpty) return; + try { + await _repo.createPlaylist(name); + await _load(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('新建失败: $e'))); + } + } + + Future _rename(PlaylistSummary playlist) async { + final name = await _promptName('重命名歌单', initial: playlist.name); + if (name == null || name.isEmpty) return; + await _repo.updatePlaylistName(playlist.id, name); + await _load(); + } + + Future _delete(PlaylistSummary playlist) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1A1F1E), + title: const Text('删除歌单', style: TextStyle(color: Colors.white)), + content: Text( + '删除「${playlist.name}」?歌单内的歌曲不会从网盘删除。', + style: const TextStyle(color: Colors.grey), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除', style: TextStyle(color: Colors.redAccent)), + ), + ], + ), + ); + if (ok == true) { + await _repo.deletePlaylist(playlist.id); + await _load(); + } + } + + Future _promptName(String title, {String initial = ''}) async { + final controller = TextEditingController(text: initial); + return showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1A1F1E), + title: Text(title, style: const TextStyle(color: Colors.white)), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(hintText: '歌单名称'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('确定'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0E1211), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: const Text('歌单'), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: Color(0xFFB8D4D0)), + tooltip: '新建歌单', + onPressed: _create, + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _playlists.isEmpty + ? _buildEmpty() + : RefreshIndicator( + onRefresh: _load, + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + itemCount: _playlists.length, + itemBuilder: (context, index) { + final playlist = _playlists[index]; + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + leading: SongArtwork( + filePath: playlist.coverPath, + size: 52, + fallbackIcon: Icons.queue_music, + ), + title: Text( + playlist.name, + style: const TextStyle( + color: Colors.white, fontSize: 16), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${playlist.songCount} 首', + style: const TextStyle( + color: Colors.grey, fontSize: 12), + ), + trailing: PopupMenuButton( + icon: const Icon(Icons.more_vert, + color: Colors.white38, size: 20), + onSelected: (value) { + if (value == 'rename') _rename(playlist); + if (value == 'delete') _delete(playlist); + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'rename', + child: Row( + children: [ + Icon(Icons.edit_outlined, + color: Color(0xFFB8D4D0), size: 18), + SizedBox(width: 10), + Text('重命名'), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline, + color: Colors.redAccent, size: 18), + SizedBox(width: 10), + Text('删除'), + ], + ), + ), + ], + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlaylistDetailPage( + playlistId: playlist.id, + playlistName: playlist.name, + ), + ), + ); + await _load(); + }, + ); + }, + ), + ), + ); + } + + Widget _buildEmpty() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.queue_music, size: 48, color: Colors.grey[700]), + const SizedBox(height: 14), + Text( + '还没有歌单', + style: TextStyle(color: Colors.grey[500], fontSize: 15), + ), + const SizedBox(height: 6), + Text( + '点右上角 + 新建一个', + style: TextStyle(color: Colors.grey[700], fontSize: 12), + ), + ], + ), + ); + } +}