mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
perf: harden download and persistence lifecycle
This commit is contained in:
@@ -8,7 +8,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
final _log = AppLogger('AppStateDb');
|
||||
|
||||
const _dbFileName = 'app_state.db';
|
||||
const _dbVersion = 3;
|
||||
const _dbVersion = 4;
|
||||
|
||||
const _queueTable = 'download_queue_items';
|
||||
const _recentTable = 'recent_access_items';
|
||||
@@ -87,12 +87,12 @@ class AppStateDatabase {
|
||||
|
||||
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);
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await _createRecentStateTable(db);
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
await _migratePlaybackSessionToV4(db);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _createRecentStateTable(Database db) {
|
||||
@@ -105,18 +105,88 @@ class AppStateDatabase {
|
||||
}
|
||||
|
||||
static Future<void> _createPlaybackSessionTable(Database db) {
|
||||
// Keep this idempotent so an interrupted migration or a database restored
|
||||
// from an intermediate build can resume v1 -> v2 without losing queue
|
||||
// state merely because the table was already created.
|
||||
return db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS $_playbackSessionTable (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
session_json TEXT NOT NULL,
|
||||
media_json TEXT NOT NULL,
|
||||
current_index INTEGER NOT NULL DEFAULT 0,
|
||||
position_ms INTEGER NOT NULL DEFAULT 0,
|
||||
shuffle INTEGER NOT NULL DEFAULT 0,
|
||||
repeat_mode TEXT NOT NULL DEFAULT 'none',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
static Future<void> _migratePlaybackSessionToV4(Database db) async {
|
||||
final table = await db.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
[_playbackSessionTable],
|
||||
);
|
||||
if (table.isEmpty) {
|
||||
await _createPlaybackSessionTable(db);
|
||||
return;
|
||||
}
|
||||
|
||||
final columns = await db.rawQuery(
|
||||
'PRAGMA table_info($_playbackSessionTable)',
|
||||
);
|
||||
if (columns.any((column) => column['name'] == 'media_json')) return;
|
||||
|
||||
Map<String, dynamic>? legacySession;
|
||||
final rows = await db.query(_playbackSessionTable, limit: 1);
|
||||
final raw = rows.isEmpty ? null : rows.first['session_json'] as String?;
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map) {
|
||||
legacySession = Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Discarding unreadable legacy playback session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
const migratedTable = '${_playbackSessionTable}_v4';
|
||||
await db.execute('DROP TABLE IF EXISTS $migratedTable');
|
||||
await db.execute('''
|
||||
CREATE TABLE $migratedTable (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
media_json TEXT NOT NULL,
|
||||
current_index INTEGER NOT NULL DEFAULT 0,
|
||||
position_ms INTEGER NOT NULL DEFAULT 0,
|
||||
shuffle INTEGER NOT NULL DEFAULT 0,
|
||||
repeat_mode TEXT NOT NULL DEFAULT 'none',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
if (legacySession != null) {
|
||||
await db.insert(migratedTable, _playbackSessionRow(legacySession));
|
||||
}
|
||||
await db.execute('DROP TABLE $_playbackSessionTable');
|
||||
await db.execute(
|
||||
'ALTER TABLE $migratedTable RENAME TO $_playbackSessionTable',
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, Object?> _playbackSessionRow(
|
||||
Map<String, dynamic> session,
|
||||
) {
|
||||
final media = session['media'];
|
||||
final currentIndex = session['index'];
|
||||
final positionMs = session['positionMs'];
|
||||
final repeatMode = session['repeat'];
|
||||
return {
|
||||
'id': 1,
|
||||
'media_json': jsonEncode(media is List ? media : const []),
|
||||
'current_index': currentIndex is num ? currentIndex.toInt() : 0,
|
||||
'position_ms': positionMs is num ? positionMs.toInt() : 0,
|
||||
'shuffle': session['shuffle'] == true ? 1 : 0,
|
||||
'repeat_mode': repeatMode is String ? repeatMode : 'none',
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> migrateQueueFromSharedPreferences() async {
|
||||
final prefs = await _prefs;
|
||||
if (prefs.getBool(_queueMigrationKey) == true) {
|
||||
@@ -288,11 +358,20 @@ class AppStateDatabase {
|
||||
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?;
|
||||
final row = rows.first;
|
||||
final raw = row['media_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);
|
||||
if (decoded is! List) return null;
|
||||
return {
|
||||
'version': 2,
|
||||
'media': decoded,
|
||||
'index': (row['current_index'] as num?)?.toInt() ?? 0,
|
||||
'positionMs': (row['position_ms'] as num?)?.toInt() ?? 0,
|
||||
'shuffle': (row['shuffle'] as num?)?.toInt() == 1,
|
||||
'repeat': row['repeat_mode'] as String? ?? 'none',
|
||||
};
|
||||
} catch (e) {
|
||||
_log.w('Discarding unreadable playback session: $e');
|
||||
}
|
||||
@@ -301,11 +380,28 @@ class AppStateDatabase {
|
||||
|
||||
Future<void> savePlaybackSession(Map<String, dynamic> session) async {
|
||||
final db = await database;
|
||||
await db.insert(_playbackSessionTable, {
|
||||
'id': 1,
|
||||
'session_json': jsonEncode(session),
|
||||
await db.insert(
|
||||
_playbackSessionTable,
|
||||
_playbackSessionRow(session),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> updatePlaybackSessionState({
|
||||
required int index,
|
||||
required int positionMs,
|
||||
required bool shuffle,
|
||||
required String repeatMode,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final changed = await db.update(_playbackSessionTable, {
|
||||
'current_index': index,
|
||||
'position_ms': positionMs,
|
||||
'shuffle': shuffle ? 1 : 0,
|
||||
'repeat_mode': repeatMode,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}, where: 'id = 1');
|
||||
return changed > 0;
|
||||
}
|
||||
|
||||
Future<void> clearPlaybackSession() async {
|
||||
|
||||
@@ -65,24 +65,39 @@ class HistoryBatchLookupRequest {
|
||||
}
|
||||
|
||||
class HistoryDatabase {
|
||||
// The FTS table is a derived, optional index and is initialized lazily after
|
||||
// the existing schema migration. Keep this contract at v13 because the
|
||||
// background native writer shares history.db and must accept the same
|
||||
// user_version without depending on FTS5.
|
||||
static const int schemaVersion = 13;
|
||||
static const String searchFtsTable = 'history_search_fts';
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
static final sqlite.SingleFlightInitializer<Database> _database =
|
||||
sqlite.SingleFlightInitializer<Database>();
|
||||
bool _searchFtsAvailable = false;
|
||||
|
||||
HistoryDatabase._init();
|
||||
|
||||
Future<Database> get database {
|
||||
return _database.getOrCreate(
|
||||
() => sqlite.openAppDatabase(
|
||||
return _database.getOrCreate(() async {
|
||||
final db = await sqlite.openAppDatabase(
|
||||
'history.db',
|
||||
version: schemaVersion,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
),
|
||||
);
|
||||
);
|
||||
// onCreate normally initializes this derived index. Retry once after
|
||||
// opening an existing database in case an earlier setup was
|
||||
// interrupted; unsupported SQLite builds remain on the LIKE fallback.
|
||||
if (!_searchFtsAvailable) {
|
||||
_searchFtsAvailable = await _createSearchFts(db);
|
||||
}
|
||||
return db;
|
||||
});
|
||||
}
|
||||
|
||||
bool get searchFtsAvailable => _searchFtsAvailable;
|
||||
|
||||
Future<void> _createDB(Database db, int version) async {
|
||||
_log.i('Creating database schema v$version');
|
||||
|
||||
@@ -151,6 +166,7 @@ class HistoryDatabase {
|
||||
await _createNormalizedIndexes(db);
|
||||
await _createQueueIndexes(db);
|
||||
await _createPathKeyTable(db);
|
||||
_searchFtsAvailable = await _createSearchFts(db);
|
||||
|
||||
_log.i('Database schema created with indexes');
|
||||
}
|
||||
@@ -258,6 +274,15 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _createSearchFts(DatabaseExecutor db) {
|
||||
return sqlite.createTrigramFtsIndex(
|
||||
db,
|
||||
ftsTable: searchFtsTable,
|
||||
contentTable: 'history',
|
||||
triggerPrefix: 'history_search_fts',
|
||||
);
|
||||
}
|
||||
|
||||
static String normalizeLookupText(String? value) =>
|
||||
sqlite.normalizeLookupText(value);
|
||||
|
||||
|
||||
@@ -16,27 +16,40 @@ final _log = AppLogger('LibraryDatabase');
|
||||
|
||||
class LibraryDatabase {
|
||||
static final LibraryDatabase instance = LibraryDatabase._init();
|
||||
// The FTS table is a derived, optional index and is initialized lazily after
|
||||
// the existing schema migration, so it does not require a user_version bump.
|
||||
static const int schemaVersion = 13;
|
||||
static const String legacySourceId = LocalLibraryItem.legacySourceId;
|
||||
static const String visibleLibraryView = 'library_visible';
|
||||
static const String searchFtsTable = 'library_search_fts';
|
||||
static const int audioMetadataScanVersion = 3;
|
||||
static final sqlite.SingleFlightInitializer<Database> _database =
|
||||
sqlite.SingleFlightInitializer<Database>();
|
||||
bool _historyAttached = false;
|
||||
bool _searchFtsAvailable = false;
|
||||
|
||||
LibraryDatabase._init();
|
||||
|
||||
Future<Database> get database {
|
||||
return _database.getOrCreate(
|
||||
() => sqlite.openAppDatabase(
|
||||
return _database.getOrCreate(() async {
|
||||
final db = await sqlite.openAppDatabase(
|
||||
'local_library.db',
|
||||
version: schemaVersion,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
),
|
||||
);
|
||||
);
|
||||
// onCreate normally initializes this derived index. Retry once after
|
||||
// opening an existing database in case an earlier setup was
|
||||
// interrupted; unsupported SQLite builds remain on the LIKE fallback.
|
||||
if (!_searchFtsAvailable) {
|
||||
_searchFtsAvailable = await _createSearchFts(db);
|
||||
}
|
||||
return db;
|
||||
});
|
||||
}
|
||||
|
||||
bool get searchFtsAvailable => _searchFtsAvailable;
|
||||
|
||||
Future<void> _ensureHistoryAttached(Database db) async {
|
||||
if (_historyAttached) return;
|
||||
await HistoryDatabase.instance.database;
|
||||
@@ -114,6 +127,7 @@ class LibraryDatabase {
|
||||
await _createQueueIndexes(db);
|
||||
await _createPathKeyTable(db);
|
||||
await _createLibrarySources(db);
|
||||
_searchFtsAvailable = await _createSearchFts(db);
|
||||
|
||||
_log.i('Library database schema created with indexes');
|
||||
}
|
||||
@@ -223,6 +237,15 @@ class LibraryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _createSearchFts(DatabaseExecutor db) {
|
||||
return sqlite.createTrigramFtsIndex(
|
||||
db,
|
||||
ftsTable: searchFtsTable,
|
||||
contentTable: 'library',
|
||||
triggerPrefix: 'library_search_fts',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createLibrarySources(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS library_sources (
|
||||
|
||||
@@ -365,9 +365,21 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
) {
|
||||
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
|
||||
if (query.isNotEmpty) {
|
||||
final like = '%${_escapeLikePattern(query)}%';
|
||||
where.add("h.search_text LIKE ? ESCAPE '\\'");
|
||||
args.add(like);
|
||||
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
|
||||
if (HistoryDatabase.instance.searchFtsAvailable && ftsQuery != null) {
|
||||
where.add('''
|
||||
h.rowid IN (
|
||||
SELECT rowid
|
||||
FROM history_db.history_search_fts
|
||||
WHERE history_search_fts MATCH ?
|
||||
)
|
||||
''');
|
||||
args.add(ftsQuery);
|
||||
} else {
|
||||
final like = '%${_escapeLikePattern(query)}%';
|
||||
where.add("h.search_text LIKE ? ESCAPE '\\'");
|
||||
args.add(like);
|
||||
}
|
||||
}
|
||||
_appendQueueCommonFilters(
|
||||
where,
|
||||
@@ -396,9 +408,21 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
) {
|
||||
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
|
||||
if (query.isNotEmpty) {
|
||||
final like = '%${_escapeLikePattern(query)}%';
|
||||
where.add("l.search_text LIKE ? ESCAPE '\\'");
|
||||
args.add(like);
|
||||
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
|
||||
if (searchFtsAvailable && ftsQuery != null) {
|
||||
where.add('''
|
||||
l.rowid IN (
|
||||
SELECT rowid
|
||||
FROM library_search_fts
|
||||
WHERE library_search_fts MATCH ?
|
||||
)
|
||||
''');
|
||||
args.add(ftsQuery);
|
||||
} else {
|
||||
final like = '%${_escapeLikePattern(query)}%';
|
||||
where.add("l.search_text LIKE ? ESCAPE '\\'");
|
||||
args.add(like);
|
||||
}
|
||||
}
|
||||
_appendQueueCommonFilters(
|
||||
where,
|
||||
|
||||
@@ -296,6 +296,9 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
DateTime? _lastPositionBroadcastAt;
|
||||
DateTime? _lastPeriodicPersistAt;
|
||||
Future<void> _sessionWriteTail = Future<void>.value();
|
||||
int _sessionQueueRevision = 0;
|
||||
int _scheduledSessionQueueRevision = -1;
|
||||
int _persistedSessionQueueRevision = -1;
|
||||
static const Duration _positionBroadcastInterval = Duration(
|
||||
milliseconds: 500,
|
||||
);
|
||||
@@ -653,27 +656,77 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
return queued;
|
||||
}
|
||||
|
||||
/// Persists queue, current index, and position so a killed process can
|
||||
/// restore the session paused on next launch. Position updates are throttled
|
||||
/// by [_handlePositionChanged], while lifecycle/pause writes flush exactly.
|
||||
void _markSessionQueueChanged() {
|
||||
_sessionQueueRevision++;
|
||||
}
|
||||
|
||||
/// Persists the queue only when it changed. Periodic position updates write
|
||||
/// fixed-size scalar columns, avoiding full queue JSON serialization every
|
||||
/// ten seconds for large playback sessions.
|
||||
Future<void> _persistSession({Duration? position}) {
|
||||
if (_restoringSession) return Future<void>.value();
|
||||
if (_media.isEmpty || _index < 0 || _index >= _media.length) {
|
||||
_scheduledSessionQueueRevision = -1;
|
||||
_persistedSessionQueueRevision = -1;
|
||||
return _enqueueSessionWrite(
|
||||
AppStateDatabase.instance.clearPlaybackSession,
|
||||
);
|
||||
}
|
||||
final session = <String, dynamic>{
|
||||
'version': 1,
|
||||
'media': _media.map((m) => m.toJson()).toList(growable: false),
|
||||
'index': _index,
|
||||
'positionMs': (position ?? Duration.zero).inMilliseconds,
|
||||
'shuffle': _shuffle,
|
||||
'repeat': _repeatMode.name,
|
||||
};
|
||||
return _enqueueSessionWrite(
|
||||
() => AppStateDatabase.instance.savePlaybackSession(session),
|
||||
);
|
||||
final queueRevision = _sessionQueueRevision;
|
||||
final index = _index;
|
||||
final positionMs = (position ?? Duration.zero).inMilliseconds;
|
||||
final shuffle = _shuffle;
|
||||
final repeatMode = _repeatMode.name;
|
||||
|
||||
if (_scheduledSessionQueueRevision != queueRevision) {
|
||||
final media = _media.map((item) => item.toJson()).toList(growable: false);
|
||||
_scheduledSessionQueueRevision = queueRevision;
|
||||
return _enqueueSessionWrite(() async {
|
||||
try {
|
||||
await AppStateDatabase.instance.savePlaybackSession({
|
||||
'version': 2,
|
||||
'media': media,
|
||||
'index': index,
|
||||
'positionMs': positionMs,
|
||||
'shuffle': shuffle,
|
||||
'repeat': repeatMode,
|
||||
});
|
||||
_persistedSessionQueueRevision = queueRevision;
|
||||
} catch (_) {
|
||||
if (_scheduledSessionQueueRevision == queueRevision) {
|
||||
_scheduledSessionQueueRevision = _persistedSessionQueueRevision;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return _enqueueSessionWrite(() async {
|
||||
final updated = await AppStateDatabase.instance
|
||||
.updatePlaybackSessionState(
|
||||
index: index,
|
||||
positionMs: positionMs,
|
||||
shuffle: shuffle,
|
||||
repeatMode: repeatMode,
|
||||
);
|
||||
if (updated) return;
|
||||
// A newer queue snapshot is already (or is about to be) scheduled. Do
|
||||
// not recreate a missing row from this older scalar snapshot with a
|
||||
// mismatched index; the newer full write will restore it consistently.
|
||||
if (_sessionQueueRevision != queueRevision) return;
|
||||
|
||||
// Defensive recovery for an externally cleared/corrupted row. This is
|
||||
// intentionally the only state-only path that serializes the queue.
|
||||
await AppStateDatabase.instance.savePlaybackSession({
|
||||
'version': 2,
|
||||
'media': _media.map((item) => item.toJson()).toList(growable: false),
|
||||
'index': index,
|
||||
'positionMs': positionMs,
|
||||
'shuffle': shuffle,
|
||||
'repeat': repeatMode,
|
||||
});
|
||||
_persistedSessionQueueRevision = queueRevision;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Duration> _currentPositionForPersist() async {
|
||||
@@ -706,6 +759,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
required int index,
|
||||
required Duration position,
|
||||
required bool shuffle,
|
||||
bool queueNeedsRewrite = false,
|
||||
AudioServiceRepeatMode repeatMode = AudioServiceRepeatMode.none,
|
||||
}) async {
|
||||
if (items.isEmpty) return;
|
||||
@@ -725,6 +779,14 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_pendingRestorePosition = position > Duration.zero ? position : null;
|
||||
_sourceReady = false;
|
||||
_lastPeriodicPersistAt = null;
|
||||
_sessionQueueRevision++;
|
||||
if (queueNeedsRewrite) {
|
||||
_scheduledSessionQueueRevision = -1;
|
||||
_persistedSessionQueueRevision = -1;
|
||||
} else {
|
||||
_scheduledSessionQueueRevision = _sessionQueueRevision;
|
||||
_persistedSessionQueueRevision = _sessionQueueRevision;
|
||||
}
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
mediaItem.add(_media[_index].toMediaItem());
|
||||
if (position > Duration.zero) {
|
||||
@@ -736,6 +798,9 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
} finally {
|
||||
_restoringSession = false;
|
||||
}
|
||||
if (queueNeedsRewrite) {
|
||||
await _persistSession(position: position);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrentPlayRequest(int generation, PlayableMedia media) {
|
||||
@@ -758,6 +823,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_queueItems
|
||||
..clear()
|
||||
..addAll(items.map((m) => m.toMediaItem()));
|
||||
_markSessionQueueChanged();
|
||||
_recent.clear();
|
||||
_playHistory.clear();
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
@@ -774,6 +840,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
: _media.length;
|
||||
_media.insert(insertAt, item);
|
||||
_queueItems.insert(insertAt, item.toMediaItem());
|
||||
_markSessionQueueChanged();
|
||||
|
||||
for (var i = 0; i < _recent.length; i++) {
|
||||
if (_recent[i] >= insertAt) _recent[i]++;
|
||||
@@ -808,6 +875,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
}
|
||||
at++;
|
||||
}
|
||||
_markSessionQueueChanged();
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
_broadcastState();
|
||||
unawaited(_persistSession(position: playbackState.value.position));
|
||||
@@ -825,6 +893,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
final qi = _queueItems.removeAt(oldIndex);
|
||||
_media.insert(newIndex, media);
|
||||
_queueItems.insert(newIndex, qi);
|
||||
_markSessionQueueChanged();
|
||||
|
||||
if (_index == oldIndex) {
|
||||
_index = newIndex;
|
||||
@@ -1127,6 +1196,8 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_recent.clear();
|
||||
_playHistory.clear();
|
||||
_pendingRestorePosition = null;
|
||||
_scheduledSessionQueueRevision = -1;
|
||||
_persistedSessionQueueRevision = -1;
|
||||
// An explicit stop ends the session for good; nothing to restore later.
|
||||
await _enqueueSessionWrite(AppStateDatabase.instance.clearPlaybackSession);
|
||||
// A stopped session has no current item; this also hides the mini player.
|
||||
@@ -1236,6 +1307,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_queueItems
|
||||
..clear()
|
||||
..addAll(kept.map((m) => m.toMediaItem()));
|
||||
_markSessionQueueChanged();
|
||||
_recent.clear();
|
||||
_playHistory.clear();
|
||||
queue.add(List<MediaItem>.unmodifiable(_queueItems));
|
||||
@@ -1376,6 +1448,7 @@ Future<void> restorePersistedPlaybackSession() async {
|
||||
index: index,
|
||||
position: position,
|
||||
shuffle: session['shuffle'] == true,
|
||||
queueNeedsRewrite: items.length != rawMedia.length,
|
||||
repeatMode: AudioServiceRepeatMode.values.firstWhere(
|
||||
(mode) => mode.name == session['repeat'],
|
||||
orElse: () => AudioServiceRepeatMode.none,
|
||||
|
||||
@@ -181,6 +181,9 @@ class PlatformBridge {
|
||||
StreamController<ExtensionSessionGrantEvent>.broadcast();
|
||||
static final StreamController<void> _libraryStorageEvents =
|
||||
StreamController<void>.broadcast();
|
||||
static final StreamController<List<String>>
|
||||
_iosBackgroundDownloadExpirationEvents =
|
||||
StreamController<List<String>>.broadcast();
|
||||
static bool _backendEventHandlerInstalled = false;
|
||||
|
||||
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
|
||||
@@ -198,6 +201,11 @@ class PlatformBridge {
|
||||
return _libraryStorageEvents.stream;
|
||||
}
|
||||
|
||||
static Stream<List<String>> iosBackgroundDownloadExpirationEvents() {
|
||||
_ensureBackendEventHandler();
|
||||
return _iosBackgroundDownloadExpirationEvents.stream;
|
||||
}
|
||||
|
||||
static void _ensureBackendEventHandler() {
|
||||
if (_backendEventHandlerInstalled) return;
|
||||
_backendEventHandlerInstalled = true;
|
||||
@@ -220,6 +228,24 @@ class PlatformBridge {
|
||||
case 'libraryStorageChanged':
|
||||
_libraryStorageEvents.add(null);
|
||||
return null;
|
||||
case 'iosBackgroundDownloadExpired':
|
||||
final raw = call.arguments;
|
||||
var itemIds = const <String>[];
|
||||
try {
|
||||
final decoded = raw is String ? jsonDecode(raw) : raw;
|
||||
if (decoded is List) {
|
||||
itemIds = decoded
|
||||
.map((value) => value.toString().trim())
|
||||
.where((value) => value.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (_) {
|
||||
// Older/native-mismatched builds may send no payload. The queue
|
||||
// still pauses its currently visible active items below.
|
||||
}
|
||||
_iosBackgroundDownloadExpirationEvents.add(itemIds);
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,11 @@ Future<Database> openAppDatabase(
|
||||
if (foreignKeys) {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
// History/library use INSERT OR REPLACE extensively. SQLite only fires
|
||||
// delete triggers for REPLACE when recursive_triggers is enabled; the
|
||||
// FTS external-content delete trigger needs that event to remove the old
|
||||
// rowid instead of accumulating unreachable index entries.
|
||||
await db.execute('PRAGMA recursive_triggers = ON');
|
||||
if (incrementalAutoVacuum) {
|
||||
final tables = await db.rawQuery('''
|
||||
SELECT 1
|
||||
@@ -93,6 +98,101 @@ String normalizeLookupText(String? value) {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Returns a literal phrase suitable for the trigram FTS5 MATCH operator.
|
||||
///
|
||||
/// The trigram tokenizer cannot answer one- or two-character searches, so
|
||||
/// callers should use their compatibility fallback when this returns null.
|
||||
/// Quoting and escaping the value keeps user-entered FTS operators literal.
|
||||
String? ftsPhraseSearchQuery(String value) {
|
||||
if (value.runes.length < 3 || value.contains('\u0000')) return null;
|
||||
return '"${value.replaceAll('"', '""')}"';
|
||||
}
|
||||
|
||||
/// Creates an external-content FTS5 index that preserves substring search
|
||||
/// semantics through SQLite's trigram tokenizer.
|
||||
///
|
||||
/// FTS5 is an optional SQLite extension on some platform/database builds, so
|
||||
/// callers must retain their existing query fallback when this returns false.
|
||||
/// The index is external-content: the source table remains authoritative and
|
||||
/// these triggers keep the index synchronized for every insert/update/delete,
|
||||
/// including writes that happen outside the Dart repository methods.
|
||||
Future<bool> createTrigramFtsIndex(
|
||||
DatabaseExecutor db, {
|
||||
required String ftsTable,
|
||||
required String contentTable,
|
||||
required String triggerPrefix,
|
||||
}) async {
|
||||
try {
|
||||
final existingIndex = await db.rawQuery(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
||||
[ftsTable],
|
||||
);
|
||||
final existingTriggers = await db.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?, ?)",
|
||||
['${triggerPrefix}_ai', '${triggerPrefix}_ad', '${triggerPrefix}_au'],
|
||||
);
|
||||
final needsRebuild = existingIndex.isEmpty || existingTriggers.length != 3;
|
||||
await db.execute('''
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS $ftsTable USING fts5(
|
||||
search_text,
|
||||
content='$contentTable',
|
||||
content_rowid='rowid',
|
||||
tokenize='trigram'
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}_ai
|
||||
AFTER INSERT ON $contentTable
|
||||
BEGIN
|
||||
INSERT INTO $ftsTable(rowid, search_text)
|
||||
VALUES (new.rowid, COALESCE(new.search_text, ''));
|
||||
END
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}_ad
|
||||
AFTER DELETE ON $contentTable
|
||||
BEGIN
|
||||
INSERT INTO $ftsTable($ftsTable, rowid, search_text)
|
||||
VALUES ('delete', old.rowid, COALESCE(old.search_text, ''));
|
||||
END
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}_au
|
||||
AFTER UPDATE OF search_text ON $contentTable
|
||||
BEGIN
|
||||
INSERT INTO $ftsTable($ftsTable, rowid, search_text)
|
||||
VALUES ('delete', old.rowid, COALESCE(old.search_text, ''));
|
||||
INSERT INTO $ftsTable(rowid, search_text)
|
||||
VALUES (new.rowid, COALESCE(new.search_text, ''));
|
||||
END
|
||||
''');
|
||||
|
||||
// Rebuild a new or partially-created index. Avoid doing this on every app
|
||||
// start: the external-content table is already kept current by triggers.
|
||||
if (needsRebuild) {
|
||||
await db.rawInsert("INSERT INTO $ftsTable($ftsTable) VALUES (?)", [
|
||||
'rebuild',
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
_log.w(
|
||||
'FTS5 index unavailable for $contentTable; using LIKE fallback: $error',
|
||||
);
|
||||
// Do not leave a half-created index/triggers behind. This makes a later
|
||||
// retry deterministic and never compromises the authoritative table.
|
||||
try {
|
||||
await db.execute('DROP TRIGGER IF EXISTS ${triggerPrefix}_ai');
|
||||
await db.execute('DROP TRIGGER IF EXISTS ${triggerPrefix}_ad');
|
||||
await db.execute('DROP TRIGGER IF EXISTS ${triggerPrefix}_au');
|
||||
await db.execute('DROP TABLE IF EXISTS $ftsTable');
|
||||
} catch (cleanupError) {
|
||||
_log.w('Failed to clean up partial FTS5 index $ftsTable: $cleanupError');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addColumnIfMissing(
|
||||
Database db,
|
||||
String table,
|
||||
|
||||
Reference in New Issue
Block a user