perf: unify queue tab with DB-backed pagination and cross-database queries

Replace in-memory list merging in the queue tab with fully database-
backed pagination using ATTACH DATABASE to join library and history
tables in a single UNION ALL query.

Queue tab:
- Remove localLibraryAllItemsProvider and _queueHistoryStatsProvider
- Add _queueLibraryPageProvider and _queueLibraryCountsProvider backed
  by LibraryDatabase.getQueueTrackPage/getQueueCounts/getQueueAlbumPage
- Implement infinite scroll via _handleLibraryScrollNotification with
  _libraryPageLimit growing by 300 per batch
- Album/single/total counts computed via SQL GROUP BY aggregates

History database (v5 -> v8):
- v6: add idx_history_track_artist index
- v7: add history_path_keys table for cross-DB dedup, backfill from
  existing rows
- v8: add spotify_id_norm, isrc_norm, match_key normalized columns
  with indexes, backfill from existing data
- Add getAlbumTracks, findByTrackAndArtist, getGroupedCounts,
  existsTrack, findExistingTrack, existingTrackKeys batch lookup
- deleteBySpotifyId now returns deleted count for accurate totalCount
- All write paths maintain history_path_keys consistency

Library database (v7 -> v8):
- v8: add library_path_keys table for cross-DB dedup
- Add getQueueTrackPage, getQueueCounts, getQueueAlbumPage with
  ATTACH DATABASE for cross-DB UNION ALL queries
- Dedup local items against history via path_keys JOIN
- All write/delete paths maintain library_path_keys consistency

Download history provider:
- Load only 100 recent items into state.items at startup
- Store lookupItems as immutable List field instead of recomputing
  from maps on every access
- Add async fallback to DB in _putInMemoryHistory for items outside
  the 100-item window
- Add downloadHistoryPageProvider, downloadHistoryGroupedCountsProvider,
  downloadedAlbumTracksProvider, downloadHistoryBatchExistsProvider
- Add catchError to adoptNativeHistoryItem async block
- Fix removeBySpotifyId to query actual DB count instead of decrement

Screen migrations:
- album/artist/playlist/home screens use async DB lookups instead of
  sync in-memory state for track existence and playback resolution
- downloaded_album_screen uses downloadedAlbumTracksProvider
- library_tracks_folder_screen uses downloadHistoryBatchExistsProvider
  for skip-downloaded checks and cover resolution
