diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 184a051b..d38303a7 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; final rootNavigatorKey = GlobalKey(); diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 9dd0bec2..b10ef7e3 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -137,4 +137,6 @@ abstract class SpotubeIcons { static const extensions = FeatherIcons.package; static const message = FeatherIcons.send; static const upload = FeatherIcons.uploadCloud; + static const plugin = Icons.extension_outlined; + static const warning = FeatherIcons.alertTriangle; } diff --git a/lib/components/dialogs/playlist_add_track_dialog.dart b/lib/components/dialogs/playlist_add_track_dialog.dart index 8bdd24bd..09d831ea 100644 --- a/lib/components/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/dialogs/playlist_add_track_dialog.dart @@ -7,7 +7,7 @@ import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { /// The id of the playlist this dialog was opened from diff --git a/lib/components/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart index ed769959..cb6028a7 100644 --- a/lib/components/fallbacks/anonymous_fallback.dart +++ b/lib/components/fallbacks/anonymous_fallback.dart @@ -5,7 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/components/heart_button/heart_button.dart b/lib/components/heart_button/heart_button.dart index af5bbd78..eca3c513 100644 --- a/lib/components/heart_button/heart_button.dart +++ b/lib/components/heart_button/heart_button.dart @@ -4,9 +4,9 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; class HeartButton extends HookConsumerWidget { final bool isLiked; diff --git a/lib/components/track_presentation/use_is_user_playlist.dart b/lib/components/track_presentation/use_is_user_playlist.dart index 18426118..8792f6e7 100644 --- a/lib/components/track_presentation/use_is_user_playlist.dart +++ b/lib/components/track_presentation/use_is_user_playlist.dart @@ -1,7 +1,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; bool useIsUserPlaylist(WidgetRef ref, String playlistId) { final userPlaylistsQuery = ref.watch(metadataPluginSavedPlaylistsProvider); diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index 03c0244d..09f361c2 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -26,11 +26,11 @@ import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:spotube/services/metadata/endpoints/error.dart'; import 'package:url_launcher/url_launcher_string.dart'; diff --git a/lib/models/metadata/metadata.dart b/lib/models/metadata/metadata.dart index 7e3496a4..97da704c 100644 --- a/lib/models/metadata/metadata.dart +++ b/lib/models/metadata/metadata.dart @@ -27,3 +27,4 @@ part 'track.dart'; part 'user.dart'; part 'plugin.dart'; +part 'repository.dart'; diff --git a/lib/models/metadata/metadata.freezed.dart b/lib/models/metadata/metadata.freezed.dart index 89361ff6..c6a3609a 100644 --- a/lib/models/metadata/metadata.freezed.dart +++ b/lib/models/metadata/metadata.freezed.dart @@ -4815,3 +4815,216 @@ abstract class _PluginConfiguration extends PluginConfiguration { _$$PluginConfigurationImplCopyWith<_$PluginConfigurationImpl> get copyWith => throw _privateConstructorUsedError; } + +MetadataPluginRepository _$MetadataPluginRepositoryFromJson( + Map json) { + return _MetadataPluginRepository.fromJson(json); +} + +/// @nodoc +mixin _$MetadataPluginRepository { + String get name => throw _privateConstructorUsedError; + String get owner => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + String get repoUrl => throw _privateConstructorUsedError; + + /// Serializes this MetadataPluginRepository to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of MetadataPluginRepository + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $MetadataPluginRepositoryCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MetadataPluginRepositoryCopyWith<$Res> { + factory $MetadataPluginRepositoryCopyWith(MetadataPluginRepository value, + $Res Function(MetadataPluginRepository) then) = + _$MetadataPluginRepositoryCopyWithImpl<$Res, MetadataPluginRepository>; + @useResult + $Res call({String name, String owner, String description, String repoUrl}); +} + +/// @nodoc +class _$MetadataPluginRepositoryCopyWithImpl<$Res, + $Val extends MetadataPluginRepository> + implements $MetadataPluginRepositoryCopyWith<$Res> { + _$MetadataPluginRepositoryCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of MetadataPluginRepository + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? owner = null, + Object? description = null, + Object? repoUrl = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + owner: null == owner + ? _value.owner + : owner // ignore: cast_nullable_to_non_nullable + as String, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + repoUrl: null == repoUrl + ? _value.repoUrl + : repoUrl // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MetadataPluginRepositoryImplCopyWith<$Res> + implements $MetadataPluginRepositoryCopyWith<$Res> { + factory _$$MetadataPluginRepositoryImplCopyWith( + _$MetadataPluginRepositoryImpl value, + $Res Function(_$MetadataPluginRepositoryImpl) then) = + __$$MetadataPluginRepositoryImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name, String owner, String description, String repoUrl}); +} + +/// @nodoc +class __$$MetadataPluginRepositoryImplCopyWithImpl<$Res> + extends _$MetadataPluginRepositoryCopyWithImpl<$Res, + _$MetadataPluginRepositoryImpl> + implements _$$MetadataPluginRepositoryImplCopyWith<$Res> { + __$$MetadataPluginRepositoryImplCopyWithImpl( + _$MetadataPluginRepositoryImpl _value, + $Res Function(_$MetadataPluginRepositoryImpl) _then) + : super(_value, _then); + + /// Create a copy of MetadataPluginRepository + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? owner = null, + Object? description = null, + Object? repoUrl = null, + }) { + return _then(_$MetadataPluginRepositoryImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + owner: null == owner + ? _value.owner + : owner // ignore: cast_nullable_to_non_nullable + as String, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + repoUrl: null == repoUrl + ? _value.repoUrl + : repoUrl // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$MetadataPluginRepositoryImpl implements _MetadataPluginRepository { + _$MetadataPluginRepositoryImpl( + {required this.name, + required this.owner, + required this.description, + required this.repoUrl}); + + factory _$MetadataPluginRepositoryImpl.fromJson(Map json) => + _$$MetadataPluginRepositoryImplFromJson(json); + + @override + final String name; + @override + final String owner; + @override + final String description; + @override + final String repoUrl; + + @override + String toString() { + return 'MetadataPluginRepository(name: $name, owner: $owner, description: $description, repoUrl: $repoUrl)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MetadataPluginRepositoryImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.owner, owner) || other.owner == owner) && + (identical(other.description, description) || + other.description == description) && + (identical(other.repoUrl, repoUrl) || other.repoUrl == repoUrl)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, name, owner, description, repoUrl); + + /// Create a copy of MetadataPluginRepository + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$MetadataPluginRepositoryImplCopyWith<_$MetadataPluginRepositoryImpl> + get copyWith => __$$MetadataPluginRepositoryImplCopyWithImpl< + _$MetadataPluginRepositoryImpl>(this, _$identity); + + @override + Map toJson() { + return _$$MetadataPluginRepositoryImplToJson( + this, + ); + } +} + +abstract class _MetadataPluginRepository implements MetadataPluginRepository { + factory _MetadataPluginRepository( + {required final String name, + required final String owner, + required final String description, + required final String repoUrl}) = _$MetadataPluginRepositoryImpl; + + factory _MetadataPluginRepository.fromJson(Map json) = + _$MetadataPluginRepositoryImpl.fromJson; + + @override + String get name; + @override + String get owner; + @override + String get description; + @override + String get repoUrl; + + /// Create a copy of MetadataPluginRepository + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$MetadataPluginRepositoryImplCopyWith<_$MetadataPluginRepositoryImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/models/metadata/metadata.g.dart b/lib/models/metadata/metadata.g.dart index ffafa931..73d42247 100644 --- a/lib/models/metadata/metadata.g.dart +++ b/lib/models/metadata/metadata.g.dart @@ -462,3 +462,21 @@ const _$PluginApisEnumMap = { const _$PluginAbilitiesEnumMap = { PluginAbilities.authentication: 'authentication', }; + +_$MetadataPluginRepositoryImpl _$$MetadataPluginRepositoryImplFromJson( + Map json) => + _$MetadataPluginRepositoryImpl( + name: json['name'] as String, + owner: json['owner'] as String, + description: json['description'] as String, + repoUrl: json['repoUrl'] as String, + ); + +Map _$$MetadataPluginRepositoryImplToJson( + _$MetadataPluginRepositoryImpl instance) => + { + 'name': instance.name, + 'owner': instance.owner, + 'description': instance.description, + 'repoUrl': instance.repoUrl, + }; diff --git a/lib/models/metadata/repository.dart b/lib/models/metadata/repository.dart new file mode 100644 index 00000000..06151dee --- /dev/null +++ b/lib/models/metadata/repository.dart @@ -0,0 +1,14 @@ +part of './metadata.dart'; + +@freezed +class MetadataPluginRepository with _$MetadataPluginRepository { + factory MetadataPluginRepository({ + required String name, + required String owner, + required String description, + required String repoUrl, + }) = _MetadataPluginRepository; + + factory MetadataPluginRepository.fromJson(Map json) => + _$MetadataPluginRepositoryFromJson(json); +} diff --git a/lib/modules/home/sections/new_releases.dart b/lib/modules/home/sections/new_releases.dart index e916ae4f..b2f46b10 100644 --- a/lib/modules/home/sections/new_releases.dart +++ b/lib/modules/home/sections/new_releases.dart @@ -4,7 +4,7 @@ import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_pl import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/album/releases.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.dart'; class HomeNewReleasesSection extends HookConsumerWidget { diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 1dcd7de1..f96c52df 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -21,7 +21,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/server/active_track_sources.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index e01b6926..4e3de7e0 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/sleep_timer_provider.dart'; class PlayerActions extends HookConsumerWidget { diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 71015bac..1d221a33 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -15,7 +15,7 @@ import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; class PlaylistCard extends HookConsumerWidget { diff --git a/lib/modules/root/sidebar/sidebar_footer.dart b/lib/modules/root/sidebar/sidebar_footer.dart index 6ff3ab23..4c46c13b 100644 --- a/lib/modules/root/sidebar/sidebar_footer.dart +++ b/lib/modules/root/sidebar/sidebar_footer.dart @@ -11,8 +11,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; class SidebarFooter extends HookConsumerWidget implements NavigationBarItem { const SidebarFooter({ diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 2ce9178c..b8e7e5dc 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -12,7 +12,7 @@ import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/metadata_plugin/artist/artist.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/artists.dart'; import 'package:spotube/utils/primitive_utils.dart'; diff --git a/lib/pages/library/user_albums.dart b/lib/pages/library/user_albums.dart index 3494211e..42c6af7c 100644 --- a/lib/pages/library/user_albums.dart +++ b/lib/pages/library/user_albums.dart @@ -14,7 +14,7 @@ import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/albums.dart'; import 'package:auto_route/auto_route.dart'; diff --git a/lib/pages/library/user_artists.dart b/lib/pages/library/user_artists.dart index 6087f41c..097dff4f 100644 --- a/lib/pages/library/user_artists.dart +++ b/lib/pages/library/user_artists.dart @@ -17,7 +17,7 @@ import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/artists.dart'; import 'package:auto_route/auto_route.dart'; diff --git a/lib/pages/library/user_playlists.dart b/lib/pages/library/user_playlists.dart index 16bd7882..c7493ec3 100644 --- a/lib/pages/library/user_playlists.dart +++ b/lib/pages/library/user_playlists.dart @@ -15,9 +15,9 @@ import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:auto_route/auto_route.dart'; @RoutePage() diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index 45d34117..eb3dec2a 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -8,7 +8,7 @@ import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:auto_route/auto_route.dart'; diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 0db34810..7dec4b04 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -17,7 +17,7 @@ import 'package:spotube/pages/search/tabs/all.dart'; import 'package:spotube/pages/search/tabs/artists.dart'; import 'package:spotube/pages/search/tabs/playlists.dart'; import 'package:spotube/pages/search/tabs/tracks.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/search/all.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:auto_route/auto_route.dart'; diff --git a/lib/pages/settings/metadata_plugins.dart b/lib/pages/settings/metadata_plugins.dart index 2de1c36c..4db82e73 100644 --- a/lib/pages/settings/metadata_plugins.dart +++ b/lib/pages/settings/metadata_plugins.dart @@ -4,14 +4,20 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/form/text_form_field.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/repositories.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:spotube/provider/metadata_plugin/utils/common.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @RoutePage() class SettingsMetadataProviderPage extends HookConsumerWidget { @@ -26,6 +32,11 @@ class SettingsMetadataProviderPage extends HookConsumerWidget { final metadataPlugin = ref.watch(metadataPluginProvider); final isAuthenticated = ref.watch(metadataPluginAuthenticatedProvider); + final pluginReposSnapshot = ref.watch(metadataPluginRepositoriesProvider); + final pluginReposNotifier = + ref.watch(metadataPluginRepositoriesProvider.notifier); + final pluginRepos = pluginReposSnapshot.asData?.value.items ?? []; + return Scaffold( headers: const [ TitleBar( @@ -103,76 +114,203 @@ class SettingsMetadataProviderPage extends HookConsumerWidget { ], ), ), + const SliverGap(12), + SliverToBoxAdapter( + child: Row( + children: [ + const Gap(8), + const Text("Installed").h4, + const Gap(8), + const Expanded(child: Divider()), + const Gap(8), + ], + ), + ), const SliverGap(20), SliverList.separated( itemCount: plugins.asData?.value.plugins.length ?? 0, - separatorBuilder: (context, index) => const Divider(), + separatorBuilder: (context, index) => const Gap(12), itemBuilder: (context, index) { final plugin = plugins.asData!.value.plugins[index]; final isDefault = plugins.asData!.value.defaultPlugin == index; final requiresAuth = isDefault && plugin.abilities.contains(PluginAbilities.authentication); + return Card( child: Column( - spacing: 8, - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 12, children: [ - Basic( - title: Text(plugin.name), - subtitle: Text(plugin.description), - trailing: Row( - spacing: 8, - children: [ - Button.primary( - enabled: !isDefault, - onPressed: () async { - await pluginsNotifier.setDefaultPlugin(plugin); - }, - child: isDefault - ? const Text("Default") - : const Text("Make default"), - ), - IconButton.destructive( + FutureBuilder( + future: pluginsNotifier.getLogoPath(plugin), + builder: (context, snapshot) { + return Basic( + leading: snapshot.hasData + ? Image.file( + snapshot.data!, + width: 36, + height: 36, + ) + : Container( + height: 36, + width: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + context.theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(SpotubeIcons.plugin), + ), + title: Text(plugin.name), + subtitle: Text(plugin.description), + trailing: IconButton.ghost( onPressed: () async { await pluginsNotifier.removePlugin(plugin); }, - icon: const Icon(SpotubeIcons.trash), - ), - ], - ), - ), - if (requiresAuth) - Row( - children: [ - const Text("Plugin requires authentication"), - const Spacer(), - if (isAuthenticated.asData?.value != true) - Button.primary( - onPressed: () async { - await metadataPlugin.asData?.value?.auth - .authenticate(); - }, - leading: const Icon(SpotubeIcons.login), - child: const Text("Login"), - ) - else - Button.destructive( - onPressed: () async { - await metadataPlugin.asData?.value?.auth - .logout(); - }, - leading: const Icon(SpotubeIcons.logout), - child: const Text("Logout"), + icon: const Icon( + SpotubeIcons.trash, + color: Colors.red, ), - ], - ) + ), + ); + }, + ), + if (plugin.abilities + .contains(PluginAbilities.authentication) && + isDefault) + Container( + decoration: BoxDecoration( + color: context.theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(12), + child: const Row( + spacing: 8, + children: [ + Icon(SpotubeIcons.warning, color: Colors.yellow), + Text("Plugin requires authentication"), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Button.secondary( + enabled: !isDefault, + onPressed: () async { + await pluginsNotifier.setDefaultPlugin(plugin); + }, + child: isDefault + ? const Text("Default") + : const Text("Set default"), + ), + if (isAuthenticated.asData?.value != true && + requiresAuth && + isDefault) + Button.primary( + onPressed: () async { + await metadataPlugin.asData?.value?.auth + .authenticate(); + }, + leading: const Icon(SpotubeIcons.login), + child: const Text("Login"), + ) + else if (isAuthenticated.asData?.value == true && + requiresAuth && + isDefault) + Button.destructive( + onPressed: () async { + await metadataPlugin.asData?.value?.auth + .logout(); + }, + leading: const Icon(SpotubeIcons.logout), + child: const Text("Logout"), + ) + ], + ) ], ), ); }, ), + const SliverGap(12), + SliverToBoxAdapter( + child: Row( + children: [ + const Gap(8), + const Text("Available plugins").h4, + const Gap(8), + const Expanded(child: Divider()), + const Gap(8), + ], + ), + ), + const SliverGap(12), + Skeletonizer.sliver( + enabled: pluginReposSnapshot.isLoading, + child: SliverInfiniteList( + isLoading: pluginReposSnapshot.isLoading && + !pluginReposSnapshot.isLoadingNextPage, + itemCount: pluginRepos.length, + onFetchData: pluginReposNotifier.fetchMore, + itemBuilder: (context, index) { + final pluginRepo = pluginRepos[index]; + final host = Uri.parse(pluginRepo.repoUrl).host; + + return Card( + child: Basic( + title: Text(pluginRepo.name), + subtitle: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text(pluginRepo.description), + Row( + spacing: 8, + children: [ + if (pluginRepo.owner == "KRTirtho") ...[ + const PrimaryBadge( + leading: Icon(SpotubeIcons.done), + child: Text("Official"), + ), + SecondaryBadge( + leading: host == "github.com" + ? const Icon(SpotubeIcons.github) + : null, + child: Text(host), + onPressed: () { + launchUrlString(pluginRepo.repoUrl); + }, + ), + ] else ...[ + Text("Author: ${pluginRepo.owner}"), + const DestructiveBadge( + leading: Icon(SpotubeIcons.warning), + child: Text("Third-party"), + ) + ] + ], + ), + ], + ), + trailing: Button.primary( + onPressed: () async { + final pluginConfig = await pluginsNotifier + .downloadAndCachePlugin(pluginRepo.repoUrl); + + await pluginsNotifier.addPlugin(pluginConfig); + }, + leading: const Icon(SpotubeIcons.add), + child: const Text("Install"), + ), + ), + ); + }, + ), + ), ], ), ), diff --git a/lib/provider/metadata_plugin/auth.dart b/lib/provider/metadata_plugin/core/auth.dart similarity index 100% rename from lib/provider/metadata_plugin/auth.dart rename to lib/provider/metadata_plugin/core/auth.dart diff --git a/lib/provider/metadata_plugin/core/repositories.dart b/lib/provider/metadata_plugin/core/repositories.dart new file mode 100644 index 00000000..55c11ed2 --- /dev/null +++ b/lib/provider/metadata_plugin/core/repositories.dart @@ -0,0 +1,89 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/metadata/metadata.dart'; +import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; +import 'package:spotube/services/dio/dio.dart'; + +class MetadataPluginRepositoriesNotifier + extends PaginatedAsyncNotifier { + MetadataPluginRepositoriesNotifier() : super(); + + Map _hasMore = {}; + + @override + fetch(int offset, int limit) async { + final gitubSearch = globalDio.get( + "https://api.github.com/search/repositories", + queryParameters: { + "q": "topic:spotube-plugin", + "sort": "stars", + "order": "desc", + "page": offset, + "per_page": limit, + }, + ); + + final codebergSearch = globalDio.get( + "https://codeberg.org/api/v1/repos/search", + queryParameters: { + "q": "spotube-plugin", + "topic": "true", + "sort": "stars", + "order": "desc", + "page": offset, + "limit": limit, + }, + ); + + final responses = await Future.wait([ + if (_hasMore["github.com"] ?? true) gitubSearch, + if (_hasMore["codeberg.org"] ?? true) codebergSearch, + ]); + + final repos = responses + .expand( + (response) => response.data["data"] ?? response.data["items"] ?? [], + ) + .map((repo) { + return MetadataPluginRepository( + name: repo["name"] ?? "", + owner: repo["owner"]["login"] ?? "", + description: repo["description"] ?? "", + repoUrl: repo["html_url"] ?? "", + ); + }).toList(); + + final hasMore = responses.any((response) { + final items = + (response.data["data"] ?? response.data["items"] ?? []) as List; + _hasMore[response.requestOptions.uri.host] = + items.length >= limit && items.isNotEmpty; + + return _hasMore[response.requestOptions.uri.host] ?? false; + }); + + return SpotubePaginationResponseObject( + items: repos, + total: responses.fold( + 0, + (previousValue, response) => previousValue + + (response.data["total_count"] ?? + int.tryParse(response.headers["x-total-count"]?[0] ?? "") ?? + 0) as int, + ), + hasMore: hasMore, + nextOffset: hasMore ? offset + 1 : null, + limit: limit, + ); + } + + @override + build() async { + return await fetch(0, 10); + } +} + +final metadataPluginRepositoriesProvider = AsyncNotifierProvider< + MetadataPluginRepositoriesNotifier, + SpotubePaginationResponseObject>( + () => MetadataPluginRepositoriesNotifier(), +); diff --git a/lib/provider/metadata_plugin/user.dart b/lib/provider/metadata_plugin/core/user.dart similarity index 88% rename from lib/provider/metadata_plugin/user.dart rename to lib/provider/metadata_plugin/core/user.dart index 7dd65766..4acfb8f7 100644 --- a/lib/provider/metadata_plugin/user.dart +++ b/lib/provider/metadata_plugin/core/user.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/models/metadata/metadata.dart'; -import 'package:spotube/provider/metadata_plugin/auth.dart'; +import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; final metadataPluginUserProvider = FutureProvider( diff --git a/lib/provider/metadata_plugin/metadata_plugin_provider.dart b/lib/provider/metadata_plugin/metadata_plugin_provider.dart index 81199699..717a81c0 100644 --- a/lib/provider/metadata_plugin/metadata_plugin_provider.dart +++ b/lib/provider/metadata_plugin/metadata_plugin_provider.dart @@ -130,7 +130,11 @@ class MetadataPluginNotifier extends AsyncNotifier { final parsedUri = Uri.parse(repoUrl); final uri = parsedUri.replace( host: "api.github.com", - path: "/repos/${parsedUri.path}/releases", + pathSegments: [ + "repos", + ...parsedUri.pathSegments, + "releases", + ], queryParameters: { "per_page": "1", "page": "1", @@ -143,7 +147,13 @@ class MetadataPluginNotifier extends AsyncNotifier { Uri _getCodebergeReleasesUrl(String repoUrl) { final parsedUri = Uri.parse(repoUrl); final uri = parsedUri.replace( - path: "/api/v1/repos/${parsedUri.path}/releases", + pathSegments: [ + "api", + "v1", + "repos", + ...parsedUri.pathSegments, + "releases", + ], queryParameters: { "limit": "1", "page": "1", @@ -154,6 +164,7 @@ class MetadataPluginNotifier extends AsyncNotifier { } Future _getPluginDownloadUrl(Uri uri) async { + print("Getting plugin download URL from: $uri"); final res = await globalDio.getUri( uri, options: Options(responseType: ResponseType.json), @@ -227,8 +238,7 @@ class MetadataPluginNotifier extends AsyncNotifier { Future downloadAndCachePlugin(String url) async { final res = await globalDio.head(url); final isSupportedWebsite = - (res.headers["Content-Type"] as String?)?.startsWith("text/html") == - true && + (res.headers["Content-Type"]?.first)?.startsWith("text/html") == true && allowedDomainsRegex.hasMatch(url); String pluginDownloadUrl = url; if (isSupportedWebsite) { @@ -329,6 +339,22 @@ class MetadataPluginNotifier extends AsyncNotifier { return await libraryFile.readAsBytes(); } + + Future getLogoPath(PluginConfiguration plugin) async { + final pluginDir = await _getPluginDir(); + final pluginExtractionDirPath = join( + pluginDir.path, + ServiceUtils.sanitizeFilename(plugin.name), + ); + + final logoFile = File(join(pluginExtractionDirPath, "logo.png")); + + if (!logoFile.existsSync()) { + return null; + } + + return logoFile; + } } final metadataPluginsProvider = diff --git a/lib/provider/metadata_plugin/playlist/playlist.dart b/lib/provider/metadata_plugin/playlist/playlist.dart index 8d5b71be..af26673a 100644 --- a/lib/provider/metadata_plugin/playlist/playlist.dart +++ b/lib/provider/metadata_plugin/playlist/playlist.dart @@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/user.dart'; +import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.dart'; import 'package:spotube/services/metadata/endpoints/error.dart'; import 'package:spotube/services/metadata/metadata.dart'; diff --git a/lib/provider/metadata_plugin/utils/paginated.dart b/lib/provider/metadata_plugin/utils/paginated.dart index 6a37929d..e1d2bf26 100644 --- a/lib/provider/metadata_plugin/utils/paginated.dart +++ b/lib/provider/metadata_plugin/utils/paginated.dart @@ -27,8 +27,7 @@ mixin PaginatedAsyncNotifierMixin state.value!.items.isEmpty ? [] : state.value!.items.cast(); final items = newState.items.isEmpty ? [] : newState.items.cast(); - return newState.copyWith(items: [...oldItems, ...items]) - as SpotubePaginationResponseObject; + return newState.copyWith(items: [...oldItems, ...items]); }, ); } @@ -48,8 +47,7 @@ mixin PaginatedAsyncNotifierMixin hasMore = newState.hasMore; final oldItems = state.items.isEmpty ? [] : state.items.cast(); final items = newState.items.isEmpty ? [] : newState.items.cast(); - return newState.copyWith(items: [...oldItems, ...items]) - as SpotubePaginationResponseObject; + return newState.copyWith(items: [...oldItems, ...items]); }); }