diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 5db6e172b..a80de703f 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -89,4 +89,6 @@ abstract class SpotubeIcons { static const timer = FeatherIcons.clock; static const logs = FeatherIcons.fileText; static const clipboard = FeatherIcons.clipboard; + static const youtube = FeatherIcons.youtube; + static const skip = FeatherIcons.fastForward; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 974b3afc8..c62e31bf6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -247,5 +247,6 @@ "custom_hours": "Custom Hours", "logs": "Logs", "developers": "Developers", - "not_logged_in": "You're not logged in" + "not_logged_in": "You're not logged in", + "search_mode": "Search Mode" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 641137b2b..27ad6c956 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'package:spotube/hooks/use_disable_battery_optimizations.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/matched_track.dart'; +import 'package:spotube/models/skip_segment.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'; @@ -101,6 +102,7 @@ Future main(List rawArgs) async { connectivity: FlQueryConnectivityPlusAdapter(), ); Hive.registerAdapter(MatchedTrackAdapter()); + Hive.registerAdapter(SkipSegmentAdapter()); await Hive.openLazyBox( MatchedTrack.boxName, diff --git a/lib/models/skip_segment.dart b/lib/models/skip_segment.dart new file mode 100644 index 000000000..889f7ac7d --- /dev/null +++ b/lib/models/skip_segment.dart @@ -0,0 +1,24 @@ +import 'package:hive/hive.dart'; + +part 'skip_segment.g.dart'; + +@HiveType(typeId: 2) +class SkipSegment { + @HiveField(0) + final int start; + @HiveField(1) + final int end; + SkipSegment(this.start, this.end); + + static const boxName = "oss.krtirtho.spotube.skip_segments"; + static LazyBox get box => Hive.lazyBox(boxName); + + SkipSegment.fromJson(Map json) + : start = json['start'], + end = json['end']; + + Map toJson() => { + 'start': start, + 'end': end, + }; +} diff --git a/lib/models/skip_segment.g.dart b/lib/models/skip_segment.g.dart new file mode 100644 index 000000000..f2ad4459a --- /dev/null +++ b/lib/models/skip_segment.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'skip_segment.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SkipSegmentAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + SkipSegment read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SkipSegment( + fields[0] as int, + fields[1] as int, + ); + } + + @override + void write(BinaryWriter writer, SkipSegment obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.start) + ..writeByte(1) + ..write(obj.end); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SkipSegmentAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart index 1d95d3bb4..4b21dafb7 100644 --- a/lib/models/spotube_track.dart +++ b/lib/models/spotube_track.dart @@ -1,19 +1,20 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:catcher/catcher.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:http/http.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/models/logger.dart'; import 'package:spotube/models/matched_track.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:collection/collection.dart'; -typedef SkipSegment = ({int start, int end}); - class SpotubeTrack extends Track { final PipedStreamResponse ytTrack; final String ytUri; @@ -68,8 +69,9 @@ class SpotubeTrack extends Track { static Future> fetchSiblings( Track track, - PipedClient client, - ) async { + PipedClient client, [ + PipedFilter filter = PipedFilter.musicSongs, + ]) async { final artists = (track.artists ?? []) .map((ar) => ar.name) .toList() @@ -82,12 +84,8 @@ class SpotubeTrack extends Track { onlyCleanArtist: true, ).trim(); - final List siblings = await client - .search( - "$title - ${artists.join(", ")}", - PipedFilter.musicSongs, - ) - .then( + final List siblings = + await client.search("$title - ${artists.join(", ")}", filter).then( (res) { final siblings = res.items .whereType() @@ -122,7 +120,14 @@ class SpotubeTrack extends Track { if (matchedCachedTrack != null) { ytVideo = await client.streams(matchedCachedTrack.youtubeId); } else { - siblings = await fetchSiblings(track, client); + siblings = await fetchSiblings( + track, + client, + switch (preferences.searchMode) { + SearchMode.youtube => PipedFilter.video, + SearchMode.youtubeMusic => PipedFilter.musicSongs, + }, + ); if (siblings.isEmpty) { throw Exception("Failed to find any results for ${track.name}"); } @@ -229,10 +234,17 @@ class SpotubeTrack extends Track { ); } - Future populatedCopy(PipedClient client) async { + Future populatedCopy( + PipedClient client, + PipedFilter filter, + ) async { if (this.siblings.isNotEmpty) return this; - final siblings = await fetchSiblings(this, client); + final siblings = await fetchSiblings( + this, + client, + filter, + ); return SpotubeTrack.fromTrack( track: this, diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index ebcfb58d8..a0d6d82d5 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -324,6 +324,45 @@ class SettingsPage extends HookConsumerWidget { Text(error.toString()), ); }), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.youtube), + title: Text(context.l10n.search_mode), + value: preferences.searchMode, + options: SearchMode.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setSearchMode(value); + }, + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: + preferences.searchMode == SearchMode.youtubeMusic + ? 0 + : 1, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + child: SizedBox( + height: preferences.searchMode == + SearchMode.youtubeMusic + ? 0 + : 50, + child: SwitchListTile( + secondary: const Icon(SpotubeIcons.skip), + title: Text(context.l10n.skip_non_music), + value: preferences.skipNonMusic, + onChanged: (state) { + preferences.setSkipNonMusic(state); + }, + ), + ), + ), + ), SwitchListTile( secondary: const Icon(SpotubeIcons.download), title: Text(context.l10n.pre_download_play), diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index dee903ec2..33720be7d 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,12 +1,19 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:catcher/catcher.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive/hive.dart'; +import 'package:http/http.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:piped_client/piped_client.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/logger.dart'; +import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; @@ -62,7 +69,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier () async { notificationService = await AudioServices.create(ref, this); - audioPlayer.activeSourceChangedStream.listen((newActiveSource) { + (String, List)? currentSegments; + bool isFetchingSegments = false; + audioPlayer.activeSourceChangedStream.listen((newActiveSource) async { final newActiveTrack = mapSourcesToTracks([newActiveSource]).firstOrNull; @@ -78,6 +87,10 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier .indexWhere((element) => element.id == newActiveTrack.id), ); + isFetchingSegments = true; + + isFetchingSegments = false; + if (preferences.albumColorSync) { updatePalette(); } @@ -101,7 +114,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier bool isPreSearching = false; listenTo60Percent(percent) async { - if (isPreSearching || audioPlayer.currentSource == null) return; + if (isPreSearching || + audioPlayer.currentSource == null || + audioPlayer.nextSource == null) return; try { isPreSearching = true; @@ -112,6 +127,19 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier if (track != null) { state = state.copyWith(tracks: mergeTracks([track], state.tracks)); + if (currentSegments == null || + (oldTrack?.id != null && + currentSegments!.$1 != oldTrack!.id!) && + !isFetchingSegments) { + isFetchingSegments = true; + currentSegments = ( + audioPlayer.currentSource!, + await getAndCacheSkipSegments( + track.ytTrack.id, + ), + ); + isFetchingSegments = false; + } } /// Sometimes fetching can take a lot of time, so we need to check @@ -144,6 +172,34 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier isPlayable(audioPlayer.nextSource!)) return; await audioPlayer.pause(); }); + + audioPlayer.positionStream.listen((position) async { + if (preferences.searchMode == SearchMode.youtubeMusic || + !preferences.skipNonMusic) return; + + if (currentSegments == null || + currentSegments!.$1 != state.activeTrack!.id! && + !isFetchingSegments) { + isFetchingSegments = true; + currentSegments = ( + audioPlayer.currentSource!, + await getAndCacheSkipSegments( + (state.activeTrack as SpotubeTrack).ytTrack.id, + ), + ); + isFetchingSegments = false; + } + + final (_, segments) = currentSegments!; + if (segments.isEmpty) return; + + for (final segment in segments) { + if ((position.inSeconds >= segment.start && + position.inSeconds < segment.end)) { + await audioPlayer.seek(Duration(seconds: segment.end)); + } + } + }); }(); } @@ -332,7 +388,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier Future populateSibling() async { if (state.activeTrack is SpotubeTrack) { final activeTrackWithSiblingsForSure = - await (state.activeTrack as SpotubeTrack).populatedCopy(pipedClient); + await (state.activeTrack as SpotubeTrack).populatedCopy( + pipedClient, + switch (preferences.searchMode) { + SearchMode.youtube => PipedFilter.video, + SearchMode.youtubeMusic => PipedFilter.musicSongs, + }, + ); state = state.copyWith( tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), @@ -449,6 +511,64 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } + Future> getAndCacheSkipSegments(String id) async { + if (!preferences.skipNonMusic || + preferences.searchMode != SearchMode.youtube) return []; + + try { + final box = await Hive.openLazyBox(SkipSegment.boxName); + final cached = await box.get(id); + if (cached != null && cached.isNotEmpty) { + return List.castFrom(cached); + } + + 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').v( + "[SponsorBlock] successfully fetched skip segments for $id", + ); + + await box.put( + id, + segments, + ); + return List.castFrom(segments); + } catch (e, stack) { + await box.put(id, []); + Catcher.reportCheckedError(e, stack); + return List.castFrom([]); + } + } + @override set state(state) { super.state = state; diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index 93cc17d58..02103407c 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -28,6 +28,15 @@ enum CloseBehavior { close, } +enum SearchMode { + youtube._internal('YouTube'), + youtubeMusic._internal('YouTubeMusic'); + + final String label; + + const SearchMode._internal(this.label); +} + class UserPreferences extends PersistedChangeNotifier { ThemeMode themeMode; String recommendationMarket; @@ -52,6 +61,10 @@ class UserPreferences extends PersistedChangeNotifier { String pipedInstance; + SearchMode searchMode; + + bool skipNonMusic; + final Ref ref; UserPreferences( @@ -70,6 +83,8 @@ class UserPreferences extends PersistedChangeNotifier { this.showSystemTrayIcon = true, this.locale = const Locale("system", "system"), this.pipedInstance = "https://pipedapi.kavin.rocks", + this.searchMode = SearchMode.youtubeMusic, + this.skipNonMusic = true, }) : super() { if (downloadLocation.isEmpty) { _getDefaultDownloadDirectory().then( @@ -170,6 +185,18 @@ class UserPreferences extends PersistedChangeNotifier { updatePersistence(); } + void setSearchMode(SearchMode mode) { + searchMode = mode; + notifyListeners(); + updatePersistence(); + } + + void setSkipNonMusic(bool skip) { + skipNonMusic = skip; + notifyListeners(); + updatePersistence(); + } + Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; @@ -217,6 +244,13 @@ class UserPreferences extends PersistedChangeNotifier { localeMap != null ? Locale(localeMap?["lc"], localeMap?["cc"]) : locale; pipedInstance = map["pipedInstance"] ?? pipedInstance; + + searchMode = SearchMode.values.firstWhere( + (mode) => mode.name == map["searchMode"], + orElse: () => SearchMode.youtubeMusic, + ); + + skipNonMusic = map["skipNonMusic"] ?? skipNonMusic; } @override @@ -237,6 +271,8 @@ class UserPreferences extends PersistedChangeNotifier { "locale": jsonEncode({"lc": locale.languageCode, "cc": locale.countryCode}), "pipedInstance": pipedInstance, + "searchMode": searchMode.name, + "skipNonMusic": skipNonMusic, }; } } diff --git a/pubspec.lock b/pubspec.lock index f845f4b55..03a2e9e6b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1654,6 +1654,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + simple_icons: + dependency: "direct main" + description: + name: simple_icons + sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b" + url: "https://pub.dev" + source: hosted + version: "7.10.0" skeleton_text: dependency: "direct main" description: diff --git a/untranslated_messages.json b/untranslated_messages.json index 0a73d6841..ef57a47ec 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -63,11 +63,13 @@ "custom_hours", "logs", "developers", - "not_logged_in" + "not_logged_in", + "search_mode" ], "de": [ - "not_logged_in" + "not_logged_in", + "search_mode" ], "fr": [ @@ -134,7 +136,8 @@ "custom_hours", "logs", "developers", - "not_logged_in" + "not_logged_in", + "search_mode" ], "hi": [ @@ -201,10 +204,12 @@ "custom_hours", "logs", "developers", - "not_logged_in" + "not_logged_in", + "search_mode" ], "ja": [ - "not_logged_in" + "not_logged_in", + "search_mode" ] }