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 { class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List<Widget> tabs; final List<Widget> tabs;
const ThemedButtonsTabBar({super.key, required this.tabs}); final TabController? controller;
const ThemedButtonsTabBar({super.key, required this.tabs, this.controller});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
bottom: 8, bottom: 8,
), ),
child: ButtonsTabBar( child: ButtonsTabBar(
controller: controller,
radius: 100, radius: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, 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/extensions/context.dart';
import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart';
import 'package:spotube/hooks/configurators/use_update_checker.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/connect/server.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
@ -173,10 +174,11 @@ class RootApp extends HookConsumerWidget {
// ignore: deprecated_member_use // ignore: deprecated_member_use
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
// if (rootPaths[location] != 0) { final routerState = GoRouterState.of(context);
// onSelectIndexChanged(0); if (routerState.matchedLocation != "/") {
// return false; context.goNamed(HomePage.name);
// } return false;
}
return true; return true;
}, },
child: Scaffold( child: Scaffold(

View File

@ -1,5 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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 { class StatsPage extends HookConsumerWidget {
static const name = "stats"; static const name = "stats";
@ -8,6 +13,23 @@ class StatsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { 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 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/state.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
class PlaybackHistoryState { class PlaybackHistoryState {
@ -36,9 +38,12 @@ class PlaybackHistoryState {
class PlaybackHistoryNotifier class PlaybackHistoryNotifier
extends PersistedStateNotifier<PlaybackHistoryState> { extends PersistedStateNotifier<PlaybackHistoryState> {
PlaybackHistoryNotifier() final Ref ref;
PlaybackHistoryNotifier(this.ref)
: super(const PlaybackHistoryState(), "playback_history"); : super(const PlaybackHistoryState(), "playback_history");
SpotifyApi get spotify => ref.read(spotifyProvider);
@override @override
FutureOr<PlaybackHistoryState> fromJson(Map<String, dynamic> json) => FutureOr<PlaybackHistoryState> fromJson(Map<String, dynamic> json) =>
PlaybackHistoryState.fromJson(json); PlaybackHistoryState.fromJson(json);
@ -69,11 +74,17 @@ 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( state = state.copyWith(
items: [ items: [
...state.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 = final playbackHistoryProvider =
StateNotifierProvider<PlaybackHistoryNotifier, PlaybackHistoryState>( 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({ factory PlaybackHistoryItem.track({
required DateTime date, required DateTime date,
required TrackSimple track, required Track track,
}) = PlaybackHistoryTrack; }) = PlaybackHistoryTrack;
factory PlaybackHistoryItem.fromJson(Map<String, dynamic> json) => factory PlaybackHistoryItem.fromJson(Map<String, dynamic> json) =>

View File

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

View File

@ -42,8 +42,7 @@ Map<String, dynamic> _$$PlaybackHistoryAlbumImplToJson(
_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => _$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) =>
_$PlaybackHistoryTrackImpl( _$PlaybackHistoryTrackImpl(
date: DateTime.parse(json['date'] as String), date: DateTime.parse(json['date'] as String),
track: track: Track.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
TrackSimple.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
$type: json['runtimeType'] as String?, $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!); scrobbler.scrobble(playlist.activeTrack!);
history.addTracks([playlist.activeTrack!]); history.addTrack(playlist.activeTrack!);
lastScrobbled = uid; lastScrobbled = uid;
} catch (e, stack) { } catch (e, stack) {
Catcher2.reportCheckedError(e, stack); Catcher2.reportCheckedError(e, stack);

View File

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