Files
SpotiFLAC-Mobile/lib/providers/library_collections_provider.dart
T
zarzet 64dbf4441c feat: add Favorite Artists collection
- Add CollectionArtistEntry model with toJson/fromJson and artistCollectionKey helper
- Create favorite_artists table in SQLite with DB migration v1→v2
- Implement toggleFavoriteArtist/removeFavoriteArtist in LibraryCollectionsNotifier
- Add FavoriteArtistsScreen with list view, empty state, and artist navigation
- Add heart toggle button on ArtistScreen header (reactive via Riverpod selector)
- Integrate favorite artists folder in queue_tab collection grid/list views
- Add 8 new localization strings across all 13 locale files
2026-05-02 00:50:02 +07:00

986 lines
30 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/library_collections_database.dart';
String trackCollectionKey(Track track) {
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
return 'isrc:${isrc.toUpperCase()}';
}
final source = (track.source?.trim().isNotEmpty ?? false)
? track.source!.trim()
: 'builtin';
return '$source:${track.id}';
}
String _stripCollectionResourcePrefix(String value) {
final colonIndex = value.indexOf(':');
if (colonIndex <= 0 || colonIndex == value.length - 1) {
return value.trim();
}
return value.substring(colonIndex + 1).trim();
}
String artistCollectionKey({
required String artistId,
required String? providerId,
}) {
final trimmedArtistId = artistId.trim();
final trimmedProviderId = providerId?.trim();
final source = trimmedProviderId != null && trimmedProviderId.isNotEmpty
? trimmedProviderId.toLowerCase()
: (trimmedArtistId.contains(':')
? trimmedArtistId.split(':').first.toLowerCase()
: 'builtin');
return '$source:${_stripCollectionResourcePrefix(trimmedArtistId)}';
}
class CollectionTrackEntry {
final String key;
final Track track;
final DateTime addedAt;
const CollectionTrackEntry({
required this.key,
required this.track,
required this.addedAt,
});
Map<String, dynamic> toJson() => {
'key': key,
'track': track.toJson(),
'addedAt': addedAt.toIso8601String(),
};
factory CollectionTrackEntry.fromJson(Map<String, dynamic> json) {
final addedAtRaw = json['addedAt'] as String?;
return CollectionTrackEntry(
key: json['key'] as String,
track: Track.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
);
}
}
class CollectionArtistEntry {
final String key;
final String artistId;
final String? providerId;
final String name;
final String? imageUrl;
final DateTime addedAt;
const CollectionArtistEntry({
required this.key,
required this.artistId,
required this.providerId,
required this.name,
this.imageUrl,
required this.addedAt,
});
Map<String, dynamic> toJson() => {
'key': key,
'artistId': artistId,
'providerId': providerId,
'name': name,
'imageUrl': imageUrl,
'addedAt': addedAt.toIso8601String(),
};
factory CollectionArtistEntry.fromJson(Map<String, dynamic> json) {
final artistId = json['artistId'] as String;
final providerId = json['providerId'] as String?;
final addedAtRaw = json['addedAt'] as String?;
return CollectionArtistEntry(
key:
json['key'] as String? ??
artistCollectionKey(artistId: artistId, providerId: providerId),
artistId: artistId,
providerId: providerId,
name: json['name'] as String? ?? '',
imageUrl: json['imageUrl'] as String?,
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
);
}
}
class UserPlaylistCollection {
final String id;
final String name;
final String? coverImagePath;
final DateTime createdAt;
final DateTime updatedAt;
final List<CollectionTrackEntry> tracks;
final Set<String> _trackKeys;
UserPlaylistCollection({
required this.id,
required this.name,
this.coverImagePath,
required this.createdAt,
required this.updatedAt,
required this.tracks,
Set<String>? trackKeys,
}) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet();
UserPlaylistCollection copyWith({
String? id,
String? name,
String? Function()? coverImagePath,
DateTime? createdAt,
DateTime? updatedAt,
List<CollectionTrackEntry>? tracks,
}) {
final nextTracks = tracks ?? this.tracks;
final keepTrackIndex = identical(nextTracks, this.tracks);
return UserPlaylistCollection(
id: id ?? this.id,
name: name ?? this.name,
coverImagePath: coverImagePath != null
? coverImagePath()
: this.coverImagePath,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
tracks: nextTracks,
trackKeys: keepTrackIndex ? _trackKeys : null,
);
}
bool containsTrack(Track track) {
final key = trackCollectionKey(track);
return _trackKeys.contains(key);
}
bool containsTrackKey(String trackKey) {
return _trackKeys.contains(trackKey);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (coverImagePath != null) 'coverImagePath': coverImagePath,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'tracks': tracks.map((e) => e.toJson()).toList(),
};
factory UserPlaylistCollection.fromJson(Map<String, dynamic> json) {
final createdAtRaw = json['createdAt'] as String?;
final updatedAtRaw = json['updatedAt'] as String?;
final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
final tracksRaw = (json['tracks'] as List?) ?? const [];
return UserPlaylistCollection(
id: json['id'] as String,
name: json['name'] as String? ?? '',
coverImagePath: json['coverImagePath'] as String?,
createdAt: createdAt,
updatedAt: updatedAt,
tracks: tracksRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
);
}
}
class PlaylistPickerSummary {
final String id;
final String name;
final String? coverImagePath;
final String? previewCover;
final DateTime createdAt;
final DateTime updatedAt;
final int trackCount;
final bool containsAllRequestedTracks;
const PlaylistPickerSummary({
required this.id,
required this.name,
this.coverImagePath,
this.previewCover,
required this.createdAt,
required this.updatedAt,
required this.trackCount,
required this.containsAllRequestedTracks,
});
}
class PlaylistPickerSummaryRequest {
final List<String> trackKeys;
PlaylistPickerSummaryRequest._(this.trackKeys);
factory PlaylistPickerSummaryRequest.fromTracks(Iterable<Track> tracks) {
final keys =
tracks
.map(trackCollectionKey)
.where((key) => key.trim().isNotEmpty)
.toSet()
.toList(growable: false)
..sort();
return PlaylistPickerSummaryRequest._(keys);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PlaylistPickerSummaryRequest &&
listEquals(trackKeys, other.trackKeys);
@override
int get hashCode => Object.hashAll(trackKeys);
}
class LibraryCollectionsState {
final List<CollectionTrackEntry> wishlist;
final List<CollectionTrackEntry> loved;
final List<UserPlaylistCollection> playlists;
final List<CollectionArtistEntry> favoriteArtists;
final bool isLoaded;
final Set<String> _wishlistKeys;
final Set<String> _lovedKeys;
final Set<String> _favoriteArtistKeys;
final Map<String, UserPlaylistCollection> _playlistsById;
final Set<String> _allPlaylistTrackKeys;
LibraryCollectionsState({
this.wishlist = const [],
this.loved = const [],
this.playlists = const [],
this.favoriteArtists = const [],
this.isLoaded = false,
Set<String>? wishlistKeys,
Set<String>? lovedKeys,
Set<String>? favoriteArtistKeys,
Map<String, UserPlaylistCollection>? playlistsById,
Set<String>? allPlaylistTrackKeys,
}) : _wishlistKeys =
wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(),
_lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(),
_favoriteArtistKeys =
favoriteArtistKeys ??
favoriteArtists.map((entry) => entry.key).toSet(),
_playlistsById =
playlistsById ??
Map.fromEntries(
playlists.map((playlist) => MapEntry(playlist.id, playlist)),
),
_allPlaylistTrackKeys =
allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists);
int get wishlistCount => wishlist.length;
int get lovedCount => loved.length;
int get playlistCount => playlists.length;
int get favoriteArtistCount => favoriteArtists.length;
bool isInWishlist(Track track) {
final key = trackCollectionKey(track);
return _wishlistKeys.contains(key);
}
bool isLoved(Track track) {
final key = trackCollectionKey(track);
return _lovedKeys.contains(key);
}
bool containsWishlistKey(String trackKey) {
return _wishlistKeys.contains(trackKey);
}
bool containsLovedKey(String trackKey) {
return _lovedKeys.contains(trackKey);
}
bool isFavoriteArtist({
required String artistId,
required String? providerId,
}) {
final key = artistCollectionKey(artistId: artistId, providerId: providerId);
return _favoriteArtistKeys.contains(key);
}
bool containsFavoriteArtistKey(String artistKey) {
return _favoriteArtistKeys.contains(artistKey);
}
UserPlaylistCollection? playlistById(String playlistId) {
return _playlistsById[playlistId];
}
bool playlistContainsTrack(String playlistId, String trackKey) {
final playlist = _playlistsById[playlistId];
if (playlist == null) return false;
return playlist.containsTrackKey(trackKey);
}
bool isTrackInAnyPlaylist(String trackKey) {
return _allPlaylistTrackKeys.contains(trackKey);
}
bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty;
LibraryCollectionsState copyWith({
List<CollectionTrackEntry>? wishlist,
List<CollectionTrackEntry>? loved,
List<UserPlaylistCollection>? playlists,
List<CollectionArtistEntry>? favoriteArtists,
bool? isLoaded,
}) {
final nextWishlist = wishlist ?? this.wishlist;
final nextLoved = loved ?? this.loved;
final nextPlaylists = playlists ?? this.playlists;
final nextFavoriteArtists = favoriteArtists ?? this.favoriteArtists;
final keepWishlistIndex = identical(nextWishlist, this.wishlist);
final keepLovedIndex = identical(nextLoved, this.loved);
final keepPlaylistIndex = identical(nextPlaylists, this.playlists);
final keepFavoriteArtistIndex = identical(
nextFavoriteArtists,
this.favoriteArtists,
);
return LibraryCollectionsState(
wishlist: nextWishlist,
loved: nextLoved,
playlists: nextPlaylists,
favoriteArtists: nextFavoriteArtists,
isLoaded: isLoaded ?? this.isLoaded,
wishlistKeys: keepWishlistIndex ? _wishlistKeys : null,
lovedKeys: keepLovedIndex ? _lovedKeys : null,
favoriteArtistKeys: keepFavoriteArtistIndex ? _favoriteArtistKeys : null,
playlistsById: keepPlaylistIndex ? _playlistsById : null,
allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null,
);
}
Map<String, dynamic> toJson() => {
'wishlist': wishlist.map((e) => e.toJson()).toList(),
'loved': loved.map((e) => e.toJson()).toList(),
'playlists': playlists.map((e) => e.toJson()).toList(),
'favoriteArtists': favoriteArtists.map((e) => e.toJson()).toList(),
};
factory LibraryCollectionsState.fromJson(Map<String, dynamic> json) {
final wishlistRaw = (json['wishlist'] as List?) ?? const [];
final lovedRaw = (json['loved'] as List?) ?? const [];
final playlistsRaw = (json['playlists'] as List?) ?? const [];
final favoriteArtistsRaw = (json['favoriteArtists'] as List?) ?? const [];
return LibraryCollectionsState(
wishlist: wishlistRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
loved: lovedRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
playlists: playlistsRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
favoriteArtists: favoriteArtistsRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionArtistEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
isLoaded: true,
);
}
}
Set<String> _buildPlaylistTrackKeys(List<UserPlaylistCollection> playlists) {
final keys = <String>{};
for (final playlist in playlists) {
for (final entry in playlist.tracks) {
keys.add(entry.key);
}
}
return keys;
}
class PlaylistAddBatchResult {
final int addedCount;
final int alreadyInPlaylistCount;
const PlaylistAddBatchResult({
required this.addedCount,
required this.alreadyInPlaylistCount,
});
}
class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
Future<void>? _loadFuture;
void _invalidatePlaylistPickerSummaries() {
ref.invalidate(libraryPlaylistPickerSummariesProvider);
}
@override
LibraryCollectionsState build() {
_loadFuture = _load();
return LibraryCollectionsState();
}
Future<void> _load() async {
try {
await _db.migrateFromSharedPreferences();
final snapshot = await _db.loadSnapshot();
final wishlist = <CollectionTrackEntry>[];
for (final row in snapshot.wishlistRows) {
final parsed = _parseTrackEntryRow(row);
if (parsed != null) {
wishlist.add(parsed);
}
}
final loved = <CollectionTrackEntry>[];
for (final row in snapshot.lovedRows) {
final parsed = _parseTrackEntryRow(row);
if (parsed != null) {
loved.add(parsed);
}
}
final favoriteArtists = <CollectionArtistEntry>[];
for (final row in snapshot.favoriteArtistRows) {
final parsed = _parseArtistEntryRow(row);
if (parsed != null) {
favoriteArtists.add(parsed);
}
}
final tracksByPlaylist = <String, List<CollectionTrackEntry>>{};
for (final row in snapshot.playlistTrackRows) {
final playlistId = row['playlist_id'] as String?;
if (playlistId == null || playlistId.isEmpty) continue;
final parsed = _parseTrackEntryRow(row);
if (parsed == null) continue;
tracksByPlaylist.putIfAbsent(playlistId, () => []).add(parsed);
}
final playlists = <UserPlaylistCollection>[];
for (final row in snapshot.playlistRows) {
final id = row['id'] as String?;
if (id == null || id.isEmpty) continue;
final createdAtRaw = row['created_at'] as String?;
final updatedAtRaw = row['updated_at'] as String?;
final createdAt =
DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
playlists.add(
UserPlaylistCollection(
id: id,
name: row['name'] as String? ?? '',
coverImagePath: row['cover_image_path'] as String?,
createdAt: createdAt,
updatedAt: updatedAt,
tracks: tracksByPlaylist[id] ?? const <CollectionTrackEntry>[],
),
);
}
state = LibraryCollectionsState(
wishlist: wishlist,
loved: loved,
playlists: playlists,
favoriteArtists: favoriteArtists,
isLoaded: true,
);
} catch (_) {
state = state.copyWith(isLoaded: true);
}
}
Future<void> _ensureLoaded() async {
if (state.isLoaded) return;
await (_loadFuture ?? _load());
}
CollectionTrackEntry? _parseTrackEntryRow(Map<String, dynamic> row) {
final key = row['track_key'] as String?;
final trackJson = row['track_json'] as String?;
if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(trackJson);
if (decoded is! Map) return null;
final track = Track.fromJson(Map<String, dynamic>.from(decoded));
final addedAtRaw = row['added_at'] as String?;
return CollectionTrackEntry(
key: key,
track: track,
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
);
} catch (_) {
return null;
}
}
CollectionArtistEntry? _parseArtistEntryRow(Map<String, dynamic> row) {
final key = row['artist_key'] as String?;
final artistJson = row['artist_json'] as String?;
if (key == null ||
key.isEmpty ||
artistJson == null ||
artistJson.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(artistJson);
if (decoded is! Map) return null;
final map = Map<String, dynamic>.from(decoded);
final addedAtRaw = row['added_at'] as String?;
return CollectionArtistEntry.fromJson({
...map,
'key': key,
'addedAt': map['addedAt'] ?? addedAtRaw,
});
} catch (_) {
return null;
}
}
bool _replacePlaylistById(
String playlistId,
UserPlaylistCollection Function(UserPlaylistCollection playlist) update,
) {
final playlist = state.playlistById(playlistId);
if (playlist == null) return false;
final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId);
if (playlistIndex < 0) return false;
final nextPlaylist = update(playlist);
if (identical(nextPlaylist, playlist)) return false;
final updatedPlaylists = [...state.playlists];
updatedPlaylists[playlistIndex] = nextPlaylist;
state = state.copyWith(playlists: updatedPlaylists);
return true;
}
Future<bool> toggleWishlist(Track track) async {
await _ensureLoaded();
final key = trackCollectionKey(track);
if (state.containsWishlistKey(key)) {
await _db.deleteWishlistEntry(key);
final updated = state.wishlist
.where((entry) => entry.key != key)
.toList(growable: false);
state = state.copyWith(wishlist: updated);
return false;
}
final entry = CollectionTrackEntry(
key: key,
track: track,
addedAt: DateTime.now(),
);
await _db.upsertWishlistEntry(
trackKey: key,
trackJson: jsonEncode(track.toJson()),
addedAt: entry.addedAt.toIso8601String(),
);
final updated = [entry, ...state.wishlist];
state = state.copyWith(wishlist: updated);
return true;
}
Future<bool> toggleLoved(Track track) async {
await _ensureLoaded();
final key = trackCollectionKey(track);
if (state.containsLovedKey(key)) {
await _db.deleteLovedEntry(key);
final updated = state.loved
.where((entry) => entry.key != key)
.toList(growable: false);
state = state.copyWith(loved: updated);
return false;
}
final entry = CollectionTrackEntry(
key: key,
track: track,
addedAt: DateTime.now(),
);
await _db.upsertLovedEntry(
trackKey: key,
trackJson: jsonEncode(track.toJson()),
addedAt: entry.addedAt.toIso8601String(),
);
final updated = [entry, ...state.loved];
state = state.copyWith(loved: updated);
return true;
}
Future<bool> toggleFavoriteArtist({
required String artistId,
required String? providerId,
required String name,
String? imageUrl,
}) async {
await _ensureLoaded();
final key = artistCollectionKey(artistId: artistId, providerId: providerId);
final sourceSeparator = key.indexOf(':');
final source = sourceSeparator > 0 ? key.substring(0, sourceSeparator) : '';
final trimmedProviderId = providerId?.trim();
final effectiveProviderId =
trimmedProviderId != null && trimmedProviderId.isNotEmpty
? trimmedProviderId
: (source.isNotEmpty && source != 'builtin' ? source : null);
if (state.containsFavoriteArtistKey(key)) {
await _db.deleteFavoriteArtistEntry(key);
final updated = state.favoriteArtists
.where((entry) => entry.key != key)
.toList(growable: false);
state = state.copyWith(favoriteArtists: updated);
return false;
}
final entry = CollectionArtistEntry(
key: key,
artistId: _stripCollectionResourcePrefix(artistId),
providerId: effectiveProviderId,
name: name,
imageUrl: imageUrl,
addedAt: DateTime.now(),
);
await _db.upsertFavoriteArtistEntry(
artistKey: key,
artistJson: jsonEncode(entry.toJson()),
addedAt: entry.addedAt.toIso8601String(),
);
final updated = [entry, ...state.favoriteArtists];
state = state.copyWith(favoriteArtists: updated);
return true;
}
Future<void> removeFavoriteArtist(String artistKey) async {
await _ensureLoaded();
if (!state.containsFavoriteArtistKey(artistKey)) return;
await _db.deleteFavoriteArtistEntry(artistKey);
final updated = state.favoriteArtists
.where((entry) => entry.key != artistKey)
.toList(growable: false);
state = state.copyWith(favoriteArtists: updated);
}
Future<void> removeFromWishlist(String trackKey) async {
await _ensureLoaded();
if (!state.containsWishlistKey(trackKey)) return;
await _db.deleteWishlistEntry(trackKey);
final updated = state.wishlist
.where((entry) => entry.key != trackKey)
.toList(growable: false);
state = state.copyWith(wishlist: updated);
}
Future<void> removeFromLoved(String trackKey) async {
await _ensureLoaded();
if (!state.containsLovedKey(trackKey)) return;
await _db.deleteLovedEntry(trackKey);
final updated = state.loved
.where((entry) => entry.key != trackKey)
.toList(growable: false);
state = state.copyWith(loved: updated);
}
Future<String> createPlaylist(String name) async {
await _ensureLoaded();
final now = DateTime.now();
final id = 'pl_${now.microsecondsSinceEpoch}';
final trimmedName = name.trim();
final playlist = UserPlaylistCollection(
id: id,
name: trimmedName,
createdAt: now,
updatedAt: now,
tracks: const [],
);
await _db.upsertPlaylist(
id: id,
name: trimmedName,
coverImagePath: null,
createdAt: now.toIso8601String(),
updatedAt: now.toIso8601String(),
);
state = state.copyWith(playlists: [playlist, ...state.playlists]);
_invalidatePlaylistPickerSummaries();
return id;
}
Future<void> renamePlaylist(String playlistId, String newName) async {
await _ensureLoaded();
final trimmed = newName.trim();
if (trimmed.isEmpty) return;
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.name == trimmed) return;
final now = DateTime.now();
await _db.renamePlaylist(
playlistId: playlistId,
name: trimmed,
updatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
return playlist.copyWith(name: trimmed, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
Future<void> deletePlaylist(String playlistId) async {
await _ensureLoaded();
final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId);
if (playlistIndex < 0) return;
await _db.deletePlaylist(playlistId);
final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex);
state = state.copyWith(playlists: updatedPlaylists);
_invalidatePlaylistPickerSummaries();
}
Future<bool> addTrackToPlaylist(String playlistId, Track track) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null) return false;
final key = trackCollectionKey(track);
if (playlist.containsTrackKey(key)) return false;
final now = DateTime.now();
final entry = CollectionTrackEntry(key: key, track: track, addedAt: now);
await _db.upsertPlaylistTrack(
playlistId: playlistId,
trackKey: key,
trackJson: jsonEncode(track.toJson()),
addedAt: entry.addedAt.toIso8601String(),
playlistUpdatedAt: now.toIso8601String(),
);
final changed = _replacePlaylistById(playlistId, (playlist) {
if (playlist.containsTrackKey(key)) return playlist;
return playlist.copyWith(
tracks: [entry, ...playlist.tracks],
updatedAt: now,
);
});
if (!changed) return false;
_invalidatePlaylistPickerSummaries();
return true;
}
Future<PlaylistAddBatchResult> addTracksToPlaylist(
String playlistId,
Iterable<Track> tracks,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null) {
return const PlaylistAddBatchResult(
addedCount: 0,
alreadyInPlaylistCount: 0,
);
}
final now = DateTime.now();
final knownKeys = <String>{...playlist._trackKeys};
final entriesToAdd = <CollectionTrackEntry>[];
var alreadyInPlaylistCount = 0;
for (final track in tracks) {
final key = trackCollectionKey(track);
if (!knownKeys.add(key)) {
alreadyInPlaylistCount++;
continue;
}
entriesToAdd.add(
CollectionTrackEntry(key: key, track: track, addedAt: now),
);
}
if (entriesToAdd.isEmpty) {
return PlaylistAddBatchResult(
addedCount: 0,
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
await _db.upsertPlaylistTracksBatch(
playlistId: playlistId,
playlistUpdatedAt: now.toIso8601String(),
tracks: entriesToAdd
.map(
(entry) => <String, String>{
'track_key': entry.key,
'track_json': jsonEncode(entry.track.toJson()),
'added_at': entry.addedAt.toIso8601String(),
},
)
.toList(growable: false),
);
final changed = _replacePlaylistById(playlistId, (current) {
return current.copyWith(
tracks: [...entriesToAdd.reversed, ...current.tracks],
updatedAt: now,
);
});
if (!changed) {
return PlaylistAddBatchResult(
addedCount: 0,
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
_invalidatePlaylistPickerSummaries();
return PlaylistAddBatchResult(
addedCount: entriesToAdd.length,
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
Future<void> removeTrackFromPlaylist(
String playlistId,
String trackKey,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null || !playlist.containsTrackKey(trackKey)) return;
final now = DateTime.now();
await _db.deletePlaylistTrack(
playlistId: playlistId,
trackKey: trackKey,
playlistUpdatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
final nextTracks = playlist.tracks
.where((entry) => entry.key != trackKey)
.toList(growable: false);
if (nextTracks.length == playlist.tracks.length) return playlist;
return playlist.copyWith(tracks: nextTracks, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
Future<Directory> _playlistCoversDir() async {
final appDir = await getApplicationSupportDirectory();
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
Future<void> setPlaylistCover(
String playlistId,
String sourceFilePath,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null) return;
final coversDir = await _playlistCoversDir();
final ext = p.extension(sourceFilePath).toLowerCase();
final destPath = p.join(coversDir.path, '$playlistId$ext');
if (playlist.coverImagePath == destPath) return;
await File(sourceFilePath).copy(destPath);
final now = DateTime.now();
await _db.updatePlaylistCover(
playlistId: playlistId,
coverImagePath: destPath,
updatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
if (playlist.coverImagePath == destPath) return playlist;
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
Future<void> removePlaylistCover(String playlistId) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.coverImagePath == null) return;
final path = playlist.coverImagePath;
if (path != null) {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
final now = DateTime.now();
await _db.updatePlaylistCover(
playlistId: playlistId,
coverImagePath: null,
updatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
if (playlist.coverImagePath == null) return playlist;
return playlist.copyWith(coverImagePath: () => null, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
}
final libraryCollectionsProvider =
NotifierProvider<LibraryCollectionsNotifier, LibraryCollectionsState>(
LibraryCollectionsNotifier.new,
);
final libraryPlaylistPickerSummariesProvider =
FutureProvider.family<
List<PlaylistPickerSummary>,
PlaylistPickerSummaryRequest
>((ref, request) async {
final db = LibraryCollectionsDatabase.instance;
await db.migrateFromSharedPreferences();
final rows = await db.loadPlaylistPickerSummaries(request.trackKeys);
return rows
.map(
(row) => PlaylistPickerSummary(
id: row.id,
name: row.name,
coverImagePath: row.coverImagePath,
previewCover: row.previewCover,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
trackCount: row.trackCount,
containsAllRequestedTracks: row.containsAllRequestedTracks,
),
)
.toList(growable: false);
});