From 2a698865567883271471ace9a44123bbfd8fcd2f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 22 Nov 2023 20:23:12 +0600 Subject: [PATCH] feat(artist): modularize page and add wikipedia section --- lib/collections/spotube_icons.dart | 1 + lib/components/library/user_playlists.dart | 49 +- lib/extensions/color.dart | 28 ++ lib/extensions/constrains.dart | 24 + lib/pages/artist/artist.dart | 475 ++---------------- lib/pages/artist/section/footer.dart | 93 ++++ lib/pages/artist/section/header.dart | 257 ++++++++++ lib/pages/artist/section/related_artists.dart | 49 ++ lib/pages/artist/section/top_tracks.dart | 126 +++++ lib/services/queries/artist.dart | 29 +- lib/services/wikipedia/wikipedia.dart | 3 + pubspec.lock | 11 +- pubspec.yaml | 3 + 13 files changed, 693 insertions(+), 455 deletions(-) create mode 100644 lib/extensions/color.dart create mode 100644 lib/pages/artist/section/footer.dart create mode 100644 lib/pages/artist/section/header.dart create mode 100644 lib/pages/artist/section/related_artists.dart create mode 100644 lib/pages/artist/section/top_tracks.dart create mode 100644 lib/services/wikipedia/wikipedia.dart diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 78cbb52c7..d00775c75 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -107,4 +107,5 @@ abstract class SpotubeIcons { static const eye = FeatherIcons.eye; static const noEye = FeatherIcons.eyeOff; static const normalize = FeatherIcons.barChart2; + static const wikipedia = SimpleIcons.wikipedia; } diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 0102a3c7a..f7736ca7e 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart' import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -120,31 +121,33 @@ class UserPlaylists extends HookConsumerWidget { const SliverToBoxAdapter( child: SizedBox(height: 10), ), - SliverGrid.builder( - itemCount: playlists.length + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: DesktopTools.platform.isMobile ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (index == playlists.length) { - if (!playlistsQuery.hasNextPage) { - return const SizedBox.shrink(); - } + SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: playlists.length + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (index == playlists.length) { + if (!playlistsQuery.hasNextPage) { + return const SizedBox.shrink(); + } - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: playlistsQuery.fetchNext, - child: const ShimmerPlaybuttonCard(count: 1), - ); - } + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: playlistsQuery.fetchNext, + child: const ShimmerPlaybuttonCard(count: 1), + ); + } - return PlaylistCard(playlists[index]); - }, - ) + return PlaylistCard(playlists[index]); + }, + ); + }) ], ), ), diff --git a/lib/extensions/color.dart b/lib/extensions/color.dart new file mode 100644 index 000000000..68cd8ef7f --- /dev/null +++ b/lib/extensions/color.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +extension ColorAlterer on Color { + Color darken(double amount) { + assert(amount >= 0 && amount <= 1); + final hsl = HSLColor.fromColor(this); + final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); + return hslDark.toColor(); + } + + Color lighten(double amount) { + assert(amount >= 0 && amount <= 1); + final hsl = HSLColor.fromColor(this); + final hslLight = + hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0)); + return hslLight.toColor(); + } + + bool isLight() { + final luminance = computeLuminance(); + return luminance > 0.5; + } + + bool isDark() { + final luminance = computeLuminance(); + return luminance <= 0.5; + } +} diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart index 85c84ca9d..1177f5ace 100644 --- a/lib/extensions/constrains.dart +++ b/lib/extensions/constrains.dart @@ -1,3 +1,4 @@ +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; // ignore: constant_identifier_names @@ -9,6 +10,29 @@ const Breakpoints = ( xl: 1280.0, ); +extension SliverBreakpoints on SliverConstraints { + bool get isXs => crossAxisExtent <= Breakpoints.xs; + bool get isSm => + crossAxisExtent > Breakpoints.xs && crossAxisExtent <= Breakpoints.sm; + bool get isMd => + crossAxisExtent > Breakpoints.sm && crossAxisExtent <= Breakpoints.md; + bool get isLg => + crossAxisExtent > Breakpoints.md && crossAxisExtent <= Breakpoints.lg; + bool get isXl => + crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl; + bool get is2Xl => crossAxisExtent > Breakpoints.xl; + + bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; + bool get mdAndUp => isMd || isLg || isXl || is2Xl; + bool get lgAndUp => isLg || isXl || is2Xl; + bool get xlAndUp => isXl || is2Xl; + + bool get smAndDown => isXs || isSm; + bool get mdAndDown => isXs || isSm || isMd; + bool get lgAndDown => isXs || isSm || isMd || isLg; + bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; +} + extension ContainerBreakpoints on BoxConstraints { bool get isXs => biggest.width <= Breakpoints.xs; bool get isSm => diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 8b57c2a82..693e825b4 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -1,32 +1,19 @@ -import 'package:collection/collection.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/artist/artist_album_list.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/pages/artist/section/footer.dart'; +import 'package:spotube/pages/artist/section/header.dart'; +import 'package:spotube/pages/artist/section/related_artists.dart'; +import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - class ArtistPage extends HookConsumerWidget { final String artistId; final logger = getLogger(ArtistPage); @@ -34,427 +21,61 @@ class ArtistPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - SpotifyApi spotify = ref.watch(spotifyProvider); - final parentScrollController = useScrollController(); + final scrollController = useScrollController(); final theme = Theme.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final textTheme = theme.textTheme; - final chipTextVariant = useBreakpointValue( - xs: textTheme.bodySmall, - sm: textTheme.bodySmall, - md: textTheme.bodyMedium, - lg: textTheme.bodyLarge, - xl: textTheme.titleSmall, - xxl: textTheme.titleMedium, - ); - - final mediaQuery = MediaQuery.of(context); - - final avatarWidth = useBreakpointValue( - xs: mediaQuery.size.width * 0.50, - sm: mediaQuery.size.width * 0.50, - md: mediaQuery.size.width * 0.40, - lg: mediaQuery.size.width * 0.18, - xl: mediaQuery.size.width * 0.18, - xxl: mediaQuery.size.width * 0.18, - ); - - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - - final auth = ref.watch(AuthenticationNotifier.provider); - final queryClient = useQueryClient(); + final artistQuery = useQueries.artist.get(ref, artistId); return SafeArea( bottom: false, child: Scaffold( appBar: const PageWindowTitleBar( leading: BackButton(), + backgroundColor: Colors.transparent, ), - body: HookBuilder( - builder: (context) { - final artistsQuery = useQueries.artist.get(ref, artistId); - - if (artistsQuery.isLoading || !artistsQuery.hasData) { - return const ShimmerArtistProfile(); - } else if (artistsQuery.hasError) { - return Center( - child: Text(artistsQuery.error.toString()), - ); - } - - final data = artistsQuery.data!; - - final blacklist = ref.watch(BlackListNotifier.provider); - final isBlackListed = blacklist.contains( - BlacklistedElement.artist(artistId, data.name!), - ); - - return InterScrollbar( - controller: parentScrollController, - child: SingleChildScrollView( - controller: parentScrollController, + extendBodyBehindAppBar: true, + body: Builder(builder: (context) { + if (artistQuery.isLoading || !artistQuery.hasData) { + const ShimmerArtistProfile(); + } else if (artistQuery.hasError) { + return Center(child: Text(artistQuery.error.toString())); + } + return CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - const SizedBox(width: 50), - Padding( - padding: const EdgeInsets.all(16), - child: CircleAvatar( - radius: avatarWidth, - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - data.images, - placeholder: ImagePlaceholder.artist, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: - BorderRadius.circular(50)), - child: Text( - data.type!.toUpperCase(), - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - if (isBlackListed) ...[ - const SizedBox(width: 5), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.red[400], - borderRadius: - BorderRadius.circular(50)), - child: Text( - context.l10n.blacklisted, - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - ] - ], - ), - Text( - data.name!, - style: mediaQuery.smAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium, - ), - Text( - context.l10n.followers( - PrimitiveUtils.toReadableNumber( - data.followers!.total!.toDouble(), - ), - ), - style: textTheme.bodyMedium?.copyWith( - fontWeight: mediaQuery.mdAndUp - ? FontWeight.bold - : null, - ), - ), - const SizedBox(height: 20), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = useQueries - .artist - .doIFollow(ref, artistId); - - final followUnfollow = - useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], - ); - await isFollowingQuery.refresh(); - - queryClient - .refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", - ); - } - }, [isFollowingQuery]); - - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: - CircularProgressIndicator(), - ); - } - - if (isFollowingQuery.data!) { - return OutlinedButton( - onPressed: followUnfollow, - child: - Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - tooltip: - context.l10n.add_artist_to_blacklist, - icon: Icon( - SpotubeIcons.userRemove, - color: !isBlackListed - ? Colors.red[400] - : Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: isBlackListed - ? Colors.red[400] - : null, - ), - onPressed: () async { - if (isBlackListed) { - ref - .read(BlackListNotifier - .provider.notifier) - .remove( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } else { - ref - .read(BlackListNotifier - .provider.notifier) - .add( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - if (data.externalUrls?.spotify != - null) { - await Clipboard.setData( - ClipboardData( - text: data.externalUrls!.spotify!, - ), - ); - } - - if (!context.mounted) return; - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.artist_url_copied, - textAlign: TextAlign.center, - ), - ), - ); - }, - ) - ], - ) - ], - ), - ), - ], - ), - const SizedBox(height: 50), - HookBuilder( - builder: (context) { - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); - - final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], - ); - - if (topTracksQuery.isLoading || - !topTracksQuery.hasData) { - return const CircularProgressIndicator(); - } else if (topTracksQuery.hasError) { - return Center( - child: Text(topTracksQuery.error.toString()), - ); - } - - final topTracks = topTracksQuery.data!; - - void playPlaylist(List tracks, - {Track? currentTrack}) async { - currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere( - (s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); - } - } - - return Column( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.top_tracks, - style: theme.textTheme.headlineSmall, - ), - ), - if (!isPlaylistPlaying) - IconButton( - icon: const Icon( - SpotubeIcons.queueAdd, - ), - onPressed: () { - playlistNotifier - .addTracks(topTracks.toList()); - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.added_to_queue( - topTracks.length, - ), - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - icon: Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - color: Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: - theme.colorScheme.primary, - ), - onPressed: () => - playPlaylist(topTracks.toList()), - ) - ], - ), - ...topTracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () async { - playPlaylist( - topTracks.toList(), - currentTrack: track, - ); - }, - ); - }), - ], - ); - }, - ), - const SizedBox(height: 50), - ArtistAlbumList(artistId), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.fans_also_like, - style: theme.textTheme.headlineSmall, - ), - ), - const SizedBox(height: 10), - HookBuilder( - builder: (context) { - final relatedArtists = - useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); - - if (relatedArtists.isLoading || - !relatedArtists.hasData) { - return const CircularProgressIndicator(); - } else if (relatedArtists.hasError) { - return Center( - child: Text(relatedArtists.error.toString()), - ); - } - - return Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: relatedArtists.data! - .map((artist) => ArtistCard(artist)) - .toList(), - ), - ); - }, - ), - ], + bottom: false, + child: ArtistPageHeader(artistId: artistId), + ), + ), + const SliverGap(50), + ArtistPageTopTracks(artistId: artistId), + const SliverGap(50), + SliverToBoxAdapter(child: ArtistAlbumList(artistId)), + const SliverGap(20), + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.fans_also_like, + style: theme.textTheme.headlineSmall, ), ), ), - ); - }, - ), + SliverSafeArea( + sliver: ArtistPageRelatedArtists(artistId: artistId), + ), + if (artistQuery.data != null) + SliverSafeArea( + top: false, + sliver: SliverToBoxAdapter( + child: ArtistPageFooter(artist: artistQuery.data!), + ), + ), + ], + ); + }), ), ); } diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart new file mode 100644 index 000000000..3c0db8a5d --- /dev/null +++ b/lib/pages/artist/section/footer.dart @@ -0,0 +1,93 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class ArtistPageFooter extends HookConsumerWidget { + final Artist artist; + const ArtistPageFooter({Key? key, required this.artist}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + final artistImage = TypeConversionUtils.image_X_UrlString( + artist.images, + placeholder: ImagePlaceholder.artist, + ); + final summary = useQueries.artist.wikipediaSummary(artist); + if (summary.hasError || !summary.hasData) return const SizedBox.shrink(); + return Container( + margin: const EdgeInsets.all(16), + padding: mediaQuery.smAndDown + ? const EdgeInsets.all(20) + : const EdgeInsets.all(30), + constraints: const BoxConstraints(minHeight: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + image: DecorationImage( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.5), + BlendMode.darken, + ), + image: UniversalImage.imageProvider( + summary.data!.originalimage?.source_ ?? artistImage, + height: summary.data!.originalimage?.height.toDouble(), + width: summary.data!.originalimage?.width.toDouble(), + ), + fit: BoxFit.cover, + alignment: Alignment.center, + ), + ), + alignment: Alignment.center, + child: RichText( + text: TextSpan( + style: textTheme.bodyLarge?.copyWith( + color: Colors.white, + ), + children: [ + // icon + const WidgetSpan( + child: Icon( + SpotubeIcons.wikipedia, + color: Colors.white, + size: 30, + ), + ), + TextSpan( + text: " Wikipedia", + style: textTheme.titleLarge?.copyWith( + color: Colors.white, + ), + ), + const TextSpan(text: '\n\n'), + TextSpan( + text: summary.data!.extract, + ), + TextSpan( + text: '\n...read more at wikipedia', + style: textTheme.bodyLarge?.copyWith( + color: Colors.lightBlue[300], + decoration: TextDecoration.underline, + decorationColor: Colors.lightBlue[300], + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + await launchUrlString( + "http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}", + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart new file mode 100644 index 000000000..9fc9d78ed --- /dev/null +++ b/lib/pages/artist/section/header.dart @@ -0,0 +1,257 @@ +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class ArtistPageHeader extends HookConsumerWidget { + final String artistId; + const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final queryClient = useQueryClient(); + final artistQuery = useQueries.artist.get(ref, artistId); + final artist = artistQuery.data; + + final scaffoldMessenger = ScaffoldMessenger.of(context); + final mediaQuery = MediaQuery.of(context); + final theme = Theme.of(context); + final ThemeData(:textTheme) = theme; + + final chipTextVariant = useBreakpointValue( + xs: textTheme.bodySmall, + sm: textTheme.bodySmall, + md: textTheme.bodyMedium, + lg: textTheme.bodyLarge, + xl: textTheme.titleSmall, + xxl: textTheme.titleMedium, + ); + + if (artist == null) { + return const SizedBox.shrink(); + } + + final spotify = ref.read(spotifyProvider); + final auth = ref.watch(AuthenticationNotifier.provider); + final blacklist = ref.watch(BlackListNotifier.provider); + final isBlackListed = blacklist.contains( + BlacklistedElement.artist(artistId, artist.name!), + ); + + final image = TypeConversionUtils.image_X_UrlString( + artist.images, + placeholder: ImagePlaceholder.artist, + ); + + return LayoutBuilder( + builder: (context, constrains) { + return Center( + child: Flex( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: constrains.smAndDown + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + direction: constrains.smAndDown ? Axis.vertical : Axis.horizontal, + children: [ + DecoratedBox( + decoration: BoxDecoration( + boxShadow: kElevationToShadow[2], + borderRadius: BorderRadius.circular(35), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(35), + child: UniversalImage( + path: image, + width: 250, + height: 250, + fit: BoxFit.cover, + ), + ), + ), + const Gap(20), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(50)), + child: Text( + artist.type!.toUpperCase(), + style: chipTextVariant.copyWith( + color: Colors.white, + ), + ), + ), + if (isBlackListed) ...[ + const SizedBox(width: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.red[400], + borderRadius: BorderRadius.circular(50)), + child: Text( + context.l10n.blacklisted, + style: chipTextVariant.copyWith( + color: Colors.white, + ), + ), + ), + ] + ], + ), + Text( + artist.name!, + style: mediaQuery.smAndDown + ? textTheme.headlineSmall + : textTheme.headlineMedium, + ), + Text( + context.l10n.followers( + PrimitiveUtils.toReadableNumber( + artist.followers!.total!.toDouble(), + ), + ), + style: textTheme.bodyMedium?.copyWith( + fontWeight: mediaQuery.mdAndUp ? FontWeight.bold : null, + ), + ), + const Gap(20), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (auth != null) + HookBuilder( + builder: (context) { + final isFollowingQuery = + useQueries.artist.doIFollow(ref, artistId); + + final followUnfollow = useCallback(() async { + try { + isFollowingQuery.data! + ? await spotify.me.unfollow( + FollowingType.artist, + [artistId], + ) + : await spotify.me.follow( + FollowingType.artist, + [artistId], + ); + await isFollowingQuery.refresh(); + + queryClient.refreshInfiniteQueryAllPages( + "user-following-artists"); + } finally { + queryClient.refreshQuery( + "user-follows-artists-query/$artistId", + ); + } + }, [isFollowingQuery]); + + if (isFollowingQuery.isLoading || + !isFollowingQuery.hasData) { + return const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ); + } + + if (isFollowingQuery.data!) { + return OutlinedButton( + onPressed: followUnfollow, + child: Text(context.l10n.following), + ); + } + + return FilledButton( + onPressed: followUnfollow, + child: Text(context.l10n.follow), + ); + }, + ), + const SizedBox(width: 5), + IconButton( + tooltip: context.l10n.add_artist_to_blacklist, + icon: Icon( + SpotubeIcons.userRemove, + color: + !isBlackListed ? Colors.red[400] : Colors.white, + ), + style: IconButton.styleFrom( + backgroundColor: + isBlackListed ? Colors.red[400] : null, + ), + onPressed: () async { + if (isBlackListed) { + ref + .read(BlackListNotifier.provider.notifier) + .remove( + BlacklistedElement.artist( + artist.id!, artist.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.artist( + artist.id!, artist.name!), + ); + } + }, + ), + IconButton( + icon: const Icon(SpotubeIcons.share), + onPressed: () async { + if (artist.externalUrls?.spotify != null) { + await Clipboard.setData( + ClipboardData( + text: artist.externalUrls!.spotify!, + ), + ); + } + + if (!context.mounted) return; + + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.artist_url_copied, + textAlign: TextAlign.center, + ), + ), + ); + }, + ) + ], + ) + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart new file mode 100644 index 000000000..2938c084d --- /dev/null +++ b/lib/pages/artist/section/related_artists.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/artist/artist_card.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class ArtistPageRelatedArtists extends HookConsumerWidget { + final String artistId; + const ArtistPageRelatedArtists({ + Key? key, + required this.artistId, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final relatedArtists = useQueries.artist.relatedArtistsOf( + ref, + artistId, + ); + + if (relatedArtists.isLoading || !relatedArtists.hasData) { + return const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator())); + } else if (relatedArtists.hasError) { + return SliverToBoxAdapter( + child: Center( + child: Text(relatedArtists.error.toString()), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverGrid.builder( + itemCount: relatedArtists.data!.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemBuilder: (context, index) { + final artist = relatedArtists.data!.elementAt(index); + return ArtistCard(artist); + }, + ), + ); + } +} diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart new file mode 100644 index 000000000..9e3e40543 --- /dev/null +++ b/lib/pages/artist/section/top_tracks.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class ArtistPageTopTracks extends HookConsumerWidget { + final String artistId; + const ArtistPageTopTracks({Key? key, required this.artistId}) + : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final topTracksQuery = useQueries.artist.topTracksOf( + ref, + artistId, + ); + + final isPlaylistPlaying = playlist.containsTracks( + topTracksQuery.data ?? [], + ); + + if (topTracksQuery.isLoading || !topTracksQuery.hasData) { + return const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ); + } else if (topTracksQuery.hasError) { + return SliverToBoxAdapter( + child: Center( + child: Text(topTracksQuery.error.toString()), + ), + ); + } + + final topTracks = topTracksQuery.data!; + + void playPlaylist(List tracks, {Track? currentTrack}) async { + currentTrack ??= tracks.first; + if (!isPlaylistPlaying) { + playlistNotifier.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } + } + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.top_tracks, + style: theme.textTheme.headlineSmall, + ), + ), + if (!isPlaylistPlaying) + IconButton( + icon: const Icon( + SpotubeIcons.queueAdd, + ), + onPressed: () { + playlistNotifier.addTracks(topTracks.toList()); + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.added_to_queue( + topTracks.length, + ), + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + const SizedBox(width: 5), + IconButton( + icon: Icon( + isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, + color: Colors.white, + ), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + ), + onPressed: () => playPlaylist(topTracks.toList()), + ) + ], + ), + ), + SliverList.builder( + itemCount: topTracks.length, + itemBuilder: (context, index) { + final track = topTracks.elementAt(index); + return TrackTile( + index: index, + track: track, + onTap: () async { + playPlaylist( + topTracks.toList(), + currentTrack: track, + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart index 7501d6190..1b939c823 100644 --- a/lib/services/queries/artist.dart +++ b/lib/services/queries/artist.dart @@ -1,8 +1,12 @@ import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; import 'package:spotube/hooks/spotify/use_spotify_query.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/wikipedia/wikipedia.dart'; +import 'package:wikipedia_api/wikipedia_api.dart'; class ArtistQueries { const ArtistQueries(); @@ -72,11 +76,11 @@ class ArtistQueries { return useSpotifyQuery( "user-follows-artists-query/$artist", (spotify) async { - final result = await spotify.me.isFollowing( + final result = await spotify.me.checkFollowing( FollowingType.artist, [artist], ); - return result.first; + return result[artist]; }, ref: ref, ); @@ -86,10 +90,12 @@ class ArtistQueries { WidgetRef ref, String artist, ) { + final preferences = ref.watch(userPreferencesProvider); return useSpotifyQuery, dynamic>( "artist-top-track-query/$artist", (spotify) { - return spotify.artists.getTopTracks(artist, "US"); + return spotify.artists + .topTracks(artist, preferences.recommendationMarket); }, ref: ref, ); @@ -122,9 +128,24 @@ class ArtistQueries { return useSpotifyQuery, dynamic>( "artist-related-artist-query/$artist", (spotify) { - return spotify.artists.getRelatedArtists(artist); + return spotify.artists.relatedArtists(artist); }, ref: ref, ); } + + Query wikipediaSummary(ArtistSimple artist) { + return useQuery( + "artist-wikipedia-query/${artist.id}", + () async { + final query = artist.name!.replaceAll(" ", "_"); + final res = await wikipedia.pageContent.pageSummaryTitleGet(query); + if (res?.type != "standard") { + return await wikipedia.pageContent + .pageSummaryTitleGet("${query}_(singer)"); + } + return res; + }, + ); + } } diff --git a/lib/services/wikipedia/wikipedia.dart b/lib/services/wikipedia/wikipedia.dart new file mode 100644 index 000000000..b571f30f1 --- /dev/null +++ b/lib/services/wikipedia/wikipedia.dart @@ -0,0 +1,3 @@ +import 'package:wikipedia_api/wikipedia_api.dart'; + +final wikipedia = WikipediaApi(); diff --git a/pubspec.lock b/pubspec.lock index 6c822604c..414297c50 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2222,6 +2222,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + wikipedia_api: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: cb7590a3d76b25f16ad3f7147ae6603350777a00 + url: "https://github.com/KRTirtho/wikipedia_api.git" + source: git + version: "0.1.0" win32: dependency: transitive description: @@ -2288,5 +2297,5 @@ packages: source: hosted version: "2.0.2" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" + dart: ">=3.2.0 <4.0.0" flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index 6a33d294b..fd99e8417 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -119,6 +119,9 @@ dependencies: git: url: https://github.com/Tommypop2/dart_discord_rpc.git html_unescape: ^2.0.0 + wikipedia_api: + git: + url: https://github.com/KRTirtho/wikipedia_api.git dev_dependencies: build_runner: ^2.3.2