feat(playlist): 歌单数据层 + 加入歌单入口
数据层: - PlaylistSummary(歌单名/歌曲数/封面),DB 用一条 SQL 取摘要, 封面取歌单内第一首有封面的歌 - PlaylistRepository.getSummaries() / addSongs()(批量加入并去重) - 修复 AudioService.playPlaylist:之前把文件路径直接当 URL, 歌单播放必然失败;现在用 WebDAVService.getFileUrl 构造 入口(视觉为临时实现,等设计稿替换): - 文件列表长按 -> "加入歌单" 底部弹层 - 可在弹层里新建歌单并直接加入 真机验证:长按歌曲 -> 新建 "TL" -> 数据库 playlists/playlist_songs 均有记录。 测试:新增 playlist_summary 4 个用例,累计 29 个全绿。
This commit is contained in:
@@ -313,6 +313,26 @@ class SongDatabase {
|
|||||||
return await db.query('playlists', orderBy: 'created_at DESC');
|
return await db.query('playlists', orderBy: 'created_at DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单摘要:歌曲数 + 封面(取歌单内第一首有封面的歌)。
|
||||||
|
/// 列表页只需要这些,不用把每首歌都拉出来。
|
||||||
|
Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>?> getPlaylist(String id) async {
|
Future<Map<String, dynamic>?> getPlaylist(String id) async {
|
||||||
_logQuery('playlists', 'get', args: {'id': id});
|
_logQuery('playlists', 'get', args: {'id': id});
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|||||||
@@ -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<String, dynamic> 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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import '../controllers/playback_controller.dart';
|
|||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
import '../widgets/song_artwork.dart';
|
import '../widgets/song_artwork.dart';
|
||||||
|
import '../widgets/add_to_playlist_sheet.dart';
|
||||||
|
|
||||||
class WebDAVFileListPage extends StatefulWidget {
|
class WebDAVFileListPage extends StatefulWidget {
|
||||||
final String currentPath;
|
final String currentPath;
|
||||||
@@ -435,6 +436,7 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
onPressed: () => _toggleFavorite(item.path),
|
onPressed: () => _toggleFavorite(item.path),
|
||||||
),
|
),
|
||||||
onTap: () => _playSong(item),
|
onTap: () => _playSong(item),
|
||||||
|
onLongPress: () => showAddToPlaylistSheet(context, songPath: item.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
|
import '../models/playlist_summary.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
|
||||||
class PlaylistRepository {
|
class PlaylistRepository {
|
||||||
@@ -37,6 +38,25 @@ class PlaylistRepository {
|
|||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单列表摘要(歌曲数 + 封面),给主页/列表页用
|
||||||
|
Future<List<PlaylistSummary>> getSummaries() async {
|
||||||
|
final rows = await _db.getPlaylistSummaries();
|
||||||
|
return rows.map(PlaylistSummary.fromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量加入歌曲(自动跳过歌单里已有的,避免打乱顺序)
|
||||||
|
Future<int> addSongs(String playlistId, List<String> 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<void> updatePlaylistName(String id, String newName) async {
|
Future<void> updatePlaylistName(String id, String newName) async {
|
||||||
await _db.updatePlaylist(id, {
|
await _db.updatePlaylist(id, {
|
||||||
'name': newName,
|
'name': newName,
|
||||||
|
|||||||
@@ -296,18 +296,19 @@ class AudioService extends ChangeNotifier {
|
|||||||
for (final id in songIds) {
|
for (final id in songIds) {
|
||||||
final dbSong = await _db.getSongByPath(id);
|
final dbSong = await _db.getSongByPath(id);
|
||||||
if (dbSong != null) {
|
if (dbSong != null) {
|
||||||
|
final path = dbSong['remote_path'] as String;
|
||||||
songs.add(Song(
|
songs.add(Song(
|
||||||
id: dbSong['remote_path'] as String,
|
id: path,
|
||||||
title: dbSong['title'] as String? ?? '',
|
title: dbSong['title'] as String? ?? '',
|
||||||
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
||||||
url: dbSong['remote_path'] as String,
|
url: WebDAVService.instance.getFileUrl(path),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
songs.add(Song(
|
songs.add(Song(
|
||||||
id: id,
|
id: id,
|
||||||
title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
||||||
artist: '未知艺术家',
|
artist: '未知艺术家',
|
||||||
url: id,
|
url: WebDAVService.instance.getFileUrl(id),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> showAddToPlaylistSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String songPath,
|
||||||
|
}) async {
|
||||||
|
final repo = PlaylistRepository();
|
||||||
|
List<PlaylistSummary> 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<void>(
|
||||||
|
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<void> _createPlaylistAndAdd(
|
||||||
|
BuildContext context,
|
||||||
|
PlaylistRepository repo,
|
||||||
|
String songPath,
|
||||||
|
) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user