From b54ee96233b29d7517eba66e3f8dd9270c2790df Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 30 Jun 2023 10:52:44 +0600 Subject: [PATCH] feat: re-introduce youtube API along with piped --- .../player/sibling_tracks_sheet.dart | 66 +++--- .../shared/dialogs/track_details_dialog.dart | 4 +- lib/l10n/app_en.arb | 3 +- lib/main.dart | 1 + lib/models/matched_track.dart | 25 +- lib/models/matched_track.g.dart | 46 +++- lib/models/spotube_track.dart | 99 +++----- lib/pages/settings/settings.dart | 159 +++++++------ lib/provider/download_manager_provider.dart | 10 +- lib/provider/piped_instances_provider.dart | 10 + lib/provider/piped_provider.dart | 18 -- .../proxy_playlist/next_fetcher_mixin.dart | 8 +- .../proxy_playlist_provider.dart | 28 +-- lib/provider/user_preferences_provider.dart | 26 ++- lib/provider/youtube_provider.dart | 8 + lib/services/youtube/youtube.dart | 221 ++++++++++++++++++ lib/utils/type_conversion_utils.dart | 30 +-- pubspec.lock | 10 +- pubspec.yaml | 1 + untranslated_messages.json | 15 +- 20 files changed, 537 insertions(+), 251 deletions(-) create mode 100644 lib/provider/piped_instances_provider.dart delete mode 100644 lib/provider/piped_provider.dart create mode 100644 lib/provider/youtube_provider.dart create mode 100644 lib/services/youtube/youtube.dart diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 36cee3056..774ad890d 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; @@ -11,10 +10,12 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/use_debounce.dart'; +import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/piped_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -31,12 +32,11 @@ class SiblingTracksSheet extends HookConsumerWidget { final theme = Theme.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final preferencesSearchMode = - ref.watch(userPreferencesProvider.select((value) => value.searchMode)); - final pipedClient = ref.watch(pipedClientProvider); + final preferences = ref.watch(userPreferencesProvider); + final youtube = ref.watch(youtubeProvider); final isSearching = useState(false); - final searchMode = useState(preferencesSearchMode); + final searchMode = useState(preferences.searchMode); final title = ServiceUtils.getTitle( playlist.activeTrack?.name ?? "", @@ -57,21 +57,10 @@ class SiblingTracksSheet extends HookConsumerWidget { final searchRequest = useMemoized(() async { if (searchTerm.trim().isEmpty) { - return []; + return []; } - return pipedClient - .search( - searchTerm.trim(), - switch (searchMode.value) { - SearchMode.youtube => PipedFilter.video, - SearchMode.youtubeMusic => PipedFilter.musicSongs, - }, - ) - .then( - (result) => - result.items.whereType().toList(), - ); + return youtube.search(searchTerm.trim()); }, [ searchTerm, searchMode.value, @@ -79,7 +68,7 @@ class SiblingTracksSheet extends HookConsumerWidget { final siblings = playlist.isFetching == false ? (playlist.activeTrack as SpotubeTrack).siblings - : []; + : []; final borderRadius = floating ? BorderRadius.circular(10) @@ -96,13 +85,13 @@ class SiblingTracksSheet extends HookConsumerWidget { return null; }, [playlist.activeTrack]); - final itemBuilder = useCallback((PipedSearchItemStream video) { + final itemBuilder = useCallback((YoutubeVideoInfo video) { return ListTile( title: Text(video.title), leading: Padding( padding: const EdgeInsets.all(8.0), child: UniversalImage( - path: video.thumbnail, + path: video.thumbnailUrl, height: 60, width: 60, ), @@ -113,7 +102,7 @@ class SiblingTracksSheet extends HookConsumerWidget { trailing: Text( PrimitiveUtils.toReadableDuration(video.duration), ), - subtitle: Text(video.uploaderName), + subtitle: Text(video.channelName), enabled: playlist.isFetching != true, selected: playlist.isFetching != true && video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id, @@ -182,21 +171,22 @@ class SiblingTracksSheet extends HookConsumerWidget { }, ) else ...[ - PopupMenuButton( - icon: const Icon(SpotubeIcons.filter, size: 18), - onSelected: (SearchMode mode) { - searchMode.value = mode; - }, - initialValue: searchMode.value, - itemBuilder: (context) => SearchMode.values - .map( - (e) => PopupMenuItem( - value: e, - child: Text(e.label), - ), - ) - .toList(), - ), + if (preferences.youtubeApiType == YoutubeApiType.piped) + PopupMenuButton( + icon: const Icon(SpotubeIcons.filter, size: 18), + onSelected: (SearchMode mode) { + searchMode.value = mode; + }, + initialValue: searchMode.value, + itemBuilder: (context) => SearchMode.values + .map( + (e) => PopupMenuItem( + value: e, + child: Text(e.label), + ), + ) + .toList(), + ), IconButton( icon: const Icon(SpotubeIcons.close, size: 18), onPressed: () { diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index a0da2f57c..9e29c32d0 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -59,8 +59,8 @@ class TrackDetailsDialog extends HookWidget { overflow: TextOverflow.ellipsis, ), context.l10n.channel: Hyperlink( - ytTrack.uploader, - "https://youtube.com${ytTrack.uploaderUrl}", + ytTrack.channelName, + "https://youtube.com${ytTrack.channelName}", maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c62e31bf6..acee737e0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -248,5 +248,6 @@ "logs": "Logs", "developers": "Developers", "not_logged_in": "You're not logged in", - "search_mode": "Search Mode" + "search_mode": "Search Mode", + "youtube_api_type": "YouTube API Type" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 27ad6c956..ace4c871c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -103,6 +103,7 @@ Future main(List rawArgs) async { ); Hive.registerAdapter(MatchedTrackAdapter()); Hive.registerAdapter(SkipSegmentAdapter()); + Hive.registerAdapter(SearchModeAdapter()); await Hive.openLazyBox( MatchedTrack.boxName, diff --git a/lib/models/matched_track.dart b/lib/models/matched_track.dart index 0ec57a300..4c7b11317 100644 --- a/lib/models/matched_track.dart +++ b/lib/models/matched_track.dart @@ -1,5 +1,4 @@ import "package:hive/hive.dart"; - part "matched_track.g.dart"; @HiveType(typeId: 1) @@ -8,6 +7,8 @@ class MatchedTrack { String youtubeId; @HiveField(1) String spotifyId; + @HiveField(2) + SearchMode searchMode; String? id; DateTime? createdAt; @@ -21,12 +22,14 @@ class MatchedTrack { MatchedTrack({ required this.youtubeId, required this.spotifyId, + required this.searchMode, this.id, this.createdAt, }); factory MatchedTrack.fromJson(Map json) { return MatchedTrack( + searchMode: SearchMode.fromString(json["searchMode"]), youtubeId: json["youtube_id"], spotifyId: json["spotify_id"], id: json["id"], @@ -39,7 +42,27 @@ class MatchedTrack { "youtube_id": youtubeId, "spotify_id": spotifyId, "id": id, + "searchMode": searchMode.name, "created_at": createdAt?.toString() }..removeWhere((key, value) => value == null); } } + +@HiveType(typeId: 4) +enum SearchMode { + @HiveField(0) + youtube._internal('YouTube'), + @HiveField(1) + youtubeMusic._internal('YouTube Music'); + + final String label; + + const SearchMode._internal(this.label); + + factory SearchMode.fromString(String value) { + return SearchMode.values.firstWhere( + (element) => element.name == value, + orElse: () => SearchMode.youtube, + ); + } +} diff --git a/lib/models/matched_track.g.dart b/lib/models/matched_track.g.dart index b89f26934..dd166e77d 100644 --- a/lib/models/matched_track.g.dart +++ b/lib/models/matched_track.g.dart @@ -19,17 +19,20 @@ class MatchedTrackAdapter extends TypeAdapter { return MatchedTrack( youtubeId: fields[0] as String, spotifyId: fields[1] as String, + searchMode: fields[2] as SearchMode, ); } @override void write(BinaryWriter writer, MatchedTrack obj) { writer - ..writeByte(2) + ..writeByte(3) ..writeByte(0) ..write(obj.youtubeId) ..writeByte(1) - ..write(obj.spotifyId); + ..write(obj.spotifyId) + ..writeByte(2) + ..write(obj.searchMode); } @override @@ -42,3 +45,42 @@ class MatchedTrackAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } + +class SearchModeAdapter extends TypeAdapter { + @override + final int typeId = 4; + + @override + SearchMode read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SearchMode.youtube; + case 1: + return SearchMode.youtubeMusic; + default: + return SearchMode.youtube; + } + } + + @override + void write(BinaryWriter writer, SearchMode obj) { + switch (obj) { + case SearchMode.youtube: + writer.writeByte(0); + break; + case SearchMode.youtubeMusic: + writer.writeByte(1); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SearchModeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart index c944ae205..32b16533d 100644 --- a/lib/models/spotube_track.dart +++ b/lib/models/spotube_track.dart @@ -1,20 +1,18 @@ import 'dart:async'; -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/matched_track.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:collection/collection.dart'; class SpotubeTrack extends Track { - final PipedStreamResponse ytTrack; + final YoutubeVideoInfo ytTrack; final String ytUri; - final List siblings; + final List siblings; SpotubeTrack( this.ytTrack, @@ -48,25 +46,10 @@ class SpotubeTrack extends Track { uri = track.uri; } - static PipedAudioStream getStreamInfo( - PipedStreamResponse item, - AudioQuality audioQuality, - ) { - final streamFormat = - kIsLinux ? PipedAudioStreamFormat.webm : PipedAudioStreamFormat.m4a; - - if (audioQuality == AudioQuality.high) { - return item.highestBitrateAudioStreamOfFormat(streamFormat)!; - } else { - return item.lowestBitrateAudioStreamOfFormat(streamFormat)!; - } - } - - static Future> fetchSiblings( + static Future> fetchSiblings( Track track, - PipedClient client, [ - PipedFilter filter = PipedFilter.musicSongs, - ]) async { + YoutubeEndpoints client, + ) async { final artists = (track.artists ?? []) .map((ar) => ar.name) .toList() @@ -79,22 +62,21 @@ class SpotubeTrack extends Track { onlyCleanArtist: true, ).trim(); - final List siblings = - await client.search("$title - ${artists.join(", ")}", filter).then( + final List siblings = + await client.search("$title - ${artists.join(", ")}").then( (res) { - final siblings = res.items - .whereType() + final siblings = res .where((item) { return artists.any( (artist) => - artist.toLowerCase() == item.uploaderName.toLowerCase(), + artist.toLowerCase() == item.channelName.toLowerCase(), ); }) .take(10) .toList(); if (siblings.isEmpty) { - return res.items.whereType().take(10).toList(); + return res.take(10).toList(); } return siblings; @@ -106,61 +88,53 @@ class SpotubeTrack extends Track { static Future fetchFromTrack( Track track, - UserPreferences preferences, - PipedClient client, + YoutubeEndpoints client, ) async { final matchedCachedTrack = await MatchedTrack.box.get(track.id!); - var siblings = []; - PipedStreamResponse ytVideo; - if (matchedCachedTrack != null) { - ytVideo = await client.streams(matchedCachedTrack.youtubeId); - } else { - siblings = await fetchSiblings( - track, - client, - switch (preferences.searchMode) { - SearchMode.youtube => PipedFilter.video, - SearchMode.youtubeMusic => PipedFilter.musicSongs, - }, + var siblings = []; + YoutubeVideoInfo ytVideo; + String ytStreamUrl; + if (matchedCachedTrack != null && + matchedCachedTrack.searchMode == client.preferences.searchMode) { + (ytVideo, ytStreamUrl) = await client.video( + matchedCachedTrack.youtubeId, + matchedCachedTrack.searchMode, ); + } else { + siblings = await fetchSiblings(track, client); if (siblings.isEmpty) { throw Exception("Failed to find any results for ${track.name}"); } - ytVideo = await client.streams(siblings.first.id); + (ytVideo, ytStreamUrl) = + await client.video(siblings.first.id, siblings.first.searchMode); await MatchedTrack.box.put( track.id!, MatchedTrack( youtubeId: ytVideo.id, spotifyId: track.id!, + searchMode: siblings.first.searchMode, ), ); } - final PipedAudioStream ytStream = - getStreamInfo(ytVideo, preferences.audioQuality); - return SpotubeTrack.fromTrack( track: track, ytTrack: ytVideo, - ytUri: ytStream.url, + ytUri: ytStreamUrl, siblings: siblings, ); } Future swappedCopy( - PipedSearchItemStream video, - UserPreferences preferences, - PipedClient client, + YoutubeVideoInfo video, + YoutubeEndpoints client, ) async { // sibling tracks that were manually searched and swapped final isStepSibling = siblings.none((element) => element.id == video.id); - final ytVideo = await client.streams(video.id); - - final ytStream = getStreamInfo(ytVideo, preferences.audioQuality); - - final ytUri = ytStream.url; + final (ytVideo, ytStreamUrl) = + await client.video(video.id, siblings.first.searchMode); if (!isStepSibling) { await MatchedTrack.box.put( @@ -168,6 +142,7 @@ class SpotubeTrack extends Track { MatchedTrack( youtubeId: video.id, spotifyId: id!, + searchMode: siblings.first.searchMode, ), ); } @@ -175,7 +150,7 @@ class SpotubeTrack extends Track { return SpotubeTrack.fromTrack( track: this, ytTrack: ytVideo, - ytUri: ytUri, + ytUri: ytStreamUrl, siblings: [ video, ...siblings.where((element) => element.id != video.id), @@ -186,24 +161,20 @@ class SpotubeTrack extends Track { static SpotubeTrack fromJson(Map map) { return SpotubeTrack.fromTrack( track: Track.fromJson(map), - ytTrack: PipedStreamResponse.fromJson(map["ytTrack"]), + ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]), ytUri: map["ytUri"], siblings: List.castFrom>(map["siblings"]) - .map((sibling) => PipedSearchItemStream.fromJson(sibling)) + .map((sibling) => YoutubeVideoInfo.fromJson(sibling)) .toList(), ); } - Future populatedCopy( - PipedClient client, - PipedFilter filter, - ) async { + Future populatedCopy(YoutubeEndpoints client) async { if (this.siblings.isNotEmpty) return this; final siblings = await fetchSiblings( this, client, - filter, ); return SpotubeTrack.fromTrack( diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 6b0bcc0c1..9a91cb2cb 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -20,10 +20,11 @@ import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/l10n/l10n.dart'; +import 'package:spotube/models/matched_track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/piped_provider.dart'; +import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; class SettingsPage extends HookConsumerWidget { @@ -290,49 +291,11 @@ class SettingsPage extends HookConsumerWidget { } }, ), - Consumer(builder: (context, ref, child) { - final instanceList = - ref.watch(pipedInstancesFutureProvider); - - return instanceList.when( - data: (data) { - return AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.piped), - title: Text(context.l10n.piped_instance), - subtitle: Text(context.l10n.piped_description), - value: preferences.pipedInstance, - showValueWhenUnfolded: false, - options: data - .sortedBy((e) => e.name) - .map( - (e) => DropdownMenuItem( - value: e.apiUrl, - child: Text( - "${e.name}\n" - "${e.locations.map(countryCodeToEmoji).join(" ")}", - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - preferences.setPipedInstance(value); - } - }, - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (error, stackTrace) => - Text(error.toString()), - ); - }), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.search), - title: Text(context.l10n.search_mode), - value: preferences.searchMode, - options: SearchMode.values + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.youtube), + title: Text(context.l10n.youtube_api_type), + value: preferences.youtubeApiType, + options: YoutubeApiType.values .map((e) => DropdownMenuItem( value: e, child: Text(e.label), @@ -340,32 +303,92 @@ class SettingsPage extends HookConsumerWidget { .toList(), onChanged: (value) { if (value == null) return; - preferences.setSearchMode(value); + preferences.setYoutubeApiType(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); - }, - ), - ), - ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.youtubeApiType == + YoutubeApiType.youtube + ? const SizedBox.shrink() + : Consumer(builder: (context, ref, child) { + final instanceList = + ref.watch(pipedInstancesFutureProvider); + + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: + const Icon(SpotubeIcons.piped), + title: + Text(context.l10n.piped_instance), + subtitle: Text( + context.l10n.piped_description), + value: preferences.pipedInstance, + showValueWhenUnfolded: false, + options: data + .sortedBy((e) => e.name) + .map( + (e) => DropdownMenuItem( + value: e.apiUrl, + child: Text( + "${e.name}\n" + "${e.locations.map(countryCodeToEmoji).join(" ")}", + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + preferences.setPipedInstance(value); + } + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => + Text(error.toString()), + ); + }), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.youtubeApiType == + YoutubeApiType.youtube + ? const SizedBox.shrink() + : AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.search), + 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); + }, + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.searchMode == + SearchMode.youtubeMusic && + preferences.youtubeApiType == + YoutubeApiType.piped + ? const SizedBox.shrink() + : SwitchListTile( + secondary: const Icon(SpotubeIcons.skip), + title: Text(context.l10n.skip_non_music), + value: preferences.skipNonMusic, + onChanged: (state) { + preferences.setSkipNonMusic(state); + }, + ), ), ListTile( leading: const Icon(SpotubeIcons.playlistRemove), diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 4ad2ed948..a568c7a4d 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -7,13 +7,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; -import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/piped_provider.dart'; + import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadManagerProvider extends StateNotifier> { @@ -104,7 +105,7 @@ class DownloadManagerProvider extends StateNotifier> { } UserPreferences get preferences => ref.read(userPreferencesProvider); - PipedClient get pipedClient => ref.read(pipedClientProvider); + YoutubeEndpoints get youtube => ref.read(youtubeProvider); int get totalDownloads => state.length; List get items => state; @@ -129,8 +130,7 @@ class DownloadManagerProvider extends StateNotifier> { ? track : await SpotubeTrack.fetchFromTrack( track, - preferences, - pipedClient, + youtube, ); state = [...state, spotubeTrack]; final task = DownloadTask( diff --git a/lib/provider/piped_instances_provider.dart b/lib/provider/piped_instances_provider.dart new file mode 100644 index 000000000..290ad2c49 --- /dev/null +++ b/lib/provider/piped_instances_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:piped_client/piped_client.dart'; +import 'package:spotube/provider/youtube_provider.dart'; + +final pipedInstancesFutureProvider = FutureProvider>( + (ref) async { + final youtube = ref.watch(youtubeProvider); + return await youtube.piped?.instanceList() ?? []; + }, +); diff --git a/lib/provider/piped_provider.dart b/lib/provider/piped_provider.dart deleted file mode 100644 index d03ed90b1..000000000 --- a/lib/provider/piped_provider.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:piped_client/piped_client.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; - -PipedClient _defaultClient = PipedClient(); - -final pipedClientProvider = Provider((ref) { - final instanceUrl = - ref.watch(userPreferencesProvider.select((s) => s.pipedInstance)); - - if (instanceUrl == "https://pipedapi.kavin.rocks") return _defaultClient; - - return PipedClient(instance: instanceUrl); -}); - -final pipedInstancesFutureProvider = FutureProvider>( - (ref) async => _defaultClient.instanceList(), -); diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart index 4cbca0ccf..ac8f5bbc4 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -1,7 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/matched_track.dart'; @@ -9,11 +8,12 @@ import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/supabase.dart'; +import 'package:spotube/services/youtube/youtube.dart'; mixin NextFetcher on StateNotifier { Future> fetchTracks( UserPreferences preferences, - PipedClient pipedClient, { + YoutubeEndpoints youtube, { int count = 3, int offset = 0, }) async { @@ -29,8 +29,7 @@ mixin NextFetcher on StateNotifier { bareTracks.mapIndexed((i, track) async { final future = SpotubeTrack.fetchFromTrack( track, - preferences, - pipedClient, + youtube, ); if (i == 0) { return await future; @@ -117,6 +116,7 @@ mixin NextFetcher on StateNotifier { MatchedTrack( youtubeId: spotubeTrack.ytTrack.id, spotifyId: spotubeTrack.id!, + searchMode: spotubeTrack.ytTrack.searchMode, ), ); } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 89137f776..7c1c5c914 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -7,11 +7,11 @@ 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/matched_track.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -19,9 +19,10 @@ import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; -import 'package:spotube/provider/piped_provider.dart'; +import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -51,7 +52,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier late final AudioServices notificationService; UserPreferences get preferences => ref.read(userPreferencesProvider); - PipedClient get pipedClient => ref.read(pipedClientProvider); + YoutubeEndpoints get youtube => ref.read(youtubeProvider); ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(BlackListNotifier.provider.notifier); @@ -213,7 +214,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final nthFetchedTrack = switch (track.runtimeType) { SpotubeTrack => track as SpotubeTrack, - _ => await SpotubeTrack.fetchFromTrack(track, preferences, pipedClient), + _ => await SpotubeTrack.fetchFromTrack(track, youtube), }; await audioPlayer.replaceSource( @@ -298,8 +299,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } else { final addableTrack = await SpotubeTrack.fetchFromTrack( tracks.elementAtOrNull(initialIndex) ?? tracks.first, - preferences, - pipedClient, + youtube, ); state = state.copyWith( @@ -387,13 +387,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier Future populateSibling() async { if (state.activeTrack is SpotubeTrack) { final activeTrackWithSiblingsForSure = - await (state.activeTrack as SpotubeTrack).populatedCopy( - pipedClient, - switch (preferences.searchMode) { - SearchMode.youtube => PipedFilter.video, - SearchMode.youtubeMusic => PipedFilter.musicSongs, - }, - ); + await (state.activeTrack as SpotubeTrack).populatedCopy(youtube); state = state.copyWith( tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), @@ -403,11 +397,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } } - Future swapSibling(PipedSearchItem video) async { - if (state.activeTrack is SpotubeTrack && video is PipedSearchItemStream) { + Future swapSibling(YoutubeVideoInfo video) async { + if (state.activeTrack is SpotubeTrack) { await populateSibling(); - final newTrack = await (state.activeTrack as SpotubeTrack) - .swappedCopy(video, preferences, pipedClient); + final newTrack = + await (state.activeTrack as SpotubeTrack).swappedCopy(video, youtube); if (newTrack == null) return; state = state.copyWith( tracks: mergeTracks([newTrack], state.tracks), diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index 2e5c082d5..c321dca7f 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/models/matched_track.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -28,13 +29,11 @@ enum CloseBehavior { close, } -enum SearchMode { - youtube._internal('YouTube'), - youtubeMusic._internal('YouTube Music'); +enum YoutubeApiType { + youtube, + piped; - final String label; - - const SearchMode._internal(this.label); + String get label => name[0].toUpperCase() + name.substring(1); } class UserPreferences extends PersistedChangeNotifier { @@ -63,6 +62,8 @@ class UserPreferences extends PersistedChangeNotifier { bool skipNonMusic; + YoutubeApiType youtubeApiType; + final Ref ref; UserPreferences( @@ -82,6 +83,7 @@ class UserPreferences extends PersistedChangeNotifier { this.pipedInstance = "https://pipedapi.kavin.rocks", this.searchMode = SearchMode.youtube, this.skipNonMusic = true, + this.youtubeApiType = YoutubeApiType.youtube, }) : super() { if (downloadLocation.isEmpty) { _getDefaultDownloadDirectory().then( @@ -188,6 +190,12 @@ class UserPreferences extends PersistedChangeNotifier { updatePersistence(); } + void setYoutubeApiType(YoutubeApiType type) { + youtubeApiType = type; + notifyListeners(); + updatePersistence(); + } + Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; @@ -240,6 +248,11 @@ class UserPreferences extends PersistedChangeNotifier { ); skipNonMusic = map["skipNonMusic"] ?? skipNonMusic; + + youtubeApiType = YoutubeApiType.values.firstWhere( + (type) => type.name == map["youtubeApiType"], + orElse: () => YoutubeApiType.youtube, + ); } @override @@ -261,6 +274,7 @@ class UserPreferences extends PersistedChangeNotifier { "pipedInstance": pipedInstance, "searchMode": searchMode.name, "skipNonMusic": skipNonMusic, + "youtubeApiType": youtubeApiType.name, }; } } diff --git a/lib/provider/youtube_provider.dart b/lib/provider/youtube_provider.dart new file mode 100644 index 000000000..0e7b7d0ea --- /dev/null +++ b/lib/provider/youtube_provider.dart @@ -0,0 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/services/youtube/youtube.dart'; + +final youtubeProvider = Provider((ref) { + final preferences = ref.watch(userPreferencesProvider); + return YoutubeEndpoints(preferences); +}); diff --git a/lib/services/youtube/youtube.dart b/lib/services/youtube/youtube.dart new file mode 100644 index 000000000..9ae6a2240 --- /dev/null +++ b/lib/services/youtube/youtube.dart @@ -0,0 +1,221 @@ +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:piped_client/piped_client.dart'; +import 'package:spotube/models/matched_track.dart'; +import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +class YoutubeVideoInfo { + final SearchMode searchMode; + final String title; + final Duration duration; + final String thumbnailUrl; + final String id; + final int likes; + final int dislikes; + final int views; + final String channelName; + final String channelId; + final DateTime publishedAt; + + YoutubeVideoInfo({ + required this.searchMode, + required this.title, + required this.duration, + required this.thumbnailUrl, + required this.id, + required this.likes, + required this.dislikes, + required this.views, + required this.channelName, + required this.publishedAt, + required this.channelId, + }); + + YoutubeVideoInfo.fromJson(Map json) + : title = json['title'], + searchMode = SearchMode.fromString(json['searchMode']), + duration = Duration(seconds: json['duration']), + thumbnailUrl = json['thumbnailUrl'], + id = json['id'], + likes = json['likes'], + dislikes = json['dislikes'], + views = json['views'], + channelName = json['channelName'], + channelId = json['channelId'], + publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now(); + + Map toJson() => { + 'title': title, + 'duration': duration.inSeconds, + 'thumbnailUrl': thumbnailUrl, + 'id': id, + 'likes': likes, + 'dislikes': dislikes, + 'views': views, + 'channelName': channelName, + 'channelId': channelId, + 'publishedAt': publishedAt.toIso8601String(), + 'searchMode': searchMode.name, + }; + + factory YoutubeVideoInfo.fromVideo(Video video) { + return YoutubeVideoInfo( + searchMode: SearchMode.youtube, + title: video.title, + duration: video.duration ?? Duration.zero, + thumbnailUrl: video.thumbnails.mediumResUrl, + id: video.id.value, + likes: video.engagement.likeCount ?? 0, + dislikes: video.engagement.dislikeCount ?? 0, + views: video.engagement.viewCount, + channelName: video.author, + channelId: '/c/${video.channelId.value}', + publishedAt: video.uploadDate ?? DateTime(2003, 9, 9), + ); + } + + factory YoutubeVideoInfo.fromSearchItemStream( + PipedSearchItemStream searchItem, + SearchMode searchMode, + ) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: searchItem.title, + duration: searchItem.duration, + thumbnailUrl: searchItem.thumbnail, + id: searchItem.id, + likes: 0, + dislikes: 0, + views: searchItem.views, + channelName: searchItem.uploaderName, + channelId: searchItem.uploaderUrl ?? "", + publishedAt: searchItem.uploadedDate != null + ? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9) + : DateTime(2003, 9, 9), + ); + } + + factory YoutubeVideoInfo.fromStreamResponse( + PipedStreamResponse stream, SearchMode searchMode) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: stream.title, + duration: stream.duration, + thumbnailUrl: stream.thumbnailUrl, + id: stream.id, + likes: stream.likes, + dislikes: stream.dislikes, + views: stream.views, + channelName: stream.uploader, + publishedAt: stream.uploadedDate != null + ? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9) + : DateTime(2003, 9, 9), + channelId: stream.uploaderUrl, + ); + } +} + +class YoutubeEndpoints { + PipedClient? piped; + YoutubeExplode? youtube; + + final UserPreferences preferences; + + YoutubeEndpoints(this.preferences) { + switch (preferences.youtubeApiType) { + case YoutubeApiType.youtube: + youtube = YoutubeExplode(); + break; + case YoutubeApiType.piped: + piped = PipedClient(instance: preferences.pipedInstance); + break; + } + } + + Future> search(String query) async { + if (youtube != null) { + final res = await youtube!.search( + query, + filter: TypeFilters.video, + ); + + return res.map(YoutubeVideoInfo.fromVideo).toList(); + } else { + final res = await piped!.search( + query, + switch (preferences.searchMode) { + SearchMode.youtube => PipedFilter.video, + SearchMode.youtubeMusic => PipedFilter.musicSongs, + }, + ); + return res.items + .whereType() + .map( + (e) => YoutubeVideoInfo.fromSearchItemStream( + e, + preferences.searchMode, + ), + ) + .toList(); + } + } + + String _pipedStreamResponseToStreamUrl(PipedStreamResponse stream) { + final streamFormat = DesktopTools.platform.isLinux + ? PipedAudioStreamFormat.webm + : PipedAudioStreamFormat.m4a; + + return switch (preferences.audioQuality) { + AudioQuality.high => + stream.highestBitrateAudioStreamOfFormat(streamFormat)!.url, + AudioQuality.low => + stream.lowestBitrateAudioStreamOfFormat(streamFormat)!.url, + }; + } + + Future streamingUrl(String id) async { + if (youtube != null) { + final res = await PrimitiveUtils.raceMultiple( + () => youtube!.videos.streams.getManifest(id), + ); + final audioOnlyManifests = res.audioOnly.where((info) { + final isMp4a = info.codec.mimeType == "audio/mp4"; + if (DesktopTools.platform.isLinux) { + return !isMp4a; + } else if (DesktopTools.platform.isMacOS || + DesktopTools.platform.isIOS) { + return isMp4a; + } else { + return true; + } + }); + + return switch (preferences.audioQuality) { + AudioQuality.high => + audioOnlyManifests.withHighestBitrate().url.toString(), + AudioQuality.low => + audioOnlyManifests.sortByBitrate().last.url.toString(), + }; + } else { + return _pipedStreamResponseToStreamUrl(await piped!.streams(id)); + } + } + + Future<(YoutubeVideoInfo info, String streamingUrl)> video( + String id, SearchMode searchMode) async { + if (youtube != null) { + final res = await youtube!.videos.get(id); + return ( + YoutubeVideoInfo.fromVideo(res), + await streamingUrl(id), + ); + } else { + final res = await piped!.streams(id); + return ( + YoutubeVideoInfo.fromStreamResponse(res, searchMode), + _pipedStreamResponseToStreamUrl(res), + ); + } + } +} diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index a528ff443..16a930c9d 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -5,11 +5,12 @@ import 'dart:io'; import 'package:flutter/widgets.dart' hide Image; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; -import 'package:piped_client/piped_client.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -127,28 +128,19 @@ abstract class TypeConversionUtils { String? art, }) { final track = SpotubeTrack( - PipedStreamResponse( + YoutubeVideoInfo( + searchMode: SearchMode.youtube, id: "dQw4w9WgXcQ", title: basenameWithoutExtension(file.path), - dash: null, - description: "", - dislikes: -1, duration: Duration(milliseconds: metadata?.durationMs?.toInt() ?? 0), - hls: null, - lbryId: "", - likes: -1, - livestream: false, - proxyUrl: "", + dislikes: 0, + likes: 0, thumbnailUrl: art ?? "", - uploadedDate: DateTime.now().toUtc().toString(), - uploader: metadata?.albumArtist ?? "", - uploaderUrl: "", - uploaderVerified: false, - views: -1, - audioStreams: [], - videoStreams: [], - relatedStreams: [], - subtitles: [], + views: 0, + channelName: metadata?.albumArtist ?? "Spotube", + channelId: metadata?.albumArtist ?? "Spotube", + publishedAt: + metadata?.year != null ? DateTime(metadata!.year!) : DateTime(2003), ), file.path, [], diff --git a/pubspec.lock b/pubspec.lock index 2190c041c..d84d4e1b3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2132,6 +2132,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + youtube_explode_dart: + dependency: "direct main" + description: + name: youtube_explode_dart + sha256: "07889a6229a63e78f8d45a3b852897c2e0fa42e96c4daa38d411be211575bc38" + url: "https://pub.dev" + source: hosted + version: "1.12.4" sdks: - dart: ">=3.0.2 <4.0.0" + dart: ">=3.0.0 <4.0.0" flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index be28e5b3b..6e814c7e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -96,6 +96,7 @@ dependencies: background_downloader: ^7.4.0 duration: ^3.0.12 disable_battery_optimization: ^1.1.0+1 + youtube_explode_dart: ^1.12.4 dev_dependencies: build_runner: ^2.3.2 diff --git a/untranslated_messages.json b/untranslated_messages.json index ef57a47ec..27f19dc8b 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -64,12 +64,14 @@ "logs", "developers", "not_logged_in", - "search_mode" + "search_mode", + "youtube_api_type" ], "de": [ "not_logged_in", - "search_mode" + "search_mode", + "youtube_api_type" ], "fr": [ @@ -137,7 +139,8 @@ "logs", "developers", "not_logged_in", - "search_mode" + "search_mode", + "youtube_api_type" ], "hi": [ @@ -205,11 +208,13 @@ "logs", "developers", "not_logged_in", - "search_mode" + "search_mode", + "youtube_api_type" ], "ja": [ "not_logged_in", - "search_mode" + "search_mode", + "youtube_api_type" ] }