import 'dart:math'; import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/extensions/list.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/playback/track_sources.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/provider/server/track_sources.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/logger/logger.dart'; class AudioPlayerNotifier extends Notifier { BlackListNotifier get _blacklist => ref.read(blacklistProvider.notifier); void _assertAllowedTracks(Iterable tracks) { assert( tracks.every( (track) => track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject, ), 'All tracks must be either SpotubeFullTrackObject or SpotubeLocalTrackObject', ); } void _assertAllowedTrack(SpotubeTrackObject tracks) { assert( tracks is SpotubeFullTrackObject || tracks is SpotubeLocalTrackObject, 'Track must be either SpotubeFullTrackObject or SpotubeLocalTrackObject', ); } Future _syncSavedState() async { final database = ref.read(databaseProvider); var playerState = await database.select(database.audioPlayerStateTable).getSingleOrNull(); if (playerState == null) { await database.into(database.audioPlayerStateTable).insert( AudioPlayerStateTableCompanion.insert( playing: audioPlayer.isPlaying, loopMode: audioPlayer.loopMode, shuffled: audioPlayer.isShuffled, collections: [], tracks: const Value([]), currentIndex: const Value(0), id: const Value(0), ), ); playerState = await database.select(database.audioPlayerStateTable).getSingle(); } else { await audioPlayer.setLoopMode(playerState.loopMode); await audioPlayer.setShuffle(playerState.shuffled); } final tracks = playerState.tracks; final currentIndex = playerState.currentIndex; if (tracks.isEmpty && state.tracks.isNotEmpty) { await _updatePlayerState( AudioPlayerStateTableCompanion( tracks: Value(state.tracks), currentIndex: Value(currentIndex), ), ); } else if (tracks.isNotEmpty) { await audioPlayer.openPlaylist( tracks.asMediaList(), initialIndex: currentIndex, autoPlay: false, ); } if (playerState.collections.isNotEmpty) { state = state.copyWith( collections: playerState.collections, ); } } Future _updatePlayerState( AudioPlayerStateTableCompanion companion, ) async { final database = ref.read(databaseProvider); await (database.update(database.audioPlayerStateTable) ..where((tb) => tb.id.equals(0))) .write(companion); } @override build() { final subscriptions = [ audioPlayer.playingStream.listen((playing) async { try { state = state.copyWith(playing: playing); await _updatePlayerState( AudioPlayerStateTableCompanion( playing: Value(playing), ), ); } catch (e, stack) { AppLogger.reportError(e, stack); } }), audioPlayer.loopModeStream.listen((loopMode) async { try { state = state.copyWith(loopMode: loopMode); await _updatePlayerState( AudioPlayerStateTableCompanion( loopMode: Value(loopMode), ), ); } catch (e, stack) { AppLogger.reportError(e, stack); } }), audioPlayer.shuffledStream.listen((shuffled) async { try { state = state.copyWith(shuffled: shuffled); await _updatePlayerState( AudioPlayerStateTableCompanion( shuffled: Value(shuffled), ), ); } catch (e, stack) { AppLogger.reportError(e, stack); } }), audioPlayer.playlistStream.listen((playlist) async { try { final queries = playlist.medias .map((media) => TrackSourceQuery.parseUri(media.uri)) .toList(); final tracks = queries .map( (query) => state.tracks .firstWhere((element) => element.id == query.id), ) .toList(); state = state.copyWith( tracks: tracks, currentIndex: playlist.index, ); await _updatePlayerState( AudioPlayerStateTableCompanion( currentIndex: Value(state.currentIndex), tracks: Value(state.tracks), ), ); } catch (e, stack) { AppLogger.reportError(e, stack); } }), ]; _syncSavedState(); ref.onDispose(() { for (final subscription in subscriptions) { subscription.cancel(); } }); return AudioPlayerState( loopMode: audioPlayer.loopMode, playing: audioPlayer.isPlaying, shuffled: audioPlayer.isShuffled, tracks: [], collections: [], ); } // Collection related methods Future addCollections(List collectionIds) async { state = state.copyWith(collections: [ ...state.collections, ...collectionIds, ]); await _updatePlayerState( AudioPlayerStateTableCompanion( collections: Value(state.collections), ), ); } Future addCollection(String collectionId) async { await addCollections([collectionId]); } Future removeCollections(List collectionIds) async { state = state.copyWith( collections: state.collections .where((element) => !collectionIds.contains(element)) .toList(), ); await _updatePlayerState( AudioPlayerStateTableCompanion( collections: Value(state.collections), ), ); } Future removeCollection(String collectionId) async { await removeCollections([collectionId]); } Future addTracksAtFirst( Iterable tracks, { bool allowDuplicates = false, }) async { _assertAllowedTracks(tracks); if (state.tracks.length == 1) { return addTracks(tracks); } tracks = _blacklist.filter(tracks).toList(); for (int i = 0; i < tracks.length; i++) { final track = tracks.elementAt(i); if (!allowDuplicates && state.tracks.any((element) => _compareTracks(element, track))) { continue; } await audioPlayer.addTrackAt( SpotubeMedia(track), max(state.currentIndex, 0) + i + 1, ); } } Future addTrack(SpotubeTrackObject track) async { _assertAllowedTrack(track); if (_blacklist.contains(track)) return; if (state.tracks.any((element) => _compareTracks(element, track))) return; state = state.copyWith( tracks: [...state.tracks, track], ); await audioPlayer.addTrack(SpotubeMedia(track)); } Future addTracks(Iterable tracks) async { _assertAllowedTracks(tracks); tracks = _blacklist.filter(tracks).toList(); state = state.copyWith( tracks: [...state.tracks, ...tracks], ); for (final track in tracks) { await audioPlayer.addTrack(SpotubeMedia(track)); } } Future removeTrack(String trackId) async { final index = state.tracks.indexWhere((element) => element.id == trackId); if (index == -1) return; await audioPlayer.removeTrack(index); } Future removeTracks(Iterable trackIds) async { for (final trackId in trackIds) { await removeTrack(trackId); } } bool _compareTracks(SpotubeTrackObject a, SpotubeTrackObject b) { if ((a is SpotubeLocalTrackObject && b is! SpotubeLocalTrackObject) || (a is! SpotubeLocalTrackObject && b is SpotubeLocalTrackObject)) { return false; } return a is SpotubeLocalTrackObject && b is SpotubeLocalTrackObject ? (a).path == (b).path : a.id == b.id; } Future load( List tracks, { int initialIndex = 0, bool autoPlay = false, }) async { _assertAllowedTracks(tracks); final medias = _blacklist .filter(tracks) .toList() .asMediaList() .unique((a, b) => a.uri == b.uri); // Giving the initial track a boost so MediaKit won't skip // because of timeout final intendedActiveTrack = medias.elementAt(initialIndex); if (intendedActiveTrack.track is! SpotubeLocalTrackObject) { await ref.read( trackSourcesProvider( TrackSourceQuery.fromTrack( intendedActiveTrack.track as SpotubeFullTrackObject), ).future, ); } if (medias.isEmpty) return; state = state.copyWith( // These are filtered tracks as well tracks: medias.map((media) => media.track).toList(), currentIndex: initialIndex, collections: [], ); await audioPlayer.openPlaylist( medias, initialIndex: initialIndex, autoPlay: autoPlay, ); } Future jumpToTrack(SpotubeTrackObject track) async { final index = state.tracks.toList().indexWhere((element) => element.id == track.id); if (index == -1) return; await audioPlayer.jumpTo(index); } 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; } await audioPlayer.moveTrack(oldIndex, newIndex); } Future stop() async { await audioPlayer.stop(); await removeCollections(state.collections); ref.read(discordProvider.notifier).clear(); } } final audioPlayerProvider = NotifierProvider( () => AudioPlayerNotifier(), );