diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 609d6771e..1c233f845 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; +import 'package:drift/extensions/json1.dart'; import 'package:encrypt/encrypt.dart'; import 'package:media_kit/media_kit.dart' hide Track; 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/kv_store.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:drift/native.dart'; import 'package:sqlite3/sqlite3.dart'; diff --git a/lib/modules/stats/top/albums.dart b/lib/modules/stats/top/albums.dart index bcaa75c54..4329b871e 100644 --- a/lib/modules/stats/top/albums.dart +++ b/lib/modules/stats/top/albums.dart @@ -4,6 +4,9 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/album_item.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 { const TopAlbums({super.key}); @@ -11,14 +14,21 @@ class TopAlbums extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final albums = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.whenData((s) => s.albums))); + final topAlbums = ref.watch(historyTopAlbumsProvider(historyDuration)); + final topAlbumsNotifier = + ref.watch(historyTopAlbumsProvider(historyDuration).notifier); - final albumsData = albums.asData?.value ?? []; + final albumsData = topAlbums.asData?.value.items ?? []; - return Skeletonizer( - enabled: albums.isLoading, - child: SliverList.builder( + return Skeletonizer.sliver( + enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topAlbumsNotifier.fetchMore(); + }, + hasError: topAlbums.hasError, + isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + hasReachedMax: topAlbums.asData?.value.hasMore ?? true, itemCount: albumsData.length, itemBuilder: (context, index) { final album = albumsData[index]; diff --git a/lib/modules/stats/top/artists.dart b/lib/modules/stats/top/artists.dart index 094353f2a..d5eb2d0e7 100644 --- a/lib/modules/stats/top/artists.dart +++ b/lib/modules/stats/top/artists.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/artist_item.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 { const TopArtists({super.key}); @@ -10,20 +15,33 @@ class TopArtists extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final artists = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.whenData((s) => s.artists))); + final topTracks = ref.watch( + 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( - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text("${compactNumberFormatter.format(artist.count)} plays"), - ); - }, + return Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + 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"), + ); + }, + ), ); } } diff --git a/lib/modules/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart index 8bffa800b..be457b2e3 100644 --- a/lib/modules/stats/top/tracks.dart +++ b/lib/modules/stats/top/tracks.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/track_item.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 { const TopTracks({super.key}); @@ -10,24 +14,34 @@ class TopTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final tracks = ref.watch( - playbackHistoryTopProvider(historyDuration) - .select((value) => value.whenData((s) => s.tracks)), + final topTracks = ref.watch( + historyTopTracksProvider(historyDuration), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(historyDuration).notifier); - final tracksData = tracks.asData?.value ?? []; + final tracksData = topTracks.asData?.value.items ?? []; - return SliverList.builder( - itemCount: tracksData.length, - itemBuilder: (context, index) { - final track = tracksData[index]; - return StatsTrackItem( - track: track.track, - info: Text( - "${compactNumberFormatter.format(track.count)} plays", - ), - ); - }, + return Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + 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", + ), + ); + }, + ), ); } } diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index 859eaf26c..db0eedf6b 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/album_item.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 { static const name = "stats_albums"; @@ -12,10 +16,12 @@ class StatsAlbumsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final albums = ref.watch(playbackHistoryTopProvider(HistoryDuration.allTime) - .select((value) => value.whenData((s) => s.albums))); + final topAlbums = + 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( appBar: const PageWindowTitleBar( @@ -23,15 +29,26 @@ class StatsAlbumsPage extends HookConsumerWidget { centerTitle: false, title: Text("Albums"), ), - body: ListView.builder( - itemCount: albumsData.length, - itemBuilder: (context, index) { - final album = albumsData[index]; - return StatsAlbumItem( - album: album.album, - info: Text("${compactNumberFormatter.format(album.count)} plays"), - ); - }, + body: Skeletonizer( + enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topAlbumsNotifier.fetchMore(); + }, + 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", + ), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index e6dadd950..80ff5f235 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.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 { static const name = "stats_artists"; @@ -12,12 +17,14 @@ class StatsArtistsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final artists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.artists)), + final topTracks = ref.watch( + historyTopTracksProvider(HistoryDuration.allTime), ); + 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( appBar: const PageWindowTitleBar( @@ -25,15 +32,25 @@ class StatsArtistsPage extends HookConsumerWidget { centerTitle: false, title: Text("Artists"), ), - body: ListView.builder( - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text("${compactNumberFormatter.format(artist.count)} plays"), - ); - }, + body: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + 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"), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index e1d701eb0..0e25c00b9 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.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 { static const name = "stats_stream_fees"; @@ -16,12 +21,14 @@ class StatsStreamFeesPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :hintColor) = Theme.of(context); - final artists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.days30) - .select((value) => value.whenData((s) => s.artists)), + final topTracks = ref.watch( + historyTopTracksProvider(HistoryDuration.allTime), ); + 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( appBar: const PageWindowTitleBar( @@ -50,15 +57,24 @@ class StatsStreamFeesPage extends HookConsumerWidget { ), ), ), - SliverList.builder( - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(usdFormatter.format(artist.count * 0.005)), - ); - }, + Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + 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)), + ); + }, + ), ), ], ), diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index 587e90079..ea3048ef4 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.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 { static const name = "stats_minutes"; @@ -15,11 +19,12 @@ class StatsMinutesPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final topTracks = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.tracks)), + historyTopTracksProvider(HistoryDuration.allTime), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - final topTracksData = topTracks.asData?.value ?? []; + final tracksData = topTracks.asData?.value.items ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -27,19 +32,27 @@ class StatsMinutesPage extends HookConsumerWidget { centerTitle: false, automaticallyImplyLeading: true, ), - body: ListView.separated( - separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracksData.length, - itemBuilder: (context, index) { - final (:track, :count) = topTracksData[index]; - - return StatsTrackItem( - track: track, - info: Text( - "${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", - ), - ); - }, + body: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + separatorBuilder: (context, index) => const Gap(8), + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + 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", + ), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index f5ee62d06..a6db3e1cf 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/playlist_item.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 { static const name = "stats_playlists"; @@ -12,12 +16,13 @@ class StatsPlaylistsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.playlists)), - ); + final topPlaylists = + ref.watch(historyTopPlaylistsProvider(HistoryDuration.allTime)); + + final topPlaylistsNotifier = ref + .watch(historyTopPlaylistsProvider(HistoryDuration.allTime).notifier); - final playlistsData = playlists.asData?.value ?? []; + final playlistsData = topPlaylists.asData?.value.items ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -25,16 +30,25 @@ class StatsPlaylistsPage extends HookConsumerWidget { centerTitle: false, title: Text("Playlists"), ), - body: ListView.builder( - itemCount: playlistsData.length, - itemBuilder: (context, index) { - final playlist = playlistsData[index]; - return StatsPlaylistItem( - playlist: playlist.playlist, - info: - Text("${compactNumberFormatter.format(playlist.count)} plays"), - ); - }, + body: Skeletonizer( + enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topPlaylistsNotifier.fetchMore(); + }, + hasError: topPlaylists.hasError, + 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"), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 20e8ff966..dd5856d0a 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.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 { static const name = "stats_streams"; @@ -15,11 +19,12 @@ class StatsStreamsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final topTracks = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.tracks)), + historyTopTracksProvider(HistoryDuration.allTime), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - final topTracksData = topTracks.asData?.value ?? []; + final tracksData = topTracks.asData?.value.items ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -27,19 +32,27 @@ class StatsStreamsPage extends HookConsumerWidget { centerTitle: false, automaticallyImplyLeading: true, ), - body: ListView.separated( - separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracksData.length, - itemBuilder: (context, index) { - final (:track, :count) = topTracksData[index]; - - return StatsTrackItem( - track: track, - info: Text( - "${compactNumberFormatter.format(count)} streams", - ), - ); - }, + body: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + separatorBuilder: (context, index) => const Gap(8), + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + 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 * track.track.duration!.inMinutes)} mins", + ), + ); + }, + ), ), ); } diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index 826812674..b52e65e20 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -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:spotify/spotify.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/database/database.dart'; enum HistoryDuration { allTime(Duration(days: 365 * 2003)), @@ -22,196 +15,3 @@ enum HistoryDuration { final playbackHistoryTopDurationProvider = 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 tracks; - final List albums; - final List playlists; - final List artists; - - const PlaybackHistoryTopState({ - required this.tracks, - required this.albums, - required this.playlists, - required this.artists, - }); - - PlaybackHistoryTopState copyWith({ - List? tracks, - List? albums, - List? playlists, - List? artists, - }) { - return PlaybackHistoryTopState( - tracks: tracks ?? this.tracks, - albums: albums ?? this.albums, - playlists: playlists ?? this.playlists, - artists: artists ?? this.artists, - ); - } -} - -class PlaybackHistoryTopNotifier - extends FamilyAsyncNotifier { - @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 = [ - tracksQuery.watch().listen((event) { - if (state.asData == null) return; - final artists = event - .map((track) => track.track!.artists) - .expand((e) => e ?? []); - 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 ?? []); - - final artistsWithCount = getArtistsWithCount(artists); - - final playlistsWithCount = getPlaylistsWithCount(playlists); - - return PlaybackHistoryTopState( - tracks: tracksWithCount, - albums: albumsWithCount, - artists: artistsWithCount, - playlists: playlistsWithCount, - ); - }); - } - - List getTracksWithCount(List 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 getAlbumsWithCount( - List 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 getArtistsWithCount(Iterable 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 getPlaylistsWithCount( - List 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); diff --git a/lib/provider/history/top/albums.dart b/lib/provider/history/top/albums.dart new file mode 100644 index 000000000..84518418c --- /dev/null +++ b/lib/provider/history/top/albums.dart @@ -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 { + HistoryTopAlbumsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + HistoryTopAlbumsState copyWith({ + List? 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 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('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 getAlbumsWithCount( + List 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(), +); diff --git a/lib/provider/history/top/playlists.dart b/lib/provider/history/top/playlists.dart new file mode 100644 index 000000000..04071f7a4 --- /dev/null +++ b/lib/provider/history/top/playlists.dart @@ -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 { + HistoryTopPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + HistoryTopPlaylistsState copyWith({ + List? 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 getPlaylistsWithCount( + List 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(), +); diff --git a/lib/provider/history/top/tracks.dart b/lib/provider/history/top/tracks.dart new file mode 100644 index 000000000..6c4e44b7b --- /dev/null +++ b/lib/provider/history/top/tracks.dart @@ -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 { + HistoryTopTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + List get artists { + return getArtistsWithCount( + items.expand((e) => e.track.artists ?? []), + ); + } + + List getArtistsWithCount(Iterable 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? 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 getTracksWithCount(List 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(), +);