From 70227afea593b9843b2694959f7a9132ed25826a Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sat, 31 Oct 2026 22:43:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(playlist):=20=E6=AD=8C=E5=8D=95=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B1=82=20+=20=E5=8A=A0=E5=85=A5=E6=AD=8C=E5=8D=95?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据层: - PlaylistSummary(歌单名/歌曲数/封面),DB 用一条 SQL 取摘要, 封面取歌单内第一首有封面的歌 - PlaylistRepository.getSummaries() / addSongs()(批量加入并去重) - 修复 AudioService.playPlaylist:之前把文件路径直接当 URL, 歌单播放必然失败;现在用 WebDAVService.getFileUrl 构造 入口(视觉为临时实现,等设计稿替换): - 文件列表长按 -> "加入歌单" 底部弹层 - 可在弹层里新建歌单并直接加入 真机验证:长按歌曲 -> 新建 "TL" -> 数据库 playlists/playlist_songs 均有记录。 测试:新增 playlist_summary 4 个用例,累计 29 个全绿。 --- lib/database/song_database.dart | 20 +++ lib/models/playlist_summary.dart | 33 +++++ lib/pages/webdav_file_list_page.dart | 2 + lib/repositories/playlist_repository.dart | 20 +++ lib/services/audio_service.dart | 7 +- lib/widgets/add_to_playlist_sheet.dart | 166 ++++++++++++++++++++++ test/playlist_summary_test.dart | 51 +++++++ 7 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 lib/models/playlist_summary.dart create mode 100644 lib/widgets/add_to_playlist_sheet.dart create mode 100644 test/playlist_summary_test.dart diff --git a/lib/database/song_database.dart b/lib/database/song_database.dart index 6567655..98af9cc 100644 --- a/lib/database/song_database.dart +++ b/lib/database/song_database.dart @@ -313,6 +313,26 @@ class SongDatabase { return await db.query('playlists', orderBy: 'created_at DESC'); } + /// 歌单摘要:歌曲数 + 封面(取歌单内第一首有封面的歌)。 + /// 列表页只需要这些,不用把每首歌都拉出来。 + Future>> getPlaylistSummaries() async { + final db = await database; + return await db.rawQuery(''' + SELECT p.id, p.name, p.updated_at, + (SELECT COUNT(*) FROM playlist_songs ps + WHERE ps.playlist_id = p.id) AS song_count, + (SELECT s.artwork_path FROM playlist_songs ps2 + JOIN songs s ON s.remote_path = ps2.song_id + WHERE ps2.playlist_id = p.id + AND s.artwork_path IS NOT NULL + AND s.artwork_path != '' + ORDER BY ps2.order_index + LIMIT 1) AS cover_path + FROM playlists p + ORDER BY p.updated_at DESC + '''); + } + Future?> getPlaylist(String id) async { _logQuery('playlists', 'get', args: {'id': id}); final db = await database; diff --git a/lib/models/playlist_summary.dart b/lib/models/playlist_summary.dart new file mode 100644 index 0000000..f2486bf --- /dev/null +++ b/lib/models/playlist_summary.dart @@ -0,0 +1,33 @@ +// lib/models/playlist_summary.dart + +/// 歌单列表用的轻量摘要(不加载歌单内全部歌曲) +class PlaylistSummary { + final String id; + final String name; + final int songCount; + + /// 封面:取歌单里第一首有封面的歌 + final String? coverPath; + final int updatedAt; + + const PlaylistSummary({ + required this.id, + required this.name, + this.songCount = 0, + this.coverPath, + this.updatedAt = 0, + }); + + factory PlaylistSummary.fromRow(Map row) { + final cover = (row['cover_path'] as String?)?.trim() ?? ''; + return PlaylistSummary( + id: row['id'] as String? ?? '', + name: (row['name'] as String?)?.trim() ?? '', + songCount: (row['song_count'] as num?)?.toInt() ?? 0, + coverPath: cover.isEmpty ? null : cover, + updatedAt: (row['updated_at'] as num?)?.toInt() ?? 0, + ); + } + + bool get isEmpty => songCount == 0; +} diff --git a/lib/pages/webdav_file_list_page.dart b/lib/pages/webdav_file_list_page.dart index c6356e3..c4b290e 100644 --- a/lib/pages/webdav_file_list_page.dart +++ b/lib/pages/webdav_file_list_page.dart @@ -7,6 +7,7 @@ import '../controllers/playback_controller.dart'; import '../database/song_database.dart'; import '../services/audio_service.dart'; import '../widgets/song_artwork.dart'; +import '../widgets/add_to_playlist_sheet.dart'; class WebDAVFileListPage extends StatefulWidget { final String currentPath; @@ -435,6 +436,7 @@ class _WebDAVFileListPageState extends BaseState { onPressed: () => _toggleFavorite(item.path), ), onTap: () => _playSong(item), + onLongPress: () => showAddToPlaylistSheet(context, songPath: item.path), ); } } diff --git a/lib/repositories/playlist_repository.dart b/lib/repositories/playlist_repository.dart index 4db5a92..b7667a1 100644 --- a/lib/repositories/playlist_repository.dart +++ b/lib/repositories/playlist_repository.dart @@ -2,6 +2,7 @@ import 'dart:math'; import '../database/song_database.dart'; import '../models/playlist.dart'; +import '../models/playlist_summary.dart'; import '../services/audio_service.dart'; class PlaylistRepository { @@ -37,6 +38,25 @@ class PlaylistRepository { return playlist; } + /// 歌单列表摘要(歌曲数 + 封面),给主页/列表页用 + Future> getSummaries() async { + final rows = await _db.getPlaylistSummaries(); + return rows.map(PlaylistSummary.fromRow).toList(); + } + + /// 批量加入歌曲(自动跳过歌单里已有的,避免打乱顺序) + Future addSongs(String playlistId, List songIds) async { + if (songIds.isEmpty) return 0; + final existing = (await getPlaylistSongIds(playlistId)).toSet(); + var added = 0; + for (final id in songIds) { + if (existing.contains(id)) continue; + await _db.addSongToPlaylist(playlistId, id); + existing.add(id); + added++; + } + return added; + } Future updatePlaylistName(String id, String newName) async { await _db.updatePlaylist(id, { 'name': newName, diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 98b40ff..fe92963 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -296,18 +296,19 @@ class AudioService extends ChangeNotifier { for (final id in songIds) { final dbSong = await _db.getSongByPath(id); if (dbSong != null) { + final path = dbSong['remote_path'] as String; songs.add(Song( - id: dbSong['remote_path'] as String, + id: path, title: dbSong['title'] as String? ?? '', artist: dbSong['artist'] as String? ?? '未知艺术家', - url: dbSong['remote_path'] as String, + url: WebDAVService.instance.getFileUrl(path), )); } else { songs.add(Song( id: id, title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''), artist: '未知艺术家', - url: id, + url: WebDAVService.instance.getFileUrl(id), )); } } diff --git a/lib/widgets/add_to_playlist_sheet.dart b/lib/widgets/add_to_playlist_sheet.dart new file mode 100644 index 0000000..7df2fa0 --- /dev/null +++ b/lib/widgets/add_to_playlist_sheet.dart @@ -0,0 +1,166 @@ +// lib/widgets/add_to_playlist_sheet.dart +import 'package:flutter/material.dart'; + +import '../models/playlist_summary.dart'; +import '../repositories/playlist_repository.dart'; + +/// 把一首歌加入歌单的底部弹层。 +/// +/// 视觉先保持系统样式,等设计定稿后只换这个文件即可, +/// 调用方(文件列表长按)不用改。 +Future showAddToPlaylistSheet( + BuildContext context, { + required String songPath, +}) async { + final repo = PlaylistRepository(); + List playlists; + try { + playlists = await repo.getSummaries(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('读取歌单失败: $e')), + ); + } + return; + } + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1A1F1E), + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(20, 18, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '加入歌单', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ListTile( + leading: const Icon(Icons.add, color: Color(0xFFB8D4D0)), + title: const Text( + '新建歌单', + style: TextStyle(color: Color(0xFFB8D4D0)), + ), + onTap: () async { + Navigator.pop(sheetContext); + await _createPlaylistAndAdd(context, repo, songPath); + }, + ), + const Divider(height: 1, color: Colors.white12), + if (playlists.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text( + '还没有歌单,先新建一个', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + return ListTile( + leading: const Icon( + Icons.queue_music, + color: Color(0xFFB8D4D0), + ), + title: Text( + playlist.name, + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + '${playlist.songCount} 首', + style: const TextStyle( + color: Colors.grey, fontSize: 12), + ), + onTap: () async { + final added = await repo.addSongs( + playlist.id, + [songPath], + ); + if (!sheetContext.mounted) return; + Navigator.pop(sheetContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(added > 0 + ? '已加入「${playlist.name}」' + : '这首歌已经在「${playlist.name}」里了'), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); +} + +Future _createPlaylistAndAdd( + BuildContext context, + PlaylistRepository repo, + String songPath, +) async { + final controller = TextEditingController(); + final name = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: const Color(0xFF1A1F1E), + title: const Text('新建歌单', style: 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(dialogContext), + child: const Text('取消'), + ), + TextButton( + onPressed: () => + Navigator.pop(dialogContext, controller.text.trim()), + child: const Text('创建'), + ), + ], + ), + ); + + if (name == null || name.isEmpty) return; + + try { + final playlist = await repo.createPlaylist(name); + await repo.addSongs(playlist.id, [songPath]); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已新建「$name」并加入这首歌')), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('新建歌单失败: $e')), + ); + } +} diff --git a/test/playlist_summary_test.dart b/test/playlist_summary_test.dart new file mode 100644 index 0000000..4c19a1d --- /dev/null +++ b/test/playlist_summary_test.dart @@ -0,0 +1,51 @@ +// test/playlist_summary_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:qingting_player/models/playlist_summary.dart'; + +void main() { + group('PlaylistSummary.fromRow', () { + test('完整行解析', () { + final s = PlaylistSummary.fromRow({ + 'id': 'abc', + 'name': '随手', + 'song_count': 12, + 'cover_path': '/data/artworks/x.jpg', + 'updated_at': 1700000000, + }); + expect(s.id, 'abc'); + expect(s.name, '随手'); + expect(s.songCount, 12); + expect(s.coverPath, '/data/artworks/x.jpg'); + expect(s.isEmpty, isFalse); + }); + + test('没有封面时 coverPath 为 null', () { + final s = PlaylistSummary.fromRow({ + 'id': 'abc', + 'name': '车用', + 'song_count': 3, + 'cover_path': '', + }); + expect(s.coverPath, isNull); + expect(s.songCount, 3); + }); + + test('空歌单 isEmpty', () { + final s = PlaylistSummary.fromRow({ + 'id': 'x', + 'name': '空', + 'song_count': 0, + }); + expect(s.isEmpty, isTrue); + expect(s.coverPath, isNull); + }); + + test('字段缺失时不崩', () { + final s = PlaylistSummary.fromRow({}); + expect(s.id, ''); + expect(s.name, ''); + expect(s.songCount, 0); + expect(s.isEmpty, isTrue); + }); + }); +}