diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index cd6fa5e07..d435a89f4 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -79,4 +79,5 @@ abstract class SpotubeIcons { static const colorSync = FeatherIcons.activity; static const language = FeatherIcons.globe; static const error = FeatherIcons.alertTriangle; + static const piped = FeatherIcons.cloud; } diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart index 0ee1d1fef..0a44697d6 100644 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ b/lib/components/shared/adaptive/adaptive_select_tile.dart @@ -14,6 +14,13 @@ class AdaptiveSelectTile extends HookWidget { final Breakpoints breakAfterOr; + /// Show the smaller value when the breakpoint is reached + /// + /// If false, the control will be hidden when the breakpoint is reached + /// + /// Defaults to `true` + final bool showValueWhenUnfolded; + const AdaptiveSelectTile({ required this.title, required this.value, @@ -23,6 +30,7 @@ class AdaptiveSelectTile extends HookWidget { this.subtitle, this.secondary, this.breakAfterOr = Breakpoints.md, + this.showValueWhenUnfolded = true, super.key, }); @@ -49,22 +57,24 @@ class AdaptiveSelectTile extends HookWidget { final control = breakpoint >= breakAfterOr ? rawControl - : Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border.all( - color: theme.colorScheme.primary, - width: 2, - ), - borderRadius: BorderRadius.circular(10), - ), - child: DefaultTextStyle( - style: TextStyle( - color: theme.colorScheme.primary, - ), - child: controlPlaceholder, - ), - ); + : showValueWhenUnfolded + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all( + color: theme.colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(10), + ), + child: DefaultTextStyle( + style: TextStyle( + color: theme.colorScheme.primary, + ), + child: controlPlaceholder, + ), + ) + : const SizedBox.shrink(); return ListTile( title: title, diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 6bddf7a46..0b5f3c146 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -182,5 +182,7 @@ "success_message": "এখন আপনি সফলভাবে আপনার Spotify অ্যাকাউন্ট দিয়ে লগ ইন করেছেন। সাধুভাত আপনাকে", "step_4": "ধাপ 4", "step_4_steps": "কপি করা \"sp_dc\" এবং \"sp_key\" এর মান সংশ্লিষ্ট ফিল্ডে পেস্ট করুন", - "something_went_wrong": "কিছু ভুল হয়েছে" + "something_went_wrong": "কিছু ভুল হয়েছে", + "piped_instance": "Piped সার্ভার এড্রেস", + "piped_description": "গান ম্যাচ করার জন্য ব্যবহৃত পাইপড সার্ভার\n এগুলোর মধ্যে কিছু ভাল কাজ নাও করতে পারে৷ তাই নিজ দায়িত্বে ব্যবহার করুন" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 62741c182..bf6533ebb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -182,5 +182,7 @@ "success_message": "Now you're successfully Logged In with your Spotify account. Good Job, mate!", "step_4": "Step 4", "step_4_steps": "Paste the copied \"sp_dc\" and \"sp_key\" values in the respective fields", - "something_went_wrong": "Something went wrong" + "something_went_wrong": "Something went wrong", + "piped_instance": "Piped Server Instance", + "piped_description": "The Piped server instance to use for track matching\nSome of them might not work well. So use at your own risk" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0e219c138..5a3c73854 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -182,5 +182,7 @@ "success_message": "Vous êtes maintenant connecté avec succès à votre compte Spotify. Bon travail, mon ami!", "step_4": "Étape 4", "step_4_steps": "Collez les valeurs copiées de \"sp_dc\" et \"sp_key\" dans les champs respectifs", - "something_went_wrong": "Quelque chose s'est mal passé" + "something_went_wrong": "Quelque chose s'est mal passé", + "piped_instance": "Instance pipée", + "piped_description": "L'instance de serveur Piped à utiliser pour la correspondance des pistes\nCertaines d'entre elles peuvent ne pas fonctionner correctement. Alors utilisez à vos risques et périls" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 02b91400a..51e85cec5 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -182,5 +182,7 @@ "success_message": "अब आप अपने स्पॉटिफाई अकाउंट से सफलतापूर्वक लॉगइन हो गए हैं। अच्छा काम किया!", "step_4": "स्टेप 4", "step_4_steps": "कॉपी की गई \"sp_dc\" और \"sp_key\" मानों को संबंधित फील्ड में पेस्ट करें", - "something_went_wrong": "कुछ गलत हो गया" + "something_went_wrong": "कुछ गलत हो गया", + "piped_instance": "पाइप्ड सर्वर", + "piped_description": "पाइप किए गए सर्वर\n गानों का मिलान करने के लिए उपयोग किए जाते हैं, हो सकता है कि उनमें से कुछ के साथ ठीक से काम न करें इसलिए अपने जोखिम पर उपयोग करें" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 712f81be0..4f971ce9d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,6 @@ import 'package:spotube/provider/downloader_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/youtube.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/custom_toast_handler.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -206,10 +205,6 @@ class SpotubeState extends ConsumerState { void initState() { super.initState(); SharedPreferences.getInstance().then(((value) => localStorage = value)); - - /// Doing the initialization here to avoid loading time - /// when in offline mode - PipedSpotube.initialize(); } @override diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart index 7b597a047..1d95d3bb4 100644 --- a/lib/models/spotube_track.dart +++ b/lib/models/spotube_track.dart @@ -8,7 +8,6 @@ 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/services/youtube.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:collection/collection.dart'; @@ -67,7 +66,10 @@ class SpotubeTrack extends Track { } } - static Future> fetchSiblings(Track track) async { + static Future> fetchSiblings( + Track track, + PipedClient client, + ) async { final artists = (track.artists ?? []) .map((ar) => ar.name) .toList() @@ -80,7 +82,7 @@ class SpotubeTrack extends Track { onlyCleanArtist: true, ).trim(); - final List siblings = await PipedSpotube.client + final List siblings = await client .search( "$title - ${artists.join(", ")}", PipedFilter.musicSongs, @@ -112,18 +114,19 @@ class SpotubeTrack extends Track { static Future fetchFromTrack( Track track, UserPreferences preferences, + PipedClient client, ) async { final matchedCachedTrack = await MatchedTrack.box.get(track.id!); var siblings = []; PipedStreamResponse ytVideo; if (matchedCachedTrack != null) { - ytVideo = await PipedSpotube.client.streams(matchedCachedTrack.youtubeId); + ytVideo = await client.streams(matchedCachedTrack.youtubeId); } else { - siblings = await fetchSiblings(track); + siblings = await fetchSiblings(track, client); if (siblings.isEmpty) { throw Exception("Failed to find any results for ${track.name}"); } - ytVideo = await PipedSpotube.client.streams(siblings.first.id); + ytVideo = await client.streams(siblings.first.id); await MatchedTrack.box.put( track.id!, @@ -167,10 +170,11 @@ class SpotubeTrack extends Track { Future swappedCopy( PipedSearchItemStream video, UserPreferences preferences, + PipedClient client, ) async { if (siblings.none((element) => element.id == video.id)) return null; - final ytVideo = await PipedSpotube.client.streams(video.id); + final ytVideo = await client.streams(video.id); final ytStream = getStreamInfo(ytVideo, preferences.audioQuality); @@ -225,10 +229,10 @@ class SpotubeTrack extends Track { ); } - Future populatedCopy() async { + Future populatedCopy(PipedClient client) async { if (this.siblings.isNotEmpty) return this; - final siblings = await fetchSiblings(this); + final siblings = await fetchSiblings(this, client); return SpotubeTrack.fromTrack( track: this, diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index b53aabeaa..78e680b69 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,10 +1,12 @@ import 'package:auto_size_text/auto_size_text.dart'; +import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:piped_client/piped_client.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/language_codes.dart'; @@ -21,6 +23,7 @@ import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/downloader_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/piped_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; class SettingsPage extends HookConsumerWidget { @@ -286,6 +289,41 @@ 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), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + preferences.setPipedInstance(value); + } + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => + Text(error.toString()), + ); + }), SwitchListTile( secondary: const Icon(SpotubeIcons.download), title: Text(context.l10n.pre_download_play), diff --git a/lib/provider/downloader_provider.dart b/lib/provider/downloader_provider.dart index f30ad7a3f..51ad9f912 100644 --- a/lib/provider/downloader_provider.dart +++ b/lib/provider/downloader_provider.dart @@ -11,6 +11,7 @@ import 'package:spotify/spotify.dart' hide Image, Queue; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/models/logger.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/utils/type_conversion_utils.dart'; @@ -50,6 +51,7 @@ class Downloader with ChangeNotifier { final track = await SpotubeTrack.fetchFromTrack( baseTrack, ref.read(userPreferencesProvider), + ref.read(pipedClientProvider), ); _queue.add(() async { diff --git a/lib/provider/piped_provider.dart b/lib/provider/piped_provider.dart new file mode 100644 index 000000000..d03ed90b1 --- /dev/null +++ b/lib/provider/piped_provider.dart @@ -0,0 +1,18 @@ +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 d32afaf8f..4cbca0ccf 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -1,6 +1,7 @@ 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'; @@ -11,7 +12,8 @@ import 'package:spotube/services/supabase.dart'; mixin NextFetcher on StateNotifier { Future> fetchTracks( - UserPreferences preferences, { + UserPreferences preferences, + PipedClient pipedClient, { int count = 3, int offset = 0, }) async { @@ -25,7 +27,11 @@ mixin NextFetcher on StateNotifier { /// fetch [bareTracks] one by one with 100ms delay final fetchedTracks = await Future.wait( bareTracks.mapIndexed((i, track) async { - final future = SpotubeTrack.fetchFromTrack(track, preferences); + final future = SpotubeTrack.fetchFromTrack( + track, + preferences, + pipedClient, + ); if (i == 0) { return await future; } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 70fdffcdb..2ed3567c0 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -15,7 +15,7 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; -import 'package:spotube/services/youtube.dart'; +import 'package:spotube/provider/piped_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -45,6 +45,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier late final AudioServices notificationService; UserPreferences get preferences => ref.read(userPreferencesProvider); + PipedClient get pipedClient => ref.read(pipedClientProvider); ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(BlackListNotifier.provider.notifier); @@ -157,7 +158,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final nthFetchedTrack = switch (track.runtimeType) { SpotubeTrack => track as SpotubeTrack, - _ => await SpotubeTrack.fetchFromTrack(track, preferences), + _ => await SpotubeTrack.fetchFromTrack(track, preferences, pipedClient), }; await audioPlayer.replaceSource( @@ -220,6 +221,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final addableTrack = await SpotubeTrack.fetchFromTrack( tracks.elementAtOrNull(initialIndex) ?? tracks.first, preferences, + pipedClient, ); state = state.copyWith( @@ -307,7 +309,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier Future populateSibling() async { if (state.activeTrack is SpotubeTrack) { final activeTrackWithSiblingsForSure = - await (state.activeTrack as SpotubeTrack).populatedCopy(); + await (state.activeTrack as SpotubeTrack).populatedCopy(pipedClient); state = state.copyWith( tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), @@ -321,7 +323,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier if (state.activeTrack is SpotubeTrack && video is PipedSearchItemStream) { populateSibling(); final newTrack = await (state.activeTrack as SpotubeTrack) - .swappedCopy(video, preferences); + .swappedCopy(video, preferences, pipedClient); if (newTrack == null) return; state = state.copyWith( tracks: mergeTracks([newTrack], state.tracks), @@ -438,13 +440,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier onInit() async { if (state.tracks.isEmpty) return null; - if (await PipedSpotube.initialized) { - await load( - state.tracks, - initialIndex: state.active ?? 0, - autoPlay: false, - ); - } + await load( + state.tracks, + initialIndex: state.active ?? 0, + autoPlay: false, + ); } @override diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index 7eed354e8..93cc17d58 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -50,6 +50,8 @@ class UserPreferences extends PersistedChangeNotifier { Locale locale; + String pipedInstance; + final Ref ref; UserPreferences( @@ -67,6 +69,7 @@ class UserPreferences extends PersistedChangeNotifier { this.closeBehavior = CloseBehavior.minimizeToTray, this.showSystemTrayIcon = true, this.locale = const Locale("system", "system"), + this.pipedInstance = "https://pipedapi.kavin.rocks", }) : super() { if (downloadLocation.isEmpty) { _getDefaultDownloadDirectory().then( @@ -161,6 +164,12 @@ class UserPreferences extends PersistedChangeNotifier { updatePersistence(); } + void setPipedInstance(String instance) { + pipedInstance = instance; + notifyListeners(); + updatePersistence(); + } + Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; @@ -206,6 +215,8 @@ class UserPreferences extends PersistedChangeNotifier { final localeMap = map["locale"] != null ? jsonDecode(map["locale"]) : null; locale = localeMap != null ? Locale(localeMap?["lc"], localeMap?["cc"]) : locale; + + pipedInstance = map["pipedInstance"] ?? pipedInstance; } @override @@ -225,6 +236,7 @@ class UserPreferences extends PersistedChangeNotifier { "showSystemTrayIcon": showSystemTrayIcon, "locale": jsonEncode({"lc": locale.languageCode, "cc": locale.countryCode}), + "pipedInstance": pipedInstance, }; } } diff --git a/lib/services/youtube.dart b/lib/services/youtube.dart deleted file mode 100644 index 1f37d463d..000000000 --- a/lib/services/youtube.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:async'; - -import 'package:piped_client/piped_client.dart'; - -PipedClient _defaultClient = PipedClient(); - -class PipedSpotube { - static final Completer _initialized = Completer(); - static Future get initialized => _initialized.future; - - /// Checks for a working instance of piped.video - /// - /// To distribute the load, in each startup it randomizes public instances - /// and selects a working instance and uses that throughout the session - static Future initialize() async { - final pipedInstances = await _defaultClient.instanceList(); - pipedInstances.shuffle(); - for (final instance in pipedInstances) { - final client = PipedClient(instance: instance.apiUrl); - try { - await client.streams("dQw4w9WgXcQ"); - _defaultClient = client; - _initialized.complete(true); - break; - } catch (e) { - continue; - } - } - } - - static PipedClient get client => _defaultClient; -} diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 4f04f612a..c3b648a80 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -10,7 +10,7 @@ ThemeData theme(Color seed, Brightness brightness) { useMaterial3: true, colorScheme: scheme, listTileTheme: ListTileThemeData( - horizontalTitleGap: 0, + horizontalTitleGap: 5, iconColor: scheme.onSurface, ), appBarTheme: const AppBarTheme(surfaceTintColor: Colors.transparent),