diff --git a/lib/database/song_database.dart b/lib/database/song_database.dart index fc6f959..db5a3af 100644 --- a/lib/database/song_database.dart +++ b/lib/database/song_database.dart @@ -1,8 +1,10 @@ // lib/database/song_database.dart +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; +import '../services/audio_service.dart'; // 用于 PlayMode 枚举,但为了解耦我们传字符串 class SongDatabase { static final SongDatabase _instance = SongDatabase._internal(); @@ -11,7 +13,6 @@ class SongDatabase { static Database? _database; - // ⭐ 开启/关闭日志(开发阶段开启) static bool enableLog = true; Future get database async { @@ -26,19 +27,14 @@ class SongDatabase { _log('📂 数据库路径: $path'); return await openDatabase( path, - version: 3, + version: 4, // 升级版本号 onCreate: _onCreate, onUpgrade: _onUpgrade, ); } - // ════════════════════════════════════════════════════════════ - // 日志工具 - // ════════════════════════════════════════════════════════════ void _log(String message) { - if (enableLog) { - debugPrint('📦 [DB] $message'); - } + if (enableLog) debugPrint('📦 [DB] $message'); } void _logQuery(String table, String operation, {Map? args}) { @@ -48,9 +44,6 @@ class SongDatabase { } } - // ════════════════════════════════════════════════════════════ - // 辅助方法 - // ════════════════════════════════════════════════════════════ Future _tableExists(Database db, String tableName) async { final result = await db.query( 'sqlite_master', @@ -66,9 +59,6 @@ class SongDatabase { return result.any((col) => col['name'] == columnName); } - // ════════════════════════════════════════════════════════════ - // 创建表 - // ════════════════════════════════════════════════════════════ Future _onCreate(Database db, int version) async { _log('🆕 创建数据库 (version $version)'); await _createSongsTable(db); @@ -76,6 +66,7 @@ class SongDatabase { await _createPlaylistsTable(db); await _createPlaylistSongsTable(db); await _createFavoritesTable(db); + await _createPlaybackStateTable(db); await _createIndexes(db); _log('✅ 数据库创建完成'); } @@ -155,6 +146,21 @@ class SongDatabase { _log('📋 表创建: favorites'); } + Future _createPlaybackStateTable(Database db) async { + await db.execute(''' + CREATE TABLE playback_state ( + id INTEGER PRIMARY KEY, + queue_json TEXT, + current_index INTEGER, + play_mode TEXT DEFAULT 'sequential', + current_playlist_id TEXT, + position_ms INTEGER DEFAULT 0, + updated_at INTEGER + ) + '''); + _log('📋 表创建: playback_state'); + } + Future _createIndexes(Database db) async { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist)'); @@ -170,7 +176,7 @@ class SongDatabase { } // ════════════════════════════════════════════════════════════ - // 升级逻辑 + // 升级逻辑(版本 3 → 4) // ════════════════════════════════════════════════════════════ Future _onUpgrade(Database db, int oldVersion, int newVersion) async { _log('⬆️ 数据库升级: $oldVersion → $newVersion'); @@ -180,7 +186,6 @@ class SongDatabase { await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT'); _log('🔧 添加列: songs.content_hash'); } - final playlistsExists = await _tableExists(db, 'playlists'); if (!playlistsExists) { await _createPlaylistsTable(db); @@ -195,13 +200,11 @@ class SongDatabase { } await _createIndexes(db); } - if (oldVersion < 3) { if (!await _columnExists(db, 'songs', 'content_hash')) { await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT'); _log('🔧 添加列: songs.content_hash'); } - final playlistsExists = await _tableExists(db, 'playlists'); if (!playlistsExists) { await _createPlaylistsTable(db); @@ -216,12 +219,18 @@ class SongDatabase { } await _createIndexes(db); } - + // ⭐ 升级到版本 4:添加 playback_state 表 + if (oldVersion < 4) { + final exists = await _tableExists(db, 'playback_state'); + if (!exists) { + await _createPlaybackStateTable(db); + } + } _log('✅ 数据库升级完成'); } // ════════════════════════════════════════════════════════════ - // 查询方法(带日志) + // 查询方法(原有) // ════════════════════════════════════════════════════════════ Future?> getSong(String songKey) async { _logQuery('songs', 'get', args: {'song_key': songKey}); @@ -261,7 +270,7 @@ class SongDatabase { } // ════════════════════════════════════════════════════════════ - // 播放列表 CRUD(带日志) + // 播放列表 CRUD(原有) // ════════════════════════════════════════════════════════════ Future>> getAllPlaylists() async { _logQuery('playlists', 'getAll'); @@ -298,7 +307,7 @@ class SongDatabase { } // ════════════════════════════════════════════════════════════ - // 播放列表歌曲(带日志) + // 播放列表歌曲(原有) // ════════════════════════════════════════════════════════════ Future>> getPlaylistSongs(String playlistId) async { _logQuery('playlist_songs', 'get', args: {'playlist_id': playlistId}); @@ -359,7 +368,7 @@ class SongDatabase { } // ════════════════════════════════════════════════════════════ - // 收藏(带日志) + // 收藏(原有) // ════════════════════════════════════════════════════════════ Future>> getFavorites() async { _logQuery('favorites', 'getAll'); @@ -403,7 +412,7 @@ class SongDatabase { } // ════════════════════════════════════════════════════════════ - // 原有方法(带日志) + // 原有方法(歌曲 CRUD) // ════════════════════════════════════════════════════════════ Future insertSong(Map song) async { _logQuery('songs', 'insert', args: {'title': song['title']}); @@ -449,6 +458,43 @@ class SongDatabase { return result.isNotEmpty ? result.first : null; } + // ════════════════════════════════════════════════════════════ + // ⭐ 新增:播放状态持久化 + // ════════════════════════════════════════════════════════════ + + /// 保存播放状态 + Future savePlaybackState({ + required List> queueJson, // 预先序列化的列表 + required int currentIndex, + required String playMode, + String? currentPlaylistId, + required int positionMs, + }) async { + final db = await database; + await db.insert( + 'playback_state', + { + 'id': 1, + 'queue_json': jsonEncode(queueJson), + 'current_index': currentIndex, + 'play_mode': playMode, + 'current_playlist_id': currentPlaylistId, + 'position_ms': positionMs, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + _log('💾 播放状态已保存'); + } + + /// 获取播放状态 + Future?> getPlaybackState() async { + final db = await database; + final result = + await db.query('playback_state', where: 'id = ?', whereArgs: [1]); + return result.isNotEmpty ? result.first : null; + } + Future close() async { _log('🔒 关闭数据库连接'); final db = await database; diff --git a/lib/main.dart b/lib/main.dart index 92835bb..df70fff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -80,6 +80,10 @@ void main() async { ); debugPrint('⏱️ T0.8 runApp 完成: ${stopwatch.elapsedMilliseconds}ms'); + + WidgetsBinding.instance.addPostFrameCallback((_) { + AudioService().restorePlaybackState(); + }); } // ════════════════════════════════════════════════════════════ diff --git a/lib/pages/playlist_page.dart b/lib/pages/playlist_page.dart index aac044f..b2d60a1 100644 --- a/lib/pages/playlist_page.dart +++ b/lib/pages/playlist_page.dart @@ -1,11 +1,175 @@ +// lib/pages/playlist_page.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/audio_service.dart'; -import '../constants/ui_constants.dart'; -class PlaylistPage extends StatelessWidget { +class PlaylistPage extends StatefulWidget { const PlaylistPage({super.key}); + @override + State createState() => _PlaylistPageState(); +} + +class _PlaylistPageState extends State { + final ScrollController _scrollController = ScrollController(); + final GlobalKey _currentTagKey = GlobalKey(); + + bool _hasScrolledToCurrent = false; + int _retryCount = 0; + static const int _maxRetries = 3; + + // ⭐ 统一滚动时长 1.5 秒 + static const Duration _scrollDuration = Duration(milliseconds: 1500); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToCurrentSong(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + double _calculateAverageItemHeight(int itemCount) { + final maxExtent = _scrollController.position.maxScrollExtent; + if (maxExtent <= 0 || itemCount <= 0) return 72.0; + return maxExtent / itemCount; + } + + double _calculateTargetOffset( + int currentIndex, int totalCount, double avgHeight) { + final viewportHeight = _scrollController.position.viewportDimension; + return (currentIndex * avgHeight) - (viewportHeight / 2) + (avgHeight / 2); + } + + Future _scrollToCurrentSong() async { + if (_hasScrolledToCurrent) return; + + final service = context.read(); + final queue = service.queue; + final currentIndex = service.currentIndex; + + if (queue.isEmpty || currentIndex < 0 || currentIndex >= queue.length) { + return; + } + + await Future.delayed(const Duration(milliseconds: 300)); + if (!mounted) return; + + final totalCount = queue.length; + final avgHeight = _calculateAverageItemHeight(totalCount); + final maxExtent = _scrollController.position.maxScrollExtent; + + var tagContext = _currentTagKey.currentContext; + if (tagContext != null) { + await _calibrateWithTag(tagContext); + _hasScrolledToCurrent = true; + return; + } + + // ⭐ 粗定位:1.5 秒平滑滚动 + final targetOffset = + _calculateTargetOffset(currentIndex, totalCount, avgHeight); + final clampedOffset = targetOffset.clamp(0.0, maxExtent); + + await _scrollController.animateTo( + clampedOffset, + duration: _scrollDuration, + curve: Curves.easeOutCubic, + ); + if (!mounted) return; + + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + tagContext = _currentTagKey.currentContext; + if (tagContext != null) { + await _calibrateWithTag(tagContext); + _hasScrolledToCurrent = true; + return; + } + + // ⭐ 迭代修正:每次 1.5 秒平滑滚动 + _retryCount = 0; + var currentOffset = _scrollController.offset; + var lastOffset = currentOffset; + + while (_retryCount < _maxRetries) { + _retryCount++; + + final viewportHeight = _scrollController.position.viewportDimension; + final step = viewportHeight * 0.5; + final direction = (currentIndex > totalCount / 2) ? -1 : 1; + final newOffset = + (currentOffset + direction * step).clamp(0.0, maxExtent); + + if ((newOffset - currentOffset).abs() < 50) { + final bigStep = viewportHeight * 0.8 * direction; + final forcedOffset = (currentOffset + bigStep).clamp(0.0, maxExtent); + await _scrollController.animateTo( + forcedOffset, + duration: _scrollDuration, + curve: Curves.easeOutCubic, + ); + } else { + await _scrollController.animateTo( + newOffset, + duration: _scrollDuration, + curve: Curves.easeOutCubic, + ); + } + + if (!mounted) return; + + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + tagContext = _currentTagKey.currentContext; + if (tagContext != null) { + await _calibrateWithTag(tagContext); + _hasScrolledToCurrent = true; + return; + } + + currentOffset = _scrollController.offset; + if ((currentOffset - lastOffset).abs() < 10) break; + lastOffset = currentOffset; + } + + _hasScrolledToCurrent = true; + } + + /// 精确定位:1.5 秒平滑滚动 + Future _calibrateWithTag(BuildContext tagContext) async { + final renderBox = tagContext.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final tagPosition = renderBox.localToGlobal(Offset.zero); + final tagSize = renderBox.size; + final tagCenter = tagPosition.dy + tagSize.height / 2; + + final screenHeight = MediaQuery.of(context).size.height; + final screenCenter = screenHeight / 2; + + final delta = tagCenter - screenCenter; + final currentOffset = _scrollController.offset; + final targetOffset = (currentOffset + delta) + .clamp(0.0, _scrollController.position.maxScrollExtent); + + if ((targetOffset - currentOffset).abs() < 2) return; + + await _scrollController.animateTo( + targetOffset, + duration: _scrollDuration, + curve: Curves.easeOutCubic, + ); + } + @override Widget build(BuildContext context) { final service = context.watch(); @@ -31,6 +195,10 @@ class PlaylistPage extends StatelessWidget { onPressed: () => Navigator.pop(context), ), actions: [ + IconButton( + icon: Icon(service.playModeIcon, color: Colors.white54), + onPressed: service.togglePlayMode, + ), IconButton( icon: const Icon(Icons.clear_all, color: Colors.white54), onPressed: () { @@ -38,29 +206,22 @@ class PlaylistPage extends StatelessWidget { context: context, builder: (context) => AlertDialog( backgroundColor: const Color(0xFF1A1F1E), - title: const Text( - '清空播放列表', - style: TextStyle(color: Colors.white), - ), - content: const Text( - '确定要清空当前播放列表吗?', - style: TextStyle(color: Colors.grey), - ), + title: const Text('清空播放列表', + style: TextStyle(color: Colors.white)), + content: const Text('确定要清空当前播放列表吗?', + style: TextStyle(color: Colors.grey)), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), + onPressed: () => Navigator.pop(context), + child: const Text('取消')), TextButton( onPressed: () { service.clearQueue(); Navigator.pop(context); Navigator.pop(context); }, - child: const Text( - '清空', - style: TextStyle(color: Colors.red), - ), + child: + const Text('清空', style: TextStyle(color: Colors.red)), ), ], ), @@ -76,20 +237,13 @@ class PlaylistPage extends StatelessWidget { children: [ Icon(Icons.playlist_play, size: 48, color: Colors.grey), SizedBox(height: 16), - Text( - '播放列表为空', - style: TextStyle(color: Colors.grey), - ), + Text('播放列表为空', style: TextStyle(color: Colors.grey)), ], ), ) : ListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: UIConstants.miniPlayerBottomSpace, - ), + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: queue.length, itemBuilder: (context, index) { final song = queue[index]; @@ -127,12 +281,14 @@ class PlaylistPage extends StatelessWidget { ), trailing: isCurrent ? Container( + key: _currentTagKey, padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( - color: const Color(0xFFB8D4D0).withOpacity(0.2), + color: + const Color(0xFFB8D4D0).withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: const Text( diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index ca3909f..8a2bc3b 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -1,5 +1,6 @@ // lib/services/audio_service.dart import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -33,6 +34,13 @@ class Song { this.url, this.artwork, }); + + Map toJson() => { + 'id': id, + 'title': title, + 'artist': artist, + 'url': url, + }; } class AudioService extends ChangeNotifier { @@ -70,6 +78,9 @@ class AudioService extends ChangeNotifier { int _playbackGeneration = 0; + // ---- 待恢复的播放进度 ---- + Duration? _pendingSeekPosition; + void Function(Song)? _onSongChanged; // ---- Repository ---- @@ -121,6 +132,7 @@ class AudioService extends ChangeNotifier { if (_currentPlaylistId != null) { _playlistRepo.updatePlaylistPlayMode(_currentPlaylistId!, _playMode); } + savePlaybackState(); // 模式改变时保存 } // ════════════════════════════════════════════════════════════ @@ -159,6 +171,13 @@ class AudioService extends ChangeNotifier { _shuffledIndex = -1; _currentPlaylistId = null; stopPlay(); + // 清空队列时也清除持久化状态 + _db.savePlaybackState( + queueJson: [], + currentIndex: 0, + playMode: 'sequential', + positionMs: 0, + ); } Future playSong(Song song) async { @@ -255,12 +274,25 @@ class AudioService extends ChangeNotifier { await _playWithHeaders(song.url!); await _waitForPlaybackStarted(); + // ⭐ 如果有待恢复的进度,执行 seek + if (_pendingSeekPosition != null && + _pendingSeekPosition!.inMilliseconds > 0) { + final pos = _pendingSeekPosition!; + _pendingSeekPosition = null; + await PlaybackService().seek(pos); + positionNotifier.value = pos; + debugPrint('🎯 [AudioService] restored position: $pos'); + } + _syncPlayerStateDelayed(); if (_hasStartedCurrentPlayback) { _onSongChanged?.call(song); _loadMetadataForCurrentSong(generation); } + + // 切歌完成后保存状态 + await savePlaybackState(); } finally { _isChangingTrack = false; } @@ -355,7 +387,7 @@ class AudioService extends ChangeNotifier { return; } - // ⭐ 第四层:_currentSong 校验(防止状态不同步) + // 第四层:_currentSong 校验 if (_currentSong == null || _currentSong!.id != songId) { debugPrint('⚠️ [AudioService] metadata stale: current song mismatch'); return; @@ -510,6 +542,8 @@ class AudioService extends ChangeNotifier { } else { PlaybackService().resume(); } + // 保存状态(包括进度) + savePlaybackState(); } void stopPlay() { @@ -520,6 +554,8 @@ class AudioService extends ChangeNotifier { bufferedNotifier.value = Duration.zero; _stopListening(); notifyListeners(); + // 停止时也保存一次 + savePlaybackState(); } void seekTo(Duration position) { @@ -530,6 +566,8 @@ class AudioService extends ChangeNotifier { Future.delayed(const Duration(milliseconds: 800), () { _isUserSeeking = false; }); + // 拖动后保存进度 + savePlaybackState(); } void clearQueue() { @@ -584,6 +622,8 @@ class AudioService extends ChangeNotifier { if (_isPlaying != playing) { _isPlaying = playing; notifyListeners(); + // 播放状态变化时保存进度(暂停时已保存,但播放开始也可保存一次) + if (playing) savePlaybackState(); } }), ); @@ -682,7 +722,6 @@ class AudioService extends ChangeNotifier { return; } - // ⭐ 关键判断:是否是最后一首 final isLastSong = _currentIndex + 1 >= _queue.length; if (isLastSong && _playMode != PlayMode.repeatOne) { debugPrint( @@ -707,8 +746,133 @@ class AudioService extends ChangeNotifier { } } + // ════════════════════════════════════════════════════════════ + // ⭐ 播放状态持久化 + // ════════════════════════════════════════════════════════════ + + /// 保存当前播放状态 + Future savePlaybackState() async { + if (_queue.isEmpty) { + // 空队列时保存空状态 + await _db.savePlaybackState( + queueJson: [], + currentIndex: 0, + playMode: 'sequential', + positionMs: 0, + ); + return; + } + + final queueJson = _queue.map((song) => song.toJson()).toList(); + final modeStr = _playMode == PlayMode.sequential + ? 'sequential' + : _playMode == PlayMode.repeatOne + ? 'repeat_one' + : 'shuffle'; + await _db.savePlaybackState( + queueJson: queueJson, + currentIndex: _currentIndex, + playMode: modeStr, + currentPlaylistId: _currentPlaylistId, + positionMs: positionNotifier.value.inMilliseconds, + ); + debugPrint('💾 [AudioService] playback state saved'); + } + + /// 恢复播放状态 + /// 恢复播放状态 + Future restorePlaybackState() async { + final state = await _db.getPlaybackState(); + if (state == null) return false; + + try { + final queueJson = jsonDecode(state['queue_json'] as String) as List; + if (queueJson.isEmpty) return false; + + final queue = queueJson + .map((item) => Song( + id: item['id'] as String, + title: item['title'] as String? ?? '', + artist: item['artist'] as String? ?? '未知艺术家', + url: item['url'] as String?, + artwork: null, + )) + .toList(); + + if (queue.isEmpty) return false; + + _queue = queue; + _currentIndex = state['current_index'] as int; + if (_currentIndex >= _queue.length) _currentIndex = 0; + + final modeStr = state['play_mode'] as String? ?? 'sequential'; + _playMode = modeStr == 'sequential' + ? PlayMode.sequential + : modeStr == 'repeat_one' + ? PlayMode.repeatOne + : PlayMode.shuffle; + _currentPlaylistId = state['current_playlist_id'] as String?; + + // 保存待恢复的进度 + final savedPos = state['position_ms'] as int? ?? 0; + _pendingSeekPosition = Duration(milliseconds: savedPos); + + final song = _queue[_currentIndex]; + _currentSong = song; + _onSongChanged?.call(song); + + debugPrint( + '♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}'); + + // ⭐ 关键:加载音频但不自动播放 + await _loadRestoredPlayback(); + + return true; + } catch (e) { + debugPrint('⚠️ [AudioService] restore playback state failed: $e'); + return false; + } + } + + /// ⭐ 加载恢复的播放状态(加载音频,不自动播放) + Future _loadRestoredPlayback() async { + if (_queue.isEmpty || _currentIndex < 0) return; + final song = _queue[_currentIndex]; + if (song.url == null || song.url!.isEmpty) return; + + // 启动监听 + _startListening(); + + // 加载音频(带 headers) + final headers = await _getAuthHeadersForUrl(song.url!); + await PlaybackService().play(song.url!, headers: headers); + + // 等待播放器准备好 + await _waitForPlaybackStarted(); + + // 如果有待恢复的进度,执行 seek + if (_pendingSeekPosition != null && + _pendingSeekPosition!.inMilliseconds > 0) { + final pos = _pendingSeekPosition!; + _pendingSeekPosition = null; + await PlaybackService().seek(pos); + positionNotifier.value = pos; + debugPrint('🎯 [AudioService] restored position: $pos'); + } + + // ⭐ 加载完成后立即暂停(用户点击播放按钮后才继续) + await PlaybackService().pause(); + _isPlaying = false; + _hasStartedCurrentPlayback = true; + notifyListeners(); + + debugPrint('🎵 [AudioService] restored playback loaded and paused'); + } + @override void dispose() { + // 先保存再清理 + savePlaybackState(); _stopListening(); positionNotifier.dispose(); durationNotifier.dispose();