Files
SpotiFLAC-Mobile/lib/models/library_collections.dart
T

433 lines
13 KiB
Dart

import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:spotiflac_android/models/track.dart';
/// Shared identity for catalog, downloaded, and local playlist entries.
String libraryTrackCollectionKey({
required String id,
String? source,
String? isrc,
}) {
final trimmedIsrc = isrc?.trim();
if (trimmedIsrc != null && trimmedIsrc.isNotEmpty) {
return 'isrc:${trimmedIsrc.toUpperCase()}';
}
final trimmedSource = source?.trim();
return '${trimmedSource?.isNotEmpty == true ? trimmedSource : 'builtin'}:$id';
}
String trackCollectionKey(Track track) => libraryTrackCollectionKey(
id: track.id,
source: track.source,
isrc: track.isrc,
);
String collectionArtistId(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:${collectionArtistId(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 String? previewCover;
final bool tracksLoaded;
final Set<String> _trackKeys;
UserPlaylistCollection({
required this.id,
required this.name,
this.coverImagePath,
required this.createdAt,
required this.updatedAt,
required this.tracks,
this.previewCover,
this.tracksLoaded = true,
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,
String? previewCover,
bool? tracksLoaded,
}) {
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,
previewCover: previewCover ?? this.previewCover,
tracksLoaded:
tracksLoaded ??
(identical(nextTracks, this.tracks) ? this.tracksLoaded : true),
trackKeys: keepTrackIndex ? _trackKeys : null,
);
}
bool containsTrackKey(String trackKey) {
return _trackKeys.contains(trackKey);
}
Set<String> get trackKeys => UnmodifiableSetView(_trackKeys);
int get trackCount => _trackKeys.length;
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),
);
}
}
/// A bounded database projection shared directly with playlist picker UI.
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._(List.unmodifiable(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 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) {
keys.addAll(playlist._trackKeys);
}
return keys;
}
class PlaylistAddBatchResult {
final int addedCount;
final int alreadyInPlaylistCount;
const PlaylistAddBatchResult({
required this.addedCount,
required this.alreadyInPlaylistCount,
});
}