播放列表持久化设计、播放列表点击显示当前播放歌曲的列表内位置
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
// lib/database/song_database.dart
|
// lib/database/song_database.dart
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import '../services/audio_service.dart'; // 用于 PlayMode 枚举,但为了解耦我们传字符串
|
||||||
|
|
||||||
class SongDatabase {
|
class SongDatabase {
|
||||||
static final SongDatabase _instance = SongDatabase._internal();
|
static final SongDatabase _instance = SongDatabase._internal();
|
||||||
@@ -11,7 +13,6 @@ class SongDatabase {
|
|||||||
|
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
// ⭐ 开启/关闭日志(开发阶段开启)
|
|
||||||
static bool enableLog = true;
|
static bool enableLog = true;
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
@@ -26,19 +27,14 @@ class SongDatabase {
|
|||||||
_log('📂 数据库路径: $path');
|
_log('📂 数据库路径: $path');
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 3,
|
version: 4, // 升级版本号
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// 日志工具
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
void _log(String message) {
|
void _log(String message) {
|
||||||
if (enableLog) {
|
if (enableLog) debugPrint('📦 [DB] $message');
|
||||||
debugPrint('📦 [DB] $message');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _logQuery(String table, String operation, {Map<String, dynamic>? args}) {
|
void _logQuery(String table, String operation, {Map<String, dynamic>? args}) {
|
||||||
@@ -48,9 +44,6 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// 辅助方法
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
Future<bool> _tableExists(Database db, String tableName) async {
|
Future<bool> _tableExists(Database db, String tableName) async {
|
||||||
final result = await db.query(
|
final result = await db.query(
|
||||||
'sqlite_master',
|
'sqlite_master',
|
||||||
@@ -66,9 +59,6 @@ class SongDatabase {
|
|||||||
return result.any((col) => col['name'] == columnName);
|
return result.any((col) => col['name'] == columnName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// 创建表
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
_log('🆕 创建数据库 (version $version)');
|
_log('🆕 创建数据库 (version $version)');
|
||||||
await _createSongsTable(db);
|
await _createSongsTable(db);
|
||||||
@@ -76,6 +66,7 @@ class SongDatabase {
|
|||||||
await _createPlaylistsTable(db);
|
await _createPlaylistsTable(db);
|
||||||
await _createPlaylistSongsTable(db);
|
await _createPlaylistSongsTable(db);
|
||||||
await _createFavoritesTable(db);
|
await _createFavoritesTable(db);
|
||||||
|
await _createPlaybackStateTable(db);
|
||||||
await _createIndexes(db);
|
await _createIndexes(db);
|
||||||
_log('✅ 数据库创建完成');
|
_log('✅ 数据库创建完成');
|
||||||
}
|
}
|
||||||
@@ -155,6 +146,21 @@ class SongDatabase {
|
|||||||
_log('📋 表创建: favorites');
|
_log('📋 表创建: favorites');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _createIndexes(Database db) async {
|
Future<void> _createIndexes(Database db) async {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist)');
|
'CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist)');
|
||||||
@@ -170,7 +176,7 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 升级逻辑
|
// 升级逻辑(版本 3 → 4)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
_log('⬆️ 数据库升级: $oldVersion → $newVersion');
|
_log('⬆️ 数据库升级: $oldVersion → $newVersion');
|
||||||
@@ -180,7 +186,6 @@ class SongDatabase {
|
|||||||
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
|
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
|
||||||
_log('🔧 添加列: songs.content_hash');
|
_log('🔧 添加列: songs.content_hash');
|
||||||
}
|
}
|
||||||
|
|
||||||
final playlistsExists = await _tableExists(db, 'playlists');
|
final playlistsExists = await _tableExists(db, 'playlists');
|
||||||
if (!playlistsExists) {
|
if (!playlistsExists) {
|
||||||
await _createPlaylistsTable(db);
|
await _createPlaylistsTable(db);
|
||||||
@@ -195,13 +200,11 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
await _createIndexes(db);
|
await _createIndexes(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldVersion < 3) {
|
if (oldVersion < 3) {
|
||||||
if (!await _columnExists(db, 'songs', 'content_hash')) {
|
if (!await _columnExists(db, 'songs', 'content_hash')) {
|
||||||
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
|
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
|
||||||
_log('🔧 添加列: songs.content_hash');
|
_log('🔧 添加列: songs.content_hash');
|
||||||
}
|
}
|
||||||
|
|
||||||
final playlistsExists = await _tableExists(db, 'playlists');
|
final playlistsExists = await _tableExists(db, 'playlists');
|
||||||
if (!playlistsExists) {
|
if (!playlistsExists) {
|
||||||
await _createPlaylistsTable(db);
|
await _createPlaylistsTable(db);
|
||||||
@@ -216,12 +219,18 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
await _createIndexes(db);
|
await _createIndexes(db);
|
||||||
}
|
}
|
||||||
|
// ⭐ 升级到版本 4:添加 playback_state 表
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
final exists = await _tableExists(db, 'playback_state');
|
||||||
|
if (!exists) {
|
||||||
|
await _createPlaybackStateTable(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
_log('✅ 数据库升级完成');
|
_log('✅ 数据库升级完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 查询方法(带日志)
|
// 查询方法(原有)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
Future<Map<String, dynamic>?> getSong(String songKey) async {
|
Future<Map<String, dynamic>?> getSong(String songKey) async {
|
||||||
_logQuery('songs', 'get', args: {'song_key': songKey});
|
_logQuery('songs', 'get', args: {'song_key': songKey});
|
||||||
@@ -261,7 +270,7 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 播放列表 CRUD(带日志)
|
// 播放列表 CRUD(原有)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
Future<List<Map<String, dynamic>>> getAllPlaylists() async {
|
Future<List<Map<String, dynamic>>> getAllPlaylists() async {
|
||||||
_logQuery('playlists', 'getAll');
|
_logQuery('playlists', 'getAll');
|
||||||
@@ -298,7 +307,7 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 播放列表歌曲(带日志)
|
// 播放列表歌曲(原有)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
Future<List<Map<String, dynamic>>> getPlaylistSongs(String playlistId) async {
|
Future<List<Map<String, dynamic>>> getPlaylistSongs(String playlistId) async {
|
||||||
_logQuery('playlist_songs', 'get', args: {'playlist_id': playlistId});
|
_logQuery('playlist_songs', 'get', args: {'playlist_id': playlistId});
|
||||||
@@ -359,7 +368,7 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 收藏(带日志)
|
// 收藏(原有)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
Future<List<Map<String, dynamic>>> getFavorites() async {
|
Future<List<Map<String, dynamic>>> getFavorites() async {
|
||||||
_logQuery('favorites', 'getAll');
|
_logQuery('favorites', 'getAll');
|
||||||
@@ -403,7 +412,7 @@ class SongDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 原有方法(带日志)
|
// 原有方法(歌曲 CRUD)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
Future<void> insertSong(Map<String, dynamic> song) async {
|
Future<void> insertSong(Map<String, dynamic> song) async {
|
||||||
_logQuery('songs', 'insert', args: {'title': song['title']});
|
_logQuery('songs', 'insert', args: {'title': song['title']});
|
||||||
@@ -449,6 +458,43 @@ class SongDatabase {
|
|||||||
return result.isNotEmpty ? result.first : null;
|
return result.isNotEmpty ? result.first : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ⭐ 新增:播放状态持久化
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// 保存播放状态
|
||||||
|
Future<void> savePlaybackState({
|
||||||
|
required List<Map<String, dynamic>> 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<Map<String, dynamic>?> getPlaybackState() async {
|
||||||
|
final db = await database;
|
||||||
|
final result =
|
||||||
|
await db.query('playback_state', where: 'id = ?', whereArgs: [1]);
|
||||||
|
return result.isNotEmpty ? result.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
_log('🔒 关闭数据库连接');
|
_log('🔒 关闭数据库连接');
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ void main() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('⏱️ T0.8 runApp 完成: ${stopwatch.elapsedMilliseconds}ms');
|
debugPrint('⏱️ T0.8 runApp 完成: ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
AudioService().restorePlaybackState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+183
-27
@@ -1,11 +1,175 @@
|
|||||||
|
// lib/pages/playlist_page.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
import '../constants/ui_constants.dart';
|
|
||||||
|
|
||||||
class PlaylistPage extends StatelessWidget {
|
class PlaylistPage extends StatefulWidget {
|
||||||
const PlaylistPage({super.key});
|
const PlaylistPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistPage> createState() => _PlaylistPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistPageState extends State<PlaylistPage> {
|
||||||
|
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<void> _scrollToCurrentSong() async {
|
||||||
|
if (_hasScrolledToCurrent) return;
|
||||||
|
|
||||||
|
final service = context.read<AudioService>();
|
||||||
|
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<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final service = context.watch<AudioService>();
|
final service = context.watch<AudioService>();
|
||||||
@@ -31,6 +195,10 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(service.playModeIcon, color: Colors.white54),
|
||||||
|
onPressed: service.togglePlayMode,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear_all, color: Colors.white54),
|
icon: const Icon(Icons.clear_all, color: Colors.white54),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -38,29 +206,22 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: const Color(0xFF1A1F1E),
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
title: const Text(
|
title: const Text('清空播放列表',
|
||||||
'清空播放列表',
|
style: TextStyle(color: Colors.white)),
|
||||||
style: TextStyle(color: Colors.white),
|
content: const Text('确定要清空当前播放列表吗?',
|
||||||
),
|
style: TextStyle(color: Colors.grey)),
|
||||||
content: const Text(
|
|
||||||
'确定要清空当前播放列表吗?',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('取消'),
|
child: const Text('取消')),
|
||||||
),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
service.clearQueue();
|
service.clearQueue();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text(
|
child:
|
||||||
'清空',
|
const Text('清空', style: TextStyle(color: Colors.red)),
|
||||||
style: TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -76,20 +237,13 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.playlist_play, size: 48, color: Colors.grey),
|
Icon(Icons.playlist_play, size: 48, color: Colors.grey),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text('播放列表为空', style: TextStyle(color: Colors.grey)),
|
||||||
'播放列表为空',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.only(
|
controller: _scrollController,
|
||||||
left: 16,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
right: 16,
|
|
||||||
top: 8,
|
|
||||||
bottom: UIConstants.miniPlayerBottomSpace,
|
|
||||||
),
|
|
||||||
itemCount: queue.length,
|
itemCount: queue.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final song = queue[index];
|
final song = queue[index];
|
||||||
@@ -127,12 +281,14 @@ class PlaylistPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
trailing: isCurrent
|
trailing: isCurrent
|
||||||
? Container(
|
? Container(
|
||||||
|
key: _currentTagKey,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFB8D4D0).withOpacity(0.2),
|
color:
|
||||||
|
const Color(0xFFB8D4D0).withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// lib/services/audio_service.dart
|
// lib/services/audio_service.dart
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -33,6 +34,13 @@ class Song {
|
|||||||
this.url,
|
this.url,
|
||||||
this.artwork,
|
this.artwork,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'title': title,
|
||||||
|
'artist': artist,
|
||||||
|
'url': url,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class AudioService extends ChangeNotifier {
|
class AudioService extends ChangeNotifier {
|
||||||
@@ -70,6 +78,9 @@ class AudioService extends ChangeNotifier {
|
|||||||
|
|
||||||
int _playbackGeneration = 0;
|
int _playbackGeneration = 0;
|
||||||
|
|
||||||
|
// ---- 待恢复的播放进度 ----
|
||||||
|
Duration? _pendingSeekPosition;
|
||||||
|
|
||||||
void Function(Song)? _onSongChanged;
|
void Function(Song)? _onSongChanged;
|
||||||
|
|
||||||
// ---- Repository ----
|
// ---- Repository ----
|
||||||
@@ -121,6 +132,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
if (_currentPlaylistId != null) {
|
if (_currentPlaylistId != null) {
|
||||||
_playlistRepo.updatePlaylistPlayMode(_currentPlaylistId!, _playMode);
|
_playlistRepo.updatePlaylistPlayMode(_currentPlaylistId!, _playMode);
|
||||||
}
|
}
|
||||||
|
savePlaybackState(); // 模式改变时保存
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -159,6 +171,13 @@ class AudioService extends ChangeNotifier {
|
|||||||
_shuffledIndex = -1;
|
_shuffledIndex = -1;
|
||||||
_currentPlaylistId = null;
|
_currentPlaylistId = null;
|
||||||
stopPlay();
|
stopPlay();
|
||||||
|
// 清空队列时也清除持久化状态
|
||||||
|
_db.savePlaybackState(
|
||||||
|
queueJson: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
playMode: 'sequential',
|
||||||
|
positionMs: 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> playSong(Song song) async {
|
Future<void> playSong(Song song) async {
|
||||||
@@ -255,12 +274,25 @@ class AudioService extends ChangeNotifier {
|
|||||||
await _playWithHeaders(song.url!);
|
await _playWithHeaders(song.url!);
|
||||||
await _waitForPlaybackStarted();
|
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();
|
_syncPlayerStateDelayed();
|
||||||
|
|
||||||
if (_hasStartedCurrentPlayback) {
|
if (_hasStartedCurrentPlayback) {
|
||||||
_onSongChanged?.call(song);
|
_onSongChanged?.call(song);
|
||||||
_loadMetadataForCurrentSong(generation);
|
_loadMetadataForCurrentSong(generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切歌完成后保存状态
|
||||||
|
await savePlaybackState();
|
||||||
} finally {
|
} finally {
|
||||||
_isChangingTrack = false;
|
_isChangingTrack = false;
|
||||||
}
|
}
|
||||||
@@ -355,7 +387,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ 第四层:_currentSong 校验(防止状态不同步)
|
// 第四层:_currentSong 校验
|
||||||
if (_currentSong == null || _currentSong!.id != songId) {
|
if (_currentSong == null || _currentSong!.id != songId) {
|
||||||
debugPrint('⚠️ [AudioService] metadata stale: current song mismatch');
|
debugPrint('⚠️ [AudioService] metadata stale: current song mismatch');
|
||||||
return;
|
return;
|
||||||
@@ -510,6 +542,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
PlaybackService().resume();
|
PlaybackService().resume();
|
||||||
}
|
}
|
||||||
|
// 保存状态(包括进度)
|
||||||
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopPlay() {
|
void stopPlay() {
|
||||||
@@ -520,6 +554,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
bufferedNotifier.value = Duration.zero;
|
bufferedNotifier.value = Duration.zero;
|
||||||
_stopListening();
|
_stopListening();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
// 停止时也保存一次
|
||||||
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void seekTo(Duration position) {
|
void seekTo(Duration position) {
|
||||||
@@ -530,6 +566,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
Future.delayed(const Duration(milliseconds: 800), () {
|
Future.delayed(const Duration(milliseconds: 800), () {
|
||||||
_isUserSeeking = false;
|
_isUserSeeking = false;
|
||||||
});
|
});
|
||||||
|
// 拖动后保存进度
|
||||||
|
savePlaybackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearQueue() {
|
void clearQueue() {
|
||||||
@@ -584,6 +622,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
if (_isPlaying != playing) {
|
if (_isPlaying != playing) {
|
||||||
_isPlaying = playing;
|
_isPlaying = playing;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
// 播放状态变化时保存进度(暂停时已保存,但播放开始也可保存一次)
|
||||||
|
if (playing) savePlaybackState();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -682,7 +722,6 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ 关键判断:是否是最后一首
|
|
||||||
final isLastSong = _currentIndex + 1 >= _queue.length;
|
final isLastSong = _currentIndex + 1 >= _queue.length;
|
||||||
if (isLastSong && _playMode != PlayMode.repeatOne) {
|
if (isLastSong && _playMode != PlayMode.repeatOne) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -707,8 +746,133 @@ class AudioService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// ⭐ 播放状态持久化
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// 保存当前播放状态
|
||||||
|
Future<void> 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<bool> 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<void> _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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// 先保存再清理
|
||||||
|
savePlaybackState();
|
||||||
_stopListening();
|
_stopListening();
|
||||||
positionNotifier.dispose();
|
positionNotifier.dispose();
|
||||||
durationNotifier.dispose();
|
durationNotifier.dispose();
|
||||||
|
|||||||
Reference in New Issue
Block a user