feat(stats): add lazy loading support

This commit is contained in:
Kingkor Roy Tirtho 2024-06-30 21:08:29 +06:00
parent 4c5564fd2f
commit 3bdc46da4d
14 changed files with 610 additions and 319 deletions

View File

@ -4,6 +4,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/extensions/json1.dart';
import 'package:encrypt/encrypt.dart'; import 'package:encrypt/encrypt.dart';
import 'package:media_kit/media_kit.dart' hide Track; import 'package:media_kit/media_kit.dart' hide Track;
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -13,7 +14,7 @@ import 'package:spotube/models/lyrics.dart';
import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/encrypted_kv_store.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/enums.dart';
import 'package:flutter/material.dart' hide Table, Key; import 'package:flutter/material.dart' hide Table, Key, View;
import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';

View File

@ -4,6 +4,9 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/modules/stats/common/album_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/albums.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TopAlbums extends HookConsumerWidget { class TopAlbums extends HookConsumerWidget {
const TopAlbums({super.key}); const TopAlbums({super.key});
@ -11,14 +14,21 @@ class TopAlbums extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final albums = ref.watch(playbackHistoryTopProvider(historyDuration) final topAlbums = ref.watch(historyTopAlbumsProvider(historyDuration));
.select((value) => value.whenData((s) => s.albums))); final topAlbumsNotifier =
ref.watch(historyTopAlbumsProvider(historyDuration).notifier);
final albumsData = albums.asData?.value ?? []; final albumsData = topAlbums.asData?.value.items ?? [];
return Skeletonizer( return Skeletonizer.sliver(
enabled: albums.isLoading, enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
child: SliverList.builder( child: SliverInfiniteList(
onFetchData: () async {
await topAlbumsNotifier.fetchMore();
},
hasError: topAlbums.hasError,
isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
hasReachedMax: topAlbums.asData?.value.hasMore ?? true,
itemCount: albumsData.length, itemCount: albumsData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final album = albumsData[index]; final album = albumsData[index];

View File

@ -1,8 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/modules/stats/common/artist_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TopArtists extends HookConsumerWidget { class TopArtists extends HookConsumerWidget {
const TopArtists({super.key}); const TopArtists({super.key});
@ -10,20 +15,33 @@ class TopArtists extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final artists = ref.watch(playbackHistoryTopProvider(historyDuration) final topTracks = ref.watch(
.select((value) => value.whenData((s) => s.artists))); historyTopTracksProvider(historyDuration),
);
final topTracksNotifier =
ref.watch(historyTopTracksProvider(historyDuration).notifier);
final artistsData = artists.asData?.value ?? []; final artistsData = useMemoized(
() => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]);
return SliverList.builder( return Skeletonizer.sliver(
itemCount: artistsData.length, enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
itemBuilder: (context, index) { child: SliverInfiniteList(
final artist = artistsData[index]; onFetchData: () async {
return StatsArtistItem( await topTracksNotifier.fetchMore();
artist: artist.artist, },
info: Text("${compactNumberFormatter.format(artist.count)} plays"), hasError: topTracks.hasError,
); isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
}, hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: artistsData.length,
itemBuilder: (context, index) {
final artist = artistsData[index];
return StatsArtistItem(
artist: artist.artist,
info: Text("${compactNumberFormatter.format(artist.count)} plays"),
);
},
),
); );
} }
} }

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/modules/stats/common/track_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TopTracks extends HookConsumerWidget { class TopTracks extends HookConsumerWidget {
const TopTracks({super.key}); const TopTracks({super.key});
@ -10,24 +14,34 @@ class TopTracks extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final tracks = ref.watch( final topTracks = ref.watch(
playbackHistoryTopProvider(historyDuration) historyTopTracksProvider(historyDuration),
.select((value) => value.whenData((s) => s.tracks)),
); );
final topTracksNotifier =
ref.watch(historyTopTracksProvider(historyDuration).notifier);
final tracksData = tracks.asData?.value ?? []; final tracksData = topTracks.asData?.value.items ?? [];
return SliverList.builder( return Skeletonizer.sliver(
itemCount: tracksData.length, enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
itemBuilder: (context, index) { child: SliverInfiniteList(
final track = tracksData[index]; onFetchData: () async {
return StatsTrackItem( await topTracksNotifier.fetchMore();
track: track.track, },
info: Text( hasError: topTracks.hasError,
"${compactNumberFormatter.format(track.count)} plays", isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
), hasReachedMax: topTracks.asData?.value.hasMore ?? true,
); itemCount: tracksData.length,
}, itemBuilder: (context, index) {
final track = tracksData[index];
return StatsTrackItem(
track: track.track,
info: Text(
"${compactNumberFormatter.format(track.count)} plays",
),
);
},
),
); );
} }
} }

View File

@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/modules/stats/common/album_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/albums.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class StatsAlbumsPage extends HookConsumerWidget { class StatsAlbumsPage extends HookConsumerWidget {
static const name = "stats_albums"; static const name = "stats_albums";
@ -12,10 +16,12 @@ class StatsAlbumsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final albums = ref.watch(playbackHistoryTopProvider(HistoryDuration.allTime) final topAlbums =
.select((value) => value.whenData((s) => s.albums))); ref.watch(historyTopAlbumsProvider(HistoryDuration.allTime));
final topAlbumsNotifier =
ref.watch(historyTopAlbumsProvider(HistoryDuration.allTime).notifier);
final albumsData = albums.asData?.value ?? []; final albumsData = topAlbums.asData?.value.items ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
@ -23,15 +29,26 @@ class StatsAlbumsPage extends HookConsumerWidget {
centerTitle: false, centerTitle: false,
title: Text("Albums"), title: Text("Albums"),
), ),
body: ListView.builder( body: Skeletonizer(
itemCount: albumsData.length, enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
itemBuilder: (context, index) { child: InfiniteList(
final album = albumsData[index]; onFetchData: () async {
return StatsAlbumItem( await topAlbumsNotifier.fetchMore();
album: album.album, },
info: Text("${compactNumberFormatter.format(album.count)} plays"), hasError: topAlbums.hasError,
); isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage,
}, hasReachedMax: topAlbums.asData?.value.hasMore ?? true,
itemCount: albumsData.length,
itemBuilder: (context, index) {
final album = albumsData[index];
return StatsAlbumItem(
album: album.album,
info: Text(
"${compactNumberFormatter.format(album.count)} plays",
),
);
},
),
), ),
); );
} }

