diff --git a/.env.example b/.env.example index 0ea04df78..920fe826b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +SUPABASE_URL= +SUPABASE_API_KEY= + # The format: # SPOTIFY_SECRETS=clintId1:clientSecret1,clientId2:clientSecret2 SPOTIFY_SECRETS= diff --git a/lib/collections/env.dart b/lib/collections/env.dart index e03104e7f..c279a0f94 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -4,6 +4,12 @@ part 'env.g.dart'; @Envied(obfuscate: true, requireEnvFile: true, path: ".env") abstract class Env { + @EnviedField(varName: 'SUPABASE_URL') + static final supabaseUrl = _Env.supabaseUrl; + + @EnviedField(varName: 'SUPABASE_API_KEY') + static final supabaseAnonKey = _Env.supabaseAnonKey; + @EnviedField(varName: 'SPOTIFY_SECRETS') static final spotifySecrets = _Env.spotifySecrets.split(',').map((e) { final secrets = e.trim().split(":").map((e) => e.trim()); diff --git a/lib/components/shared/image/universal_image.dart b/lib/components/shared/image/universal_image.dart index 7d01bc47d..04c624786 100644 --- a/lib/components/shared/image/universal_image.dart +++ b/lib/components/shared/image/universal_image.dart @@ -59,6 +59,16 @@ class UniversalImage extends HookWidget { height: height, width: width, placeholder: AssetImage(placeholder ?? Assets.placeholder.path), + imageErrorBuilder: (context, error, stackTrace) { + return Image.asset( + placeholder ?? Assets.placeholder.path, + width: width, + height: height, + cacheHeight: height?.toInt(), + cacheWidth: width?.toInt(), + scale: scale, + ); + }, fit: fit, ); } else if (Uri.tryParse(path) != null && !path.startsWith("assets")) { diff --git a/lib/main.dart b/lib/main.dart index 4e0150817..32f42ec3b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/intents.dart'; @@ -28,6 +29,7 @@ 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/persisted_state_notifier.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotube/hooks/use_init_sys_tray.dart'; @@ -71,6 +73,11 @@ Future main(List rawArgs) async { exit(0); } + await Supabase.initialize( + url: Env.supabaseUrl, + anonKey: Env.supabaseAnonKey, + ); + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); diff --git a/lib/models/matched_track.dart b/lib/models/matched_track.dart index c1146dc40..0ec57a300 100644 --- a/lib/models/matched_track.dart +++ b/lib/models/matched_track.dart @@ -9,9 +9,37 @@ class MatchedTrack { @HiveField(1) String spotifyId; + String? id; + DateTime? createdAt; + + bool get isSynced => id != null; + static const boxName = "oss.krtirtho.spotube.matched_tracks"; static LazyBox get box => Hive.lazyBox(boxName); - MatchedTrack({required this.youtubeId, required this.spotifyId}); + MatchedTrack({ + required this.youtubeId, + required this.spotifyId, + this.id, + this.createdAt, + }); + + factory MatchedTrack.fromJson(Map json) { + return MatchedTrack( + youtubeId: json["youtube_id"], + spotifyId: json["spotify_id"], + id: json["id"], + createdAt: DateTime.parse(json["created_at"]), + ); + } + + Map toJson() { + return { + "youtube_id": youtubeId, + "spotify_id": spotifyId, + "id": id, + "created_at": createdAt?.toString() + }..removeWhere((key, value) => value == null); + } } diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart index 25418a191..a6ddb9ed5 100644 --- a/lib/models/spotube_track.dart +++ b/lib/models/spotube_track.dart @@ -8,6 +8,7 @@ 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/supabase.dart'; import 'package:spotube/services/youtube.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart index 9588702f4..41a88a1a4 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -1,10 +1,13 @@ +import 'package:catcher/catcher.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/matched_track.dart'; 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'; mixin NextFetcher on StateNotifier { Future> fetchTracks( @@ -78,4 +81,19 @@ mixin NextFetcher on StateNotifier { return "https://youtube.com/unplayable.m4a?id=${track.id}"; } } + + /// This method must be called after any playback operation as + /// it can increase the latency + Future storeTrack(Track track, SpotubeTrack spotubeTrack) async { + if (track is! SpotubeTrack) { + await supabase + .insertTrack( + MatchedTrack( + youtubeId: spotubeTrack.ytTrack.id, + spotifyId: spotubeTrack.id!, + ), + ) + .catchError(Catcher.reportCheckedError); + } + } } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 4b499ee30..89c502dbb 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -75,6 +75,8 @@ class ProxyPlaylistNotifier extends StateNotifier isPreSearching = true; // TODO: Make repeat mode sensitive changes later + final oldTrack = + state.tracks.elementAtOrNull(audioPlayer.currentIndex); final track = await ensureNthSourcePlayable(audioPlayer.currentIndex + 1); @@ -92,6 +94,13 @@ class ProxyPlaylistNotifier extends StateNotifier if (audioPlayer.isPaused) { await audioPlayer.resume(); } + + if (oldTrack != null && track != null) { + await storeTrack( + oldTrack, + track, + ); + } } finally { isPreSearching = false; } @@ -120,9 +129,10 @@ class ProxyPlaylistNotifier extends StateNotifier return null; } - final nthFetchedTrack = nthTrack is SpotubeTrack - ? nthTrack - : await SpotubeTrack.fetchFromTrack(nthTrack, preferences); + final nthFetchedTrack = switch (nthTrack.runtimeType) { + SpotubeTrack => nthTrack as SpotubeTrack, + _ => await SpotubeTrack.fetchFromTrack(nthTrack, preferences), + }; if (nthSource == nthFetchedTrack.ytUri) return null; @@ -196,14 +206,27 @@ class ProxyPlaylistNotifier extends StateNotifier initialIndex: initialIndex, autoPlay: autoPlay, ); + + await storeTrack( + tracks[initialIndex], + addableTrack, + ); } Future jumpTo(int index) async { + final oldTrack = state.tracks.elementAtOrNull(audioPlayer.currentIndex); final track = await ensureNthSourcePlayable(index); if (track != null) { state = state.copyWith(tracks: mergeTracks([track], state.tracks)); } await audioPlayer.jumpTo(index); + + if (oldTrack != null && track != null) { + await storeTrack( + oldTrack, + track, + ); + } } Future jumpToTrack(Track track) async { @@ -234,19 +257,34 @@ class ProxyPlaylistNotifier extends StateNotifier Future swapSibling(PipedSearchItem video) async {} Future next() async { + final oldTrack = state.tracks.elementAtOrNull(audioPlayer.currentIndex + 1); final track = await ensureNthSourcePlayable(audioPlayer.currentIndex + 1); if (track != null) { state = state.copyWith(tracks: mergeTracks([track], state.tracks)); } await audioPlayer.skipToNext(); + + if (oldTrack != null && track != null) { + await storeTrack( + oldTrack, + track, + ); + } } Future previous() async { + final oldTrack = state.tracks.elementAtOrNull(audioPlayer.currentIndex - 1); final track = await ensureNthSourcePlayable(audioPlayer.currentIndex - 1); if (track != null) { state = state.copyWith(tracks: mergeTracks([track], state.tracks)); } await audioPlayer.skipToPrevious(); + if (oldTrack != null && track != null) { + await storeTrack( + oldTrack, + track, + ); + } } Future stop() async { diff --git a/lib/services/supabase.dart b/lib/services/supabase.dart new file mode 100644 index 000000000..08bd508ef --- /dev/null +++ b/lib/services/supabase.dart @@ -0,0 +1,12 @@ +import 'package:spotube/models/matched_track.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class SupabaseService { + static SupabaseClient get api => Supabase.instance.client; + + Future insertTrack(MatchedTrack track) async { + await api.from("tracks").insert(track.toJson()); + } +} + +final supabase = SupabaseService(); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7210ed75e..af840e219 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_links import audio_service import audio_session import catcher @@ -16,6 +17,7 @@ import package_info_plus import path_provider_foundation import screen_retriever import shared_preferences_foundation +import sign_in_with_apple import sqflite import system_theme import system_tray @@ -24,6 +26,7 @@ import window_manager import window_size func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) CatcherPlugin.register(with: registry.registrar(forPlugin: "CatcherPlugin")) @@ -35,6 +38,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 9550de622..969e5a80b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.0" + app_links: + dependency: transitive + description: + name: app_links + sha256: d572dcdff49c4cfcfa6f315e2683e518ec6eb54e084d01e51d9631a4dcc1b5e8 + url: "https://pub.dev" + source: hosted + version: "3.4.2" app_package_maker: dependency: transitive description: @@ -777,6 +785,14 @@ packages: description: flutter source: sdk version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "26059c5fb000ffd0986ae3144d43c2a6f54931610fd61c2584e18e308c7eaa52" + url: "https://pub.dev" + source: hosted + version: "1.2.1" fuzzywuzzy: dependency: "direct main" description: @@ -801,6 +817,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.6" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: c08f5ac76dcae2dd06cc7f8e80a8ede12c66454fef06caac3b191c8c7a603811 + url: "https://pub.dev" + source: hosted + version: "1.7.1" graphs: dependency: transitive description: @@ -966,6 +990,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.7" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" lints: dependency: transitive description: @@ -1312,6 +1344,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8+1" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: "7b91eb7b40621d07aaae687f47f3032f30e1b86a9ccebfcfca52d001223f8b6e" + url: "https://pub.dev" + source: hosted + version: "1.2.4" process: dependency: transitive description: @@ -1376,6 +1416,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "0f2614f72e5639ddd7abc3dede336f23554f9f744d0b064d41009f9ca94a53d2" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + retry: + dependency: transitive + description: + name: retry + sha256: a8a1e475a100a0bdc73f529ca8ea1e9c9c76bec8ad86a1f47780139a34ce7aea + url: "https://pub.dev" + source: hosted + version: "3.1.1" riverpod: dependency: transitive description: @@ -1512,6 +1568,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.0" + sign_in_with_apple: + dependency: transitive + description: + name: sign_in_with_apple + sha256: ac3b113767dfdd765078c507dad9d4d9fe96b669cc7bd88fc36fc15376fb3400 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + sign_in_with_apple_platform_interface: + dependency: transitive + description: + name: sign_in_with_apple_platform_interface + sha256: a5883edee09ed6be19de19e7d9f618a617fe41a6fa03f76d082dfb787e9ea18d + url: "https://pub.dev" + source: hosted + version: "1.0.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: "44b66528f576e77847c14999d5e881e17e7223b7b0625a185417829e5306f47a" + url: "https://pub.dev" + source: hosted + version: "1.0.1" simple_circular_progress_bar: dependency: "direct main" description: @@ -1606,6 +1686,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2+1" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "4ed4dc8a990d178c96962319d6d8c267c3e206fca2c2b98660bad6e001220ffc" + url: "https://pub.dev" + source: hosted + version: "1.3.1" stream_channel: dependency: transitive description: @@ -1630,6 +1718,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + supabase: + dependency: transitive + description: + name: supabase + sha256: "403739cdfea48ba633450e5b191ceeaae81ac10ec89166c0e109235b3e1532f3" + url: "https://pub.dev" + source: hosted + version: "1.8.1" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "7cbdd9a7264dd5b7ab5a6e2da63346054b8e5ddf358467c7f2bc23d5c14d732c" + url: "https://pub.dev" + source: hosted + version: "1.9.1" sync_http: dependency: transitive description: @@ -1878,6 +1982,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: "1a37bdbaaf5fbe09ad8579ab09ecfd473284ce482f900b5aea27cf834386a567" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "1acea8def62592123e2fbbca164ed8681a98a890bdcbb88f916d5b4a22687759" + url: "https://pub.dev" + source: hosted + version: "3.7.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "78715dc442b7849dbde74e92bb67de1cecf5addf95531c5fb474e72f5fe9a507" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "4646bb68297803bdbb96d46853e8fcb560d6cb5e04153fa64581535767875dfe" + url: "https://pub.dev" + source: hosted + version: "3.4.3" win32: dependency: transitive description: @@ -1935,6 +2071,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: "94ba4947ac1ce44bd6a1634d9df712e07b9b5025ba12abd6750be77ba5c08f18" + url: "https://pub.dev" + source: hosted + version: "1.0.4" sdks: dart: ">=3.0.0 <4.0.0" flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index ddcd6f379..b7ee0408f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -101,6 +101,7 @@ dependencies: git: url: https://github.com/KRTirtho/piped_client ref: 2036a78d3414a0fc7fe3b081f1029dd086352fcd + supabase_flutter: ^1.9.1 dev_dependencies: build_runner: ^2.3.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b8983c9c7..f0256a87f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -19,6 +20,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); CatcherPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("CatcherPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e066d2236..186806c74 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links catcher flutter_secure_storage_windows local_notifier