diff --git a/assets/fonts/Ubuntu_Mono/UFL.txt b/assets/fonts/Ubuntu_Mono/UFL.txt new file mode 100644 index 00000000..6e722c88 --- /dev/null +++ b/assets/fonts/Ubuntu_Mono/UFL.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf new file mode 100644 index 00000000..01ad81bf Binary files /dev/null and b/assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf differ diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf new file mode 100644 index 00000000..731884eb Binary files /dev/null and b/assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf differ diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf new file mode 100644 index 00000000..b89338d4 Binary files /dev/null and b/assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf differ diff --git a/assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf b/assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf new file mode 100644 index 00000000..4977028d Binary files /dev/null and b/assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf differ diff --git a/lib/components/fallbacks/error_box.dart b/lib/components/fallbacks/error_box.dart new file mode 100644 index 00000000..68d0b66f --- /dev/null +++ b/lib/components/fallbacks/error_box.dart @@ -0,0 +1,137 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/spotube_icons.dart'; + +class ErrorBox extends StatelessWidget { + final Object error; + final VoidCallback? onRetry; + const ErrorBox({ + super.key, + required this.error, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + // Make a monospace error log view. Make sure it's only 4 lines + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 12, + children: [ + const Basic( + leading: Icon(SpotubeIcons.error), + contentSpacing: 8, + title: Text("An error occurred"), + ), + Card( + padding: const EdgeInsets.all(8.0), + filled: true, + fillColor: context.theme.colorScheme.muted, + child: Text( + error.toString(), + style: TextStyle( + // Use monospace + fontFamily: 'Ubuntu Mono', + color: context.theme.colorScheme.mutedForeground, + fontSize: 14, + ), + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + ), + // Show a dialog with full log and a retry button as well + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Button.text( + leading: const Icon(SpotubeIcons.logs), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 480, + maxHeight: + MediaQuery.of(context).size.height * 0.8, + ), + child: AlertDialog( + padding: const EdgeInsets.all(12), + title: Row( + spacing: 8, + children: [ + const Icon(SpotubeIcons.logs), + const Text("Logs"), + const Spacer(), + IconButton.ghost( + icon: const Icon(SpotubeIcons.close), + onPressed: () => context.maybePop(), + ) + ], + ), + actions: [ + HookBuilder(builder: (context) { + final copied = useState(false); + + return Button.ghost( + leading: copied.value + ? const Icon(SpotubeIcons.done) + : const Icon(SpotubeIcons.clipboard), + child: const Text("Copy to clipboard"), + onPressed: () { + Clipboard.setData( + ClipboardData(text: error.toString()), + ); + copied.value = true; + }, + ); + }) + ], + content: SingleChildScrollView( + child: Card( + padding: const EdgeInsets.all(8.0), + filled: true, + fillColor: context.theme.colorScheme.muted, + child: SelectableText( + error.toString(), + style: TextStyle( + // Use monospace + fontFamily: 'Ubuntu Mono', + color: context + .theme.colorScheme.mutedForeground, + fontSize: 16, + ), + ), + ), + ), + ), + ); + }, + ); + }, + child: const Text("View logs"), + ), + if (onRetry != null) + Button.text( + leading: const Icon(SpotubeIcons.refresh), + onPressed: onRetry, + child: const Text("Retry"), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/fallbacks/no_default_metadata_plugin.dart b/lib/components/fallbacks/no_default_metadata_plugin.dart new file mode 100644 index 00000000..d5d00259 --- /dev/null +++ b/lib/components/fallbacks/no_default_metadata_plugin.dart @@ -0,0 +1,41 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +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/collections/spotube_icons.dart'; + +class NoDefaultMetadataPlugin extends StatelessWidget { + const NoDefaultMetadataPlugin({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Undraw( + height: 200 * context.theme.scaling, + illustration: UndrawIllustration.stars, + color: context.theme.colorScheme.primary, + ), + AutoSizeText( + "You've no default metadata provider set", + style: context.theme.typography.h4, + maxLines: 1, + ), + Button.primary( + leading: const Icon(SpotubeIcons.extensions), + child: const Text("Manage metadata providers"), + onPressed: () { + context.pushRoute(const SettingsMetadataProviderRoute()); + }, + ), + ], + ), + ); + } +} diff --git a/lib/modules/home/sections/sections.dart b/lib/modules/home/sections/sections.dart index cffbaf9e..a4a0d82d 100644 --- a/lib/modules/home/sections/sections.dart +++ b/lib/modules/home/sections/sections.dart @@ -2,10 +2,13 @@ import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; +import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/metadata_plugin/browse/sections.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:flutter_undraw/flutter_undraw.dart'; @@ -44,6 +47,29 @@ class HomePageBrowseSection extends HookConsumerWidget { ); } + if (browseSections.error + case MetadataPluginException( + errorCode: MetadataPluginErrorCode.noDefaultPlugin, + message: _, + )) { + return const SliverFillRemaining( + child: Center(child: NoDefaultMetadataPlugin()), + ); + } + + if (browseSections.hasError) { + return SliverFillRemaining( + child: Center( + child: ErrorBox( + error: browseSections.error!, + onRetry: () { + ref.invalidate(metadataPluginBrowseSectionsProvider); + }, + ), + ), + ); + } + return SliverInfiniteList( hasReachedMax: browseSections.asData?.value.hasMore == false, isLoading: !browseSections.isLoading && browseSections.isLoadingNextPage, diff --git a/lib/modules/root/sidebar/sidebar.dart b/lib/modules/root/sidebar/sidebar.dart index 74ff4a7f..62571581 100644 --- a/lib/modules/root/sidebar/sidebar.dart +++ b/lib/modules/root/sidebar/sidebar.dart @@ -80,6 +80,9 @@ class Sidebar extends HookConsumerWidget { ), for (final tile in sidebarTileList) NavigationButton( + style: router.currentPath.startsWith(tile.pathPrefix) + ? const ButtonStyle.secondary() + : null, label: mediaQuery.lgAndUp ? Text(tile.title) : null, child: Tooltip( tooltip: TooltipContainer(child: Text(tile.title)).call, @@ -94,6 +97,9 @@ class Sidebar extends HookConsumerWidget { NavigationLabel(child: Text(context.l10n.library)), for (final tile in sidebarLibraryTileList) NavigationButton( + style: router.currentPath.startsWith(tile.pathPrefix) + ? const ButtonStyle.secondary() + : null, label: mediaQuery.lgAndUp ? Text(tile.title) : null, onPressed: () { context.navigateTo(tile.route); diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 1b50f839..d85433d8 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -104,15 +104,15 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Button.secondary( - leading: const Icon(SpotubeIcons.anonymous), + Button.primary( + leading: const Icon(SpotubeIcons.extensions), onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.navigateTo(const HomeRoute()); + context.pushRoute(const SettingsMetadataProviderRoute()); } }, - child: Text(context.l10n.browse_anonymously), + child: const Text("Install a Metadata Provider"), ), ], ), diff --git a/lib/pages/library/user_albums.dart b/lib/pages/library/user_albums.dart index 42c6af7c..adec2e83 100644 --- a/lib/pages/library/user_albums.dart +++ b/lib/pages/library/user_albums.dart @@ -9,6 +9,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; +import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; @@ -17,6 +19,7 @@ import 'package:spotube/extensions/context.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'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; @RoutePage() class UserAlbumsPage extends HookConsumerWidget { @@ -50,10 +53,27 @@ class UserAlbumsPage extends HookConsumerWidget { []; }, [albumsQuery.asData?.value, searchText.value]); + if (albumsQuery.error + case MetadataPluginException( + errorCode: MetadataPluginErrorCode.noDefaultPlugin, + message: _, + )) { + return const Center(child: NoDefaultMetadataPlugin()); + } + if (authenticated.asData?.value != true) { return const AnonymousFallback(); } + if (albumsQuery.hasError) { + return ErrorBox( + error: albumsQuery.error!, + onRetry: () { + ref.invalidate(metadataPluginSavedAlbumsProvider); + }, + ); + } + return SafeArea( bottom: false, child: Scaffold( diff --git a/lib/pages/library/user_artists.dart b/lib/pages/library/user_artists.dart index 097dff4f..d5df13e5 100644 --- a/lib/pages/library/user_artists.dart +++ b/lib/pages/library/user_artists.dart @@ -12,6 +12,8 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; +import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/waypoint.dart'; @@ -20,6 +22,7 @@ import 'package:spotube/extensions/context.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'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; @RoutePage() class UserArtistsPage extends HookConsumerWidget { @@ -55,10 +58,27 @@ class UserArtistsPage extends HookConsumerWidget { final controller = useScrollController(); + if (artistQuery.error + case MetadataPluginException( + errorCode: MetadataPluginErrorCode.noDefaultPlugin, + message: _, + )) { + return const Center(child: NoDefaultMetadataPlugin()); + } + if (authenticated.asData?.value != true) { return const AnonymousFallback(); } + if (artistQuery.hasError) { + return ErrorBox( + error: artistQuery.error!, + onRetry: () { + ref.invalidate(metadataPluginSavedArtistsProvider); + }, + ); + } + return SafeArea( bottom: false, child: Scaffold( diff --git a/lib/pages/library/user_playlists.dart b/lib/pages/library/user_playlists.dart index c7493ec3..3e80801f 100644 --- a/lib/pages/library/user_playlists.dart +++ b/lib/pages/library/user_playlists.dart @@ -8,6 +8,8 @@ import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; +import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; @@ -19,6 +21,7 @@ import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:auto_route/auto_route.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; @RoutePage() class UserPlaylistsPage extends HookConsumerWidget { @@ -78,10 +81,27 @@ class UserPlaylistsPage extends HookConsumerWidget { final controller = useScrollController(); + if (playlistsQuery.error + case MetadataPluginException( + errorCode: MetadataPluginErrorCode.noDefaultPlugin, + message: _, + )) { + return const Center(child: NoDefaultMetadataPlugin()); + } + if (authenticated.asData?.value != true) { return const AnonymousFallback(); } + if (playlistsQuery.hasError) { + return ErrorBox( + error: playlistsQuery.error!, + onRetry: () { + ref.invalidate(metadataPluginSavedPlaylistsProvider); + }, + ); + } + return material.RefreshIndicator.adaptive( onRefresh: () async { ref.invalidate(metadataPluginSavedPlaylistsProvider); diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 7dec4b04..c6118a97 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -7,7 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; +import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/string.dart'; @@ -17,10 +18,10 @@ 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/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'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; final searchTermStateProvider = StateProvider((ref) { return ""; @@ -37,8 +38,6 @@ class SearchPage extends HookConsumerWidget { final controller = useShadcnTextEditingController(); final focusNode = useFocusNode(); - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - final searchTerm = ref.watch(searchTermStateProvider); final searchChipSnapshot = ref.watch(metadataPluginSearchChipsProvider); final selectedChip = useState( @@ -83,147 +82,163 @@ class SearchPage extends HookConsumerWidget { if (kTitlebarVisible) const TitleBar(automaticallyImplyLeading: false, height: 30) ], - child: authenticated.asData?.value != true - ? const AnonymousFallback() - : Column( + child: Builder(builder: (context) { + if (searchChipSnapshot.error + case MetadataPluginException( + errorCode: MetadataPluginErrorCode.noDefaultPlugin, + message: _ + )) { + return const NoDefaultMetadataPlugin(); + } + + if (searchChipSnapshot.hasError) { + return ErrorBox( + error: searchChipSnapshot.error!, + onRetry: () { + ref.invalidate(metadataPluginSearchChipsProvider); + }, + ); + } + + return Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - child: ListenableBuilder( - listenable: controller, - builder: (context, _) { - final suggestions = controller.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - controller.text.toLowerCase(), - ) > - 50, - ) - .toList(); + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + child: ListenableBuilder( + listenable: controller, + builder: (context, _) { + final suggestions = controller.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + controller.text.toLowerCase(), + ) > + 50, + ) + .toList(); - return KeyboardListener( - focusNode: focusNode, + return KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: (value) { + final isEnter = value.logicalKey == + LogicalKeyboardKey.enter; + + if (isEnter) { + onSubmitted(controller.text); + focusNode.unfocus(); + } + }, + child: AutoComplete( + suggestions: suggestions.length <= 2 + ? [ + ...suggestions, + "Twenty One Pilots", + "Linkin Park", + "d4vd" + ] + : suggestions, + completer: (suggestion) => suggestion, + mode: AutoCompleteMode.replaceAll, + child: TextField( autofocus: true, - onKeyEvent: (value) { - final isEnter = value.logicalKey == - LogicalKeyboardKey.enter; - - if (isEnter) { - onSubmitted(controller.text); - focusNode.unfocus(); - } - }, - child: AutoComplete( - suggestions: suggestions.length <= 2 - ? [ - ...suggestions, - "Twenty One Pilots", - "Linkin Park", - "d4vd" - ] - : suggestions, - completer: (suggestion) => suggestion, - mode: AutoCompleteMode.replaceAll, - child: TextField( - autofocus: true, - controller: controller, - features: [ - const InputFeature.leading( - Icon(SpotubeIcons.search), - ), - InputFeature.trailing( - AnimatedCrossFade( - duration: const Duration( - milliseconds: 300), - crossFadeState: controller - .text.isNotEmpty + controller: controller, + features: [ + const InputFeature.leading( + Icon(SpotubeIcons.search), + ), + InputFeature.trailing( + AnimatedCrossFade( + duration: + const Duration(milliseconds: 300), + crossFadeState: + controller.text.isNotEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: IconButton.ghost( - size: ButtonSize.small, - icon: const Icon( - SpotubeIcons.close), - onPressed: () { - controller.clear(); - }, - ), - secondChild: - const SizedBox.square( - dimension: 28), - ), - ) - ], - textInputAction: TextInputAction.search, - placeholder: Text(context.l10n.search), - onSubmitted: onSubmitted, - ), - ), - ); - }), - ), - ), - ], - ), - Row( - spacing: 8, - children: [ - const Gap(12), - if (searchChipSnapshot.asData?.value != null) - for (final chip in searchChipSnapshot.asData!.value) - Chip( - style: selectedChip.value == chip - ? ButtonVariance.primary.copyWith( - decoration: (context, states, value) { - return ButtonVariance.primary - .decoration(context, states) - .copyWithIfBoxDecoration( - borderRadius: - BorderRadius.circular(100), - ); - }, - ) - : ButtonVariance.secondary.copyWith( - decoration: (context, states, value) { - return ButtonVariance.secondary - .decoration(context, states) - .copyWithIfBoxDecoration( - borderRadius: - BorderRadius.circular(100), - ); - }, - ), - child: Text(chip.capitalize()), - onPressed: () { - selectedChip.value = chip; - }, - ), - ], - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: switch (selectedChip.value) { - "tracks" => const SearchPageTracksTab(), - "albums" => const SearchPageAlbumsTab(), - "artists" => const SearchPageArtistsTab(), - "playlists" => const SearchPagePlaylistsTab(), - _ => const SearchPageAllTab(), - }, + firstChild: IconButton.ghost( + size: ButtonSize.small, + icon: + const Icon(SpotubeIcons.close), + onPressed: () { + controller.clear(); + }, + ), + secondChild: const SizedBox.square( + dimension: 28), + ), + ) + ], + textInputAction: TextInputAction.search, + placeholder: Text(context.l10n.search), + onSubmitted: onSubmitted, + ), + ), + ); + }), ), ), ], ), + Row( + spacing: 8, + children: [ + const Gap(12), + if (searchChipSnapshot.asData?.value != null) + for (final chip in searchChipSnapshot.asData!.value) + Chip( + style: selectedChip.value == chip + ? ButtonVariance.primary.copyWith( + decoration: (context, states, value) { + return ButtonVariance.primary + .decoration(context, states) + .copyWithIfBoxDecoration( + borderRadius: + BorderRadius.circular(100), + ); + }, + ) + : ButtonVariance.secondary.copyWith( + decoration: (context, states, value) { + return ButtonVariance.secondary + .decoration(context, states) + .copyWithIfBoxDecoration( + borderRadius: + BorderRadius.circular(100), + ); + }, + ), + child: Text(chip.capitalize()), + onPressed: () { + selectedChip.value = chip; + }, + ), + ], + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: switch (selectedChip.value) { + "tracks" => const SearchPageTracksTab(), + "albums" => const SearchPageAlbumsTab(), + "artists" => const SearchPageArtistsTab(), + "playlists" => const SearchPagePlaylistsTab(), + _ => const SearchPageAllTab(), + }, + ), + ), + ], + ); + }), ), ), ); diff --git a/lib/pages/search/tabs/albums.dart b/lib/pages/search/tabs/albums.dart index 19781c05..e27772c6 100644 --- a/lib/pages/search/tabs/albums.dart +++ b/lib/pages/search/tabs/albums.dart @@ -2,6 +2,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/search/loading.dart'; @@ -23,6 +24,15 @@ class SearchPageAlbumsTab extends HookConsumerWidget { final searchAlbums = searchAlbumsSnapshot.asData?.value.items ?? [FakeData.albumSimple]; + if (searchAlbumsSnapshot.hasError) { + return ErrorBox( + error: searchAlbumsSnapshot.error!, + onRetry: () { + ref.invalidate(metadataPluginSearchAlbumsProvider(searchTerm)); + }, + ); + } + return SearchPlaceholder( snapshot: searchAlbumsSnapshot, child: Padding( diff --git a/lib/pages/search/tabs/all.dart b/lib/pages/search/tabs/all.dart index 42ff1e69..306bdfce 100644 --- a/lib/pages/search/tabs/all.dart +++ b/lib/pages/search/tabs/all.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/modules/search/loading.dart'; import 'package:spotube/pages/search/search.dart'; @@ -19,6 +20,15 @@ class SearchPageAllTab extends HookConsumerWidget { final searchSnapshot = ref.watch(metadataPluginSearchAllProvider(searchTerm)); + if (searchSnapshot.hasError) { + return ErrorBox( + error: searchSnapshot.error!, + onRetry: () { + ref.invalidate(metadataPluginSearchAllProvider(searchTerm)); + }, + ); + } + return SearchPlaceholder( snapshot: searchSnapshot, child: InterScrollbar( diff --git a/lib/pages/search/tabs/artists.dart b/lib/pages/search/tabs/artists.dart index 59c77a70..8cea7b58 100644 --- a/lib/pages/search/tabs/artists.dart +++ b/lib/pages/search/tabs/artists.dart @@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; @@ -27,6 +28,15 @@ class SearchPageArtistsTab extends HookConsumerWidget { ref.read(metadataPluginSearchArtistsProvider(searchTerm).notifier); final searchArtists = searchArtistsSnapshot.asData?.value.items ?? []; + if (searchArtistsSnapshot.hasError) { + return ErrorBox( + error: searchArtistsSnapshot.error!, + onRetry: () { + ref.invalidate(metadataPluginSearchArtistsProvider(searchTerm)); + }, + ); + } + return SearchPlaceholder( snapshot: searchArtistsSnapshot, child: AnimatedSwitcher( diff --git a/lib/pages/search/tabs/playlists.dart b/lib/pages/search/tabs/playlists.dart index 2ea9d430..f00153cb 100644 --- a/lib/pages/search/tabs/playlists.dart +++ b/lib/pages/search/tabs/playlists.dart @@ -2,6 +2,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/modules/search/loading.dart'; @@ -23,6 +24,15 @@ class SearchPagePlaylistsTab extends HookConsumerWidget { final searchPlaylists = searchPlaylistsSnapshot.asData?.value.items ?? [FakeData.playlistSimple]; + if (searchPlaylistsSnapshot.hasError) { + return ErrorBox( + error: searchPlaylistsSnapshot.error!, + onRetry: () { + ref.invalidate(metadataPluginSearchPlaylistsProvider(searchTerm)); + }, + ); + } + return SearchPlaceholder( snapshot: searchPlaylistsSnapshot, child: Padding( diff --git a/lib/pages/search/tabs/tracks.dart b/lib/pages/search/tabs/tracks.dart index 2212c010..e4c56891 100644 --- a/lib/pages/search/tabs/tracks.dart +++ b/lib/pages/search/tabs/tracks.dart @@ -4,6 +4,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/fallbacks/error_box.dart'; import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; @@ -31,6 +32,15 @@ class SearchPageTracksTab extends HookConsumerWidget { final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + if (searchTracksSnapshot.hasError) { + return ErrorBox( + error: searchTracksSnapshot.error!, + onRetry: () { + ref.invalidate(metadataPluginSearchTracksProvider(searchTerm)); + }, + ); + } + return SearchPlaceholder( snapshot: searchTracksSnapshot, child: InfiniteList( diff --git a/lib/provider/metadata_plugin/album/album.dart b/lib/provider/metadata_plugin/album/album.dart index 72c62202..3a386236 100644 --- a/lib/provider/metadata_plugin/album/album.dart +++ b/lib/provider/metadata_plugin/album/album.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/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/metadata/endpoints/error.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; final metadataPluginAlbumProvider = FutureProvider.autoDispose.family( @@ -12,9 +12,7 @@ final metadataPluginAlbumProvider = final metadataPlugin = await ref.watch(metadataPluginProvider.future); if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "No metadata plugin is not set", - ); + throw MetadataPluginException.noDefaultPlugin(); } return metadataPlugin.album.getAlbum(id); diff --git a/lib/provider/metadata_plugin/artist/artist.dart b/lib/provider/metadata_plugin/artist/artist.dart index e55d6103..f1691657 100644 --- a/lib/provider/metadata_plugin/artist/artist.dart +++ b/lib/provider/metadata_plugin/artist/artist.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/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.dart'; -import 'package:spotube/services/metadata/endpoints/error.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; final metadataPluginArtistProvider = FutureProvider.autoDispose.family( @@ -12,9 +12,7 @@ final metadataPluginArtistProvider = final metadataPlugin = await ref.watch(metadataPluginProvider.future); if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "No metadata plugin is not set", - ); + throw MetadataPluginException.noDefaultPlugin(); } return metadataPlugin.artist.getArtist(artistId); diff --git a/lib/provider/metadata_plugin/library/playlists.dart b/lib/provider/metadata_plugin/library/playlists.dart index 832e7a04..dbd3ae28 100644 --- a/lib/provider/metadata_plugin/library/playlists.dart +++ b/lib/provider/metadata_plugin/library/playlists.dart @@ -4,7 +4,7 @@ import 'package:spotube/models/metadata/metadata.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/utils/paginated.dart'; -import 'package:spotube/services/metadata/endpoints/error.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; class MetadataPluginSavedPlaylistsNotifier extends PaginatedAsyncNotifier { @@ -111,9 +111,7 @@ final metadataPluginIsSavedPlaylistProvider = final plugin = await ref.watch(metadataPluginProvider.future); if (plugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "Failed to get metadata plugin", - ); + throw MetadataPluginException.noDefaultPlugin(); } final follows = await plugin.user.isSavedPlaylist(id); diff --git a/lib/provider/metadata_plugin/playlist/playlist.dart b/lib/provider/metadata_plugin/playlist/playlist.dart index 79ead185..71062b95 100644 --- a/lib/provider/metadata_plugin/playlist/playlist.dart +++ b/lib/provider/metadata_plugin/playlist/playlist.dart @@ -4,7 +4,7 @@ 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/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/errors/exceptions.dart'; import 'package:spotube/services/metadata/metadata.dart'; class MetadataPluginPlaylistNotifier @@ -13,9 +13,7 @@ class MetadataPluginPlaylistNotifier final metadataPlugin = await ref.read(metadataPluginProvider.future); if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "Metadata plugin is not set", - ); + throw MetadataPluginException.noDefaultPlugin(); } return metadataPlugin; diff --git a/lib/provider/metadata_plugin/search/all.dart b/lib/provider/metadata_plugin/search/all.dart index 92f60175..b40ee78a 100644 --- a/lib/provider/metadata_plugin/search/all.dart +++ b/lib/provider/metadata_plugin/search/all.dart @@ -1,7 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/endpoints/error.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; final metadataPluginSearchAllProvider = FutureProvider.autoDispose.family( @@ -9,9 +9,7 @@ final metadataPluginSearchAllProvider = final metadataPlugin = await ref.watch(metadataPluginProvider.future); if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "No default metadata plugin found", - ); + throw MetadataPluginException.noDefaultPlugin(); } return metadataPlugin.search.all(query); @@ -22,9 +20,7 @@ final metadataPluginSearchChipsProvider = FutureProvider((ref) async { final metadataPlugin = await ref.watch(metadataPluginProvider.future); if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "No default metadata plugin found", - ); + throw MetadataPluginException.noDefaultPlugin(); } return metadataPlugin.search.chips; }); diff --git a/lib/provider/metadata_plugin/tracks/track.dart b/lib/provider/metadata_plugin/tracks/track.dart index 261e967d..502780e1 100644 --- a/lib/provider/metadata_plugin/tracks/track.dart +++ b/lib/provider/metadata_plugin/tracks/track.dart @@ -1,15 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/endpoints/error.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; final metadataPluginTrackProvider = FutureProvider.family((ref, trackId) async { final metadataPlugin = await ref.watch(metadataPluginProvider.future); if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "No metadata plugin is set as default."); + throw MetadataPluginException.noDefaultPlugin(); } return metadataPlugin.track.getTrack(trackId); diff --git a/lib/provider/metadata_plugin/utils/common.dart b/lib/provider/metadata_plugin/utils/common.dart index 98a1f4e4..087b8a1b 100644 --- a/lib/provider/metadata_plugin/utils/common.dart +++ b/lib/provider/metadata_plugin/utils/common.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/endpoints/error.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:spotube/services/metadata/metadata.dart'; extension PaginationExtension on AsyncValue { @@ -20,8 +20,7 @@ mixin MetadataPluginMixin final plugin = await ref.read(metadataPluginProvider.future); if (plugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "Metadata plugin is not set"); + throw MetadataPluginException.noDefaultPlugin(); } return plugin; diff --git a/lib/provider/track_options/track_options_provider.dart b/lib/provider/track_options/track_options_provider.dart index a1c8b785..7e6bc16e 100644 --- a/lib/provider/track_options/track_options_provider.dart +++ b/lib/provider/track_options/track_options_provider.dart @@ -20,7 +20,7 @@ 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/tracks.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/services/metadata/endpoints/error.dart'; +import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:url_launcher/url_launcher_string.dart'; enum TrackOptionValue { @@ -97,9 +97,7 @@ class TrackOptionsActions { final metadataPlugin = await ref.read(metadataPluginProvider.future); if (metadataPlugin == null) { - throw MetadataPluginException.noDefaultPlugin( - "No default metadata plugin set", - ); + throw MetadataPluginException.noDefaultPlugin(); } final tracks = await metadataPlugin.track.radio(track.id); diff --git a/lib/services/metadata/endpoints/error.dart b/lib/services/metadata/endpoints/error.dart deleted file mode 100644 index f4c6af94..00000000 --- a/lib/services/metadata/endpoints/error.dart +++ /dev/null @@ -1,12 +0,0 @@ -class MetadataPluginException implements Exception { - final String exceptionType; - final String message; - - MetadataPluginException.noDefaultPlugin(this.message) - : exceptionType = "NoDefault"; - - @override - String toString() { - return "${exceptionType}MetadataPluginException: $message"; - } -} diff --git a/lib/services/metadata/errors/exceptions.dart b/lib/services/metadata/errors/exceptions.dart index be460745..62cc3779 100644 --- a/lib/services/metadata/errors/exceptions.dart +++ b/lib/services/metadata/errors/exceptions.dart @@ -9,6 +9,7 @@ enum MetadataPluginErrorCode { pluginDownloadFailed, duplicatePlugin, pluginByteCodeFileNotFound, + noDefaultPlugin, } class MetadataPluginException implements Exception { @@ -67,6 +68,11 @@ class MetadataPluginException implements Exception { 'Plugin byte code file, plugin.out not found. Please ensure the plugin is correctly packaged.', errorCode: MetadataPluginErrorCode.pluginByteCodeFileNotFound, ); + MetadataPluginException.noDefaultPlugin() + : this._( + 'No default metadata plugin is set. Please set a default plugin in the settings.', + errorCode: MetadataPluginErrorCode.noDefaultPlugin, + ); @override String toString() => 'MetadataPluginException: $message'; diff --git a/pubspec.yaml b/pubspec.yaml index 7b6639b0..3802b2c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -216,6 +216,7 @@ flutter: - packages/flutter_undraw/assets/undraw/empty.svg - packages/flutter_undraw/assets/undraw/no_data.svg - packages/flutter_undraw/assets/undraw/process.svg + - packages/flutter_undraw/assets/undraw/stars.svg # hetu script bytecode - packages/hetu_std/assets/bytecode/std.out - packages/hetu_otp_util/assets/bytecode/otp_util.out @@ -232,6 +233,20 @@ flutter: - asset: assets/fonts/Cookie-Regular.ttf style: normal weight: 500 + - family: Ubuntu Mono + fonts: + - asset: assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf + style: normal + weight: 400 + - asset: assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf + style: normal + weight: 700 + - asset: assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf + style: italic + weight: 400 + - asset: assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf + style: italic + weight: 700 flutter_gen: output: lib/collections