import 'dart:async'; import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; class ProxyPlaylistNotifier extends PersistedStateNotifier with NextFetcher { final Ref ref; late final AudioServices notificationService; ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); UserPreferences get preferences => ref.read(userPreferencesProvider); ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(BlackListNotifier.provider.notifier); Discord get discord => ref.read(discordProvider); static final provider = StateNotifierProvider( (ref) => ProxyPlaylistNotifier(ref), ); static AlwaysAliveRefreshable get notifier => provider.notifier; List _subscriptions = []; ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { AudioServices.create(ref, this).then( (value) => notificationService = value, ); _subscriptions = [ // These are subscription methods from player_listeners.dart subscribeToSourceChanges(), subscribeToPercentCompletion(), subscribeToShuffleChanges(), subscribeToSkipSponsor(), subscribeToScrobbleChanged(), ]; } Future ensureSourcePlayable(String source) async { if (isPlayable(source)) return null; final track = mapSourcesToTracks([source]).firstOrNull; if (track == null || track is LocalTrack) { return null; } final nthFetchedTrack = switch (track.runtimeType) { SourcedTrack() => track as SourcedTrack, _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), }; await audioPlayer.replaceSource( source, nthFetchedTrack.url, ); return nthFetchedTrack; } // Basic methods for adding or removing tracks to playlist Future addTrack(Track track) async { if (blacklist.contains(track)) return; state = state.copyWith(tracks: {...state.tracks, track}); await audioPlayer.addTrack(makeAppropriateSource(track)); } Future addTracks(Iterable tracks) async { tracks = blacklist.filter(tracks).toList() as List; state = state.copyWith(tracks: {...state.tracks, ...tracks}); for (final track in tracks) { await audioPlayer.addTrack(makeAppropriateSource(track)); } } void addCollection(String collectionId) { state = state.copyWith(collections: { ...state.collections, collectionId, }); } void removeCollection(String collectionId) { state = state.copyWith(collections: { ...state.collections..remove(collectionId), }); } Future removeTrack(String trackId) async { final track = state.tracks.firstWhereOrNull((element) => element.id == trackId); if (track == null) return; state = state.copyWith(tracks: {...state.tracks..remove(track)}); final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); if (index == -1) return; await audioPlayer.removeTrack(index); } Future removeTracks(Iterable tracksIds) async { final tracks = state.tracks.where((element) => tracksIds.contains(element.id)); state = state.copyWith(tracks: { ...state.tracks..removeWhere((element) => tracksIds.contains(element.id)) }); for (final track in tracks) { final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); if (index == -1) continue; await audioPlayer.removeTrack(index); } } Future load( Iterable tracks, { int initialIndex = 0, bool autoPlay = false, }) async { tracks = blacklist.filter(tracks).toList() as List; final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first; if (indexTrack is LocalTrack) { state = state.copyWith( tracks: tracks.toSet(), active: initialIndex, collections: {}, ); await notificationService.addTrack(indexTrack); discord.updatePresence(indexTrack); } else { final addableTrack = await SourcedTrack.fetchFromTrack( ref: ref, track: tracks.elementAtOrNull(initialIndex) ?? tracks.first, ).catchError((e, stackTrace) { return SourcedTrack.fetchFromTrack( ref: ref, track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, ); }); state = state.copyWith( tracks: mergeTracks([addableTrack], tracks), active: initialIndex, collections: {}, ); await notificationService.addTrack(addableTrack); discord.updatePresence(addableTrack); } await audioPlayer.openPlaylist( state.tracks.map(makeAppropriateSource).toList(), initialIndex: initialIndex, autoPlay: autoPlay, ); } Future jumpTo(int index) async { final oldTrack = mapSourcesToTracks([audioPlayer.currentSource!]).firstOrNull; state = state.copyWith(active: index); await audioPlayer.pause(); final track = await ensureSourcePlayable(audioPlayer.sources[index]); if (track != null) { state = state.copyWith( tracks: mergeTracks([track], state.tracks), active: index, ); } await audioPlayer.jumpTo(index); if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); discord.updatePresence(track ?? oldTrack!); } } Future jumpToTrack(Track track) async { final index = state.tracks.toList().indexWhere((element) => element.id == track.id); if (index == -1) return; await jumpTo(index); } // TODO: add safe guards for active/playing track that needs to be moved Future moveTrack(int oldIndex, int newIndex) async { if (oldIndex == newIndex || newIndex < 0 || oldIndex < 0 || newIndex > state.tracks.length - 1 || oldIndex > state.tracks.length - 1) return; final tracks = state.tracks.toList(); final track = tracks.removeAt(oldIndex); tracks.insert(newIndex, track); state = state.copyWith(tracks: {...tracks}); await audioPlayer.moveTrack(oldIndex, newIndex); } Future addTracksAtFirst(Iterable tracks) async { if (state.tracks.length == 1) { return addTracks(tracks); } tracks = blacklist.filter(tracks).toList() as List; final destIndex = state.active != null ? state.active! + 1 : 0; final newTracks = state.tracks.toList()..insertAll(destIndex, tracks); state = state.copyWith(tracks: newTracks.toSet()); tracks.forEachIndexed((index, track) async { audioPlayer.addTrackAt( makeAppropriateSource(track), destIndex + index, ); }); } Future populateSibling() async { if (state.activeTrack is SourcedTrack) { final activeTrackWithSiblingsForSure = await (state.activeTrack as SourcedTrack).copyWithSibling(); state = state.copyWith( tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), active: state.tracks.toList().indexWhere( (element) => element.id == activeTrackWithSiblingsForSure.id), ); } } Future swapSibling(SourceInfo sibling) async { if (state.activeTrack is SourcedTrack) { await populateSibling(); final newTrack = await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); if (newTrack == null) return; state = state.copyWith( tracks: mergeTracks([newTrack], state.tracks), active: state.tracks .toList() .indexWhere((element) => element.id == newTrack.id), ); await audioPlayer.pause(); await audioPlayer.replaceSource( audioPlayer.currentSource!, makeAppropriateSource(newTrack), ); } } Future next() async { if (audioPlayer.nextSource == null) return; final oldTrack = mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; state = state.copyWith( active: state.tracks .toList() .indexWhere((element) => element.id == oldTrack?.id), ); await audioPlayer.pause(); final track = await ensureSourcePlayable(audioPlayer.nextSource!); if (track != null) { state = state.copyWith( tracks: mergeTracks([track], state.tracks), active: state.tracks .toList() .indexWhere((element) => element.id == track.id), ); } await audioPlayer.skipToNext(); if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); discord.updatePresence(track ?? oldTrack!); } } Future previous() async { if (audioPlayer.previousSource == null) return; final oldTrack = mapSourcesToTracks([audioPlayer.previousSource!]).firstOrNull; state = state.copyWith( active: state.tracks .toList() .indexWhere((element) => element.id == oldTrack?.id), ); await audioPlayer.pause(); final track = await ensureSourcePlayable(audioPlayer.previousSource!); if (track != null) { state = state.copyWith( tracks: mergeTracks([track], state.tracks), active: state.tracks .toList() .indexWhere((element) => element.id == track.id), ); } await audioPlayer.skipToPrevious(); if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); discord.updatePresence(track ?? oldTrack!); } } Future stop() async { state = ProxyPlaylist({}); await audioPlayer.stop(); discord.clear(); } Future updatePalette() async { final palette = ref.read(paletteProvider); if (!preferences.albumColorSync) { if (palette != null) ref.read(paletteProvider.notifier).state = null; return; } return Future.microtask(() async { if (state.activeTrack == null) return; final palette = await PaletteGenerator.fromImageProvider( UniversalImage.imageProvider( (state.activeTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 50, width: 50, ), ); ref.read(paletteProvider.notifier).state = palette; }); } @override set state(state) { super.state = state; if (state.tracks.isEmpty && ref.read(paletteProvider) != null) { ref.read(paletteProvider.notifier).state = null; } else { updatePalette(); } } @override onInit() async { if (state.tracks.isEmpty) return null; final oldCollections = state.collections; await load( state.tracks, initialIndex: max(state.active ?? 0, 0), autoPlay: false, ); state = state.copyWith(collections: oldCollections); } @override FutureOr fromJson(Map json) { return ProxyPlaylist.fromJson(json, ref); } @override Map toJson() { final json = state.toJson(); return json; } @override void dispose() { for (final subscription in _subscriptions) { subscription.cancel(); } super.dispose(); } }