mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 20:57:57 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user