View File

@ -1,10 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/modules/stats/common/artist_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class StatsArtistsPage extends HookConsumerWidget { class StatsArtistsPage extends HookConsumerWidget {
static const name = "stats_artists"; static const name = "stats_artists";
@ -12,12 +17,14 @@ class StatsArtistsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final artists = ref.watch( final topTracks = ref.watch(
playbackHistoryTopProvider(HistoryDuration.allTime) historyTopTracksProvider(HistoryDuration.allTime),
.select((s) => s.whenData((s) => s.artists)),
); );
final topTracksNotifier =
ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier);
final artistsData = artists.asData?.value ?? []; final artistsData = useMemoized(
() => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]);
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
@ -25,15 +32,25 @@ class StatsArtistsPage extends HookConsumerWidget {
centerTitle: false, centerTitle: false,
title: Text("Artists"), title: Text("Artists"),
), ),
body: ListView.builder( body: Skeletonizer(
itemCount: artistsData.length, enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
itemBuilder: (context, index) { child: InfiniteList(
final artist = artistsData[index]; onFetchData: () async {
return StatsArtistItem( await topTracksNotifier.fetchMore();
artist: artist.artist, },
info: Text("${compactNumberFormatter.format(artist.count)} plays"), hasError: topTracks.hasError,
); isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
}, hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: artistsData.length,
itemBuilder: (context, index) {
final artist = artistsData[index];
return StatsArtistItem(
artist: artist.artist,
info:
Text("${compactNumberFormatter.format(artist.count)} plays"),
);
},
),
), ),
); );
} }

View File

