diff --git a/.vscode/settings.json b/.vscode/settings.json index 0fedc5447..462d33ef4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "Amoled", "Buildless", "danceability", "fuzzywuzzy", diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 8f5f9e8b9..c5379ec61 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -6,7 +6,7 @@ abstract class FakeData { static final Image image = Image() ..height = 1 ..width = 1 - ..url = "url"; + ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg"; static final Followers followers = Followers() ..href = "text" diff --git a/lib/main.dart b/lib/main.dart index 2a2d8d186..8de524c72 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,6 @@ import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart new file mode 100644 index 000000000..9069f3e16 --- /dev/null +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -0,0 +1,132 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:async'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; + +extension ProxyPlaylistListeners on ProxyPlaylistNotifier { + StreamSubscription subscribeToSourceChanges() => + audioPlayer.activeSourceChangedStream.listen((event) { + try { + final newActiveTrack = mapSourcesToTracks([event]).firstOrNull; + + if (newActiveTrack == null || + newActiveTrack.id == state.activeTrack?.id) { + return; + } + + notificationService.addTrack(newActiveTrack); + discord.updatePresence(newActiveTrack); + state = state.copyWith( + active: state.tracks + .toList() + .indexWhere((element) => element.id == newActiveTrack.id), + ); + + updatePalette(); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + + StreamSubscription subscribeToPercentCompletion() { + final isPreSearching = ObjectRef(false); + + return audioPlayer.percentCompletedStream(2).listen((event) async { + if (isPreSearching.value || + audioPlayer.currentSource == null || + audioPlayer.nextSource == null || + isPlayable(audioPlayer.nextSource!)) return; + + try { + isPreSearching.value = true; + + final track = await ensureSourcePlayable(audioPlayer.nextSource!); + + if (track != null) { + state = state.copyWith(tracks: mergeTracks([track], state.tracks)); + } + } catch (e, stackTrace) { + // Removing tracks that were not found to avoid queue interruption + if (e is TrackNotFoundError) { + final oldTrack = + mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; + await removeTrack(oldTrack!.id!); + } + Catcher2.reportCheckedError(e, stackTrace); + } finally { + isPreSearching.value = false; + } + }); + } + + StreamSubscription subscribeToShuffleChanges() { + return audioPlayer.shuffledStream.listen((event) { + try { + final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); + + final newActiveIndex = newlyOrderedTracks.indexWhere( + (element) => element.id == state.activeTrack?.id, + ); + + if (newActiveIndex == -1) return; + + state = state.copyWith( + tracks: newlyOrderedTracks.toSet(), + active: newActiveIndex, + ); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + } + + StreamSubscription subscribeToSkipSponsor() { + return audioPlayer.positionStream.listen((position) async { + final currentSegments = await ref.read(segmentProvider.future); + + if (currentSegments?.segments.isNotEmpty != true || + position < const Duration(seconds: 3)) return; + + for (final segment in currentSegments!.segments) { + final seconds = position.inSeconds; + + if (seconds < segment.start || seconds >= segment.end) continue; + + await audioPlayer.seek(Duration(seconds: segment.end + 1)); + } + }); + } + + StreamSubscription subscribeToScrobbleChanged() { + String? lastScrobbled; + return audioPlayer.positionStream.listen((position) { + try { + final uid = state.activeTrack is LocalTrack + ? (state.activeTrack as LocalTrack).path + : state.activeTrack?.id; + + if (state.activeTrack == null || + lastScrobbled == uid || + position.inSeconds < 30) { + return; + } + + scrobbler.scrobble(state.activeTrack!); + lastScrobbled = uid; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + }); + } + + StreamSubscription subscribeToPlayerError() { + return audioPlayer.errorStream.listen((event) {}); + } +} diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index b5bcdefe1..438088de7 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,24 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; -import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.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/models/logger.dart'; - -import 'package:spotube/models/skip_segment.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'; @@ -26,34 +20,11 @@ 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/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/services/sourced_track/sources/piped.dart'; -import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -/// Things implemented: -/// * [x] Sponsor-Block skip -/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack] -/// * [x] Modification of the Queue -/// * [x] Add track at the end -/// * [x] Add track at the beginning -/// * [x] Remove track -/// * [x] Reorder track -/// * [x] Caching and loading of cache of tracks -/// * [x] Shuffling -/// * [x] loop => playlist, track, none -/// * [x] Alternative Track Source -/// * [x] Blacklisting of tracks and artist -/// -/// Don'ts: -/// * It'll not have any proxy method for [SpotubeAudioPlayer] -/// * It'll not store any sort of player state e.g playing, paused, shuffled etc -/// * For that, use [SpotubeAudioPlayer] - class ProxyPlaylistNotifier extends PersistedStateNotifier with NextFetcher { final Ref ref; @@ -74,162 +45,21 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier static AlwaysAliveRefreshable get notifier => provider.notifier; - ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { - () async { - notificationService = await AudioServices.create(ref, this); - - // listeners state - final currentSegments = - // using source as unique id because alternative track source support - ObjectRef<({String source, List segments})?>(null); - final isPreSearching = ObjectRef(false); - final isFetchingSegments = ObjectRef(false); - - audioPlayer.activeSourceChangedStream.listen((newActiveSource) async { - try { - final newActiveTrack = - mapSourcesToTracks([newActiveSource]).firstOrNull; - - if (newActiveTrack == null || - newActiveTrack.id == state.activeTrack?.id) { - return; - } - - notificationService.addTrack(newActiveTrack); - discord.updatePresence(newActiveTrack); - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == newActiveTrack.id), - ); - - updatePalette(); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - audioPlayer.shuffledStream.listen((event) { - try { - final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); - - final newActiveIndex = newlyOrderedTracks.indexWhere( - (element) => element.id == state.activeTrack?.id, - ); - - if (newActiveIndex == -1) return; + List _subscriptions = []; - state = state.copyWith( - tracks: newlyOrderedTracks.toSet(), - active: newActiveIndex, - ); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - listenTo2Percent(int percent) async { - if (isPreSearching.value || - audioPlayer.currentSource == null || - audioPlayer.nextSource == null || - isPlayable(audioPlayer.nextSource!)) return; - - try { - isPreSearching.value = true; - - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith(tracks: mergeTracks([track], state.tracks)); - } - } catch (e, stackTrace) { - // Removing tracks that were not found to avoid queue interruption - if (e is TrackNotFoundError) { - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - await removeTrack(oldTrack!.id!); - } - Catcher2.reportCheckedError(e, stackTrace); - } finally { - isPreSearching.value = false; - } - } - - audioPlayer.percentCompletedStream(2).listen(listenTo2Percent); - - audioPlayer.positionStream.listen((position) async { - if (state.activeTrack == null || state.activeTrack is LocalTrack) { - isFetchingSegments.value = false; - return; - } - try { - final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack && - (state.activeTrack is PipedSourcedTrack && - preferences.searchMode == SearchMode.youtubeMusic); - - if (isNotYTMode || !preferences.skipNonMusic) return; - - final isNotSameSegmentId = - currentSegments.value?.source != audioPlayer.currentSource; - - if (currentSegments.value == null || - (isNotSameSegmentId && !isFetchingSegments.value)) { - isFetchingSegments.value = true; - try { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: await getAndCacheSkipSegments( - (state.activeTrack as SourcedTrack).sourceInfo.id, - ), - ); - } catch (e) { - if (audioPlayer.currentSource != null) { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: [], - ); - } - } finally { - isFetchingSegments.value = false; - } - } - - // skipping in first 2 second breaks stream - if (currentSegments.value == null || - currentSegments.value!.segments.isEmpty || - position < const Duration(seconds: 3)) return; - - for (final segment in currentSegments.value!.segments) { - if (position.inSeconds >= segment.start && - position.inSeconds < segment.end) { - await audioPlayer.seek(Duration(seconds: segment.end)); - } - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); + ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { + AudioServices.create(ref, this).then( + (value) => notificationService = value, + ); - String? lastScrobbled; - audioPlayer.positionStream.listen((position) { - try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; - - if (state.activeTrack == null || - lastScrobbled == uid || - position.inSeconds < 30) { - return; - } - - scrobbler.scrobble(state.activeTrack!); - lastScrobbled = uid; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - } - }); - }(); + _subscriptions = [ + // These are subscription methods from player_listeners.dart + subscribeToSourceChanges(), + subscribeToPercentCompletion(), + subscribeToShuffleChanges(), + subscribeToSkipSponsor(), + subscribeToScrobbleChanged(), + ]; } Future ensureSourcePlayable(String source) async { @@ -283,8 +113,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - // TODO: Safely Remove playing tracks - Future removeTrack(String trackId) async { final track = state.tracks.firstWhereOrNull((element) => element.id == trackId); @@ -533,72 +361,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - Future> getAndCacheSkipSegments(String id) async { - if (!preferences.skipNonMusic || - (preferences.audioSource == AudioSource.piped && - preferences.searchMode == SearchMode.youtubeMusic)) return []; - - try { - final cached = await SkipSegment.box.get(id); - if (cached != null && cached.isNotEmpty) { - return List.castFrom( - (cached as List) - .map( - (json) => SkipSegment.fromJson( - Map.castFrom(json), - ), - ) - .toList(), - ); - } - - final res = await get(Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - )); - - if (res.body == "Not Found") { - return List.castFrom([]); - } - - final data = jsonDecode(res.body) as List; - final segments = data.map((obj) { - final start = obj["segment"].first.toInt(); - final end = obj["segment"].last.toInt(); - return SkipSegment( - start, - end, - ); - }).toList(); - getLogger('getSkipSegments').t( - "[SponsorBlock] successfully fetched skip segments for $id", - ); - - await SkipSegment.box.put( - id, - segments.map((e) => e.toJson()).toList(), - ); - return List.castFrom(segments); - } catch (e, stack) { - await SkipSegment.box.put(id, []); - Catcher2.reportCheckedError(e, stack); - return List.castFrom([]); - } - } - @override set state(state) { super.state = state; @@ -631,4 +393,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final json = state.toJson(); return json; } + + @override + void dispose() { + for (final subscription in _subscriptions) { + subscription.cancel(); + } + super.dispose(); + } } diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart new file mode 100644 index 000000000..94a633245 --- /dev/null +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/skip_segment.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_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/sourced_track/sourced_track.dart'; + +class SourcedSegments { + final String source; + final List segments; + + SourcedSegments({required this.source, required this.segments}); +} + +Future> getAndCacheSkipSegments(String id) async { + try { + final cached = await SkipSegment.box.get(id) as List?; + if (cached != null && cached.isNotEmpty) { + return List.castFrom( + cached + .map( + (json) => SkipSegment.fromJson( + Map.castFrom(json), + ), + ) + .toList(), + ); + } + + final res = await get(Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + )); + + if (res.body == "Not Found") { + return List.castFrom([]); + } + + final data = jsonDecode(res.body) as List; + final segments = data.map((obj) { + final start = obj["segment"].first.toInt(); + final end = obj["segment"].last.toInt(); + return SkipSegment(start, end); + }).toList(); + + await SkipSegment.box.put( + id, + segments.map((e) => e.toJson()).toList(), + ); + return List.castFrom(segments); + } catch (e, stack) { + await SkipSegment.box.put(id, []); + Catcher2.reportCheckedError(e, stack); + return List.castFrom([]); + } +} + +final segmentProvider = FutureProvider( + (ref) async { + final track = ref.watch( + ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), + ); + if (track == null) return null; + + if (track is LocalTrack || track is! SourcedTrack) return null; + + final skipNonMusic = ref.watch( + userPreferencesProvider.select( + (s) { + final isPipedYTMusicMode = s.audioSource == AudioSource.piped && + s.searchMode == SearchMode.youtubeMusic; + + return s.skipNonMusic && !isPipedYTMusicMode; + }, + ), + ); + + if (!skipNonMusic) { + return SourcedSegments( + segments: [], + source: track.sourceInfo.id, + ); + } + + final segments = await getAndCacheSkipSegments(track.sourceInfo.id); + + return SourcedSegments( + source: track.sourceInfo.id, + segments: segments, + ); + }, +); diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index f05ba5efe..54e36c6b4 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -146,4 +146,6 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get selectedDeviceStream => _mkPlayer.stream.audioDevice.asBroadcastStream(); + + Stream get errorStream => _mkPlayer.stream.error; } diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c06efd877..a5e094ed5 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -131,6 +131,8 @@ abstract class SourcedTrack extends Track { }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); + } on VideoUnplayableException catch (_) { + return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { if (e is DioException || e is ClientException || e is SocketException) { if (preferences.audioSource == AudioSource.jiosaavn) {