mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
433 lines
13 KiB
Dart
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,
|
|
});
|
|
}
|