@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/modules/stats/common/artist_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class StatsStreamFeesPage extends HookConsumerWidget { class StatsStreamFeesPage extends HookConsumerWidget {
static const name = "stats_stream_fees"; static const name = "stats_stream_fees";
@ -16,12 +21,14 @@ class StatsStreamFeesPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :hintColor) = Theme.of(context); final ThemeData(:textTheme, :hintColor) = Theme.of(context);
final artists = ref.watch( final topTracks = ref.watch(
playbackHistoryTopProvider(HistoryDuration.days30) historyTopTracksProvider(HistoryDuration.allTime),
.select((value) => value.whenData((s) => s.artists)),
); );
final topTracksNotifier =
ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier);
final artistsData = artists.asData?.value ?? []; final artistsData = useMemoized(
() => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]);
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
@ -50,15 +57,24 @@ class StatsStreamFeesPage extends HookConsumerWidget {
), ),
), ),
), ),
SliverList.builder( Skeletonizer.sliver(
itemCount: artistsData.length, enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
itemBuilder: (context, index) { child: SliverInfiniteList(
final artist = artistsData[index]; onFetchData: () async {
return StatsArtistItem( await topTracksNotifier.fetchMore();
artist: artist.artist, },
info: Text(usdFormatter.format(artist.count * 0.005)), hasError: topTracks.hasError,
); isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
}, hasReachedMax: topTracks.asData?.value.hasMore ?? true,
itemCount: artistsData.length,
itemBuilder: (context, index) {
final artist = artistsData[index];
return StatsArtistItem(
artist: artist.artist,
info: Text(usdFormatter.format(artist.count * 0.005)),
);
},
),
), ),
], ],
), ),

View File

@ -1,11 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/modules/stats/common/track_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class StatsMinutesPage extends HookConsumerWidget { class StatsMinutesPage extends HookConsumerWidget {
static const name = "stats_minutes"; static const name = "stats_minutes";
@ -15,11 +19,12 @@ class StatsMinutesPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final topTracks = ref.watch( final topTracks = ref.watch(
playbackHistoryTopProvider(HistoryDuration.allTime) historyTopTracksProvider(HistoryDuration.allTime),
.select((s) => s.whenData((s) => s.tracks)),
); );
final topTracksNotifier =
ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier);
final topTracksData = topTracks.asData?.value ?? []; final tracksData = topTracks.asData?.value.items ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
@ -27,19 +32,27 @@ class StatsMinutesPage extends HookConsumerWidget {
centerTitle: false, centerTitle: false,
automaticallyImplyLeading: true, automaticallyImplyLeading: true,
), ),
body: ListView.separated( body: Skeletonizer(
separatorBuilder: (context, index) => const Gap(8), enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
itemCount: topTracksData.length, child: InfiniteList(
itemBuilder: (context, index) { separatorBuilder: (context, index) => const Gap(8),
final (:track, :count) = topTracksData[index]; onFetchData: () async {
await topTracksNotifier.fetchMore();
return StatsTrackItem( },
track: track, hasError: topTracks.hasError,
info: Text( isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
"${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", hasReachedMax: topTracks.asData?.value.hasMore ?? true,
), itemCount: tracksData.length,
); itemBuilder: (context, index) {
}, final track = tracksData[index];
return StatsTrackItem(
track: track.track,
info: Text(
"${compactNumberFormatter.format(track.count)} plays",
),
);
},
),
), ),
); );
} }

View File

@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/modules/stats/common/playlist_item.dart'; import 'package:spotube/modules/stats/common/playlist_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/playlists.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class StatsPlaylistsPage extends HookConsumerWidget { class StatsPlaylistsPage extends HookConsumerWidget {
static const name = "stats_playlists"; static const name = "stats_playlists";
@ -12,12 +16,13 @@ class StatsPlaylistsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlists = ref.watch( final topPlaylists =
playbackHistoryTopProvider(HistoryDuration.allTime) ref.watch(historyTopPlaylistsProvider(HistoryDuration.allTime));
.select((s) => s.whenData((s) => s.playlists)),
);
final playlistsData = playlists.asData?.value ?? []; final topPlaylistsNotifier = ref
.watch(historyTopPlaylistsProvider(HistoryDuration.allTime).notifier);
final playlistsData = topPlaylists.asData?.value.items ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
@ -25,16 +30,25 @@ class StatsPlaylistsPage extends HookConsumerWidget {
centerTitle: false, centerTitle: false,
title: Text("Playlists"), title: Text("Playlists"),
), ),
body: ListView.builder( body: Skeletonizer(
itemCount: playlistsData.length, enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage,
itemBuilder: (context, index) { child: InfiniteList(
final playlist = playlistsData[index]; onFetchData: () async {
return StatsPlaylistItem( await topPlaylistsNotifier.fetchMore();
playlist: playlist.playlist, },
info: hasError: topPlaylists.hasError,
Text("${compactNumberFormatter.format(playlist.count)} plays"), isLoading: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage,
); hasReachedMax: topPlaylists.asData?.value.hasMore ?? true,
}, itemCount: playlistsData.length,
itemBuilder: (context, index) {
final playlist = playlistsData[index];
return StatsPlaylistItem(
playlist: playlist.playlist,
info: Text(
"${compactNumberFormatter.format(playlist.count)} plays"),
);
},
),
), ),
); );
} }

