feat: add stats summary and top tracks/artists/albums

This commit is contained in:
Kingkor Roy Tirtho 2024-04-29 14:50:05 +06:00
parent 5f442a1ff7
commit c9bd42c847
18 changed files with 568 additions and 33 deletions

View File

@ -0,0 +1,3 @@
import 'package:intl/intl.dart';
final compactNumberFormatter = NumberFormat.compact();

View File

@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List<Widget> tabs;
const ThemedButtonsTabBar({super.key, required this.tabs});
final TabController? controller;
const ThemedButtonsTabBar({super.key, required this.tabs, this.controller});
@override
Widget build(BuildContext context) {
@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
bottom: 8,
),
child: ButtonsTabBar(
controller: controller,
radius: 100,
decoration: BoxDecoration(
color: bgColor,

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/stats/summary/summary_card.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/history/summary.dart';
class StatsPageSummarySection extends HookConsumerWidget {
const StatsPageSummarySection({super.key});
@override
Widget build(BuildContext context, ref) {
final summary = ref.watch(playbackHistorySummaryProvider);
return SliverPadding(
padding: const EdgeInsets.all(10),
sliver: SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: constrains.isXs
? 2
: constrains.smAndDown
? 3
: constrains.mdAndDown
? 4
: 5,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: constrains.isXs ? 1.3 : 1.5,
),
delegate: SliverChildListDelegate([
switch (summary.duration) {
>= const Duration(hours: 1) => SummaryCard(
title: summary.duration.inHours.toDouble(),
unit: "hours",
description: 'Listened to music',
color: Colors.green,
),
_ => SummaryCard(
title: summary.duration.inMinutes.toDouble(),
unit: "minutes",
description: 'Listened to music',
color: Colors.green,
),
},
SummaryCard(
title: summary.tracks.toDouble(),
unit: "songs",
description: 'Streamed overall',
color: Colors.lightBlue,
),
SummaryCard(
title: summary.artists.toDouble(),
unit: "artist's",
description: 'Music reached you',
color: Colors.yellow,
),
SummaryCard(
title: summary.albums.toDouble(),
unit: "full albums",
description: 'Got your love',
color: Colors.pink,
),
SummaryCard(
title: summary.playlists.toDouble(),
unit: "playlists",
description: 'Were on repeat',
color: Colors.teal,
),
]),
);
}),
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:spotube/collections/formatters.dart';
class SummaryCard extends StatelessWidget {
final double title;
final String unit;
final String description;
final MaterialColor color;
const SummaryCard({
super.key,
required this.title,
required this.unit,
required this.description,
required this.color,
});
@override
Widget build(BuildContext context) {
final ThemeData(:textTheme, :brightness) = Theme.of(context);
return Card(
color: brightness == Brightness.dark ? color.shade100 : color.shade50,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AutoSizeText.rich(
TextSpan(
children: [
TextSpan(
text: compactNumberFormatter.format(title),
style: textTheme.headlineLarge?.copyWith(
color: color.shade900,
),
),
TextSpan(
text: " $unit",
style: textTheme.titleMedium?.copyWith(
color: color.shade900,
),
),
],
),
maxLines: 1,
),
const Gap(5),
AutoSizeText(
description,
maxLines: 1,
minFontSize: 9,
style: textTheme.labelMedium!.copyWith(
color: color.shade900,
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/provider/history/top.dart';
import 'package:spotube/utils/service_utils.dart';
class TopAlbums extends HookConsumerWidget {
const TopAlbums({super.key});
@override
Widget build(BuildContext context, ref) {
final albums =
ref.watch(playbackHistoryTopProvider.select((value) => value.albums));
return SliverList.builder(
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return ListTile(
horizontalTitleGap: 8,
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
path: (album.album.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
width: 40,
height: 40,
),
),
title: Text(album.album.name!),
subtitle: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("${album.album.albumType?.formatted}"),
Flexible(
child: ArtistLink(
artists: album.album.artists!,
mainAxisAlignment: WrapAlignment.start,
),
),
],
),
trailing: Text(
"${compactNumberFormatter.format(album.count)} plays",
),
onTap: () {
ServiceUtils.pushNamed(
context,
AlbumPage.name,
pathParameters: {
"id": album.album.id!,
},
extra: album.album,
);
},
);
},
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/provider/history/top.dart';
import 'package:spotube/utils/service_utils.dart';
class TopArtists extends HookConsumerWidget {
const TopArtists({super.key});
@override
Widget build(BuildContext context, ref) {
final artists =
ref.watch(playbackHistoryTopProvider.select((value) => value.artists));
return SliverList.builder(
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index];
return ListTile(
title: Text(artist.artist.name!),
horizontalTitleGap: 8,
leading: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
(artist.artist.images).asUrlString(
placeholder: ImagePlaceholder.artist,
),
),
),
trailing: Text(
"${compactNumberFormatter.format(artist.count)} plays",
),
onTap: () {
ServiceUtils.pushNamed(
context,
ArtistPage.name,
pathParameters: {
"id": artist.artist.id!,
},
);
},
);
},
);
}
}

View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/themed_button_tab_bar.dart';
import 'package:spotube/components/stats/top/albums.dart';
import 'package:spotube/components/stats/top/artists.dart';
import 'package:spotube/components/stats/top/tracks.dart';
class StatsPageTopSection extends HookConsumerWidget {
const StatsPageTopSection({super.key});
@override
Widget build(BuildContext context, ref) {
final tabController = useTabController(initialLength: 3);
return SliverMainAxisGroup(
slivers: [
SliverAppBar(
floating: true,
flexibleSpace: ThemedButtonsTabBar(
controller: tabController,
tabs: const [
Tab(
child: Padding(
padding: EdgeInsets.all(5),
child: Text("Top Tracks"),
),
),
Tab(
child: Padding(
padding: EdgeInsets.all(5),
child: Text("Top Artists"),
),
),
Tab(
child: Padding(
padding: EdgeInsets.all(5),
child: Text("Top Albums"),
),
),
],
),
),
ListenableBuilder(
listenable: tabController,
builder: (context, _) {
return switch (tabController.index) {
1 => const TopArtists(),
2 => const TopAlbums(),
_ => const TopTracks(),
};
},
),
],
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/track/track.dart';
import 'package:spotube/provider/history/top.dart';
import 'package:spotube/utils/service_utils.dart';
class TopTracks extends HookConsumerWidget {
const TopTracks({super.key});
@override
Widget build(BuildContext context, ref) {
final tracks =
ref.watch(playbackHistoryTopProvider.select((value) => value.tracks));
return SliverList.builder(
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
return ListTile(
horizontalTitleGap: 8,
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
path: (track.track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
width: 40,
height: 40,
),
),
title: Text(track.track.name!),
subtitle: ArtistLink(
artists: track.track.artists!,
mainAxisAlignment: WrapAlignment.start,
),
trailing: Text(
"${compactNumberFormatter.format(track.count)} plays",
),
onTap: () {
ServiceUtils.pushNamed(
context,
TrackPage.name,
pathParameters: {
"id": track.track.id!,
},
);
},
);
},
);
}
}

View File

@ -15,6 +15,7 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/configurators/use_endless_playback.dart';
import 'package:spotube/hooks/configurators/use_update_checker.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/provider/connect/server.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
@ -173,10 +174,11 @@ class RootApp extends HookConsumerWidget {
// ignore: deprecated_member_use
return WillPopScope(
onWillPop: () async {
// if (rootPaths[location] != 0) {
// onSelectIndexChanged(0);
// return false;
// }
final routerState = GoRouterState.of(context);
if (routerState.matchedLocation != "/") {
context.goNamed(HomePage.name);
return false;
}
return true;
},
child: Scaffold(

View File

@ -1,5 +1,10 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/stats/summary/summary.dart';
import 'package:spotube/components/stats/top/top.dart';
import 'package:spotube/utils/platform.dart';
class StatsPage extends HookConsumerWidget {
static const name = "stats";
@ -8,6 +13,23 @@ class StatsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
return Container();
return SafeArea(
bottom: false,
child: Scaffold(
appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(),
body: CustomScrollView(
slivers: [
if (kIsMacOS) const SliverGap(20),
const StatsPageSummarySection(),
const StatsPageTopSection(),
const SliverToBoxAdapter(
child: SafeArea(
child: SizedBox(),
),
)
],
),
),
);
}
}

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/history/state.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
class PlaybackHistoryState {
@ -36,9 +38,12 @@ class PlaybackHistoryState {
class PlaybackHistoryNotifier
extends PersistedStateNotifier<PlaybackHistoryState> {
PlaybackHistoryNotifier()
final Ref ref;
PlaybackHistoryNotifier(this.ref)
: super(const PlaybackHistoryState(), "playback_history");
SpotifyApi get spotify => ref.read(spotifyProvider);
@override
FutureOr<PlaybackHistoryState> fromJson(Map<String, dynamic> json) =>
PlaybackHistoryState.fromJson(json);
@ -69,12 +74,18 @@ class PlaybackHistoryNotifier
);
}
void addTracks(List<TrackSimple> tracks) {
void addTrack(Track track) async {
// For some reason Track's artists images are `null`
// so we need to fetch them from the API
final artists =
await spotify.artists.list(track.artists!.map((e) => e.id!).toList());
track.artists = artists.toList();
state = state.copyWith(
items: [
...state.items,
for (final track in tracks)
PlaybackHistoryItem.track(date: DateTime.now(), track: track),
PlaybackHistoryItem.track(date: DateTime.now(), track: track),
],
);
}
@ -86,5 +97,33 @@ class PlaybackHistoryNotifier
final playbackHistoryProvider =
StateNotifierProvider<PlaybackHistoryNotifier, PlaybackHistoryState>(
(ref) => PlaybackHistoryNotifier(),
(ref) => PlaybackHistoryNotifier(ref),
);
typedef PlaybackHistoryGrouped = ({
List<PlaybackHistoryTrack> tracks,
List<PlaybackHistoryAlbum> albums,
List<PlaybackHistoryPlaylist> playlists,
});
final playbackHistoryGroupedProvider = Provider<PlaybackHistoryGrouped>((ref) {
final history = ref.watch(playbackHistoryProvider);
final tracks = history.items
.whereType<PlaybackHistoryTrack>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
final albums = history.items
.whereType<PlaybackHistoryAlbum>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
final playlists = history.items
.whereType<PlaybackHistoryPlaylist>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
return (
tracks: tracks,
albums: albums,
playlists: playlists,
);
});

View File

@ -18,7 +18,7 @@ class PlaybackHistoryItem with _$PlaybackHistoryItem {
factory PlaybackHistoryItem.track({
required DateTime date,
required TrackSimple track,
required Track track,
}) = PlaybackHistoryTrack;
factory PlaybackHistoryItem.fromJson(Map<String, dynamic> json) =>

View File

@ -36,21 +36,21 @@ mixin _$PlaybackHistoryItem {
TResult when<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, TrackSimple track) track,
required TResult Function(DateTime date, Track track) track,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, TrackSimple track)? track,
TResult? Function(DateTime date, Track track)? track,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, TrackSimple track)? track,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@ -205,7 +205,7 @@ class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist {
TResult when<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, TrackSimple track) track,
required TResult Function(DateTime date, Track track) track,
}) {
return playlist(date, this.playlist);
}
@ -215,7 +215,7 @@ class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, TrackSimple track)? track,
TResult? Function(DateTime date, Track track)? track,
}) {
return playlist?.call(date, this.playlist);
}
@ -225,7 +225,7 @@ class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist {
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, TrackSimple track)? track,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) {
if (playlist != null) {
@ -380,7 +380,7 @@ class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum {
TResult when<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, TrackSimple track) track,
required TResult Function(DateTime date, Track track) track,
}) {
return album(date, this.album);
}
@ -390,7 +390,7 @@ class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, TrackSimple track)? track,
TResult? Function(DateTime date, Track track)? track,
}) {
return album?.call(date, this.album);
}
@ -400,7 +400,7 @@ class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum {
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, TrackSimple track)? track,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) {
if (album != null) {
@ -476,7 +476,7 @@ abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res>
__$$PlaybackHistoryTrackImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({DateTime date, TrackSimple track});
$Res call({DateTime date, Track track});
}
/// @nodoc
@ -501,7 +501,7 @@ class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>
track: null == track
? _value.track
: track // ignore: cast_nullable_to_non_nullable
as TrackSimple,
as Track,
));
}
}
@ -519,7 +519,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack {
@override
final DateTime date;
@override
final TrackSimple track;
final Track track;
@JsonKey(name: 'runtimeType')
final String $type;
@ -555,7 +555,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack {
TResult when<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, TrackSimple track) track,
required TResult Function(DateTime date, Track track) track,
}) {
return track(date, this.track);
}
@ -565,7 +565,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, TrackSimple track)? track,
TResult? Function(DateTime date, Track track)? track,
}) {
return track?.call(date, this.track);
}
@ -575,7 +575,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack {
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, TrackSimple track)? track,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) {
if (track != null) {
@ -629,14 +629,14 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack {
abstract class PlaybackHistoryTrack implements PlaybackHistoryItem {
factory PlaybackHistoryTrack(
{required final DateTime date,
required final TrackSimple track}) = _$PlaybackHistoryTrackImpl;
required final Track track}) = _$PlaybackHistoryTrackImpl;
factory PlaybackHistoryTrack.fromJson(Map<String, dynamic> json) =
_$PlaybackHistoryTrackImpl.fromJson;
@override
DateTime get date;
TrackSimple get track;
Track get track;
@override
@JsonKey(ignore: true)
_$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl>

View File

@ -42,8 +42,7 @@ Map<String, dynamic> _$$PlaybackHistoryAlbumImplToJson(
_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) =>
_$PlaybackHistoryTrackImpl(
date: DateTime.parse(json['date'] as String),
track:
TrackSimple.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
track: Track.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
$type: json['runtimeType'] as String?,
);

View File

@ -0,0 +1,53 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/history/history.dart';
final playbackHistorySummaryProvider = Provider((ref) {
final (:tracks, :albums, :playlists) =
ref.watch(playbackHistoryGroupedProvider);
final totalDurationListened = tracks.fold(
Duration.zero,
(previousValue, element) => previousValue + element.track.duration!,
);
final totalTracksListened = tracks
.whereIndexed(
(i, track) =>
i == tracks.lastIndexWhere((e) => e.track.id == track.track.id),
)
.length;
final artists =
tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList();
final totalArtistsListened = artists
.whereIndexed(
(i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id),
)
.length;
final totalAlbumsListened = albums
.whereIndexed(
(i, album) =>
i == albums.lastIndexWhere((e) => e.album.id == album.album.id),
)
.length;
final totalPlaylistsListened = playlists
.whereIndexed(
(i, playlist) =>
i ==
playlists
.lastIndexWhere((e) => e.playlist.id == playlist.playlist.id),
)
.length;
return (
duration: totalDurationListened,
tracks: totalTracksListened,
artists: totalArtistsListened,
albums: totalAlbumsListened,
playlists: totalPlaylistsListened,
);
});

View File

@ -0,0 +1,47 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/history/history.dart';
final playbackHistoryTopProvider = Provider((ref) {
final (:tracks, :albums, playlists: _) =
ref.watch(playbackHistoryGroupedProvider);
final tracksWithCount = groupBy(tracks, (track) => track.track.id!)
.entries
.map((entry) {
return (count: entry.value.length, track: entry.value.first.track);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
final albumsWithTrackAlbums = [
for (final historicAlbum in albums) historicAlbum.album,
for (final track in tracks) track.track.album!
];
final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!)
.entries
.map((entry) {
return (count: entry.value.length, album: entry.value.first);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
final artists =
tracks.map((track) => track.track.artists).expand((e) => e ?? <Artist>[]);
final artistsWithCount = groupBy(artists, (artist) => artist.id!)
.entries
.map((entry) {
return (count: entry.value.length, artist: entry.value.first);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
return (
tracks: tracksWithCount,
albums: albumsWithCount,
artists: artistsWithCount
);
});

View File

@ -83,7 +83,7 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier {
}
scrobbler.scrobble(playlist.activeTrack!);
history.addTracks([playlist.activeTrack!]);
history.addTrack(playlist.activeTrack!);
lastScrobbled = uid;
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);

View File

@ -1,6 +1,5 @@
import 'package:collection/collection.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';