mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-10 01:07:29 +00:00
feat: add stats summary and top tracks/artists/albums
This commit is contained in:
parent
5f442a1ff7
commit
c9bd42c847
3
lib/collections/formatters.dart
Normal file
3
lib/collections/formatters.dart
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
final compactNumberFormatter = NumberFormat.compact();
|
||||||
@ -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,
|
||||||
|
|||||||
74
lib/components/stats/summary/summary.dart
Normal file
74
lib/components/stats/summary/summary.dart
Normal 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,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/components/stats/summary/summary_card.dart
Normal file
67
lib/components/stats/summary/summary_card.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/components/stats/top/albums.dart
Normal file
67
lib/components/stats/top/albums.dart
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/components/stats/top/artists.dart
Normal file
48
lib/components/stats/top/artists.dart
Normal 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!,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
lib/components/stats/top/top.dart
Normal file
57
lib/components/stats/top/top.dart
Normal 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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
lib/components/stats/top/tracks.dart
Normal file
56
lib/components/stats/top/tracks.dart
Normal 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!,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
53
lib/provider/history/summary.dart
Normal file
53
lib/provider/history/summary.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
47
lib/provider/history/top.dart
Normal file
47
lib/provider/history/top.dart
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user