View File

@ -1,11 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/modules/stats/common/track_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/history/top/tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class StatsStreamsPage extends HookConsumerWidget { class StatsStreamsPage extends HookConsumerWidget {
static const name = "stats_streams"; static const name = "stats_streams";
@ -15,11 +19,12 @@ class StatsStreamsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final topTracks = ref.watch( final topTracks = ref.watch(
playbackHistoryTopProvider(HistoryDuration.allTime) historyTopTracksProvider(HistoryDuration.allTime),
.select((s) => s.whenData((s) => s.tracks)),
); );
final topTracksNotifier =
ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier);
final topTracksData = topTracks.asData?.value ?? []; final tracksData = topTracks.asData?.value.items ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
@ -27,19 +32,27 @@ class StatsStreamsPage extends HookConsumerWidget {
centerTitle: false, centerTitle: false,
automaticallyImplyLeading: true, automaticallyImplyLeading: true,
), ),
body: ListView.separated( body: Skeletonizer(
separatorBuilder: (context, index) => const Gap(8), enabled: topTracks.isLoading && !topTracks.isLoadingNextPage,
itemCount: topTracksData.length, child: InfiniteList(
itemBuilder: (context, index) { separatorBuilder: (context, index) => const Gap(8),
final (:track, :count) = topTracksData[index]; onFetchData: () async {
await topTracksNotifier.fetchMore();
return StatsTrackItem( },
track: track, hasError: topTracks.hasError,
info: Text( isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage,
"${compactNumberFormatter.format(count)} streams", hasReachedMax: topTracks.asData?.value.hasMore ?? true,
), itemCount: tracksData.length,
); itemBuilder: (context, index) {
}, final track = tracksData[index];
return StatsTrackItem(
track: track.track,
info: Text(
"${compactNumberFormatter.format(track.count * track.track.duration!.inMinutes)} mins",
),
);
},
),
), ),
); );
} }

View File

