spotube/lib/provider/playlist_queue_provider.dart

556 lines
16 KiB
Dart

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<Track> tracks;
final int active;
Track get activeTrack => tracks.elementAt(active);
final bool shuffled;
final PlaybackLoopMode loopMode;
static Future<PlaylistQueue> fromJson(
Map<String, dynamic> 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<dynamic, dynamic, String, dynamic>(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<String, dynamic> 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<Track>? 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<PlaylistQueue?> {
final Ref ref;
late AudioServices audioServices;
static final provider =
StateNotifierProvider<PlaylistQueueNotifier, PlaylistQueue?>(
(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<SpotubeTrack> 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<Video> get siblings => state?.isLoading == false
? (state!.activeTrack as SpotubeTrack).siblings
: [];
// modifiers
void add(List<Track> tracks) {
if (!isLoaded) {
loadAndPlay(tracks);
} else {
state = state?.copyWith(
tracks: state!.tracks..addAll(tracks),
);
}
}
void playNext(List<Track> tracks) {
if (!isLoaded) {
loadAndPlay(tracks);
} else {
final stateTracks = state!.tracks.toList();
stateTracks.insertAll(state!.active + 1, tracks);
state = state?.copyWith(tracks: Set.from(stateTracks));
}
}
// TODO: Removal of track support
void remove(List<Track> tracks) {
if (!isLoaded) return;
final trackIds = tracks.map((e) => e.id!).toSet();
final newTracks = state!.tracks.whereNot(
(element) => trackIds.contains(element.id),
);
if (newTracks.isEmpty) {
stop();
return;
}
state = state?.copyWith(
tracks: newTracks.toSet(),
active: !newTracks.contains(state!.activeTrack)
? state!.active > newTracks.length - 1
? newTracks.length - 1
: state!.active
: null,
);
// if (state!.isLoading) {
// play();
// }
}
// TODO: Swap sibling support
Future<void> swapSibling(Video video) async {
if (!isLoaded || state!.isLoading) return;
await pause();
final tracks = state!.tracks.toList();
final track = await (state!.activeTrack as SpotubeTrack)
.swappedCopy(video, preferences);
if (track == null) return;
tracks[state!.active] = track;
state = state!.copyWith(tracks: Set.from(tracks));
// await play();
}
Future<void> populateSibling() async {
if (!isLoaded || state!.isLoading) return;
final tracks = state!.tracks.toList();
final track = await (state!.activeTrack as SpotubeTrack).populatedCopy();
tracks[state!.active] = track;
state = state!.copyWith(tracks: Set.from(tracks));
}
// Future<void> play() async {
// if (!isLoaded) return;
// await pause();
// await audioServices.addTrack(state!.activeTrack);
// if (state!.activeTrack is LocalTrack) {
// await audioPlayer.play((state!.activeTrack as LocalTrack).path);
// return;
// }
// if (state!.activeTrack is! SpotubeTrack) {
// final tracks = state!.tracks.toList();
// tracks[state!.active] = await SpotubeTrack.fetchFromTrack(
// state!.activeTrack,
// preferences,
// );
// state = state!.copyWith(tracks: Set.from(tracks));
// }
// await audioServices.addTrack(state!.activeTrack);
// final cached =
// await DefaultCacheManager().getFileFromCache(state!.activeTrack.id!);
// if (preferences.predownload && cached != null) {
// await audioPlayer.play(cached.file.path);
// } else {
// await audioPlayer.play((state!.activeTrack as SpotubeTrack).ytUri);
// }
// }
// TODO: Implement Playtrack
Future<void> playTrack(Track track) async {
if (!isLoaded) return;
final active =
state!.tracks.toList().indexWhere((element) => element.id == track.id);
if (active == -1) return;
state = state!.copyWith(active: active);
}
Future<void> load(Iterable<Track> tracks, {int active = 0}) async {
final activeTrack = tracks.elementAt(active);
final filtered = Set.from(blacklist.filter(tracks));
state = PlaylistQueue(
Set.from(blacklist.filter(tracks)),
active: filtered
.toList()
.indexWhere((element) => element.id == activeTrack.id),
);
// load 3 items first to avoid huge initial loading time
final firstTracks = await Future.wait(
filtered
.skip(active == 0 ? 0 : active - 1)
.take(3)
.mapIndexed((i, track) {
if (track is LocalTrack) return Future.value(track.path);
if (i == 0) {
return SpotubeTrack.fetchFromTrack(track, preferences).then(
(s) => s.ytUri,
);
}
// Adding delay to not spoof the YouTube API for IP Block
return Future.delayed(
const Duration(milliseconds: 100),
() => SpotubeTrack.fetchFromTrack(track, preferences).then(
(s) => s.ytUri,
),
);
}),
);
final localTracks = tracks
.where(
(element) =>
element is LocalTrack && !firstTracks.contains(element.path),
)
.map((e) => (e as LocalTrack).path);
await audioPlayer.openPlaylist(
[...firstTracks, ...localTracks],
autoPlay: false,
);
}
Future<void> loadAndPlay(Iterable<Track> tracks, {int active = 0}) async {
await load(tracks, active: active);
await resume();
}
Future<void> pause() {
return audioPlayer.pause();
}
Future<void> resume() {
return audioPlayer.resume();
}
Future<void> stop() async {
audioServices.deactivateSession();
state = null;
return audioPlayer.stop();
}
Future<void> next() async {
return audioPlayer.skipToNext();
}
Future<void> previous() async {
return audioPlayer.skipToPrevious();
}
Future<void> seek(Duration position) async {
if (!isLoaded) return;
await audioPlayer.seek(position);
await resume();
}
Future<void> setShuffle(bool shuffle) async {
if (!isLoaded) return;
audioPlayer.setShuffle(shuffle);
state = state!.copyWith(shuffled: await audioPlayer.isShuffled());
}
Future<void> setLoopMode(PlaybackLoopMode loopMode) async {
if (!isLoaded) return;
audioPlayer.setLoopMode(loopMode);
state = state!.copyWith(loopMode: loopMode);
}
// utility
bool isPlayingPlaylist(Iterable<TrackSimple> playlist) {
if (!isLoaded || playlist.isEmpty) return false;
final trackIds = state!.tracks.map((track) => track.id!);
return blacklist
.filter(playlist)
.every((track) => trackIds.contains(track.id!));
}
bool isTrackOnQueue(TrackSimple track) {
if (!isLoaded) return false;
final trackIds = state!.tracks.map((track) => track.id!);
return trackIds.contains(track.id!);
}
void reorder(int oldIndex, int newIndex) {
if (!isLoaded) return;
final tracks = state!.tracks.toList();
final track = tracks.removeAt(oldIndex);
tracks.insert(newIndex, track);
final active =
tracks.indexWhere((element) => element.id == state!.activeTrack.id);
state = state!.copyWith(tracks: Set.from(tracks), active: active);
}
Future<void> updatePalette() {
return Future.microtask(() async {
final palette = await PaletteGenerator.fromImageProvider(
UniversalImage.imageProvider(
TypeConversionUtils.image_X_UrlString(
state?.activeTrack.album?.images,
placeholder: ImagePlaceholder.albumArt,
),
height: 50,
width: 50,
),
);
ref.read(paletteProvider.notifier).state = palette;
});
}
@override
set state(state) {
if (preferences.albumColorSync &&
state != null &&
state.active != this.state?.active) {
updatePalette();
} else if (state == null && ref.read(paletteProvider) != null) {
ref.read(paletteProvider.notifier).state = null;
}
super.state = state;
}
@override
Future<PlaylistQueue>? fromJson(Map<String, dynamic> json) {
if (json.isEmpty) return null;
return PlaylistQueue.fromJson(json, preferences);
}
@override
Map<String, dynamic> toJson() {
return state?.toJson() ?? {};
}
@override
void dispose() {
audioServices.dispose();
super.dispose();
}
}
class VolumeProvider extends PersistedStateNotifier<double> {
VolumeProvider() : super(1, 'volume');
static final provider = StateNotifierProvider<VolumeProvider, double>((ref) {
return VolumeProvider();
});
Future<void> setVolume(double volume) async {
if (volume > 1) {
state = 1;
} else if (volume < 0) {
state = 0;
} else {
state = volume;
}
await audioPlayer.setVolume(state);
await audioPlayer.setVolume(state);
}
void increaseVolume() {
setVolume(state + 0.1);
}
void decreaseVolume() {
setVolume(state - 0.1);
}
@override
double fromJson(Map<String, dynamic> json) {
return json['volume'] as double;
}
@override
Map<String, dynamic> toJson() {
return {'volume': state};
}
}