feat(artist): modularize page and add wikipedia section

This commit is contained in:
Kingkor Roy Tirtho 2023-11-22 20:23:12 +06:00
parent 4511a0bd00
commit 2a69886556
13 changed files with 693 additions and 455 deletions

View File

@ -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;
}

View File

@ -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,11 +121,12 @@ class UserPlaylists extends HookConsumerWidget {
const SliverToBoxAdapter(
child: SizedBox(height: 10),
),
SliverGrid.builder(
SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid.builder(
itemCount: playlists.length + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: DesktopTools.platform.isMobile ? 225 : 250,
mainAxisExtent: constrains.smAndDown ? 225 : 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
@ -144,7 +146,8 @@ class UserPlaylists extends HookConsumerWidget {
return PlaylistCard(playlists[index]);
},
)
);
})
],
),
),

28
lib/extensions/color.dart Normal file
View File

@ -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;
}
}

View File

@ -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 =>

View File

@ -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()),
);
extendBodyBehindAppBar: true,
body: Builder(builder: (context) {
if (artistQuery.isLoading || !artistQuery.hasData) {
const ShimmerArtistProfile();
} else if (artistQuery.hasError) {
return Center(child: Text(artistQuery.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,
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,
bottom: false,
child: ArtistPageHeader(artistId: artistId),
),
),
),
),
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 ?? <Track>[],
);
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<Track> 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(
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,
),
),
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(),
),
);
},
SliverSafeArea(
sliver: ArtistPageRelatedArtists(artistId: artistId),
),
if (artistQuery.data != null)
SliverSafeArea(
top: false,
sliver: SliverToBoxAdapter(
child: ArtistPageFooter(artist: artistQuery.data!),
),
),
],
),
),
),
);
},
),
}),
),
);
}

View File

@ -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}",
);
},
),
],
),
),
);
}
}

View File

@ -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,
),
),
);
},
)
],
)
],
),
],
),
);
},
);
}
}

View File

@ -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);
},
),
);
}
}

View File

@ -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 ?? <Track>[],
);
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<Track> 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,
);
},
);
},
),
],
);
}
}

View File

@ -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<bool, dynamic>(
"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<Iterable<Track>, 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<Iterable<Artist>, dynamic>(
"artist-related-artist-query/$artist",
(spotify) {
return spotify.artists.getRelatedArtists(artist);
return spotify.artists.relatedArtists(artist);
},
ref: ref,
);
}
Query<Summary?, dynamic> wikipediaSummary(ArtistSimple artist) {
return useQuery<Summary?, dynamic>(
"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;
},
);
}
}

View File

@ -0,0 +1,3 @@
import 'package:wikipedia_api/wikipedia_api.dart';
final wikipedia = WikipediaApi();

View File

@ -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"

View File

@ -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