From e58be9c17f98df84d7d5ecc2156f1cf432abc92c Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Thu, 27 Aug 2026 22:29:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=95=E5=85=A5=E6=92=AD=E6=94=BE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=BA=93=EF=BC=8C=E5=90=8C=E6=97=B6=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E5=85=A8=E6=96=B0=E7=9A=84=E6=92=AD=E6=94=BE=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E4=BB=A5=E6=8E=A7=E5=88=B6=E8=AF=BB=E5=BA=93?= =?UTF-8?q?=E4=B8=8E=E6=92=AD=E6=94=BE=E6=A8=A1=E5=BC=8F=E7=9A=84=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=EF=BC=8C=E4=BD=86=E6=98=AF=E5=8A=9F=E8=83=BD=E4=BB=8D?= =?UTF-8?q?=E5=9C=A8=E5=AE=8C=E5=96=84=EF=BC=8C=E9=9C=80=E8=A6=81=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E9=83=A8=E5=88=86=E8=BE=B9=E7=95=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/controllers/playback_controller.dart | 131 +++++++++++ lib/database/song_database.dart | 254 ++++++++++++++++++++-- lib/models/playlist.dart | 69 ++++++ lib/pages/webdav_file_list_page.dart | 125 ++--------- lib/repositories/playlist_repository.dart | 127 +++++++++++ lib/services/audio_service.dart | 97 ++++++++- 6 files changed, 677 insertions(+), 126 deletions(-) create mode 100644 lib/controllers/playback_controller.dart create mode 100644 lib/models/playlist.dart create mode 100644 lib/repositories/playlist_repository.dart diff --git a/lib/controllers/playback_controller.dart b/lib/controllers/playback_controller.dart new file mode 100644 index 0000000..26da116 --- /dev/null +++ b/lib/controllers/playback_controller.dart @@ -0,0 +1,131 @@ +// lib/controllers/playback_controller.dart +import '../services/audio_service.dart'; +import '../services/webdav_service.dart'; +import '../database/song_database.dart'; + +class PlaybackController { + static final PlaybackController _instance = PlaybackController._internal(); + factory PlaybackController() => _instance; + PlaybackController._internal(); + + final AudioService _audioService = AudioService(); + final SongDatabase _db = SongDatabase(); + + // ════════════════════════════════════════════════════════════ + // 播放 WebDAV 目录中的歌曲(从点击歌曲开始) + // ════════════════════════════════════════════════════════════ + Future playFromWebDAV({ + required String directoryPath, + required String clickedSongPath, + required List allItems, + }) async { + final musicFiles = allItems.where((item) => !item.isDirectory).toList(); + if (musicFiles.isEmpty) return; + + final clickedIndex = + musicFiles.indexWhere((item) => item.path == clickedSongPath); + if (clickedIndex == -1) { + await _playSingleSong(clickedSongPath); + return; + } + + final queue = await _buildSongQueue(musicFiles); + + final playMode = _audioService.playMode; + // ⭐ 修复:显式指定类型 + List finalQueue = List.from(queue); + int startIndex = clickedIndex; + + if (playMode == PlayMode.shuffle) { + final shuffled = List.from(queue)..shuffle(); + startIndex = shuffled.indexWhere((s) => s.id == clickedSongPath); + if (startIndex == -1) startIndex = 0; + finalQueue = shuffled; + } + + _audioService.setQueue(finalQueue, startIndex: startIndex); + } + + // ════════════════════════════════════════════════════════════ + // 播放整个目录(从第一首开始) + // ════════════════════════════════════════════════════════════ + Future playAllFromWebDAV({ + required String directoryPath, + required List allItems, + }) async { + final musicFiles = allItems.where((item) => !item.isDirectory).toList(); + if (musicFiles.isEmpty) return; + + final queue = await _buildSongQueue(musicFiles); + + final playMode = _audioService.playMode; + // ⭐ 修复:显式指定类型 + List finalQueue = List.from(queue); + if (playMode == PlayMode.shuffle) { + finalQueue = List.from(queue)..shuffle(); + } + + _audioService.setQueue(finalQueue, startIndex: 0); + } + + // ════════════════════════════════════════════════════════════ + // 播放单曲(fallback) + // ════════════════════════════════════════════════════════════ + Future _playSingleSong(String songPath) async { + final url = WebDAVService.instance.getFileUrl(songPath); + final dbSong = await _db.getSongByPath(songPath); + final song = Song( + id: songPath, + title: dbSong?['title'] as String? ?? + songPath.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''), + artist: dbSong?['artist'] as String? ?? '未知艺术家', + url: url, + ); + _audioService.playSong(song); + } + + // ════════════════════════════════════════════════════════════ + // 辅助方法:从音乐文件列表构建 Song 队列 + // ════════════════════════════════════════════════════════════ + Future> _buildSongQueue(List musicFiles) async { + final queue = []; + for (final item in musicFiles) { + final dbSong = await _db.getSongByPath(item.path); + final url = WebDAVService.instance.getFileUrl(item.path); + if (dbSong != null) { + queue.add(Song( + id: item.path, + title: dbSong['title'] as String? ?? + item.name.replaceAll(RegExp(r'\.[^.]*$'), ''), + artist: dbSong['artist'] as String? ?? '未知艺术家', + url: url, + )); + } else { + queue.add(Song( + id: item.path, + title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''), + artist: '未知艺术家', + url: url, + )); + } + } + return queue; + } + + // ════════════════════════════════════════════════════════════ + // 播放控制(透传到 AudioService) + // ════════════════════════════════════════════════════════════ + void next() { + _audioService.next(); + } + + void previous() { + _audioService.previous(); + } + + void togglePlayMode() { + _audioService.togglePlayMode(); + } + + PlayMode get playMode => _audioService.playMode; +} diff --git a/lib/database/song_database.dart b/lib/database/song_database.dart index af86cb2..7fb2ea4 100644 --- a/lib/database/song_database.dart +++ b/lib/database/song_database.dart @@ -21,16 +21,57 @@ class SongDatabase { final path = join(dir.path, 'qingting_songs.db'); return await openDatabase( path, - version: 1, + version: 2, onCreate: _onCreate, + onUpgrade: _onUpgrade, ); } + // ⭐ 新增:检查表是否存在 + Future _tableExists(Database db, String tableName) async { + final result = await db.query( + 'sqlite_master', + where: 'type = ? AND name = ?', + whereArgs: ['table', tableName], + ); + return result.isNotEmpty; + } + Future _onCreate(Database db, int version) async { + await _createSongsTable(db); + await _createMetadataCacheTable(db); + await _createPlaylistsTable(db); + await _createPlaylistSongsTable(db); + await _createFavoritesTable(db); + await _createIndexes(db); + } + + // ⭐ 升级逻辑(从版本 1 升级到 2) + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + // 检查 playlists 表是否存在 + final playlistsExists = await _tableExists(db, 'playlists'); + + if (!playlistsExists) { + // 表不存在 → 创建所有新表 + await _createPlaylistsTable(db); + await _createPlaylistSongsTable(db); + await _createFavoritesTable(db); + await _createIndexes(db); + } else { + // 表已存在 → 只添加新字段 + await db.execute( + 'ALTER TABLE playlists ADD COLUMN play_mode TEXT DEFAULT "sequential"'); + } + } + } + + Future _createSongsTable(Database db) async { await db.execute(''' CREATE TABLE songs ( song_key TEXT PRIMARY KEY, remote_path TEXT, + content_hash TEXT, file_size INTEGER, modified_time INTEGER, etag TEXT, @@ -46,7 +87,9 @@ class SongDatabase { metadata_status TEXT DEFAULT 'pending' ) '''); + } + Future _createMetadataCacheTable(Database db) async { await db.execute(''' CREATE TABLE metadata_cache ( song_key TEXT PRIMARY KEY, @@ -56,12 +99,59 @@ class SongDatabase { FOREIGN KEY (song_key) REFERENCES songs(song_key) ON DELETE CASCADE ) '''); - - await db.execute('CREATE INDEX idx_songs_artist ON songs(artist)'); - await db.execute('CREATE INDEX idx_songs_title ON songs(title)'); } - // ---- 查询 ---- + Future _createPlaylistsTable(Database db) async { + await db.execute(''' + CREATE TABLE playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + play_mode TEXT DEFAULT 'sequential', + created_at INTEGER, + updated_at INTEGER + ) + '''); + } + + Future _createPlaylistSongsTable(Database db) async { + await db.execute(''' + CREATE TABLE playlist_songs ( + playlist_id TEXT, + song_id TEXT, + order_index INTEGER, + added_at INTEGER, + PRIMARY KEY (playlist_id, song_id), + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE + ) + '''); + } + + Future _createFavoritesTable(Database db) async { + await db.execute(''' + CREATE TABLE favorites ( + song_id TEXT PRIMARY KEY, + favorited_at INTEGER, + FOREIGN KEY (song_id) REFERENCES songs(remote_path) ON DELETE CASCADE + ) + '''); + } + + Future _createIndexes(Database db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist)'); + await db + .execute('CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_songs_content_hash ON songs(content_hash)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_playlist_songs_order ON playlist_songs(order_index)'); + } + + // ════════════════════════════════════════════════════════════ + // 查询方法(保持不变) + // ════════════════════════════════════════════════════════════ Future?> getSong(String songKey) async { final db = await database; final result = @@ -69,6 +159,20 @@ class SongDatabase { return result.isNotEmpty ? result.first : null; } + Future?> getSongByPath(String remotePath) async { + final db = await database; + final result = await db + .query('songs', where: 'remote_path = ?', whereArgs: [remotePath]); + return result.isNotEmpty ? result.first : null; + } + + Future?> getSongByHash(String contentHash) async { + final db = await database; + final result = await db + .query('songs', where: 'content_hash = ?', whereArgs: [contentHash]); + return result.isNotEmpty ? result.first : null; + } + Future>> getSongsByArtist(String artist, {int limit = 20}) async { if (artist.isEmpty) return []; @@ -81,7 +185,135 @@ class SongDatabase { ); } - // ---- 写入 ---- + // ════════════════════════════════════════════════════════════ + // 播放列表 CRUD(保持不变) + // ════════════════════════════════════════════════════════════ + Future>> getAllPlaylists() async { + final db = await database; + return await db.query('playlists', orderBy: 'created_at DESC'); + } + + Future?> getPlaylist(String id) async { + final db = await database; + final result = + await db.query('playlists', where: 'id = ?', whereArgs: [id]); + return result.isNotEmpty ? result.first : null; + } + + Future insertPlaylist(Map playlist) async { + final db = await database; + await db.insert('playlists', playlist, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future updatePlaylist(String id, Map updates) async { + final db = await database; + await db.update('playlists', updates, where: 'id = ?', whereArgs: [id]); + } + + Future deletePlaylist(String id) async { + final db = await database; + await db.delete('playlists', where: 'id = ?', whereArgs: [id]); + } + + // ════════════════════════════════════════════════════════════ + // 播放列表歌曲操作(保持不变) + // ════════════════════════════════════════════════════════════ + Future>> getPlaylistSongs(String playlistId) async { + final db = await database; + return await db.query( + 'playlist_songs', + where: 'playlist_id = ?', + whereArgs: [playlistId], + orderBy: 'order_index ASC', + ); + } + + Future addSongToPlaylist(String playlistId, String songId, + {int? orderIndex}) async { + final db = await database; + int finalOrder = orderIndex ?? 0; + if (orderIndex == null) { + final existing = await db.query( + 'playlist_songs', + where: 'playlist_id = ?', + whereArgs: [playlistId], + orderBy: 'order_index DESC', + limit: 1, + ); + finalOrder = + existing.isNotEmpty ? (existing.first['order_index'] as int) + 1 : 0; + } + await db.insert( + 'playlist_songs', + { + 'playlist_id': playlistId, + 'song_id': songId, + 'order_index': finalOrder, + 'added_at': DateTime.now().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future removeSongFromPlaylist(String playlistId, String songId) async { + final db = await database; + await db.delete( + 'playlist_songs', + where: 'playlist_id = ? AND song_id = ?', + whereArgs: [playlistId, songId], + ); + } + + Future clearPlaylist(String playlistId) async { + final db = await database; + await db.delete('playlist_songs', + where: 'playlist_id = ?', whereArgs: [playlistId]); + } + + // ════════════════════════════════════════════════════════════ + // 收藏操作(保持不变) + // ════════════════════════════════════════════════════════════ + Future>> getFavorites() async { + final db = await database; + return await db.query('favorites', orderBy: 'favorited_at DESC'); + } + + Future isFavorite(String songId) async { + final db = await database; + final result = + await db.query('favorites', where: 'song_id = ?', whereArgs: [songId]); + return result.isNotEmpty; + } + + Future addFavorite(String songId) async { + final db = await database; + await db.insert( + 'favorites', + { + 'song_id': songId, + 'favorited_at': DateTime.now().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future removeFavorite(String songId) async { + final db = await database; + await db.delete('favorites', where: 'song_id = ?', whereArgs: [songId]); + } + + Future toggleFavorite(String songId) async { + if (await isFavorite(songId)) { + await removeFavorite(songId); + } else { + await addFavorite(songId); + } + } + + // ════════════════════════════════════════════════════════════ + // 原有写入/删除方法(保持不变) + // ════════════════════════════════════════════════════════════ Future insertSong(Map song) async { final db = await database; await db.insert('songs', song, @@ -90,15 +322,10 @@ class SongDatabase { Future updateSong(String songKey, Map updates) async { final db = await database; - await db.update( - 'songs', - updates, - where: 'song_key = ?', - whereArgs: [songKey], - ); + await db + .update('songs', updates, where: 'song_key = ?', whereArgs: [songKey]); } - // ---- 删除 ---- Future deleteSong(String songKey) async { final db = await database; await db.delete('songs', where: 'song_key = ?', whereArgs: [songKey]); @@ -110,7 +337,6 @@ class SongDatabase { .delete('metadata_cache', where: 'song_key = ?', whereArgs: [songKey]); } - // ---- 缓存 ---- Future insertCache(Map cache) async { final db = await database; await db.insert('metadata_cache', cache, diff --git a/lib/models/playlist.dart b/lib/models/playlist.dart new file mode 100644 index 0000000..80b89f8 --- /dev/null +++ b/lib/models/playlist.dart @@ -0,0 +1,69 @@ +// lib/models/playlist.dart +import 'package:qingting_player/services/audio_service.dart'; + +class Playlist { + final String id; + final String name; + final PlayMode playMode; + final DateTime createdAt; + final DateTime updatedAt; + + Playlist({ + required this.id, + required this.name, + this.playMode = PlayMode.sequential, + DateTime? createdAt, + DateTime? updatedAt, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + Map toJson() => { + 'id': id, + 'name': name, + 'play_mode': Playlist.playModeToString(playMode), // ⭐ 改用公开静态方法 + 'created_at': createdAt.millisecondsSinceEpoch, + 'updated_at': updatedAt.millisecondsSinceEpoch, + }; + + factory Playlist.fromJson(Map json) => Playlist( + id: json['id'] as String, + name: json['name'] as String, + playMode: Playlist.stringToPlayMode( + json['play_mode'] as String? ?? 'sequential'), + createdAt: + DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int), + updatedAt: + DateTime.fromMillisecondsSinceEpoch(json['updated_at'] as int), + ); + + Playlist copyWith({String? name, PlayMode? playMode}) => Playlist( + id: id, + name: name ?? this.name, + playMode: playMode ?? this.playMode, + createdAt: createdAt, + updatedAt: DateTime.now(), + ); + + // ⭐ 改为公开静态方法 + static String playModeToString(PlayMode mode) { + switch (mode) { + case PlayMode.sequential: + return 'sequential'; + case PlayMode.repeatOne: + return 'repeat_one'; + case PlayMode.shuffle: + return 'shuffle'; + } + } + + static PlayMode stringToPlayMode(String value) { + switch (value) { + case 'repeat_one': + return PlayMode.repeatOne; + case 'shuffle': + return PlayMode.shuffle; + default: + return PlayMode.sequential; + } + } +} diff --git a/lib/pages/webdav_file_list_page.dart b/lib/pages/webdav_file_list_page.dart index b5677ef..c42708a 100644 --- a/lib/pages/webdav_file_list_page.dart +++ b/lib/pages/webdav_file_list_page.dart @@ -1,6 +1,4 @@ // lib/pages/webdav_file_list_page.dart -// ignore: unused_import -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/webdav_service.dart'; @@ -8,6 +6,7 @@ import '../services/playback_service.dart'; import '../services/audio_service.dart'; import '../constants/ui_constants.dart'; import '../base/base_state.dart'; +import '../controllers/playback_controller.dart'; class WebDAVFileListPage extends StatefulWidget { final String currentPath; @@ -53,16 +52,14 @@ class _WebDAVFileListPageState extends BaseState { final items = await WebDAVService.instance.listDirectory(path: _currentPath); if (mounted) { - // ⭐ 加这个 - setState(() { + safeSetState(() { _items = items; _isLoading = false; }); } } catch (e) { if (mounted) { - // ⭐ 加这个 - setState(() { + safeSetState(() { _errorMessage = '加载失败: $e'; _isLoading = false; }); @@ -82,72 +79,22 @@ class _WebDAVFileListPageState extends BaseState { ); } - // ============================================================ - // ⭐ 核心:播放歌曲 + 自动构建队列 - // ============================================================ +// ============================================================ +// 播放单首(点击歌曲) +// ============================================================ void _playSong(WebDAVItem file) async { try { - // 1. 获取当前目录所有音乐文件(过滤掉目录) - final musicFiles = _items.where((item) => !item.isDirectory).toList(); - - if (musicFiles.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('当前目录没有音乐文件'), - backgroundColor: Colors.orange, - ), - ); - } - return; - } - - // 2. 获取认证头 - final headers = await WebDAVService.instance.getAuthHeaders(); - - // 3. 构建播放队列(所有音乐文件) - final queue = musicFiles.map((item) { - final itemUrl = WebDAVService.instance.getFileUrl(item.path); - return Song( - id: item.path, - title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''), - artist: '未知艺术家', - url: itemUrl, - ); - }).toList(); - - // 4. 找到当前点击歌曲在队列中的位置 - final startIndex = queue.indexWhere((s) => s.id == file.path); - if (startIndex == -1) { - // 极端情况:队列构建有问题,退化为单曲播放 - final url = WebDAVService.instance.getFileUrl(file.path); - final song = Song( - id: file.path, - title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''), - artist: '未知艺术家', - url: url, - ); - context.read().setQueue([song], startIndex: 0); - await PlaybackService().play(url, headers: headers); - return; - } - - // 5. 设置队列并播放 - final audioService = context.read(); - audioService.setQueue(queue, startIndex: startIndex); - - // 6. 播放(AudioService 内部已经调用了 PlaybackService,但为了确保认证头传递) - // 这里再显式调用一下,确保认证头正确 - final targetUrl = queue[startIndex].url!; - await PlaybackService().play(targetUrl, headers: headers); - - // 7. 更新 AudioService 的播放状态(确保 UI 同步) - // setQueue 已经调用了 _playCurrent(),所以这里不需要重复调用 + // ⭐ 直接使用 PlaybackController 单例 + await PlaybackController().playFromWebDAV( + directoryPath: _currentPath, + clickedSongPath: file.path, + allItems: _items, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('正在播放: ${file.name}'), + content: Text('正在播放: ${_safeDecode(file.name)}'), backgroundColor: const Color(0xFF4CAF50), duration: const Duration(seconds: 1), ), @@ -165,47 +112,22 @@ class _WebDAVFileListPageState extends BaseState { } } - // ============================================================ - // 全部播放(从第一首开始) - // ============================================================ +// ============================================================ +// 全部播放 +// ============================================================ void _playAll() async { try { - final musicFiles = _items.where((item) => !item.isDirectory).toList(); - - if (musicFiles.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('当前目录没有音乐文件'), - backgroundColor: Colors.orange, - ), - ); - } - return; - } - - final headers = await WebDAVService.instance.getAuthHeaders(); - - final queue = musicFiles.map((item) { - final itemUrl = WebDAVService.instance.getFileUrl(item.path); - return Song( - id: item.path, - title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''), - artist: '未知艺术家', - url: itemUrl, - ); - }).toList(); - - final audioService = context.read(); - audioService.setQueue(queue, startIndex: 0); - - final targetUrl = queue[0].url!; - await PlaybackService().play(targetUrl, headers: headers); + // ⭐ 全部播放使用 playAllFromWebDAV + await PlaybackController().playAllFromWebDAV( + directoryPath: _currentPath, + allItems: _items, + ); if (mounted) { + final musicCount = _items.where((item) => !item.isDirectory).length; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('开始播放全部 (${queue.length}首)'), + content: Text('开始播放全部 ($musicCount 首)'), backgroundColor: const Color(0xFF4CAF50), duration: const Duration(seconds: 1), ), @@ -257,7 +179,6 @@ class _WebDAVFileListPageState extends BaseState { onPressed: () => Navigator.pop(context), ), actions: [ - // 全部播放按钮 IconButton( icon: const Icon(Icons.playlist_play), onPressed: _playAll, diff --git a/lib/repositories/playlist_repository.dart b/lib/repositories/playlist_repository.dart new file mode 100644 index 0000000..4db5a92 --- /dev/null +++ b/lib/repositories/playlist_repository.dart @@ -0,0 +1,127 @@ +// lib/repositories/playlist_repository.dart +import 'dart:math'; +import '../database/song_database.dart'; +import '../models/playlist.dart'; +import '../services/audio_service.dart'; + +class PlaylistRepository { + final SongDatabase _db = SongDatabase(); + + String _generateId() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final random = Random(); + return String.fromCharCodes( + Iterable.generate( + 16, (_) => chars.codeUnitAt(random.nextInt(chars.length))), + ); + } + + // ════════════════════════════════════════════════════════════ + // 歌单操作 + // ════════════════════════════════════════════════════════════ + + Future> getAllPlaylists() async { + final data = await _db.getAllPlaylists(); + return data.map((e) => Playlist.fromJson(e)).toList(); + } + + Future getPlaylist(String id) async { + final data = await _db.getPlaylist(id); + if (data == null) return null; + return Playlist.fromJson(data); + } + + Future createPlaylist(String name) async { + final playlist = Playlist(id: _generateId(), name: name); + await _db.insertPlaylist(playlist.toJson()); + return playlist; + } + + Future updatePlaylistName(String id, String newName) async { + await _db.updatePlaylist(id, { + 'name': newName, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }); + } + + // ⭐ 新增:更新播放模式 + Future updatePlaylistPlayMode(String id, PlayMode playMode) async { + await _db.updatePlaylist(id, { + // ⭐ 修改前:_playModeToString + // 'play_mode': _playModeToString(playMode), + + // ⭐ 修改后:用公开静态方法 + 'play_mode': Playlist.playModeToString(playMode), + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }); + } + + Future deletePlaylist(String id) async { + await _db.deletePlaylist(id); + } + + // ════════════════════════════════════════════════════════════ + // 歌单歌曲操作 + // ════════════════════════════════════════════════════════════ + + Future> getPlaylistSongIds(String playlistId) async { + final data = await _db.getPlaylistSongs(playlistId); + return data.map((e) => e['song_id'] as String).toList(); + } + + Future addSong(String playlistId, String songId) async { + await _db.addSongToPlaylist(playlistId, songId); + } + + Future removeSong(String playlistId, String songId) async { + await _db.removeSongFromPlaylist(playlistId, songId); + } + + Future reorderSongs( + String playlistId, int oldIndex, int newIndex) async { + if (oldIndex == newIndex) return; + final songs = await _db.getPlaylistSongs(playlistId); + if (oldIndex < 0 || + oldIndex >= songs.length || + newIndex < 0 || + newIndex >= songs.length) { + return; + } + final items = songs.map((e) => e['song_id'] as String).toList(); + final item = items.removeAt(oldIndex); + items.insert(newIndex, item); + for (int i = 0; i < items.length; i++) { + await _db.removeSongFromPlaylist(playlistId, items[i]); + await _db.addSongToPlaylist(playlistId, items[i], orderIndex: i); + } + } + + Future clearPlaylist(String playlistId) async { + await _db.clearPlaylist(playlistId); + } + + // ════════════════════════════════════════════════════════════ + // 收藏操作 + // ════════════════════════════════════════════════════════════ + + Future> getFavorites() async { + final data = await _db.getFavorites(); + return data.map((e) => e['song_id'] as String).toList(); + } + + Future isFavorite(String songId) async { + return await _db.isFavorite(songId); + } + + Future addFavorite(String songId) async { + await _db.addFavorite(songId); + } + + Future removeFavorite(String songId) async { + await _db.removeFavorite(songId); + } + + Future toggleFavorite(String songId) async { + await _db.toggleFavorite(songId); + } +} diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index d4e442b..31fcf41 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; +import 'package:media_kit/media_kit.dart' as media_kit; // ⭐ 加别名 import 'package:path_provider/path_provider.dart'; import 'playback_service.dart'; import '../metadata/metadata_service.dart'; import '../database/song_database.dart'; import '../utils/artwork_helper.dart'; +import '../repositories/playlist_repository.dart'; +import '../models/playlist.dart'; // ⭐ 你的 Playlist 模型 enum PlayMode { sequential, @@ -48,6 +50,9 @@ class AudioService extends ChangeNotifier { List _shuffledIndices = []; int _shuffledIndex = -1; + // ---- 当前播放的歌单 ID(用于模式同步) ---- + String? _currentPlaylistId; + // ---- 高频进度 ---- final ValueNotifier positionNotifier = ValueNotifier(Duration.zero); final ValueNotifier durationNotifier = ValueNotifier(Duration.zero); @@ -64,6 +69,10 @@ class AudioService extends ChangeNotifier { // ⭐ 播放代数:每次切歌递增,用于校验异步任务是否过期 int _playbackGeneration = 0; + // ---- Repository ---- + final SongDatabase _db = SongDatabase(); + final PlaylistRepository _playlistRepo = PlaylistRepository(); + // ---- Getter ---- Song? get currentSong => _currentSong; bool get isPlaying => _isPlaying; @@ -71,6 +80,7 @@ class AudioService extends ChangeNotifier { List get queue => List.unmodifiable(_queue); int get currentIndex => _currentIndex; bool get hasQueue => _queue.isNotEmpty; + String? get currentPlaylistId => _currentPlaylistId; Duration get position => positionNotifier.value; Duration get duration => durationNotifier.value; @@ -91,6 +101,7 @@ class AudioService extends ChangeNotifier { _onSongChanged = callback; } + // ---- 切换播放模式(同步更新歌单) ---- void togglePlayMode() { switch (_playMode) { case PlayMode.sequential: @@ -104,8 +115,14 @@ class AudioService extends ChangeNotifier { break; } notifyListeners(); + + // ⭐ 如果有当前歌单,同步更新歌单的播放模式 + if (_currentPlaylistId != null) { + _playlistRepo.updatePlaylistPlayMode(_currentPlaylistId!, _playMode); + } } + // ---- 设置播放队列 ---- void setQueue(List queue, {int startIndex = 0}) { if (queue.isEmpty) { _clearQueue(); @@ -131,10 +148,14 @@ class AudioService extends ChangeNotifier { _currentIndex = -1; _shuffledIndices.clear(); _shuffledIndex = -1; + _currentPlaylistId = null; stopPlay(); } + // ---- 播放指定歌曲 ---- Future playSong(Song song) async { + // 播放单曲时清除歌单上下文 + _currentPlaylistId = null; if (_queue.isEmpty || _queue[_currentIndex].id != song.id) { setQueue([song], startIndex: 0); } else { @@ -142,6 +163,66 @@ class AudioService extends ChangeNotifier { } } + // ════════════════════════════════════════════════════════════ + // 播放歌单 + // ════════════════════════════════════════════════════════════ + + /// 播放整个歌单 + Future playPlaylist(String playlistId, {int startIndex = 0}) async { + _currentPlaylistId = playlistId; + + // 1. 获取歌单的播放模式 + final playlist = await _playlistRepo.getPlaylist(playlistId); + if (playlist != null) { + _playMode = playlist.playMode; + } + + // 2. 获取歌单歌曲 ID 列表 + final songIds = await _playlistRepo.getPlaylistSongIds(playlistId); + if (songIds.isEmpty) return; + + // 3. 将 song_id 转换为 Song 对象 + final songs = []; + for (final id in songIds) { + final dbSong = await _db.getSongByPath(id); + if (dbSong != null) { + songs.add(Song( + id: dbSong['remote_path'] as String, + title: dbSong['title'] as String? ?? '', + artist: dbSong['artist'] as String? ?? '未知艺术家', + url: dbSong['remote_path'] as String, + )); + } else { + // 如果 songs 表中没有记录,用路径作为 fallback + songs.add(Song( + id: id, + title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''), + artist: '未知艺术家', + url: id, + )); + } + } + + if (songs.isNotEmpty) { + setQueue(songs, startIndex: startIndex); + } + } + + /// 将当前播放队列保存为歌单 + Future saveQueueAsPlaylist(String name) async { + // ⭐ 等待 createPlaylist 返回 Playlist 对象 + final playlist = await _playlistRepo.createPlaylist(name); + for (int i = 0; i < _queue.length; i++) { + final song = _queue[i]; + await _playlistRepo.addSong(playlist.id, song.id); + } + return playlist; // ⭐ 直接返回 Playlist 对象 + } + + // ════════════════════════════════════════════════════════════ + // 播放核心 + // ════════════════════════════════════════════════════════════ + void _playCurrent() { if (_currentIndex < 0 || _currentIndex >= _queue.length) { stopPlay(); @@ -177,9 +258,6 @@ class AudioService extends ChangeNotifier { return raw.hashCode.toString(); } - // ⭐ 核心:带 generation 校验的 metadata 加载 - // lib/services/audio_service.dart - Future _loadMetadataForCurrentSong(int generation) async { if (_currentIndex < 0 || _currentIndex >= _queue.length) return; @@ -188,7 +266,6 @@ class AudioService extends ChangeNotifier { final songId = song.id; if (song.url == null || song.url!.isEmpty) { - // 没有 URL,直接推送 fallback _onSongChanged?.call(song); return; } @@ -200,7 +277,6 @@ class AudioService extends ChangeNotifier { fileId: song.id, ); - // generation 校验 if (_playbackGeneration != generation) { debugPrint('⚠️ [AudioService] metadata stale (generation), ignoring'); return; @@ -215,7 +291,6 @@ class AudioService extends ChangeNotifier { return; } - // 更新 Song final updatedSong = Song( id: song.id, title: metadata.title.isNotEmpty ? metadata.title : song.title, @@ -227,18 +302,16 @@ class AudioService extends ChangeNotifier { _currentSong = updatedSong; notifyListeners(); - // ⭐ 一次性推送完整数据(含 artwork) _onSongChanged?.call(updatedSong); debugPrint( '✅ [AudioService] metadata updated: ${updatedSong.title} - ${updatedSong.artist} (generation $generation)'); } catch (e) { debugPrint('⚠️ [AudioService] metadata load failed: $e, using fallback'); - // 异常时推送 fallback(无 artwork) _onSongChanged?.call(song); } } - /// 清除当前歌曲的缓存(metadata + 封面图 + 文件缓存),并强制重新播放 + /// 清除当前歌曲的缓存 Future clearCurrentSongCache() async { if (_currentSong == null) return; final song = _currentSong!; @@ -294,6 +367,7 @@ class AudioService extends ChangeNotifier { } } + // ---- 下一首 ---- void next() { if (_queue.isEmpty) return; @@ -311,6 +385,7 @@ class AudioService extends ChangeNotifier { _playCurrent(); } + // ---- 上一首 ---- void previous() { if (_queue.isEmpty) return; @@ -336,6 +411,7 @@ class AudioService extends ChangeNotifier { _playCurrent(); } + // ---- 播放/暂停 ---- void togglePlay() { if (_currentSong == null) return; @@ -371,6 +447,7 @@ class AudioService extends ChangeNotifier { _currentIndex = -1; _shuffledIndices.clear(); _shuffledIndex = -1; + _currentPlaylistId = null; stopPlay(); notifyListeners(); }