import 'dart:async'; import 'package:flutter_cache_manager/flutter_cache_manager.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/models/local_track.dart'; import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist; import 'package:collection/collection.dart'; class PlaylistQueue { final Set tracks; final int active; Track get activeTrack => tracks.elementAt(active); final bool shuffled; final PlaybackLoopMode loopMode; static Future fromJson( Map json, UserPreferences preferences, ) async { final List? tracks = json['tracks']; return PlaylistQueue( Set.from( await Future.wait( tracks?.mapIndexed( (i, e) async { final jsonTrack = Map.castFrom(e); if (e["path"] != null) { return LocalTrack.fromJson(jsonTrack); } else if (i == json["active"] && !json.containsKey("path")) { return await SpotubeTrack.fetchFromTrack( Track.fromJson(jsonTrack), preferences, ); } else { return Track.fromJson(jsonTrack); } }, ) ?? [], ), ), active: json['active'], shuffled: json['shuffled'], loopMode: PlaybackLoopMode.fromString(json['loopMode'] ?? ''), ); } Map toJson() { return { 'tracks': tracks.map( (e) { if (e is SpotubeTrack) { return e.toJson(); } else if (e is LocalTrack) { return e.toJson(); } else { return e.toJson(); } }, ).toList(), 'active': active, 'shuffled': shuffled, 'loopMode': loopMode.name, }; } bool get isLoading => activeTrack is LocalTrack ? false : activeTrack is! SpotubeTrack; PlaylistQueue( this.tracks, { this.active = 0, this.shuffled = false, this.loopMode = PlaybackLoopMode.none, }) : assert(active < tracks.length && active >= 0, "Invalid active index"); PlaylistQueue copyWith({ Set? tracks, int? active, bool? shuffled, PlaybackLoopMode? loopMode, }) { return PlaylistQueue( tracks ?? this.tracks, active: active ?? this.active, shuffled: shuffled ?? this.shuffled, loopMode: loopMode ?? this.loopMode, ); } } class PlaylistQueueNotifier extends PersistedStateNotifier { final Ref ref; late AudioServices audioServices; static final provider = StateNotifierProvider( (ref) => PlaylistQueueNotifier._(ref), ); static final notifier = provider.notifier; PlaylistQueueNotifier._(this.ref) : super(null, "playlist") { configure(); } void configure() async { audioServices = await AudioServices.create(ref, this); audioPlayer.currentIndexChangedStream.listen((index) async { if (!isLoaded) return; state = state!.copyWith(active: index); await audioServices.addTrack(state!.activeTrack); }); audioPlayer.almostCompleteStream.listen((_) async { if (!isLoaded) return; final nextTrack = state!.tracks.elementAtOrNull(state!.active + 1); final sources = audioPlayer.sources; // we don't have a next track or the next track is already loaded // only when the next track isn't loaded we load next 3 tracks if (nextTrack == null || nextTrack is SpotubeTrack && sources.contains(nextTrack.ytUri)) { return; } final List fetchedTracks = []; // load next 3 tracks final tracks = await Future.wait(state!.tracks .toList() .skip(state!.active + 1) .take(3) .mapIndexed((i, track) async { if (track is LocalTrack) return Future.value(track.path); if (track is SpotubeTrack) return Future.value(track.ytUri); if (i == 0) { final fetchedTrack = await SpotubeTrack.fetchFromTrack(track, preferences); fetchedTracks.add(fetchedTrack); return fetchedTrack.ytUri; } // Adding delay to not spoof the YouTube API for IP Block final fetchedTrack = await Future.delayed( const Duration(milliseconds: 100), () => SpotubeTrack.fetchFromTrack(track, preferences), ); fetchedTracks.add(fetchedTrack); return fetchedTrack.ytUri; })); // replacing the tracks with the fetched tracks // in proxy playlist state = state!.copyWith( tracks: state!.tracks.map((track) { final fetchedTrack = fetchedTracks.firstWhereOrNull((e) => e.id == track.id); if (fetchedTrack != null) { return fetchedTrack; } return track; }).toSet(), ); for (final track in tracks) { if (sources.contains(track)) continue; await audioPlayer.addTrack(track); } }); bool isPreSearching = false; audioPlayer.positionStream.listen((pos) async { if (!isLoaded) return; final currentDuration = await audioPlayer.duration ?? Duration.zero; // skip all the activeTrack.skipSegments if (state?.isLoading != true && state?.activeTrack is SpotubeTrack && (state?.activeTrack as SpotubeTrack?)?.skipSegments.isNotEmpty == true && preferences.skipSponsorSegments) { for (final segment in (state!.activeTrack as SpotubeTrack).skipSegments) { if ((pos.inSeconds >= segment["start"]! && pos.inSeconds < segment["end"]!)) { await audioPlayer.pause(); await seek(Duration(seconds: segment["end"]!)); } } } // when the track progress is above 80%, track isn't the last // and is not already fetched and nothing is fetching currently if (pos.inSeconds > currentDuration.inSeconds * .8 && state!.active != state!.tracks.length - 1 && state!.tracks.elementAt(state!.active + 1) is! SpotubeTrack && !isPreSearching) { isPreSearching = true; final tracks = state!.tracks.toList(); final newTrack = await SpotubeTrack.fetchFromTrack( state!.tracks.elementAt(state!.active + 1), preferences, ); tracks[state!.active + 1] = newTrack; await audioPlayer.preload(newTrack.ytUri); state = state!.copyWith(tracks: Set.from(tracks)); isPreSearching = false; } }); } // properties // getters UserPreferences get preferences => ref.read(userPreferencesProvider); BlackListNotifier get blacklist => ref.read(BlackListNotifier.provider.notifier); bool get isLoaded => state != null; List