feat: add support for automatic plugin repository from github and codeberg

This commit is contained in:
Kingkor Roy Tirtho 2025-07-17 01:02:04 +06:00
parent 90f9cc28eb
commit e83a4bb388
29 changed files with 580 additions and 81 deletions

View File

@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/routes.gr.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'; import 'package:spotube/services/kv_store/kv_store.dart';
final rootNavigatorKey = GlobalKey<NavigatorState>(); final rootNavigatorKey = GlobalKey<NavigatorState>();

View File

@ -137,4 +137,6 @@ abstract class SpotubeIcons {
static const extensions = FeatherIcons.package; static const extensions = FeatherIcons.package;
static const message = FeatherIcons.send; static const message = FeatherIcons.send;
static const upload = FeatherIcons.uploadCloud; static const upload = FeatherIcons.uploadCloud;
static const plugin = Icons.extension_outlined;
static const warning = FeatherIcons.alertTriangle;
} }

View File

@ -7,7 +7,7 @@ import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.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 { class PlaylistAddTrackDialog extends HookConsumerWidget {
/// The id of the playlist this dialog was opened from /// The id of the playlist this dialog was opened from

View File

@ -5,7 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/extensions/context.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'; import 'package:spotube/utils/platform.dart';

View File

@ -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/components/heart_button/use_track_toggle_like.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.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/library/tracks.dart';
import 'package:spotube/provider/metadata_plugin/user.dart'; import 'package:spotube/provider/metadata_plugin/core/user.dart';
class HeartButton extends HookConsumerWidget { class HeartButton extends HookConsumerWidget {
final bool isLiked; final bool isLiked;

View File

@ -1,7 +1,7 @@
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.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) { bool useIsUserPlaylist(WidgetRef ref, String playlistId) {
final userPlaylistsQuery = ref.watch(metadataPluginSavedPlaylistsProvider); final userPlaylistsQuery = ref.watch(metadataPluginSavedPlaylistsProvider);

View File

@ -26,11 +26,11 @@ import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/local_tracks/local_tracks_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/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/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.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/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:spotube/services/metadata/endpoints/error.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';

View File

@ -27,3 +27,4 @@ part 'track.dart';
part 'user.dart'; part 'user.dart';
part 'plugin.dart'; part 'plugin.dart';
part 'repository.dart';

View File

@ -4815,3 +4815,216 @@ abstract class _PluginConfiguration extends PluginConfiguration {
_$$PluginConfigurationImplCopyWith<_$PluginConfigurationImpl> get copyWith => _$$PluginConfigurationImplCopyWith<_$PluginConfigurationImpl> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
MetadataPluginRepository _$MetadataPluginRepositoryFromJson(
Map<String, dynamic> 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<String, dynamic> 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<MetadataPluginRepository> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View File

@ -462,3 +462,21 @@ const _$PluginApisEnumMap = {
const _$PluginAbilitiesEnumMap = { const _$PluginAbilitiesEnumMap = {
PluginAbilities.authentication: 'authentication', 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<String, dynamic> _$$MetadataPluginRepositoryImplToJson(
_$MetadataPluginRepositoryImpl instance) =>
<String, dynamic>{
'name': instance.name,
'owner': instance.owner,
'description': instance.description,
'repoUrl': instance.repoUrl,
};

View File

@ -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<String, dynamic> json) =>
_$MetadataPluginRepositoryFromJson(json);
}

View File

@ -4,7 +4,7 @@ import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_pl
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/album/releases.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'; import 'package:spotube/provider/metadata_plugin/utils/common.dart';
class HomeNewReleasesSection extends HookConsumerWidget { class HomeNewReleasesSection extends HookConsumerWidget {

View File

@ -21,7 +21,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart';
import 'package:spotube/provider/audio_player/audio_player.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/server/active_track_sources.dart';
import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart';

View File

@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.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'; import 'package:spotube/provider/sleep_timer_provider.dart';
class PlayerActions extends HookConsumerWidget { class PlayerActions extends HookConsumerWidget {

View File

@ -15,7 +15,7 @@ import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.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/library/tracks.dart';
import 'package:spotube/provider/metadata_plugin/tracks/playlist.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'; import 'package:spotube/services/audio_player/audio_player.dart';
class PlaylistCard extends HookConsumerWidget { class PlaylistCard extends HookConsumerWidget {

View File

@ -11,8 +11,8 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/modules/connect/connect_device.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/metadata_plugin/auth.dart'; import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/user.dart'; import 'package:spotube/provider/metadata_plugin/core/user.dart';
class SidebarFooter extends HookConsumerWidget implements NavigationBarItem { class SidebarFooter extends HookConsumerWidget implements NavigationBarItem {
const SidebarFooter({ const SidebarFooter({

View File

@ -12,7 +12,7 @@ import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/metadata_plugin/artist/artist.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/provider/metadata_plugin/library/artists.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';

View File

@ -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/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/extensions/context.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:spotube/provider/metadata_plugin/library/albums.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';

View File

@ -17,7 +17,7 @@ import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/waypoint.dart'; import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.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:spotube/provider/metadata_plugin/library/artists.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';

View File

@ -15,9 +15,9 @@ import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/extensions/context.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/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'; import 'package:auto_route/auto_route.dart';
@RoutePage() @RoutePage()

View File

@ -8,7 +8,7 @@ import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.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:url_launcher/url_launcher_string.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';

View File

@ -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/artists.dart';
import 'package:spotube/pages/search/tabs/playlists.dart'; import 'package:spotube/pages/search/tabs/playlists.dart';
import 'package:spotube/pages/search/tabs/tracks.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/provider/metadata_plugin/search/all.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';

View File

@ -4,14 +4,20 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.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/collections/spotube_icons.dart';
import 'package:spotube/components/form/text_form_field.dart'; import 'package:spotube/components/form/text_form_field.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/models/metadata/metadata.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:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:file_picker/file_picker.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:spotube/utils/platform.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@RoutePage() @RoutePage()
class SettingsMetadataProviderPage extends HookConsumerWidget { class SettingsMetadataProviderPage extends HookConsumerWidget {
@ -26,6 +32,11 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
final metadataPlugin = ref.watch(metadataPluginProvider); final metadataPlugin = ref.watch(metadataPluginProvider);
final isAuthenticated = ref.watch(metadataPluginAuthenticatedProvider); final isAuthenticated = ref.watch(metadataPluginAuthenticatedProvider);
final pluginReposSnapshot = ref.watch(metadataPluginRepositoriesProvider);
final pluginReposNotifier =
ref.watch(metadataPluginRepositoriesProvider.notifier);
final pluginRepos = pluginReposSnapshot.asData?.value.items ?? [];
return Scaffold( return Scaffold(
headers: const [ headers: const [
TitleBar( TitleBar(
@ -103,52 +114,101 @@ 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), const SliverGap(20),
SliverList.separated( SliverList.separated(
itemCount: plugins.asData?.value.plugins.length ?? 0, itemCount: plugins.asData?.value.plugins.length ?? 0,
separatorBuilder: (context, index) => const Divider(), separatorBuilder: (context, index) => const Gap(12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final plugin = plugins.asData!.value.plugins[index]; final plugin = plugins.asData!.value.plugins[index];
final isDefault = plugins.asData!.value.defaultPlugin == index; final isDefault = plugins.asData!.value.defaultPlugin == index;
final requiresAuth = isDefault && final requiresAuth = isDefault &&
plugin.abilities.contains(PluginAbilities.authentication); plugin.abilities.contains(PluginAbilities.authentication);
return Card( return Card(
child: Column( child: Column(
spacing: 8,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [ children: [
Basic( 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), title: Text(plugin.name),
subtitle: Text(plugin.description), subtitle: Text(plugin.description),
trailing: Row( trailing: IconButton.ghost(
onPressed: () async {
await pluginsNotifier.removePlugin(plugin);
},
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, spacing: 8,
children: [ children: [
Button.primary( Icon(SpotubeIcons.warning, color: Colors.yellow),
Text("Plugin requires authentication"),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Button.secondary(
enabled: !isDefault, enabled: !isDefault,
onPressed: () async { onPressed: () async {
await pluginsNotifier.setDefaultPlugin(plugin); await pluginsNotifier.setDefaultPlugin(plugin);
}, },
child: isDefault child: isDefault
? const Text("Default") ? const Text("Default")
: const Text("Make default"), : const Text("Set default"),
), ),
IconButton.destructive( if (isAuthenticated.asData?.value != true &&
onPressed: () async { requiresAuth &&
await pluginsNotifier.removePlugin(plugin); isDefault)
},
icon: const Icon(SpotubeIcons.trash),
),
],
),
),
if (requiresAuth)
Row(
children: [
const Text("Plugin requires authentication"),
const Spacer(),
if (isAuthenticated.asData?.value != true)
Button.primary( Button.primary(
onPressed: () async { onPressed: () async {
await metadataPlugin.asData?.value?.auth await metadataPlugin.asData?.value?.auth
@ -157,7 +217,9 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
leading: const Icon(SpotubeIcons.login), leading: const Icon(SpotubeIcons.login),
child: const Text("Login"), child: const Text("Login"),
) )
else else if (isAuthenticated.asData?.value == true &&
requiresAuth &&
isDefault)
Button.destructive( Button.destructive(
onPressed: () async { onPressed: () async {
await metadataPlugin.asData?.value?.auth await metadataPlugin.asData?.value?.auth
@ -165,7 +227,7 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
}, },
leading: const Icon(SpotubeIcons.logout), leading: const Icon(SpotubeIcons.logout),
child: const Text("Logout"), child: const Text("Logout"),
), )
], ],
) )
], ],
@ -173,6 +235,82 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
); );
}, },
), ),
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"),
),
),
);
},
),
),
], ],
), ),
), ),

View File

@ -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<MetadataPluginRepository> {
MetadataPluginRepositoriesNotifier() : super();
Map<String, bool> _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<int>(
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<MetadataPluginRepository>>(
() => MetadataPluginRepositoriesNotifier(),
);

View File

@ -1,6 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.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'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
final metadataPluginUserProvider = FutureProvider<SpotubeUserObject?>( final metadataPluginUserProvider = FutureProvider<SpotubeUserObject?>(

View File

@ -130,7 +130,11 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
final parsedUri = Uri.parse(repoUrl); final parsedUri = Uri.parse(repoUrl);
final uri = parsedUri.replace( final uri = parsedUri.replace(
host: "api.github.com", host: "api.github.com",
path: "/repos/${parsedUri.path}/releases", pathSegments: [
"repos",
...parsedUri.pathSegments,
"releases",
],
queryParameters: { queryParameters: {
"per_page": "1", "per_page": "1",
"page": "1", "page": "1",
@ -143,7 +147,13 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
Uri _getCodebergeReleasesUrl(String repoUrl) { Uri _getCodebergeReleasesUrl(String repoUrl) {
final parsedUri = Uri.parse(repoUrl); final parsedUri = Uri.parse(repoUrl);
final uri = parsedUri.replace( final uri = parsedUri.replace(
path: "/api/v1/repos/${parsedUri.path}/releases", pathSegments: [
"api",
"v1",
"repos",
...parsedUri.pathSegments,
"releases",
],
queryParameters: { queryParameters: {
"limit": "1", "limit": "1",
"page": "1", "page": "1",
@ -154,6 +164,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
} }
Future<String> _getPluginDownloadUrl(Uri uri) async { Future<String> _getPluginDownloadUrl(Uri uri) async {
print("Getting plugin download URL from: $uri");
final res = await globalDio.getUri( final res = await globalDio.getUri(
uri, uri,
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),
@ -227,8 +238,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
Future<PluginConfiguration> downloadAndCachePlugin(String url) async { Future<PluginConfiguration> downloadAndCachePlugin(String url) async {
final res = await globalDio.head(url); final res = await globalDio.head(url);
final isSupportedWebsite = final isSupportedWebsite =
(res.headers["Content-Type"] as String?)?.startsWith("text/html") == (res.headers["Content-Type"]?.first)?.startsWith("text/html") == true &&
true &&
allowedDomainsRegex.hasMatch(url); allowedDomainsRegex.hasMatch(url);
String pluginDownloadUrl = url; String pluginDownloadUrl = url;
if (isSupportedWebsite) { if (isSupportedWebsite) {
@ -329,6 +339,22 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
return await libraryFile.readAsBytes(); return await libraryFile.readAsBytes();
} }
Future<File?> 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 = final metadataPluginsProvider =

View File

@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.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/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/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/services/metadata/endpoints/error.dart'; import 'package:spotube/services/metadata/endpoints/error.dart';
import 'package:spotube/services/metadata/metadata.dart'; import 'package:spotube/services/metadata/metadata.dart';

View File

@ -27,8 +27,7 @@ mixin PaginatedAsyncNotifierMixin<K>
state.value!.items.isEmpty ? <K>[] : state.value!.items.cast<K>(); state.value!.items.isEmpty ? <K>[] : state.value!.items.cast<K>();
final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>(); final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>();
return newState.copyWith(items: <K>[...oldItems, ...items]) return newState.copyWith(items: <K>[...oldItems, ...items]);
as SpotubePaginationResponseObject<K>;
}, },
); );
} }
@ -48,8 +47,7 @@ mixin PaginatedAsyncNotifierMixin<K>
hasMore = newState.hasMore; hasMore = newState.hasMore;
final oldItems = state.items.isEmpty ? <K>[] : state.items.cast<K>(); final oldItems = state.items.isEmpty ? <K>[] : state.items.cast<K>();
final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>(); final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>();
return newState.copyWith(items: <K>[...oldItems, ...items]) return newState.copyWith(items: <K>[...oldItems, ...items]);
as SpotubePaginationResponseObject<K>;
}); });
} }