This commit is contained in:
zarzet
2026-05-06 04:38:51 +07:00
parent 149cdc782d
commit 01a5b43613
15 changed files with 2410 additions and 981 deletions
+397 -66
View File
@@ -22,6 +22,9 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
export 'package:spotiflac_android/services/history_database.dart'
show HistoryLookupRequest, HistoryBatchLookupRequest;
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
@@ -231,33 +234,41 @@ class DownloadHistoryItem {
class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final int totalCount;
final int loadedIndexVersion;
final List<DownloadHistoryItem> _lookupItems;
final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _byIsrc;
final Map<String, DownloadHistoryItem> _byTrackArtistKey;
DownloadHistoryState({this.items = const []})
: _bySpotifyId = Map.fromEntries(
items
.where(
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
)
.map((item) => MapEntry(item.spotifyId!, item)),
),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
),
_byTrackArtistKey = Map.fromEntries(
items
.map(
(item) => MapEntry(
_trackArtistKey(item.trackName, item.artistName),
item,
),
)
.where((entry) => entry.key.isNotEmpty),
);
DownloadHistoryState({
this.items = const [],
this.totalCount = 0,
this.loadedIndexVersion = 0,
List<DownloadHistoryItem>? lookupItems,
}) : _lookupItems = List.unmodifiable(lookupItems ?? items),
_bySpotifyId = Map.fromEntries(
(lookupItems ?? items)
.where(
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
)
.map((item) => MapEntry(item.spotifyId!, item)),
),
_byIsrc = Map.fromEntries(
(lookupItems ?? items)
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
),
_byTrackArtistKey = Map.fromEntries(
(lookupItems ?? items)
.map(
(item) => MapEntry(
_trackArtistKey(item.trackName, item.artistName),
item,
),
)
.where((entry) => entry.key.isNotEmpty),
);
static String _trackArtistKey(String trackName, String artistName) {
final normalizedTrack = trackName.trim().toLowerCase();
@@ -282,12 +293,25 @@ class DownloadHistoryState {
return _byTrackArtistKey[key];
}
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
return DownloadHistoryState(items: items ?? this.items);
List<DownloadHistoryItem> get lookupItems => _lookupItems;
DownloadHistoryState copyWith({
List<DownloadHistoryItem>? items,
int? totalCount,
int? loadedIndexVersion,
List<DownloadHistoryItem>? lookupItems,
}) {
return DownloadHistoryState(
items: items ?? this.items,
totalCount: totalCount ?? this.totalCount,
loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion,
lookupItems: lookupItems ?? _lookupItems,
);
}
}
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _initialHistoryLoadLimit = 100;
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
static const int _orphanCleanupMaxPerLaunch = 80;
@@ -332,13 +356,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
}
final jsonList = await _db.getAll();
final countFuture = _db.getCount();
final jsonList = await _db.getAll(limit: _initialHistoryLoadLimit);
final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e))
.toList();
final totalCount = await countFuture;
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from SQLite database');
state = state.copyWith(
items: items,
totalCount: totalCount,
loadedIndexVersion: state.loadedIndexVersion + 1,
lookupItems: items,
);
_historyLog.i(
'Loaded ${items.length}/$totalCount recent history items from SQLite database',
);
_scheduleStartupMaintenance(items);
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
@@ -543,7 +576,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (changed) {
await _db.upsertBatch(persistedUpdates);
state = state.copyWith(items: updatedItems);
state = state.copyWith(
items: updatedItems,
loadedIndexVersion: state.loadedIndexVersion + 1,
lookupItems: _lookupItemsWithUpdates(updatedItems),
);
_historyLog.i(
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}',
);
@@ -813,7 +850,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (persistedUpdates.isNotEmpty && updatedItems != null) {
await _db.upsertBatch(persistedUpdates);
state = state.copyWith(items: updatedItems);
state = state.copyWith(
items: updatedItems,
loadedIndexVersion: state.loadedIndexVersion + 1,
lookupItems: _lookupItemsWithUpdates(updatedItems),
);
}
await _writeStartupCursor(
@@ -837,7 +878,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
await _loadFromDatabase();
}
DownloadHistoryItem _putInMemoryHistory(DownloadHistoryItem item) {
void _bumpHistoryRevision() {
state = state.copyWith(loadedIndexVersion: state.loadedIndexVersion + 1);
}
Future<DownloadHistoryItem> _putInMemoryHistory(
DownloadHistoryItem item,
) async {
DownloadHistoryItem? existing;
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
existing = state.getBySpotifyId(item.spotifyId!);
@@ -845,10 +892,31 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) {
existing = state.getByIsrc(item.isrc!);
}
if (existing == null) {
final json = await _db.findExisting(
spotifyId: item.spotifyId,
isrc: item.isrc,
);
if (json != null) {
existing = DownloadHistoryItem.fromJson(json);
}
}
if (existing == null) {
final json = await _db.findByTrackAndArtist(
item.trackName,
item.artistName,
);
if (json != null) {
existing = DownloadHistoryItem.fromJson(json);
}
}
final incomingItem = existing != null && existing.id != item.id
? DownloadHistoryItem.fromJson(item.toJson()..['id'] = existing.id)
: item;
final mergedItem = existing == null
? item
: item.copyWith(
? incomingItem
: incomingItem.copyWith(
trackNumber: item.trackNumber ?? existing.trackNumber,
totalTracks: item.totalTracks ?? existing.totalTracks,
discNumber: item.discNumber ?? existing.discNumber,
@@ -872,43 +940,103 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
.where((i) => i.id != existing!.id)
.toList();
updatedItems.insert(0, mergedItem);
state = state.copyWith(items: updatedItems);
final updatedLookupItems = state.lookupItems
.where((i) => i.id != existing!.id)
.toList(growable: false);
state = state.copyWith(
items: updatedItems,
lookupItems: [mergedItem, ...updatedLookupItems],
);
_historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
} else {
state = state.copyWith(items: [mergedItem, ...state.items]);
state = state.copyWith(
items: [mergedItem, ...state.items],
totalCount: state.totalCount + 1,
lookupItems: [mergedItem, ...state.lookupItems],
);
_historyLog.d('Added new history entry: ${mergedItem.trackName}');
}
return mergedItem;
}
List<DownloadHistoryItem> _lookupItemsWithUpdates(
Iterable<DownloadHistoryItem> updates, {
Set<String> deletedIds = const <String>{},
}) {
final byId = <String, DownloadHistoryItem>{
for (final item in state.lookupItems)
if (!deletedIds.contains(item.id)) item.id: item,
};
for (final item in updates) {
if (!deletedIds.contains(item.id)) {
byId[item.id] = item;
}
}
return byId.values.toList(growable: false);
}
void addToHistory(DownloadHistoryItem item) {
final mergedItem = _putInMemoryHistory(item);
_db.upsert(mergedItem.toJson()).catchError((Object e) {
_historyLog.e('Failed to save to database: $e');
});
unawaited(
() async {
final mergedItem = await _putInMemoryHistory(item);
await _db.upsert(mergedItem.toJson());
_bumpHistoryRevision();
}().catchError((Object e, StackTrace stack) {
_historyLog.e('Failed to save to database: $e', e, stack);
}),
);
}
void adoptNativeHistoryItem(DownloadHistoryItem item) {
_putInMemoryHistory(item);
unawaited(
() async {
final mergedItem = await _putInMemoryHistory(item);
await _db.upsert(mergedItem.toJson());
_bumpHistoryRevision();
}().catchError((Object e, StackTrace stack) {
_historyLog.e('Failed to adopt native history item: $e', e, stack);
}),
);
}
void removeFromHistory(String id) {
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
totalCount: state.totalCount > 0
? state.totalCount - 1
: state.totalCount,
lookupItems: state.lookupItems
.where((item) => item.id != id)
.toList(growable: false),
);
_db.deleteById(id).catchError((Object e) {
_historyLog.e('Failed to delete from database: $e');
});
_db
.deleteById(id)
.catchError((Object e) {
_historyLog.e('Failed to delete from database: $e');
})
.then((_) {
_bumpHistoryRevision();
});
}
void removeBySpotifyId(String spotifyId) {
state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
lookupItems: state.lookupItems
.where((item) => item.spotifyId != spotifyId)
.toList(growable: false),
);
unawaited(
() async {
final deleted = await _db.deleteBySpotifyId(spotifyId);
final totalCount = await _db.getCount();
state = state.copyWith(totalCount: totalCount);
_bumpHistoryRevision();
_historyLog.d('Removed $deleted item(s) with spotifyId: $spotifyId');
}().catchError((Object e, StackTrace stack) {
_historyLog.e('Failed to delete from database: $e', e, stack);
}),
);
_db.deleteBySpotifyId(spotifyId).catchError((Object e) {
_historyLog.e('Failed to delete from database: $e');
});
_historyLog.d('Removed item with spotifyId: $spotifyId');
}
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
@@ -928,6 +1056,63 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryItem.fromJson(json);
}
Future<DownloadHistoryItem?> getByIsrcAsync(String isrc) async {
final inMemory = state.getByIsrc(isrc);
if (inMemory != null) return inMemory;
final json = await _db.getByIsrc(isrc);
if (json == null) return null;
return DownloadHistoryItem.fromJson(json);
}
Future<DownloadHistoryItem?> findByTrackAndArtistAsync(
String trackName,
String artistName,
) async {
final inMemory = state.findByTrackAndArtist(trackName, artistName);
if (inMemory != null) return inMemory;
final json = await _db.findByTrackAndArtist(trackName, artistName);
if (json == null) return null;
return DownloadHistoryItem.fromJson(json);
}
Future<DownloadHistoryItem?> findExistingTrackAsync(
HistoryLookupRequest request,
) async {
final bySpotifyId = state.getBySpotifyId(request.spotifyId);
if (bySpotifyId != null) return bySpotifyId;
final isrc = request.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
final byTrackArtist = state.findByTrackAndArtist(
request.trackName,
request.artistName,
);
if (byTrackArtist != null) return byTrackArtist;
final json = await _db.findExistingTrack(request);
if (json == null) return null;
return DownloadHistoryItem.fromJson(json);
}
Future<({DownloadHistoryItem item, int index})?> _historyItemForUpdate(
String id,
) async {
final index = state.items.indexWhere((item) => item.id == id);
if (index >= 0) {
return (item: state.items[index], index: index);
}
final json = await _db.getById(id);
if (json == null) return null;
return (item: DownloadHistoryItem.fromJson(json), index: -1);
}
Future<void> updateAudioMetadataForItem({
required String id,
String? quality,
@@ -940,10 +1125,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
int? duration,
String? composer,
}) async {
final index = state.items.indexWhere((item) => item.id == id);
if (index < 0) return;
final target = await _historyItemForUpdate(id);
if (target == null) {
_historyLog.w(
'Cannot update audio metadata for missing history item: $id',
);
return;
}
final current = state.items[index];
final current = target.item;
final updated = current.copyWith(
quality: quality,
bitDepth: bitDepth,
@@ -968,10 +1158,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return;
}
final updatedItems = [...state.items];
updatedItems[index] = updated;
state = state.copyWith(items: updatedItems);
final updatedItems = target.index >= 0
? ([...state.items]..[target.index] = updated)
: state.items;
state = state.copyWith(
items: updatedItems,
lookupItems: _lookupItemsWithUpdates([updated]),
);
await _db.upsert(updated.toJson());
_bumpHistoryRevision();
}
Future<void> updateMetadataForItem({
@@ -991,10 +1186,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
String? label,
String? copyright,
}) async {
final index = state.items.indexWhere((item) => item.id == id);
if (index < 0) return;
final target = await _historyItemForUpdate(id);
if (target == null) {
_historyLog.w('Cannot update metadata for missing history item: $id');
return;
}
final current = state.items[index];
final current = target.item;
final updated = current.copyWith(
trackName: trackName,
artistName: artistName,
@@ -1012,10 +1210,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
copyright: copyright,
);
final updatedItems = [...state.items];
updatedItems[index] = updated;
state = state.copyWith(items: updatedItems);
final updatedItems = target.index >= 0
? ([...state.items]..[target.index] = updated)
: state.items;
state = state.copyWith(
items: updatedItems,
lookupItems: _lookupItemsWithUpdates([updated]),
);
await _db.upsert(updated.toJson());
_bumpHistoryRevision();
}
static const _audioExtensions = [
@@ -1126,7 +1329,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updatedItems.add(item);
}
}
state = state.copyWith(items: updatedItems);
state = state.copyWith(
items: updatedItems,
loadedIndexVersion: state.loadedIndexVersion + 1,
lookupItems: _lookupItemsWithUpdates(
updatedItems,
deletedIds: deletedSet,
),
totalCount: max(0, state.totalCount - deletedSet.length),
);
}
Future<int> _cleanupOrphanedDownloadsIncremental({
@@ -1224,10 +1435,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
void clearHistory() {
state = DownloadHistoryState();
_db.clearAll().catchError((Object e) {
_historyLog.e('Failed to clear database: $e');
});
state = DownloadHistoryState(loadedIndexVersion: state.loadedIndexVersion);
_db
.clearAll()
.then((_) {
_bumpHistoryRevision();
})
.catchError((Object e) {
_historyLog.e('Failed to clear database: $e');
});
}
Future<int> getDatabaseCount() async {
@@ -1240,6 +1456,121 @@ final downloadHistoryProvider =
DownloadHistoryNotifier.new,
);
class DownloadHistoryPageRequest {
final int limit;
final int offset;
const DownloadHistoryPageRequest({this.limit = 100, this.offset = 0});
@override
bool operator ==(Object other) =>
other is DownloadHistoryPageRequest &&
other.limit == limit &&
other.offset == offset;
@override
int get hashCode => Object.hash(limit, offset);
}
final downloadHistoryPageProvider =
FutureProvider.family<
List<DownloadHistoryItem>,
DownloadHistoryPageRequest
>((ref, request) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
final rows = await HistoryDatabase.instance.getAll(
limit: request.limit,
offset: request.offset,
);
return rows.map(DownloadHistoryItem.fromJson).toList(growable: false);
});
class DownloadHistoryGroupedCounts {
final int albumCount;
final int singleTrackCount;
const DownloadHistoryGroupedCounts({
required this.albumCount,
required this.singleTrackCount,
});
}
final downloadHistoryGroupedCountsProvider =
FutureProvider<DownloadHistoryGroupedCounts>((ref) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
final counts = await HistoryDatabase.instance.getGroupedCounts();
return DownloadHistoryGroupedCounts(
albumCount: counts['albums'] ?? 0,
singleTrackCount: counts['singles'] ?? 0,
);
});
HistoryLookupRequest historyLookupForTrack(Track track) {
return HistoryLookupRequest(
spotifyId: track.id,
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
);
}
final downloadHistoryExistsProvider =
FutureProvider.family<bool, HistoryLookupRequest>((ref, request) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
return HistoryDatabase.instance.existsTrack(request);
});
final downloadHistoryBatchExistsProvider =
FutureProvider.family<Set<String>, HistoryBatchLookupRequest>((
ref,
request,
) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
return HistoryDatabase.instance.existingTrackKeys(request.tracks);
});
class DownloadedAlbumTracksRequest {
final String albumName;
final String artistName;
const DownloadedAlbumTracksRequest({
required this.albumName,
required this.artistName,
});
@override
bool operator ==(Object other) =>
other is DownloadedAlbumTracksRequest &&
other.albumName == albumName &&
other.artistName == artistName;
@override
int get hashCode => Object.hash(albumName, artistName);
}
final downloadedAlbumTracksProvider =
FutureProvider.family<
List<DownloadHistoryItem>,
DownloadedAlbumTracksRequest
>((ref, request) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
final rows = await HistoryDatabase.instance.getAlbumTracks(
request.albumName,
request.artistName,
);
return rows.map(DownloadHistoryItem.fromJson).toList(growable: false);
});
class DownloadQueueState {
static const Object _noChange = Object();
final List<DownloadItem> items;
@@ -7560,9 +7891,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final existingInHistory =
historyNotifier.getBySpotifyId(trackToDownload.id) ??
await historyNotifier.getBySpotifyIdAsync(trackToDownload.id) ??
(trackToDownload.isrc != null
? historyNotifier.getByIsrc(trackToDownload.isrc!)
? await historyNotifier.getByIsrcAsync(trackToDownload.isrc!)
: null);
if (wasExisting && existingInHistory != null) {
-19
View File
@@ -1308,22 +1308,3 @@ final localLibraryAlbumCountProvider =
searchQuery: request.searchQuery,
);
});
final localLibraryAllItemsProvider = FutureProvider<List<LocalLibraryItem>>((
ref,
) async {
ref.watch(localLibraryProvider.select((state) => state.loadedIndexVersion));
const pageSize = 500;
final items = <LocalLibraryItem>[];
var offset = 0;
while (true) {
final rows = await LibraryDatabase.instance.getPage(
const LocalLibraryPageRequest(limit: pageSize).copyWithOffset(offset),
);
if (rows.isEmpty) break;
items.addAll(rows.map(LocalLibraryItem.fromJson));
if (rows.length < pageSize) break;
offset += pageSize;
}
return items;
});
+21 -4
View File
@@ -84,7 +84,10 @@ class PlaybackController extends Notifier<PlaybackState> {
return localItem.filePath;
}
final historyItem = _findDownloadHistoryItemForTrack(track, historyState);
final historyItem = await _findDownloadHistoryItemForTrack(
track,
historyState,
);
if (historyItem != null) {
if (await fileExists(historyItem.filePath)) {
return historyItem.filePath;
@@ -114,15 +117,22 @@ class PlaybackController extends Notifier<PlaybackState> {
);
}
DownloadHistoryItem? _findDownloadHistoryItemForTrack(
Future<DownloadHistoryItem?> _findDownloadHistoryItemForTrack(
Track track,
DownloadHistoryState historyState,
) {
) async {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
for (final candidateId in _spotifyIdLookupCandidates(track.id)) {
final bySpotifyId = historyState.getBySpotifyId(candidateId);
if (bySpotifyId != null) {
return bySpotifyId;
}
final bySpotifyIdAsync = await historyNotifier.getBySpotifyIdAsync(
candidateId,
);
if (bySpotifyIdAsync != null) {
return bySpotifyIdAsync;
}
}
final isrc = track.isrc?.trim();
@@ -131,9 +141,16 @@ class PlaybackController extends Notifier<PlaybackState> {
if (byIsrc != null) {
return byIsrc;
}
final byIsrcAsync = await historyNotifier.getByIsrcAsync(isrc);
if (byIsrcAsync != null) {
return byIsrcAsync;
}
}
return historyState.findByTrackAndArtist(track.name, track.artistName);
return historyNotifier.findByTrackAndArtistAsync(
track.name,
track.artistName,
);
}
List<String> _spotifyIdLookupCandidates(String rawId) {
+39 -26
View File
@@ -620,15 +620,29 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
ColorScheme colorScheme,
List<Track> tracks,
) {
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _AlbumTrackItem(
track: track,
isInHistory: isInHistory,
onDownload: () => _downloadTrack(context, track),
),
),
@@ -676,11 +690,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
void _downloadAll(BuildContext context) {
Future<void> _downloadAll(BuildContext context) async {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
if (!context.mounted) return;
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
@@ -689,12 +711,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
final isInHistory = existingHistoryKeys.contains(
historyLookups[i].lookupKey,
);
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc,
@@ -929,9 +950,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
class _AlbumTrackItem extends ConsumerWidget {
final Track track;
final bool isInHistory;
final VoidCallback onDownload;
const _AlbumTrackItem({required this.track, required this.onDownload});
const _AlbumTrackItem({
required this.track,
required this.isInHistory,
required this.onDownload,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -943,17 +969,6 @@ class _AlbumTrackItem extends ConsumerWidget {
),
);
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
@@ -1085,18 +1100,16 @@ class _AlbumTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
DownloadHistoryItem? historyItem = await historyNotifier
.getBySpotifyIdAsync(track.id);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
? await historyNotifier.getByIsrcAsync(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
historyItem ??= await historyNotifier.findByTrackAndArtistAsync(
track.name,
track.artistName,
);
+31 -25
View File
@@ -1011,14 +1011,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return;
}
final historyState = ref.read(downloadHistoryProvider);
final historyLookups = allTracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in allTracks) {
final isDownloaded =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null);
for (var i = 0; i < allTracks.length; i++) {
final track = allTracks[i];
final isDownloaded = existingHistoryKeys.contains(
historyLookups[i].lookupKey,
);
if (!isDownloaded) {
tracksToQueue.add(track);
@@ -1334,6 +1342,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
final tracks = _topTracks!;
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
const tracksPerPage = 5;
final pageCount = (tracks.length / tracksPerPage).ceil();
@@ -1374,6 +1392,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
globalIndex + 1,
entry.value,
colorScheme,
existingHistoryKeys.contains(
historyLookups[globalIndex].lookupKey,
),
);
}).toList(),
);
@@ -1411,6 +1432,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int rank,
Track track,
ColorScheme colorScheme,
bool isInHistory,
) {
return Consumer(
builder: (context, ref, child) {
@@ -1420,20 +1442,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
);
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null &&
isrc.isNotEmpty &&
state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) !=
null;
}),
);
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
@@ -1600,18 +1608,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
Future<bool> _playLocalIfAvailable(Track track) async {
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
DownloadHistoryItem? historyItem = await historyNotifier
.getBySpotifyIdAsync(track.id);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
? await historyNotifier.getByIsrcAsync(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
historyItem ??= await historyNotifier.findByTrackAndArtistAsync(
track.name,
track.artistName,
);
+18 -3
View File
@@ -356,10 +356,25 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
final tracksValue = ref.watch(
downloadedAlbumTracksProvider(
DownloadedAlbumTracksRequest(
albumName: widget.albumName,
artistName: widget.artistName,
),
),
);
final tracks = _getAlbumTracks(allHistoryItems);
final tracks = tracksValue.maybeWhen(
data: (items) => _getAlbumTracks(items),
orElse: () => const <DownloadHistoryItem>[],
);
if (tracks.isEmpty && tracksValue.isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: CircularProgressIndicator()),
);
}
if (tracks.isEmpty) {
return Scaffold(
+19 -15
View File
@@ -349,8 +349,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final canonicalFilter = _canonicalSearchFilterId(filter);
if (currentSearchProvider == null ||
currentSearchProvider.isEmpty) {
if (currentSearchProvider == null || currentSearchProvider.isEmpty) {
switch (canonicalFilter) {
case 'track':
case 'artist':
@@ -993,13 +992,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
var skippedDownloadedCount = 0;
if (options.skipDownloaded) {
final historyState = ref.read(downloadHistoryProvider);
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
tracksToQueue = [];
for (final track in tracks) {
final isDownloaded =
historyState.isDownloaded(track.id) ||
(track.isrc != null &&
historyState.getByIsrc(track.isrc!) != null);
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
final isDownloaded = existingHistoryKeys.contains(
historyLookups[i].lookupKey,
);
if (isDownloaded) {
skippedDownloadedCount++;
continue;
@@ -1120,10 +1126,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final extensionReadiness = ref.watch(
extensionProvider.select(
(s) => (
isInitialized: s.isInitialized,
error: s.error,
),
(s) => (isInitialized: s.isInitialized, error: s.error),
),
);
@@ -2339,7 +2342,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
void _navigateToRecentItem(RecentAccessItem item) {
Future<void> _navigateToRecentItem(RecentAccessItem item) async {
_searchFocusNode.unfocus();
switch (item.type) {
@@ -2407,9 +2410,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
return;
case RecentAccessType.track:
final historyItem = ref
final historyItem = await ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(item.id);
.getBySpotifyIdAsync(item.id);
if (!mounted) return;
if (historyItem != null) {
_navigateToMetadataScreen(historyItem);
} else {
+20 -26
View File
@@ -232,11 +232,10 @@ class _TrackItemWithStatus extends ConsumerWidget {
),
);
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}),
);
final historyLookup = historyLookupForTrack(track);
final isInHistory = ref
.watch(downloadHistoryExistsProvider(historyLookup))
.maybeWhen(data: (exists) => exists, orElse: () => false);
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
@@ -414,28 +413,23 @@ class _TrackItemWithStatus extends ConsumerWidget {
return;
}
if (isInHistory) {
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final historyItem = await historyNotifier.findExistingTrackAsync(
historyLookupForTrack(track),
);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)),
),
);
}
return;
} else {
historyNotifier.removeFromHistory(historyItem.id);
}
}
+43 -16
View File
@@ -310,6 +310,16 @@ class _LibraryTracksFolderScreenState
final folderTracks = entries
.map((entry) => entry.track)
.toList(growable: false);
final historyLookups = folderTracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
final bottomPadding = MediaQuery.of(context).padding.bottom;
@@ -340,6 +350,9 @@ class _LibraryTracksFolderScreenState
delegate: SliverChildBuilderDelegate((context, index) {
final entry = entries[index];
final isSelected = _selectedKeys.contains(entry.key);
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(entry.key),
child: StaggeredListItem(
@@ -349,6 +362,7 @@ class _LibraryTracksFolderScreenState
mode: widget.mode,
playlistId: widget.playlistId,
folderTracks: folderTracks,
isInHistory: isInHistory,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
@@ -871,9 +885,17 @@ class _LibraryTracksFolderScreenState
);
}
void _downloadAll(List<Track> tracks) {
Future<void> _downloadAll(List<Track> tracks) async {
if (tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
if (!mounted) return;
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
@@ -885,12 +907,11 @@ class _LibraryTracksFolderScreenState
final tracksToQueue = <Track>[];
var skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
final isInHistory = existingHistoryKeys.contains(
historyLookups[i].lookupKey,
);
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc,
@@ -1059,6 +1080,7 @@ class _CollectionTrackTile extends ConsumerWidget {
final LibraryTracksFolderMode mode;
final String? playlistId;
final List<Track> folderTracks;
final bool isInHistory;
final bool isSelectionMode;
final bool isSelected;
final VoidCallback? onTap;
@@ -1069,6 +1091,7 @@ class _CollectionTrackTile extends ConsumerWidget {
required this.mode,
required this.playlistId,
required this.folderTracks,
required this.isInHistory,
this.isSelectionMode = false,
this.isSelected = false,
this.onTap,
@@ -1097,7 +1120,7 @@ class _CollectionTrackTile extends ConsumerWidget {
// Fine-grained provider watches only this tile rebuilds when its own
// history / local-library entry changes.
final historyItem = ref.watch(
final inMemoryHistoryItem = ref.watch(
downloadHistoryProvider.select((state) {
final byId = state.getBySpotifyId(track.id);
if (byId != null) return byId;
@@ -1127,8 +1150,9 @@ class _CollectionTrackTile extends ConsumerWidget {
)
: false;
final isInHistory = historyItem != null;
final heroTag = historyItem != null ? 'cover_${historyItem.id}' : null;
final heroTag = inMemoryHistoryItem != null
? 'cover_${inMemoryHistoryItem.id}'
: null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1241,7 +1265,7 @@ class _CollectionTrackTile extends ConsumerWidget {
),
trailing: isSelectionMode
? null
: historyItem != null || isInLocalLibrary
: isInHistory || isInLocalLibrary
? IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () {
@@ -1379,18 +1403,21 @@ class _CollectionTrackTile extends ConsumerWidget {
Future<void> _navigateToMetadata(BuildContext context, WidgetRef ref) async {
final track = entry.track;
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
var historyItem = historyState.getBySpotifyId(track.id);
var historyItem = await historyNotifier.getBySpotifyIdAsync(track.id);
if (!context.mounted) return;
if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) {
historyItem = historyState.getByIsrc(track.isrc!);
historyItem = await historyNotifier.getByIsrcAsync(track.isrc!);
if (!context.mounted) return;
}
historyItem ??= historyState.findByTrackAndArtist(
historyItem ??= await historyNotifier.findByTrackAndArtistAsync(
track.name,
track.artistName,
);
if (!context.mounted) return;
if (historyItem != null) {
await Navigator.of(context).push(
+39 -26
View File
@@ -477,15 +477,29 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
);
}
final historyLookups = _tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _PlaylistTrackItem(
track: track,
isInHistory: isInHistory,
onDownload: () => _downloadTrack(context, track),
),
),
@@ -695,10 +709,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
_downloadTracks(context, _tracks);
}
void _downloadTracks(BuildContext context, List<Track> tracks) {
Future<void> _downloadTracks(BuildContext context, List<Track> tracks) async {
if (tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
);
if (!context.mounted) return;
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
@@ -707,12 +729,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
final isInHistory = existingHistoryKeys.contains(
historyLookups[i].lookupKey,
);
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc,
@@ -781,9 +802,14 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
class _PlaylistTrackItem extends ConsumerWidget {
final Track track;
final bool isInHistory;
final VoidCallback onDownload;
const _PlaylistTrackItem({required this.track, required this.onDownload});
const _PlaylistTrackItem({
required this.track,
required this.isInHistory,
required this.onDownload,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -795,17 +821,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
),
);
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
@@ -942,18 +957,16 @@ class _PlaylistTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
DownloadHistoryItem? historyItem = await historyNotifier
.getBySpotifyIdAsync(track.id);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
? await historyNotifier.getByIsrcAsync(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
historyItem ??= await historyNotifier.findByTrackAndArtistAsync(
track.name,
track.artistName,
);
+180 -77
View File
@@ -58,6 +58,7 @@ class QueueTab extends ConsumerStatefulWidget {
}
class _QueueTabState extends ConsumerState<QueueTab> {
static const int _libraryPageSize = 300;
final _FileExistsListenableCache _fileExistsCache =
_FileExistsListenableCache();
static const int _maxSearchIndexCacheSize = 4000;
@@ -147,6 +148,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String _sortMode = 'latest';
double _libraryGridExtent = _libraryGridDefaultExtent;
double? _libraryGridScaleStartExtent;
int _libraryPageLimit = _libraryPageSize;
bool _libraryPageLoadScheduled = false;
double _effectiveTextScale() {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
@@ -217,7 +220,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final normalized = value.trim().toLowerCase();
_searchDebounce = Timer(const Duration(milliseconds: 180), () {
if (!mounted || _searchQuery == normalized) return;
setState(() => _searchQuery = normalized);
setState(() {
_searchQuery = normalized;
_libraryPageLimit = _libraryPageSize;
});
_requestFilterRefresh();
});
}
@@ -225,10 +231,46 @@ class _QueueTabState extends ConsumerState<QueueTab> {
void _clearSearch() {
_searchDebounce?.cancel();
if (_searchQuery.isEmpty) return;
setState(() => _searchQuery = '');
setState(() {
_searchQuery = '';
_libraryPageLimit = _libraryPageSize;
});
_requestFilterRefresh();
}
void _loadMoreLibraryItems({required bool hasMoreLibrary}) {
if (_libraryPageLoadScheduled) return;
_libraryPageLoadScheduled = true;
setState(() {
if (hasMoreLibrary) _libraryPageLimit += _libraryPageSize;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_libraryPageLoadScheduled = false;
});
}
bool _handleLibraryScrollNotification({
required ScrollNotification notification,
required String filterMode,
required bool hasMoreLibrary,
required bool isPageLoading,
}) {
if (isPageLoading || !hasMoreLibrary || notification.depth != 0) {
return false;
}
final metrics = notification.metrics;
if (metrics.maxScrollExtent <= 0) return false;
final threshold = metrics.maxScrollExtent * 0.7;
final nearEnd =
metrics.pixels >= threshold ||
metrics.extentAfter <= metrics.viewportDimension * 1.5;
if (!nearEnd) return false;
_loadMoreLibraryItems(hasMoreLibrary: hasMoreLibrary);
return false;
}
void _invalidateFilterContentCache() {
_filterContentDataCache.clear();
_filterCacheAllHistoryItems = null;
@@ -237,6 +279,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterCacheCollectionState = null;
}
// ignore: unused_element
void _prepareFilterContentCache({
required List<DownloadHistoryItem> allHistoryItems,
required _HistoryStats historyStats,
@@ -272,6 +315,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterCacheSortMode = _sortMode;
}
// ignore: unused_element
void _ensureHistoryCaches(
List<DownloadHistoryItem> items,
List<LocalLibraryItem> localItems,
@@ -1252,6 +1296,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterFormat = null;
_filterMetadata = null;
_sortMode = 'latest';
_libraryPageLimit = _libraryPageSize;
_unifiedItemsCache.clear();
_invalidateFilterContentCache();
});
@@ -1844,6 +1889,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterFormat = tempFormat;
_filterMetadata = tempMetadata;
_sortMode = tempSortMode;
_libraryPageLimit = _libraryPageSize;
_unifiedItemsCache.clear();
_invalidateFilterContentCache();
});
@@ -1971,6 +2017,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
coverHeroTag: 'cover_lib_dl_${item.id}',
),
),
);
@@ -2002,6 +2049,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
localItem: item,
localNavigationItems: navigationItems,
navigationIndex: navigationIndex,
coverHeroTag: 'cover_lib_local_${item.id}',
),
),
).then((_) => _searchFocusNode.unfocus());
@@ -2059,14 +2107,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
void _navigateToLocalAlbum(_GroupedLocalAlbum album) {
Future<void> _navigateToLocalAlbum(_GroupedLocalAlbum album) async {
var tracks = album.tracks;
if (tracks.isEmpty && album.displayTrackCount > 0) {
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracks(
album.albumName,
album.artistName,
);
tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false);
if (!mounted) return;
}
_navigateWithUnfocus(
slidePageRoute(
page: LocalAlbumScreen(
albumName: album.albumName,
artistName: album.artistName,
coverPath: album.coverPath,
tracks: album.tracks,
tracks: tracks,
),
),
);
@@ -2362,20 +2419,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final hasQueueItems = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
);
final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final localLibraryEnabled = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
);
final localLibraryItems = localLibraryEnabled
? ref
.watch(localLibraryAllItemsProvider)
.maybeWhen(
data: (items) => items,
orElse: () => const <LocalLibraryItem>[],
)
: const <LocalLibraryItem>[];
// Watch with selector on key fields to reduce unnecessary rebuilds.
// LibraryCollectionsState doesn't implement == so watching without
// selector rebuilds on every provider notification.
@@ -2392,20 +2438,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
final collectionState = ref.read(libraryCollectionsProvider);
final historyStats = ref.watch(_queueHistoryStatsProvider);
final filteredGrouped = ref.watch(
_queueFilteredAlbumsProvider(
_QueueGroupedAlbumFilterRequest(
searchQuery: _searchQuery,
filterSource: _filterSource,
filterQuality: _filterQuality,
filterFormat: _filterFormat,
filterMetadata: _filterMetadata,
sortMode: _sortMode,
),
),
);
_ensureHistoryCaches(allHistoryItems, localLibraryItems, historyStats);
final historyViewMode = ref.watch(
settingsProvider.select((s) => s.historyViewMode),
);
@@ -2414,33 +2446,78 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final filteredGroupedAlbums = filteredGrouped.albums;
final filteredGroupedLocalAlbums = filteredGrouped.localAlbums;
final albumCount = historyStats.totalAlbumCount;
final singleCount = historyStats.totalSingleTracks;
_prepareFilterContentCache(
allHistoryItems: allHistoryItems,
historyStats: historyStats,
localLibraryItems: localLibraryItems,
collectionState: collectionState,
final countsRequest = _QueueLibraryCountsRequest(
searchQuery: _searchQuery,
filterSource: _filterSource,
filterQuality: _filterQuality,
filterFormat: _filterFormat,
filterMetadata: _filterMetadata,
localLibraryEnabled: localLibraryEnabled,
);
final countsValue = ref.watch(_queueLibraryCountsProvider(countsRequest));
final queueCounts = countsValue.maybeWhen(
data: (counts) => counts,
orElse: () => const QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
singleTrackCount: 0,
),
);
_FilterContentData getFilterData(String filterMode) {
return _filterContentDataCache.putIfAbsent(
filterMode,
() => _computeFilterContentData(
_QueueLibraryPageRequest pageRequest(String filterMode) =>
_QueueLibraryPageRequest(
filterMode: filterMode,
allHistoryItems: allHistoryItems,
filteredGroupedAlbums: filteredGroupedAlbums,
filteredGroupedLocalAlbums: filteredGroupedLocalAlbums,
albumCounts: historyStats.albumCounts,
localAlbumCounts: historyStats.localAlbumCounts,
localLibraryItems: localLibraryItems,
collectionState: collectionState,
),
limit: _libraryPageLimit,
searchQuery: _searchQuery,
filterSource: _filterSource,
filterQuality: _filterQuality,
filterFormat: _filterFormat,
filterMetadata: _filterMetadata,
sortMode: _sortMode,
localLibraryEnabled: localLibraryEnabled,
);
final pageValues = <String, AsyncValue<_QueueLibraryPageData>>{
for (final mode in _filterModes)
mode: ref.watch(_queueLibraryPageProvider(pageRequest(mode))),
};
_QueueLibraryPageData pageData(String filterMode) =>
pageValues[filterMode]?.maybeWhen(
data: (data) => data,
orElse: () => const _QueueLibraryPageData(),
) ??
const _QueueLibraryPageData();
_FilterContentData getFilterData(String filterMode) {
return pageData(filterMode).toFilterContentData(
collectionState,
totalTrackCount: switch (filterMode) {
'singles' => queueCounts.singleTrackCount,
'albums' => 0,
_ => queueCounts.allTrackCount,
},
totalAlbumCount: filterMode == 'albums' ? queueCounts.albumCount : null,
);
}
final currentPageData = pageData(historyFilterMode);
final currentLoadedCount = historyFilterMode == 'albums'
? currentPageData.groupedAlbums.length +
currentPageData.groupedLocalAlbums.length
: currentPageData.items.length;
final currentTotalCount = switch (historyFilterMode) {
'albums' => queueCounts.albumCount,
'singles' => queueCounts.singleTrackCount,
_ => queueCounts.allTrackCount,
};
final hasMoreLibrary = currentLoadedCount < currentTotalCount;
final isLibraryPageLoading =
countsValue.isLoading ||
(pageValues[historyFilterMode]?.isLoading ?? false);
final hasAnyLibraryItems =
queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0;
final bottomPadding = MediaQuery.paddingOf(context).bottom;
final selectionItems = getFilterData(
historyFilterMode,
@@ -2519,9 +2596,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
if (allHistoryItems.isNotEmpty ||
hasQueueItems ||
localLibraryItems.isNotEmpty)
if (hasAnyLibraryItems || hasQueueItems)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -2586,7 +2661,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (hasQueueItems) _buildQueueItemsSliver(context, colorScheme),
if (allHistoryItems.isNotEmpty || localLibraryItems.isNotEmpty)
if (hasAnyLibraryItems)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
@@ -2596,20 +2671,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
int filteredAlbumCount;
int filteredSingleCount;
if (_activeFilterCount == 0 && _searchQuery.isEmpty) {
filteredAllCount =
allHistoryItems.length +
localLibraryItems.length;
filteredAlbumCount = albumCount;
filteredSingleCount = singleCount;
} else {
final allData = getFilterData('all');
final albumsData = getFilterData('albums');
final singlesData = getFilterData('singles');
filteredAllCount = allData.totalTrackCount;
filteredAlbumCount = albumsData.totalAlbumCount;
filteredSingleCount = singlesData.totalTrackCount;
}
filteredAllCount = queueCounts.allTrackCount;
filteredAlbumCount = queueCounts.albumCount;
filteredSingleCount = queueCounts.singleTrackCount;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
@@ -2664,8 +2728,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
historyViewMode: historyViewMode,
hasQueueItems: hasQueueItems,
filterData: filterData,
localLibraryItems: localLibraryItems,
collectionState: collectionState,
hasMoreLibrary: filterMode == historyFilterMode
? hasMoreLibrary
: false,
isPageLoading: isLibraryPageLoading,
);
},
),
@@ -2738,6 +2805,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return merged;
}
// ignore: unused_element
_FilterContentData _computeFilterContentData({
required String filterMode,
required List<DownloadHistoryItem> allHistoryItems,
@@ -3259,8 +3327,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required String historyViewMode,
required bool hasQueueItems,
required _FilterContentData filterData,
required List<LocalLibraryItem> localLibraryItems,
required LibraryCollectionsState collectionState,
required bool hasMoreLibrary,
required bool isPageLoading,
}) {
final historyItems = filterData.historyItems;
final showFilteringIndicator = filterData.showFilteringIndicator;
@@ -3345,7 +3414,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (filteredGroupedAlbums.isEmpty &&
filteredGroupedLocalAlbums.isEmpty &&
filterMode == 'albums' &&
(historyItems.isNotEmpty || localLibraryItems.isNotEmpty))
(historyItems.isNotEmpty || unifiedItems.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -3697,20 +3766,51 @@ class _QueueTabState extends ConsumerState<QueueTab> {
hasScrollBody: false,
child: _buildEmptyState(context, colorScheme, filterMode),
)
else
else if (isPageLoading && filterMode != 'albums')
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
),
),
),
if (hasQueueItems ||
totalTrackCount > 0 ||
(filterMode == 'albums' &&
(filteredGroupedAlbums.isNotEmpty ||
filteredGroupedLocalAlbums.isNotEmpty)))
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 100 : 16),
),
],
);
if (historyViewMode != 'grid') return content;
final scrollAwareContent = NotificationListener<ScrollNotification>(
onNotification: (notification) => _handleLibraryScrollNotification(
notification: notification,
filterMode: filterMode,
hasMoreLibrary: hasMoreLibrary,
isPageLoading: isPageLoading,
),
child: content,
);
if (historyViewMode != 'grid') return scrollAwareContent;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onScaleStart: _handleLibraryGridScaleStart,
onScaleUpdate: _handleLibraryGridScaleUpdate,
onScaleEnd: _handleLibraryGridScaleEnd,
child: content,
child: scrollAwareContent,
);
}
@@ -3848,7 +3948,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context: context,
albumName: album.albumName,
artistName: album.artistName,
trackCount: album.tracks.length,
trackCount: album.displayTrackCount,
colorScheme: colorScheme,
coverWidget: embeddedCoverPath != null
? Image.file(
@@ -3890,7 +3990,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context: context,
albumName: album.albumName,
artistName: album.artistName,
trackCount: album.tracks.length,
trackCount: album.displayTrackCount,
colorScheme: colorScheme,
coverWidget: album.coverPath != null
? Image.file(
@@ -6033,7 +6133,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
children: [
AspectRatio(
aspectRatio: 1,
child: _buildUnifiedCoverImage(item, colorScheme),
child: Hero(
tag: 'cover_lib_${item.id}',
child: _buildUnifiedCoverImage(item, colorScheme),
),
),
Positioned(
right: 4,
+256 -627
View File
@@ -178,6 +178,7 @@ class _GroupedAlbum {
final String? coverUrl;
final String sampleFilePath;
final List<DownloadHistoryItem> tracks;
final int? trackCount;
final DateTime latestDownload;
final String searchKey;
@@ -187,10 +188,13 @@ class _GroupedAlbum {
this.coverUrl,
required this.sampleFilePath,
required this.tracks,
this.trackCount,
required this.latestDownload,
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
String get key => '$albumName|$artistName';
int get displayTrackCount => trackCount ?? tracks.length;
}
class _GroupedLocalAlbum {
@@ -198,6 +202,7 @@ class _GroupedLocalAlbum {
final String artistName;
final String? coverPath;
final List<LocalLibraryItem> tracks;
final int? trackCount;
final DateTime latestScanned;
final String searchKey;
@@ -206,36 +211,27 @@ class _GroupedLocalAlbum {
required this.artistName,
this.coverPath,
required this.tracks,
this.trackCount,
required this.latestScanned,
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
String get key => '$albumName|$artistName';
int get displayTrackCount => trackCount ?? tracks.length;
}
class _HistoryStats {
final Map<String, int> albumCounts;
final Map<String, int> localAlbumCounts;
final List<_GroupedAlbum> groupedAlbums;
final List<_GroupedLocalAlbum> groupedLocalAlbums;
final int albumCount;
final int singleTracks;
final int localAlbumCount;
final int localSingleTracks;
const _HistoryStats({
required this.albumCounts,
this.localAlbumCounts = const {},
required this.groupedAlbums,
this.groupedLocalAlbums = const [],
required this.albumCount,
required this.singleTracks,
this.localAlbumCount = 0,
this.localSingleTracks = 0,
});
int get totalAlbumCount => albumCount + localAlbumCount;
int get totalSingleTracks => singleTracks + localSingleTracks;
}
class _FilterContentData {
@@ -245,6 +241,8 @@ class _FilterContentData {
final List<_GroupedAlbum> filteredGroupedAlbums;
final List<_GroupedLocalAlbum> filteredGroupedLocalAlbums;
final bool showFilteringIndicator;
final int? totalTrackCountOverride;
final int? totalAlbumCountOverride;
const _FilterContentData({
required this.historyItems,
@@ -253,13 +251,258 @@ class _FilterContentData {
required this.filteredGroupedAlbums,
required this.filteredGroupedLocalAlbums,
required this.showFilteringIndicator,
this.totalTrackCountOverride,
this.totalAlbumCountOverride,
});
int get totalTrackCount => filteredUnifiedItems.length;
int get totalTrackCount =>
totalTrackCountOverride ?? filteredUnifiedItems.length;
int get totalAlbumCount =>
totalAlbumCountOverride ??
filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length;
}
class _QueueLibraryPageRequest {
final String filterMode;
final int limit;
final String searchQuery;
final String? filterSource;
final String? filterQuality;
final String? filterFormat;
final String? filterMetadata;
final String sortMode;
final bool localLibraryEnabled;
const _QueueLibraryPageRequest({
required this.filterMode,
required this.limit,
required this.searchQuery,
required this.filterSource,
required this.filterQuality,
required this.filterFormat,
required this.filterMetadata,
required this.sortMode,
required this.localLibraryEnabled,
});
QueueLibraryDbQuery toDbQuery() => QueueLibraryDbQuery(
limit: limit,
filterMode: filterMode,
searchQuery: searchQuery,
source: filterSource,
quality: filterQuality,
format: filterFormat,
metadata: filterMetadata,
sortMode: sortMode,
includeLocal: localLibraryEnabled,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _QueueLibraryPageRequest &&
filterMode == other.filterMode &&
limit == other.limit &&
searchQuery == other.searchQuery &&
filterSource == other.filterSource &&
filterQuality == other.filterQuality &&
filterFormat == other.filterFormat &&
filterMetadata == other.filterMetadata &&
sortMode == other.sortMode &&
localLibraryEnabled == other.localLibraryEnabled;
@override
int get hashCode => Object.hash(
filterMode,
limit,
searchQuery,
filterSource,
filterQuality,
filterFormat,
filterMetadata,
sortMode,
localLibraryEnabled,
);
}
class _QueueLibraryCountsRequest {
final String searchQuery;
final String? filterSource;
final String? filterQuality;
final String? filterFormat;
final String? filterMetadata;
final bool localLibraryEnabled;
const _QueueLibraryCountsRequest({
required this.searchQuery,
required this.filterSource,
required this.filterQuality,
required this.filterFormat,
required this.filterMetadata,
required this.localLibraryEnabled,
});
QueueLibraryDbQuery toDbQuery() => QueueLibraryDbQuery(
searchQuery: searchQuery,
source: filterSource,
quality: filterQuality,
format: filterFormat,
metadata: filterMetadata,
includeLocal: localLibraryEnabled,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _QueueLibraryCountsRequest &&
searchQuery == other.searchQuery &&
filterSource == other.filterSource &&
filterQuality == other.filterQuality &&
filterFormat == other.filterFormat &&
filterMetadata == other.filterMetadata &&
localLibraryEnabled == other.localLibraryEnabled;
@override
int get hashCode => Object.hash(
searchQuery,
filterSource,
filterQuality,
filterFormat,
filterMetadata,
localLibraryEnabled,
);
}
class _QueueLibraryPageData {
final List<UnifiedLibraryItem> items;
final List<DownloadHistoryItem> historyItems;
final List<LocalLibraryItem> localItems;
final List<_GroupedAlbum> groupedAlbums;
final List<_GroupedLocalAlbum> groupedLocalAlbums;
const _QueueLibraryPageData({
this.items = const [],
this.historyItems = const [],
this.localItems = const [],
this.groupedAlbums = const [],
this.groupedLocalAlbums = const [],
});
_FilterContentData toFilterContentData(
LibraryCollectionsState collectionState, {
int? totalTrackCount,
int? totalAlbumCount,
}) {
final filteredItems = !collectionState.hasPlaylistTracks
? items
: items
.where(
(item) =>
!collectionState.isTrackInAnyPlaylist(item.collectionKey),
)
.toList(growable: false);
return _FilterContentData(
historyItems: historyItems,
unifiedItems: items,
filteredUnifiedItems: filteredItems,
filteredGroupedAlbums: groupedAlbums,
filteredGroupedLocalAlbums: groupedLocalAlbums,
showFilteringIndicator: false,
totalTrackCountOverride: totalTrackCount,
totalAlbumCountOverride: totalAlbumCount,
);
}
}
final _queueLibraryPageProvider =
FutureProvider.family<_QueueLibraryPageData, _QueueLibraryPageRequest>((
ref,
request,
) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
final dbQuery = request.toDbQuery();
if (request.filterMode == 'albums') {
final rows = await LibraryDatabase.instance.getQueueAlbumPage(dbQuery);
final groupedAlbums = <_GroupedAlbum>[];
final groupedLocalAlbums = <_GroupedLocalAlbum>[];
for (final row in rows) {
final source = row['queue_source'] as String? ?? '';
final latestMillis = (row['sort_added'] as num?)?.toInt() ?? 0;
final latest = DateTime.fromMillisecondsSinceEpoch(latestMillis);
if (source == 'local') {
groupedLocalAlbums.add(
_GroupedLocalAlbum(
albumName: row['album_name'] as String? ?? '',
artistName: row['artist_name'] as String? ?? '',
coverPath: row['cover_path'] as String?,
tracks: const [],
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
latestScanned: latest,
),
);
} else if (source == 'downloaded') {
groupedAlbums.add(
_GroupedAlbum(
albumName: row['album_name'] as String? ?? '',
artistName: row['artist_name'] as String? ?? '',
coverUrl: row['cover_url'] as String?,
sampleFilePath: row['sample_file_path'] as String? ?? '',
tracks: const [],
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
latestDownload: latest,
),
);
}
}
return _QueueLibraryPageData(
groupedAlbums: groupedAlbums,
groupedLocalAlbums: groupedLocalAlbums,
);
}
final rows = await LibraryDatabase.instance.getQueueTrackPage(dbQuery);
final items = <UnifiedLibraryItem>[];
final historyItems = <DownloadHistoryItem>[];
final localItems = <LocalLibraryItem>[];
for (final row in rows) {
final source = row['source'] as String? ?? '';
final itemJson = Map<String, dynamic>.from(row['item'] as Map);
if (source == 'local') {
final item = LocalLibraryItem.fromJson(itemJson);
localItems.add(item);
items.add(UnifiedLibraryItem.fromLocalLibrary(item));
} else if (source == 'downloaded') {
final item = DownloadHistoryItem.fromJson(itemJson);
historyItems.add(item);
items.add(UnifiedLibraryItem.fromDownloadHistory(item));
}
}
return _QueueLibraryPageData(
items: items,
historyItems: historyItems,
localItems: localItems,
);
});
final _queueLibraryCountsProvider =
FutureProvider.family<QueueLibraryCounts, _QueueLibraryCountsRequest>((
ref,
request,
) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
return LibraryDatabase.instance.getQueueCounts(request.toDbQuery());
});
class _UnifiedCacheEntry {
final List<DownloadHistoryItem> historyItems;
final List<LocalLibraryItem> localItems;
@@ -290,59 +533,6 @@ class _QueueItemIdsSnapshot {
int get hashCode => Object.hashAll(ids);
}
class _QueueGroupedAlbumFilterRequest {
final String searchQuery;
final String? filterSource;
final String? filterQuality;
final String? filterFormat;
final String? filterMetadata;
final String sortMode;
const _QueueGroupedAlbumFilterRequest({
required this.searchQuery,
required this.filterSource,
required this.filterQuality,
required this.filterFormat,
required this.filterMetadata,
required this.sortMode,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _QueueGroupedAlbumFilterRequest &&
searchQuery == other.searchQuery &&
filterSource == other.filterSource &&
filterQuality == other.filterQuality &&
filterFormat == other.filterFormat &&
filterMetadata == other.filterMetadata &&
sortMode == other.sortMode;
@override
int get hashCode => Object.hash(
searchQuery,
filterSource,
filterQuality,
filterFormat,
filterMetadata,
sortMode,
);
}
class _QueueHistoryStatsMemoEntry {
final List<DownloadHistoryItem> historyItems;
final List<LocalLibraryItem> localItems;
final _HistoryStats stats;
const _QueueHistoryStatsMemoEntry({
required this.historyItems,
required this.localItems,
required this.stats,
});
}
_QueueHistoryStatsMemoEntry? _queueHistoryStatsMemo;
class _FileExistsListenableCache {
static const int _maxCacheSize = 500;
@@ -413,19 +603,6 @@ class _FileExistsListenableCache {
}
}
String _queueHistoryAlbumKey(String albumName, String artistName) {
return '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
}
String _queueFileExtLower(String filePath) {
final slashIndex = filePath.lastIndexOf('/');
final dotIndex = filePath.lastIndexOf('.');
if (dotIndex == -1 || dotIndex < slashIndex + 1) {
return '';
}
return filePath.substring(dotIndex + 1).toLowerCase();
}
bool _queueHasMetadataValue(String? value) {
return value != null && value.trim().isNotEmpty;
}
@@ -583,554 +760,6 @@ int _queueCompareOptionalDate(
return descending ? -comparison : comparison;
}
DateTime? _queueGroupedAlbumReleaseDate(_GroupedAlbum album) {
for (final track in album.tracks) {
final releaseDate = _queueParseReleaseDate(track.releaseDate);
if (releaseDate != null) {
return releaseDate;
}
}
return null;
}
DateTime? _queueGroupedLocalAlbumReleaseDate(_GroupedLocalAlbum album) {
for (final track in album.tracks) {
final releaseDate = _queueParseReleaseDate(track.releaseDate);
if (releaseDate != null) {
return releaseDate;
}
}
return null;
}
String? _queueGroupedAlbumGenre(_GroupedAlbum album) {
for (final track in album.tracks) {
if (_queueHasMetadataValue(track.genre)) {
return track.genre;
}
}
return null;
}
String? _queueGroupedLocalAlbumGenre(_GroupedLocalAlbum album) {
for (final track in album.tracks) {
if (_queueHasMetadataValue(track.genre)) {
return track.genre;
}
}
return null;
}
String? _queueLocalQualityLabel(LocalLibraryItem item) {
if (item.bitrate != null && item.bitrate! > 0) {
return '${item.bitrate}kbps';
}
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
return null;
}
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
bool _queuePassesQualityFilter(String? filterQuality, String? quality) {
if (filterQuality == null) return true;
if (quality == null) return filterQuality == 'lossy';
final normalized = quality.toLowerCase();
switch (filterQuality) {
case 'hires':
return normalized.startsWith('24');
case 'cd':
return normalized.startsWith('16');
case 'lossy':
return !normalized.startsWith('24') && !normalized.startsWith('16');
default:
return true;
}
}
bool _queuePassesFormatFilter(String? filterFormat, String filePath) {
if (filterFormat == null) return true;
return _queueFileExtLower(filePath) == filterFormat;
}
_HistoryStats _buildQueueHistoryStats(
List<DownloadHistoryItem> items, [
List<LocalLibraryItem> localItems = const [],
]) {
final memo = _queueHistoryStatsMemo;
if (memo != null &&
identical(memo.historyItems, items) &&
identical(memo.localItems, localItems)) {
return memo.stats;
}
final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
for (final item in items) {
final key = _queueHistoryAlbumKey(
item.albumName,
item.albumArtist ?? item.artistName,
);
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
albumMap.putIfAbsent(key, () => []).add(item);
}
var singleTracks = 0;
var albumCount = 0;
for (final count in albumCounts.values) {
if (count > 1) {
albumCount++;
} else {
singleTracks += count;
}
}
final groupedAlbums = <_GroupedAlbum>[];
albumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedAlbums.add(
_GroupedAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverUrl: tracks.first.coverUrl,
sampleFilePath: tracks.first.filePath,
tracks: tracks,
latestDownload: tracks
.map((t) => t.downloadedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
final downloadedPathKeys = <String>{};
for (final item in items) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
}
final dedupedLocalItems = localItems
.where((item) {
final localPathKeys = buildPathMatchKeys(item.filePath);
return !localPathKeys.any(downloadedPathKeys.contains);
})
.toList(growable: false);
final localAlbumCounts = <String, int>{};
final localAlbumMap = <String, List<LocalLibraryItem>>{};
for (final item in dedupedLocalItems) {
final key = _queueHistoryAlbumKey(
item.albumName,
item.albumArtist ?? item.artistName,
);
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
localAlbumMap.putIfAbsent(key, () => []).add(item);
}
var localAlbumCount = 0;
var localSingleTracks = 0;
for (final count in localAlbumCounts.values) {
if (count > 1) {
localAlbumCount++;
} else {
localSingleTracks++;
}
}
final groupedLocalAlbums = <_GroupedLocalAlbum>[];
localAlbumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedLocalAlbums.add(
_GroupedLocalAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverPath: tracks
.firstWhere(
(t) => t.coverPath != null && t.coverPath!.isNotEmpty,
orElse: () => tracks.first,
)
.coverPath,
tracks: tracks,
latestScanned: tracks
.map((t) => t.scannedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned));
final stats = _HistoryStats(
albumCounts: albumCounts,
localAlbumCounts: localAlbumCounts,
groupedAlbums: groupedAlbums,
groupedLocalAlbums: groupedLocalAlbums,
albumCount: albumCount,
singleTracks: singleTracks,
localAlbumCount: localAlbumCount,
localSingleTracks: localSingleTracks,
);
_queueHistoryStatsMemo = _QueueHistoryStatsMemoEntry(
historyItems: items,
localItems: localItems,
stats: stats,
);
return stats;
}
List<_GroupedAlbum> _queueFilterGroupedAlbums(
List<_GroupedAlbum> albums,
_QueueGroupedAlbumFilterRequest request,
) {
if (request.filterSource == 'local') return const [];
if (request.filterSource == null &&
request.filterQuality == null &&
request.filterFormat == null &&
request.filterMetadata == null &&
request.searchQuery.isEmpty &&
request.sortMode == 'latest') {
return albums;
}
final result = <_GroupedAlbum>[];
for (final album in albums) {
if (request.searchQuery.isNotEmpty &&
!album.searchKey.contains(request.searchQuery)) {
continue;
}
if (request.filterQuality != null ||
request.filterFormat != null ||
request.filterMetadata != null) {
var hasMatchingTrack = false;
for (final track in album.tracks) {
if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) {
continue;
}
if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) {
continue;
}
if (!_queueMatchesMetadataFilter(
filterMetadata: request.filterMetadata,
artistName: track.artistName,
albumArtist: track.albumArtist,
releaseDate: track.releaseDate,
genre: track.genre,
trackNumber: track.trackNumber,
discNumber: track.discNumber,
isrc: track.isrc,
label: track.label,
)) {
continue;
}
hasMatchingTrack = true;
break;
}
if (!hasMatchingTrack) continue;
}
result.add(album);
}
switch (request.sortMode) {
case 'oldest':
result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload));
case 'artist-asc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
a.artistName,
b.artistName,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'artist-desc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
a.artistName,
b.artistName,
descending: true,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'a-z':
result.sort(
(a, b) =>
a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()),
);
case 'z-a':
result.sort(
(a, b) =>
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
);
case 'album-asc':
result.sort(
(a, b) => _queueCompareOptionalText(a.albumName, b.albumName),
);
case 'album-desc':
result.sort(
(a, b) => _queueCompareOptionalText(
a.albumName,
b.albumName,
descending: true,
),
);
case 'release-oldest':
result.sort((a, b) {
final comparison = _queueCompareOptionalDate(
_queueGroupedAlbumReleaseDate(a),
_queueGroupedAlbumReleaseDate(b),
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'release-newest':
result.sort((a, b) {
final comparison = _queueCompareOptionalDate(
_queueGroupedAlbumReleaseDate(a),
_queueGroupedAlbumReleaseDate(b),
descending: true,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'genre-asc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
_queueGroupedAlbumGenre(a),
_queueGroupedAlbumGenre(b),
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'genre-desc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
_queueGroupedAlbumGenre(a),
_queueGroupedAlbumGenre(b),
descending: true,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
default:
break;
}
return result;
}
List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums(
List<_GroupedLocalAlbum> albums,
_QueueGroupedAlbumFilterRequest request,
) {
if (request.filterSource == 'downloaded') return const [];
if (request.filterSource == null &&
request.filterQuality == null &&
request.filterFormat == null &&
request.filterMetadata == null &&
request.searchQuery.isEmpty &&
request.sortMode == 'latest') {
return albums;
}
final result = <_GroupedLocalAlbum>[];
for (final album in albums) {
if (request.searchQuery.isNotEmpty &&
!album.searchKey.contains(request.searchQuery)) {
continue;
}
if (request.filterQuality != null ||
request.filterFormat != null ||
request.filterMetadata != null) {
var hasMatchingTrack = false;
for (final track in album.tracks) {
if (!_queuePassesQualityFilter(
request.filterQuality,
_queueLocalQualityLabel(track),
)) {
continue;
}
if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) {
continue;
}
if (!_queueMatchesMetadataFilter(
filterMetadata: request.filterMetadata,
artistName: track.artistName,
albumArtist: track.albumArtist,
releaseDate: track.releaseDate,
genre: track.genre,
trackNumber: track.trackNumber,
discNumber: track.discNumber,
isrc: track.isrc,
label: track.label,
)) {
continue;
}
hasMatchingTrack = true;
break;
}
if (!hasMatchingTrack) continue;
}
result.add(album);
}
switch (request.sortMode) {
case 'oldest':
result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned));
case 'artist-asc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
a.artistName,
b.artistName,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'artist-desc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
a.artistName,
b.artistName,
descending: true,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'a-z':
result.sort(
(a, b) =>
a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()),
);
case 'z-a':
result.sort(
(a, b) =>
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
);
case 'album-asc':
result.sort(
(a, b) => _queueCompareOptionalText(a.albumName, b.albumName),
);
case 'album-desc':
result.sort(
(a, b) => _queueCompareOptionalText(
a.albumName,
b.albumName,
descending: true,
),
);
case 'release-oldest':
result.sort((a, b) {
final comparison = _queueCompareOptionalDate(
_queueGroupedLocalAlbumReleaseDate(a),
_queueGroupedLocalAlbumReleaseDate(b),
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'release-newest':
result.sort((a, b) {
final comparison = _queueCompareOptionalDate(
_queueGroupedLocalAlbumReleaseDate(a),
_queueGroupedLocalAlbumReleaseDate(b),
descending: true,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'genre-asc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
_queueGroupedLocalAlbumGenre(a),
_queueGroupedLocalAlbumGenre(b),
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
case 'genre-desc':
result.sort((a, b) {
final comparison = _queueCompareOptionalText(
_queueGroupedLocalAlbumGenre(a),
_queueGroupedLocalAlbumGenre(b),
descending: true,
);
if (comparison != 0) {
return comparison;
}
return _queueCompareOptionalText(a.albumName, b.albumName);
});
default:
break;
}
return result;
}
final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) {
final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final localLibraryEnabled = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
);
final localItems = localLibraryEnabled
? ref
.watch(localLibraryAllItemsProvider)
.maybeWhen(
data: (items) => items,
orElse: () => const <LocalLibraryItem>[],
)
: const <LocalLibraryItem>[];
return _buildQueueHistoryStats(historyItems, localItems);
});
final _queueFilteredAlbumsProvider =
Provider.family<
({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}),
_QueueGroupedAlbumFilterRequest
>((ref, request) {
final historyStats = ref.watch(_queueHistoryStatsProvider);
return (
albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request),
localAlbums: _queueFilterGroupedLocalAlbums(
historyStats.groupedLocalAlbums,
request,
),
);
});
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
final entries = (payload['entries'] as List).cast<List<Object?>>();
final albumCounts = Map<String, int>.from(payload['albumCounts'] as Map);
+6 -5
View File
@@ -3,9 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
@@ -25,6 +23,7 @@ import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
part 'track_metadata_edit_sheet.dart';
@@ -46,6 +45,7 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
final List<DownloadHistoryItem>? historyNavigationItems;
final List<LocalLibraryItem>? localNavigationItems;
final int? navigationIndex;
final String? coverHeroTag;
const TrackMetadataScreen({
super.key,
@@ -54,6 +54,7 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
this.historyNavigationItems,
this.localNavigationItems,
this.navigationIndex,
this.coverHeroTag,
}) : assert(
item != null || localItem != null,
'Either item or localItem must be provided',
@@ -684,7 +685,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String get _coverHeroTag =>
_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId';
widget.coverHeroTag ??
(_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId');
String? get _coverUrl =>
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
String? get _localCoverPath =>
@@ -1133,11 +1135,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
: _coverUrl != null
? CachedNetworkImage(
? CachedCoverImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
+461 -22
View File
@@ -5,12 +5,65 @@ import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
String? _currentContainerPath;
class HistoryLookupRequest {
final String spotifyId;
final String? isrc;
final String trackName;
final String artistName;
const HistoryLookupRequest({
required this.spotifyId,
this.isrc,
required this.trackName,
required this.artistName,
});
String get lookupKey =>
'${spotifyId.trim()}|${HistoryDatabase.normalizeIsrc(isrc)}|'
'${HistoryDatabase.matchKeyFor(trackName, artistName)}';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HistoryLookupRequest &&
spotifyId == other.spotifyId &&
isrc == other.isrc &&
trackName == other.trackName &&
artistName == other.artistName;
@override
int get hashCode => Object.hash(spotifyId, isrc, trackName, artistName);
}
class HistoryBatchLookupRequest {
final List<HistoryLookupRequest> tracks;
const HistoryBatchLookupRequest(this.tracks);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! HistoryBatchLookupRequest ||
other.tracks.length != tracks.length) {
return false;
}
for (var i = 0; i < tracks.length; i++) {
if (tracks[i] != other.tracks[i]) return false;
}
return true;
}
@override
int get hashCode => Object.hashAll(tracks);
}
class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database;
@@ -31,7 +84,7 @@ class HistoryDatabase {
return await openDatabase(
path,
version: 5,
version: 8,
onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
@@ -74,7 +127,10 @@ class HistoryDatabase {
genre TEXT,
composer TEXT,
label TEXT,
copyright TEXT
copyright TEXT,
spotify_id_norm TEXT,
isrc_norm TEXT,
match_key TEXT
)
''');
@@ -86,6 +142,11 @@ class HistoryDatabase {
await db.execute(
'CREATE INDEX idx_album ON history(album_name, album_artist)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)',
);
await _createNormalizedIndexes(db);
await _createPathKeyTable(db);
_log.i('Database schema created with indexes');
}
@@ -126,6 +187,152 @@ class HistoryDatabase {
await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER');
}
}
if (oldVersion < 6) {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)',
);
}
if (oldVersion < 7) {
await _createPathKeyTable(db);
await _backfillPathKeys(db);
}
if (oldVersion < 8) {
await _addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT');
await _addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT');
await _addColumnIfMissing(db, 'history', 'match_key', 'TEXT');
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
}
}
static String normalizeLookupText(String? value) {
return (value ?? '').trim().toLowerCase();
}
static String normalizeIsrc(String? value) {
return (value ?? '').trim().toUpperCase().replaceAll(RegExp(r'[-\s]'), '');
}
static String normalizeSpotifyId(String? value) {
return (value ?? '').trim().toLowerCase();
}
static String matchKeyFor(String? trackName, String? artistName) {
final track = normalizeLookupText(trackName);
if (track.isEmpty) return '';
return '$track|${normalizeLookupText(artistName)}';
}
static List<String> spotifyLookupCandidates(String? rawId) {
final trimmed = rawId?.trim() ?? '';
if (trimmed.isEmpty) return const [];
final candidates = <String>{trimmed};
final lowered = trimmed.toLowerCase();
if (lowered.startsWith('spotify:track:')) {
final compact = trimmed.split(':').last.trim();
if (compact.isNotEmpty) candidates.add(compact);
} else if (!trimmed.contains(':')) {
candidates.add('spotify:track:$trimmed');
}
return candidates.toList(growable: false);
}
Future<void> _addColumnIfMissing(
Database db,
String table,
String column,
String type,
) async {
final columns = await db.rawQuery('PRAGMA table_info($table)');
final exists = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == column,
);
if (!exists) {
await db.execute('ALTER TABLE $table ADD COLUMN $column $type');
}
}
Future<void> _createNormalizedIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)',
);
}
Future<void> _backfillNormalizedColumns(Database db) async {
final rows = await db.query(
'history',
columns: ['id', 'spotify_id', 'isrc', 'track_name', 'artist_name'],
);
final batch = db.batch();
for (final row in rows) {
batch.update(
'history',
_normalizedColumns(
spotifyId: row['spotify_id'] as String?,
isrc: row['isrc'] as String?,
trackName: row['track_name'] as String?,
artistName: row['artist_name'] as String?,
),
where: 'id = ?',
whereArgs: [row['id']],
);
}
await batch.commit(noResult: true);
}
Map<String, dynamic> _normalizedColumns({
required String? spotifyId,
required String? isrc,
required String? trackName,
required String? artistName,
}) {
return {
'spotify_id_norm': normalizeSpotifyId(spotifyId),
'isrc_norm': normalizeIsrc(isrc),
'match_key': matchKeyFor(trackName, artistName),
};
}
Future<void> _createPathKeyTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS history_path_keys (
item_id TEXT NOT NULL,
path_key TEXT NOT NULL,
PRIMARY KEY (item_id, path_key)
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_path_keys_key ON history_path_keys(path_key)',
);
}
Future<void> _backfillPathKeys(Database db) async {
final rows = await db.query('history', columns: ['id', 'file_path']);
final batch = db.batch();
for (final row in rows) {
_putPathKeysInBatch(
batch,
row['id'] as String,
row['file_path'] as String?,
);
}
await batch.commit(noResult: true);
}
void _putPathKeysInBatch(Batch batch, String id, String? filePath) {
batch.delete('history_path_keys', where: 'item_id = ?', whereArgs: [id]);
for (final key in buildPathMatchKeys(filePath)) {
batch.insert('history_path_keys', {
'item_id': id,
'path_key': key,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
}
}
static final _iosContainerPattern = RegExp(
@@ -202,6 +409,7 @@ class HistoryDatabase {
where: 'id = ?',
whereArgs: [id],
);
_putPathKeysInBatch(batch, id, newPath);
updatedCount++;
}
}
@@ -253,6 +461,11 @@ class HistoryDatabase {
_jsonToDbRow(map),
conflictAlgorithm: ConflictAlgorithm.replace,
);
_putPathKeysInBatch(
batch,
map['id'] as String,
map['filePath'] as String?,
);
}
await batch.commit(noResult: true);
@@ -268,7 +481,7 @@ class HistoryDatabase {
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
final row = {
'id': json['id'],
'track_name': json['trackName'],
'artist_name': json['artistName'],
@@ -299,6 +512,15 @@ class HistoryDatabase {
'label': json['label'],
'copyright': json['copyright'],
};
row.addAll(
_normalizedColumns(
spotifyId: json['spotifyId'] as String?,
isrc: json['isrc'] as String?,
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
),
);
return row;
}
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
@@ -337,25 +559,41 @@ class HistoryDatabase {
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
'history',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
await db.transaction((txn) async {
await txn.insert(
'history',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
final batch = txn.batch();
_putPathKeysInBatch(
batch,
json['id'] as String,
json['filePath'] as String?,
);
await batch.commit(noResult: true);
});
}
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
if (items.isEmpty) return;
final db = await database;
final batch = db.batch();
for (final json in items) {
batch.insert(
'history',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
await db.transaction((txn) async {
final batch = txn.batch();
for (final json in items) {
batch.insert(
'history',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
_putPathKeysInBatch(
batch,
json['id'] as String,
json['filePath'] as String?,
);
}
await batch.commit(noResult: true);
});
}
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
@@ -369,6 +607,38 @@ class HistoryDatabase {
return rows.map(_dbRowToJson).toList();
}
Future<List<Map<String, dynamic>>> getAlbumTracks(
String albumName,
String artistName,
) async {
final db = await database;
final rows = await db.query(
'history',
where:
'LOWER(album_name) = ? AND LOWER(COALESCE(album_artist, artist_name)) = ?',
whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()],
orderBy:
'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name',
);
return rows.map(_dbRowToJson).toList(growable: false);
}
Future<Map<String, dynamic>?> findByTrackAndArtist(
String trackName,
String artistName,
) async {
final db = await database;
final rows = await db.query(
'history',
where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?',
whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()],
orderBy: 'downloaded_at DESC',
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
final rows = await db.query(
@@ -405,6 +675,117 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
Future<bool> existsTrack(HistoryLookupRequest request) async {
final row = await findExistingTrack(request, columns: ['id']);
return row != null;
}
Future<Map<String, dynamic>?> findExistingTrack(
HistoryLookupRequest request, {
List<String>? columns,
}) async {
final db = await database;
final spotifyCandidates = spotifyLookupCandidates(request.spotifyId);
if (spotifyCandidates.isNotEmpty) {
final placeholders = List.filled(spotifyCandidates.length, '?').join(',');
final normalized = spotifyCandidates.map(normalizeSpotifyId).toList();
final rows = await db.query(
'history',
columns: columns,
where:
'spotify_id IN ($placeholders) OR spotify_id_norm IN ($placeholders)',
whereArgs: [...spotifyCandidates, ...normalized],
orderBy: 'downloaded_at DESC',
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
}
final isrcNorm = normalizeIsrc(request.isrc);
if (isrcNorm.isNotEmpty) {
final rows = await db.query(
'history',
columns: columns,
where: 'isrc_norm = ?',
whereArgs: [isrcNorm],
orderBy: 'downloaded_at DESC',
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
}
final matchKey = matchKeyFor(request.trackName, request.artistName);
if (matchKey.isNotEmpty) {
final rows = await db.query(
'history',
columns: columns,
where: 'match_key = ?',
whereArgs: [matchKey],
orderBy: 'downloaded_at DESC',
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
}
return null;
}
Future<Set<String>> existingTrackKeys(
List<HistoryLookupRequest> requests,
) async {
if (requests.isEmpty) return const <String>{};
final db = await database;
final found = <String>{};
final rawSpotifyToKeys = <String, Set<String>>{};
final normSpotifyToKeys = <String, Set<String>>{};
final isrcToKeys = <String, Set<String>>{};
final matchToKeys = <String, Set<String>>{};
void add(Map<String, Set<String>> map, String value, String key) {
if (value.isEmpty) return;
map.putIfAbsent(value, () => <String>{}).add(key);
}
for (final request in requests) {
final key = request.lookupKey;
for (final candidate in spotifyLookupCandidates(request.spotifyId)) {
add(rawSpotifyToKeys, candidate, key);
add(normSpotifyToKeys, normalizeSpotifyId(candidate), key);
}
add(isrcToKeys, normalizeIsrc(request.isrc), key);
add(matchToKeys, matchKeyFor(request.trackName, request.artistName), key);
}
Future<void> queryColumn(
String column,
Map<String, Set<String>> keyMap,
) async {
final values = keyMap.keys.toList(growable: false);
const chunkSize = 450;
for (var i = 0; i < values.length; i += chunkSize) {
final end = (i + chunkSize < values.length)
? i + chunkSize
: values.length;
final chunk = values.sublist(i, end);
final placeholders = List.filled(chunk.length, '?').join(',');
final rows = await db.rawQuery(
'SELECT DISTINCT $column AS lookup_value FROM history WHERE $column IN ($placeholders)',
chunk,
);
for (final row in rows) {
final value = row['lookup_value'] as String?;
if (value == null) continue;
found.addAll(keyMap[value] ?? const <String>{});
}
}
}
await queryColumn('spotify_id', rawSpotifyToKeys);
await queryColumn('spotify_id_norm', normSpotifyToKeys);
await queryColumn('isrc_norm', isrcToKeys);
await queryColumn('match_key', matchToKeys);
return found;
}
Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database;
final result = await db.rawQuery(
@@ -424,17 +805,47 @@ class HistoryDatabase {
Future<void> deleteById(String id) async {
final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]);
await db.transaction((txn) async {
await txn.delete(
'history_path_keys',
where: 'item_id = ?',
whereArgs: [id],
);
await txn.delete('history', where: 'id = ?', whereArgs: [id]);
});
}
Future<void> deleteBySpotifyId(String spotifyId) async {
Future<int> deleteBySpotifyId(String spotifyId) async {
final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
final rows = await db.query(
'history',
columns: ['id'],
where: 'spotify_id = ?',
whereArgs: [spotifyId],
);
final ids = rows.map((row) => row['id'] as String).toList(growable: false);
return db.transaction<int>((txn) async {
for (final id in ids) {
await txn.delete(
'history_path_keys',
where: 'item_id = ?',
whereArgs: [id],
);
}
return txn.delete(
'history',
where: 'spotify_id = ?',
whereArgs: [spotifyId],
);
});
}
Future<void> clearAll() async {
final db = await database;
await db.delete('history');
await db.transaction((txn) async {
await txn.delete('history_path_keys');
await txn.delete('history');
});
_log.i('Cleared all history');
}
@@ -444,6 +855,25 @@ class HistoryDatabase {
return Sqflite.firstIntValue(result) ?? 0;
}
Future<Map<String, int>> getGroupedCounts() async {
final db = await database;
final rows = await db.rawQuery('''
SELECT
SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END) AS albums,
SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END) AS singles
FROM (
SELECT COUNT(*) AS track_count
FROM history
GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name))
)
''');
final row = rows.isEmpty ? const <String, Object?>{} : rows.first;
return {
'albums': (row['albums'] as num?)?.toInt() ?? 0,
'singles': (row['singles'] as num?)?.toInt() ?? 0,
};
}
Future<Map<String, dynamic>?> findExisting({
String? spotifyId,
String? isrc,
@@ -506,7 +936,12 @@ class HistoryDatabase {
values['sample_rate'] = newSampleRate;
}
}
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
await db.transaction((txn) async {
await txn.update('history', values, where: 'id = ?', whereArgs: [id]);
final batch = txn.batch();
_putPathKeysInBatch(batch, id, newFilePath);
await batch.commit(noResult: true);
});
}
Future<void> updateAudioMetadata(
@@ -583,6 +1018,10 @@ class HistoryDatabase {
final end = (i + chunkSize < ids.length) ? i + chunkSize : ids.length;
final chunk = ids.sublist(i, end);
final placeholders = List.filled(chunk.length, '?').join(',');
await db.rawDelete(
'DELETE FROM history_path_keys WHERE item_id IN ($placeholders)',
chunk,
);
totalDeleted += await db.rawDelete(
'DELETE FROM history WHERE id IN ($placeholders)',
chunk,
File diff suppressed because it is too large Load Diff