From c9bd42c84733d04b074cda9027fad027b996d41f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 29 Apr 2024 14:50:05 +0600 Subject: [PATCH] feat: add stats summary and top tracks/artists/albums --- lib/collections/formatters.dart | 3 + .../shared/themed_button_tab_bar.dart | 4 +- lib/components/stats/summary/summary.dart | 74 +++++++++++++++++++ .../stats/summary/summary_card.dart | 67 +++++++++++++++++ lib/components/stats/top/albums.dart | 67 +++++++++++++++++ lib/components/stats/top/artists.dart | 48 ++++++++++++ lib/components/stats/top/top.dart | 57 ++++++++++++++ lib/components/stats/top/tracks.dart | 56 ++++++++++++++ lib/pages/root/root_app.dart | 10 ++- lib/pages/stats/stats.dart | 24 +++++- lib/provider/history/history.dart | 49 ++++++++++-- lib/provider/history/state.dart | 2 +- lib/provider/history/state.freezed.dart | 34 ++++----- lib/provider/history/state.g.dart | 3 +- lib/provider/history/summary.dart | 53 +++++++++++++ lib/provider/history/top.dart | 47 ++++++++++++ .../proxy_playlist/player_listeners.dart | 2 +- .../proxy_playlist/proxy_playlist.dart | 1 - 18 files changed, 568 insertions(+), 33 deletions(-) create mode 100644 lib/collections/formatters.dart create mode 100644 lib/components/stats/summary/summary.dart create mode 100644 lib/components/stats/summary/summary_card.dart create mode 100644 lib/components/stats/top/albums.dart create mode 100644 lib/components/stats/top/artists.dart create mode 100644 lib/components/stats/top/top.dart create mode 100644 lib/components/stats/top/tracks.dart create mode 100644 lib/provider/history/summary.dart create mode 100644 lib/provider/history/top.dart diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart new file mode 100644 index 00000000..2f823f56 --- /dev/null +++ b/lib/collections/formatters.dart @@ -0,0 +1,3 @@ +import 'package:intl/intl.dart'; + +final compactNumberFormatter = NumberFormat.compact(); diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index 017f04aa..b21ca992 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List 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, diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart new file mode 100644 index 00000000..9d735a57 --- /dev/null +++ b/lib/components/stats/summary/summary.dart @@ -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, + ), + ]), + ); + }), + ); + } +} diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart new file mode 100644 index 00000000..2601f9dd --- /dev/null +++ b/lib/components/stats/summary/summary_card.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart new file mode 100644 index 00000000..38d97dc3 --- /dev/null +++ b/lib/components/stats/top/albums.dart @@ -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, + ); + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart new file mode 100644 index 00000000..8b4941b5 --- /dev/null +++ b/lib/components/stats/top/artists.dart @@ -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!, + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/top/top.dart b/lib/components/stats/top/top.dart new file mode 100644 index 00000000..bb20ed1d --- /dev/null +++ b/lib/components/stats/top/top.dart @@ -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(), + }; + }, + ), + ], + ); + } +} diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart new file mode 100644 index 00000000..3dc88892 --- /dev/null +++ b/lib/components/stats/top/tracks.dart @@ -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!, + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 423312a9..c1b148fd 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -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( diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart index 13fe5771..95493591 100644 --- a/lib/pages/stats/stats.dart +++ b/lib/pages/stats/stats.dart @@ -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(), + ), + ) + ], + ), + ), + ); } } diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart index 9983cfae..4436626d 100644 --- a/lib/provider/history/history.dart +++ b/lib/provider/history/history.dart @@ -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 { - PlaybackHistoryNotifier() + final Ref ref; + PlaybackHistoryNotifier(this.ref) : super(const PlaybackHistoryState(), "playback_history"); + SpotifyApi get spotify => ref.read(spotifyProvider); + @override FutureOr fromJson(Map json) => PlaybackHistoryState.fromJson(json); @@ -69,12 +74,18 @@ class PlaybackHistoryNotifier ); } - void addTracks(List 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( - (ref) => PlaybackHistoryNotifier(), + (ref) => PlaybackHistoryNotifier(ref), ); + +typedef PlaybackHistoryGrouped = ({ + List tracks, + List albums, + List playlists, +}); + +final playbackHistoryGroupedProvider = Provider((ref) { + final history = ref.watch(playbackHistoryProvider); + final tracks = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final albums = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final playlists = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + + return ( + tracks: tracks, + albums: albums, + playlists: playlists, + ); +}); diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart index ae7dba95..ca2714ac 100644 --- a/lib/provider/history/state.dart +++ b/lib/provider/history/state.dart @@ -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 json) => diff --git a/lib/provider/history/state.freezed.dart b/lib/provider/history/state.freezed.dart index 634bf496..e2ee9421 100644 --- a/lib/provider/history/state.freezed.dart +++ b/lib/provider/history/state.freezed.dart @@ -36,21 +36,21 @@ mixin _$PlaybackHistoryItem { TResult when({ 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? 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 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({ 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? 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 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({ 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? 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 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({ 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? 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 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 json) = _$PlaybackHistoryTrackImpl.fromJson; @override DateTime get date; - TrackSimple get track; + Track get track; @override @JsonKey(ignore: true) _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart index 57d2ece7..dfd01c2c 100644 --- a/lib/provider/history/state.g.dart +++ b/lib/provider/history/state.g.dart @@ -42,8 +42,7 @@ Map _$$PlaybackHistoryAlbumImplToJson( _$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => _$PlaybackHistoryTrackImpl( date: DateTime.parse(json['date'] as String), - track: - TrackSimple.fromJson(Map.from(json['track'] as Map)), + track: Track.fromJson(Map.from(json['track'] as Map)), $type: json['runtimeType'] as String?, ); diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart new file mode 100644 index 00000000..1cc316a7 --- /dev/null +++ b/lib/provider/history/summary.dart @@ -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, + ); +}); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart new file mode 100644 index 00000000..f27a82da --- /dev/null +++ b/lib/provider/history/top.dart @@ -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 ?? []); + + 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 + ); +}); diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index d3faf9ce..e10e7253 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -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); diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index f70301ff..97dfaa54 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -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';