@ -1,11 +1,4 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart';
enum HistoryDuration { enum HistoryDuration {
allTime(Duration(days: 365 * 2003)), allTime(Duration(days: 365 * 2003)),
@ -22,196 +15,3 @@ enum HistoryDuration {
final playbackHistoryTopDurationProvider = final playbackHistoryTopDurationProvider =
StateProvider((ref) => HistoryDuration.days30); StateProvider((ref) => HistoryDuration.days30);
typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album});
typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist});
typedef PlaybackHistoryTrack = ({int count, Track track});
typedef PlaybackHistoryArtist = ({int count, Artist artist});
class PlaybackHistoryTopState {
final List<PlaybackHistoryTrack> tracks;
final List<PlaybackHistoryAlbum> albums;
final List<PlaybackHistoryPlaylist> playlists;
final List<PlaybackHistoryArtist> artists;
const PlaybackHistoryTopState({
required this.tracks,
required this.albums,
required this.playlists,
required this.artists,
});
PlaybackHistoryTopState copyWith({
List<PlaybackHistoryTrack>? tracks,
List<PlaybackHistoryAlbum>? albums,
List<PlaybackHistoryPlaylist>? playlists,
List<PlaybackHistoryArtist>? artists,
}) {
return PlaybackHistoryTopState(
tracks: tracks ?? this.tracks,
albums: albums ?? this.albums,
playlists: playlists ?? this.playlists,
artists: artists ?? this.artists,
);
}
}
class PlaybackHistoryTopNotifier
extends FamilyAsyncNotifier<PlaybackHistoryTopState, HistoryDuration> {
@override
build(arg) async {
final database = ref.watch(databaseProvider);
final duration = arg.duration;
final tracksQuery = (database.select(database.historyTable)
..where(
(tbl) =>
tbl.type.equalsValue(HistoryEntryType.track) &
tbl.createdAt.isBiggerOrEqualValue(
DateTime.now().subtract(duration),
),
));
final albumsQuery = database.select(database.historyTable)
..where(
(tbl) =>
tbl.type.equalsValue(HistoryEntryType.album) &
tbl.createdAt.isBiggerOrEqualValue(
DateTime.now().subtract(duration),
),
);
final playlistsQuery = database.select(database.historyTable)
..where(
(tbl) =>
tbl.type.equalsValue(HistoryEntryType.playlist) &
tbl.createdAt.isBiggerOrEqualValue(
DateTime.now().subtract(duration),
),
);
final subscriptions = <StreamSubscription>[
tracksQuery.watch().listen((event) {
if (state.asData == null) return;
final artists = event
.map((track) => track.track!.artists)
.expand((e) => e ?? <Artist>[]);
state = AsyncData(state.asData!.value.copyWith(
tracks: getTracksWithCount(event),
artists: getArtistsWithCount(artists),
));
}),
albumsQuery.watch().listen((event) async {
if (state.asData == null) return;
final tracks = await tracksQuery.get();
final albumsWithTrackAlbums = [
for (final historicAlbum in event) historicAlbum.album!,
for (final track in tracks) track.track!.album!
];
state = AsyncData(state.asData!.value.copyWith(
albums: getAlbumsWithCount(albumsWithTrackAlbums),
));
}),
playlistsQuery.watch().listen((event) {
if (state.asData == null) return;
state = AsyncData(state.asData!.value.copyWith(
playlists: getPlaylistsWithCount(event),
));
}),
];
ref.onDispose(() {
for (final subscription in subscriptions) {
subscription.cancel();
}
});
return database.transaction(() async {
final tracks = await tracksQuery.get();
final albums = await albumsQuery.get();
final playlists = await playlistsQuery.get();
final tracksWithCount = getTracksWithCount(tracks);
final albumsWithTrackAlbums = [
for (final historicAlbum in albums) historicAlbum.album!,
for (final track in tracks) track.track!.album!
];
final albumsWithCount = getAlbumsWithCount(albumsWithTrackAlbums);
final artists = tracks
.map((track) => track.track!.artists)
.expand((e) => e ?? <Artist>[]);
final artistsWithCount = getArtistsWithCount(artists);
final playlistsWithCount = getPlaylistsWithCount(playlists);
return PlaybackHistoryTopState(
tracks: tracksWithCount,
albums: albumsWithCount,
artists: artistsWithCount,
playlists: playlistsWithCount,
);
});
}
List<PlaybackHistoryTrack> getTracksWithCount(List<HistoryTableData> tracks) {
return 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();
}
List<PlaybackHistoryAlbum> getAlbumsWithCount(
List<AlbumSimple> albumsWithTrackAlbums,
) {
return 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();
}
List<PlaybackHistoryArtist> getArtistsWithCount(Iterable<Artist> artists) {
return 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();
}
List<PlaybackHistoryPlaylist> getPlaylistsWithCount(
List<HistoryTableData> playlists,
) {
return groupBy(playlists, (playlist) => playlist.playlist!.id!)
.entries
.map((entry) {
return (
count: entry.value.length,
playlist: entry.value.first.playlist!,
);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
}
}
final playbackHistoryTopProvider = AsyncNotifierProviderFamily<
PlaybackHistoryTopNotifier,
PlaybackHistoryTopState,
HistoryDuration>(PlaybackHistoryTopNotifier.new);

View File

@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/spotify/spotify.dart';
typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album});
class HistoryTopAlbumsState extends PaginatedState<PlaybackHistoryAlbum> {
HistoryTopAlbumsState({
required super.items,
required super.offset,
required super.limit,
required super.hasMore,
});
@override
HistoryTopAlbumsState copyWith({
List<PlaybackHistoryAlbum>? items,
int? offset,
int? limit,
bool? hasMore,
}) {
return HistoryTopAlbumsState(
items: items ?? this.items,
offset: offset ?? this.offset,
limit: limit ?? this.limit,
hasMore: hasMore ?? this.hasMore,
);
}
}
class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier<
PlaybackHistoryAlbum, HistoryTopAlbumsState, HistoryDuration> {
HistoryTopAlbumsNotifier() : super();
Selectable<AlbumSimple> createAlbumsQuery({int? limit, int? offset}) {
final database = ref.read(databaseProvider);
final duration = switch (arg) {
HistoryDuration.allTime => '0',
HistoryDuration.days7 => "strftime('%s', 'now', 'weekday 0', '-7 days')",
HistoryDuration.days30 => "strftime('%s', 'now', 'start of month')",
HistoryDuration.months6 =>
"strftime('%s', 'start of month', '-5 months')",
HistoryDuration.year => "strftime('%s', 'start of year')",
HistoryDuration.years2 => "strftime('%s', 'start of year', '-1 year')",
};
return database.customSelect(
"""
SELECT
history_table.created_at,
"""
r"""
json_extract(history_table.data, '$.album') as data,
json_extract(history_table.data, '$.album.id') as item_id,
json_extract(history_table.data, '$.album.type') as type
"""
"""
FROM history_table
WHERE type = 'track' AND
created_at >= $duration
UNION ALL
SELECT
history_table.created_at,
history_table.data,
history_table.item_id,
history_table.type
FROM history_table
WHERE type = 'album' AND
created_at >= $duration
ORDER BY created_at desc
${limit != null && offset != null ? 'LIMIT $limit OFFSET $offset' : ''}
""",
readsFrom: {database.historyTable},
).map((row) {
final data = row.read<String>('data');
final album = AlbumSimple.fromJson(jsonDecode(data));
return album;
});
}
@override
fetch(arg, offset, limit) async {
final albumsQuery = createAlbumsQuery(limit: limit, offset: offset);
return getAlbumsWithCount(await albumsQuery.get());
}
@override
build(arg) async {
final albums = await fetch(arg, 0, 20);
final subscription = createAlbumsQuery().watch().listen((event) {
if (state.asData == null) return;
state = AsyncData(state.asData!.value.copyWith(
items: getAlbumsWithCount(event),
hasMore: false,
));
});
ref.onDispose(() {
subscription.cancel();
});
return HistoryTopAlbumsState(
items: albums,
offset: albums.length,
limit: 20,
hasMore: true,
);
}
List<PlaybackHistoryAlbum> getAlbumsWithCount(
List<AlbumSimple> albumsWithTrackAlbums,
) {
return 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 historyTopAlbumsProvider = AsyncNotifierProviderFamily<
HistoryTopAlbumsNotifier, HistoryTopAlbumsState, HistoryDuration>(
() => HistoryTopAlbumsNotifier(),
);

View File

@ -0,0 +1,104 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/spotify/spotify.dart';
typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist});
class HistoryTopPlaylistsState extends PaginatedState<PlaybackHistoryPlaylist> {
HistoryTopPlaylistsState({
required super.items,
required super.offset,
required super.limit,
required super.hasMore,
});
@override
HistoryTopPlaylistsState copyWith({
List<PlaybackHistoryPlaylist>? items,
int? offset,
int? limit,
bool? hasMore,
}) {
return HistoryTopPlaylistsState(
items: items ?? this.items,
offset: offset ?? this.offset,
limit: limit ?? this.limit,
hasMore: hasMore ?? this.hasMore,
);
}
}
class HistoryTopPlaylistsNotifier extends FamilyPaginatedAsyncNotifier<
PlaybackHistoryPlaylist, HistoryTopPlaylistsState, HistoryDuration> {
HistoryTopPlaylistsNotifier() : super();
SimpleSelectStatement<$HistoryTableTable, HistoryTableData>
createPlaylistsQuery() {
final database = ref.read(databaseProvider);
return database.select(database.historyTable)
..where(
(tbl) =>
tbl.type.equalsValue(HistoryEntryType.playlist) &
tbl.createdAt.isBiggerOrEqualValue(
DateTime.now().subtract(arg.duration),
),
);
}
@override
fetch(arg, offset, limit) async {
final playlistsQuery = createPlaylistsQuery()..limit(limit, offset: offset);
return getPlaylistsWithCount(await playlistsQuery.get());
}
@override
build(arg) async {
final playlists = await fetch(arg, 0, 20);
final subscription = createPlaylistsQuery().watch().listen((event) {
if (state.asData == null) return;
state = AsyncData(state.asData!.value.copyWith(
items: getPlaylistsWithCount(event),
hasMore: false,
));
});
ref.onDispose(() {
subscription.cancel();
});
return HistoryTopPlaylistsState(
items: playlists,
offset: playlists.length,
limit: 20,
hasMore: true,
);
}
List<PlaybackHistoryPlaylist> getPlaylistsWithCount(
List<HistoryTableData> playlists,
) {
return groupBy(playlists, (playlist) => playlist.playlist!.id!)
.entries
.map((entry) {
return (
count: entry.value.length,
playlist: entry.value.first.playlist!,
);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
}
}
final historyTopPlaylistsProvider = AsyncNotifierProviderFamily<
HistoryTopPlaylistsNotifier, HistoryTopPlaylistsState, HistoryDuration>(
() => HistoryTopPlaylistsNotifier(),
);

View File

@ -0,0 +1,119 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/history/top.dart';
import 'package:spotube/provider/spotify/spotify.dart';
typedef PlaybackHistoryTrack = ({int count, Track track});
typedef PlaybackHistoryArtist = ({int count, Artist artist});
class HistoryTopTracksState extends PaginatedState<PlaybackHistoryTrack> {
HistoryTopTracksState({
required super.items,
required super.offset,
required super.limit,
required super.hasMore,
});
List<PlaybackHistoryArtist> get artists {
return getArtistsWithCount(
items.expand((e) => e.track.artists ?? <Artist>[]),
);
}
List<PlaybackHistoryArtist> getArtistsWithCount(Iterable<Artist> artists) {
return 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();
}
@override
HistoryTopTracksState copyWith({
List<PlaybackHistoryTrack>? items,
int? offset,
int? limit,
bool? hasMore,
}) {
return HistoryTopTracksState(
items: items ?? this.items,
offset: offset ?? this.offset,
limit: limit ?? this.limit,
hasMore: hasMore ?? this.hasMore,
);
}
}
class HistoryTopTracksNotifier extends FamilyPaginatedAsyncNotifier<
PlaybackHistoryTrack, HistoryTopTracksState, HistoryDuration> {
HistoryTopTracksNotifier() : super();
SimpleSelectStatement<$HistoryTableTable, HistoryTableData>
createTracksQuery() {
final database = ref.read(databaseProvider);
return database.select(database.historyTable)
..where(
(tbl) =>
tbl.type.equalsValue(HistoryEntryType.track) &
tbl.createdAt.isBiggerOrEqualValue(
DateTime.now().subtract(arg.duration),
),
);
}
@override
fetch(arg, offset, limit) async {
final tracksQuery = createTracksQuery()..limit(limit, offset: offset);
return getTracksWithCount(await tracksQuery.get());
}
@override
build(arg) async {
final tracks = await fetch(arg, 0, 20);
final subscription = createTracksQuery().watch().listen((event) {
if (state.asData == null) return;
state = AsyncData(state.asData!.value.copyWith(
items: getTracksWithCount(event),
hasMore: false,
));
});
ref.onDispose(() {
subscription.cancel();
});
return HistoryTopTracksState(
items: tracks,
offset: tracks.length,
limit: 20,
hasMore: true,
);
}
List<PlaybackHistoryTrack> getTracksWithCount(List<HistoryTableData> tracks) {
return 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 historyTopTracksProvider = AsyncNotifierProviderFamily<
HistoryTopTracksNotifier, HistoryTopTracksState, HistoryDuration>(
() => HistoryTopTracksNotifier(),
);