feat(player): restore playback session after process death

Queue, current index, position, and shuffle flag are snapshotted to a
new app_state playback_session table on pause, track change, and queue
mutation (never on position ticks). On launch the session is restored
paused into the mini player; the first play seeks to the saved
position. Plain file paths that no longer exist are dropped, an
explicit stop or mini-player dismiss clears the snapshot, and the
installation-restore guard wipes it alongside the download queue.
This commit is contained in:
zarzet
2026-07-26 17:45:50 +07:00
parent c9470ef751
commit 54dcdb92dd
3 changed files with 249 additions and 2 deletions
+49 -2
View File
@@ -9,11 +9,12 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('AppStateDb');
const _dbFileName = 'app_state.db';
const _dbVersion = 1;
const _dbVersion = 2;
const _queueTable = 'download_queue_items';
const _recentTable = 'recent_access_items';
const _hiddenRecentTable = 'hidden_recent_downloads';
const _playbackSessionTable = 'playback_session';
const _legacyQueueKey = 'download_queue';
const _legacyRecentAccessKey = 'recent_access_history';
@@ -90,10 +91,25 @@ class AppStateDatabase {
updated_at TEXT NOT NULL
)
''');
await _createPlaybackSessionTable(db);
}
Future<void> _upgradeDb(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading app state database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
await _createPlaybackSessionTable(db);
}
}
static Future<void> _createPlaybackSessionTable(Database db) {
return db.execute('''
CREATE TABLE $_playbackSessionTable (
id INTEGER PRIMARY KEY CHECK (id = 1),
session_json TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
}
Future<bool> migrateQueueFromSharedPreferences() async {
@@ -253,14 +269,45 @@ class AppStateDatabase {
/// A pending queue is tied to the installation's output grants and worker
/// lifecycle. It must not resume after Android restores data into a newly
/// installed package.
/// installed package. The same holds for the playback session, whose
/// sources may be content URIs granted to the old installation.
Future<void> clearPendingQueueAfterInstallationRestore() async {
await replacePendingDownloadQueueRows(const []);
await clearPlaybackSession();
final prefs = await _prefs;
await prefs.remove(_legacyQueueKey);
await prefs.setBool(_queueMigrationKey, true);
}
Future<Map<String, dynamic>?> getPlaybackSession() async {
final db = await database;
final rows = await db.query(_playbackSessionTable, limit: 1);
if (rows.isEmpty) return null;
final raw = rows.first['session_json'] as String?;
if (raw == null || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is Map) return Map<String, dynamic>.from(decoded);
} catch (e) {
_log.w('Discarding unreadable playback session: $e');
}
return null;
}
Future<void> savePlaybackSession(Map<String, dynamic> session) async {
final db = await database;
await db.insert(_playbackSessionTable, {
'id': 1,
'session_json': jsonEncode(session),
'updated_at': DateTime.now().toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> clearPlaybackSession() async {
final db = await database;
await db.delete(_playbackSessionTable);
}
Future<void> applyPendingDownloadQueueChanges({
required List<Map<String, dynamic>> upserts,
required List<String> deletedIds,