From 2d9cd58fcedacab550b83ca233f261f7a6962e48 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 15 Apr 2024 17:44:02 +0600 Subject: [PATCH 01/14] feat: add playback history provider --- lib/components/album/album_card.dart | 4 ++ lib/components/playlist/playlist_card.dart | 5 ++ lib/extensions/album_simple.dart | 15 ---- lib/extensions/artist_simple.dart | 12 ---- lib/extensions/track.dart | 29 -------- lib/models/local_track.dart | 4 +- lib/provider/history/history.dart | 83 ++++++++++++++++++++++ lib/provider/history/state.dart | 70 ++++++++++++++++++ lib/provider/history/state.g.dart | 61 ++++++++++++++++ pubspec.lock | 11 +-- pubspec.yaml | 5 +- 11 files changed, 235 insertions(+), 64 deletions(-) create mode 100644 lib/provider/history/history.dart create mode 100644 lib/provider/history/state.dart create mode 100644 lib/provider/history/state.g.dart diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index a71fbf03e..4931b162a 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -10,6 +10,7 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -32,6 +33,7 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), @@ -87,6 +89,7 @@ class AlbumCard extends HookConsumerWidget { } else { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); } } finally { updating.value = false; @@ -104,6 +107,7 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); if (context.mounted) { final snackbar = SnackBar( content: Text( diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index ae6f20e5f..98309aefd 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -7,6 +7,7 @@ import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -22,6 +23,8 @@ class PlaylistCard extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; bool isPlaylistPlaying = useMemoized( @@ -86,6 +89,7 @@ class PlaylistCard extends HookConsumerWidget { } else { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); } } finally { if (context.mounted) { @@ -104,6 +108,7 @@ class PlaylistCard extends HookConsumerWidget { playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); if (context.mounted) { final snackbar = SnackBar( content: Text("Added ${fetchedTracks.length} tracks to queue"), diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 7c8ae09e8..5678390c4 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,21 +1,6 @@ import 'package:spotify/spotify.dart'; extension AlbumExtensions on AlbumSimple { - Map toJson() { - return { - "albumType": albumType?.name, - "id": id, - "name": name, - "images": images - ?.map((image) => { - "height": image.height, - "url": image.url, - "width": image.width, - }) - .toList(), - }; - } - Album toAlbum() { Album album = Album(); album.albumType = albumType; diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index 6a80300ea..7997355d6 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -1,17 +1,5 @@ import 'package:spotify/spotify.dart'; -extension ArtistJson on ArtistSimple { - Map toJson() { - return { - "href": href, - "id": id, - "name": name, - "type": type, - "uri": uri, - }; - } -} - extension ArtistExtension on List { String asString() { return map((e) => e.name?.replaceAll(",", " ")).join(", "); diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 9755179db..02c0c4927 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension TrackExtensions on Track { @@ -39,33 +37,6 @@ extension TrackExtensions on Track { return this; } - - Map toJson() { - return TrackExtensions.trackToJson(this); - } - - static Map trackToJson(Track track) { - return { - "album": track.album?.toJson(), - "artists": track.artists?.map((artist) => artist.toJson()).toList(), - "available_markets": track.availableMarkets?.map((e) => e.name).toList(), - "disc_number": track.discNumber, - "duration_ms": track.durationMs, - "explicit": track.explicit, - // "external_ids"track.: externalIds, - // "external_urls"track.: externalUrls, - "href": track.href, - "id": track.id, - "is_playable": track.isPlayable, - // "linked_from"track.: linkedFrom, - "name": track.name, - "popularity": track.popularity, - "preview_rrl": track.previewUrl, - "track_number": track.trackNumber, - "type": track.type, - "uri": track.uri, - }; - } } extension TrackSimpleExtensions on TrackSimple { diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index 923f5f261..def3b64f9 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -1,5 +1,4 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; class LocalTrack extends Track { final String path; @@ -35,9 +34,10 @@ class LocalTrack extends Track { ); } + @override Map toJson() { return { - ...TrackExtensions.trackToJson(this), + ...super.toJson(), 'path': path, }; } diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart new file mode 100644 index 000000000..d2ff89a20 --- /dev/null +++ b/lib/provider/history/history.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; + +class PlaybackHistoryState { + final List items; + const PlaybackHistoryState({this.items = const []}); + + factory PlaybackHistoryState.fromJson(Map json) { + return PlaybackHistoryState( + items: + json["items"]?.map((json) => PlaybackHistoryBase.fromJson(json))); + } + + Map toJson() { + return { + "items": items.map((s) => s.toJson()).toList(), + }; + } + + PlaybackHistoryState copyWith({ + List? items, + }) { + return PlaybackHistoryState(items: items ?? this.items); + } +} + +class PlaybackHistoryNotifier + extends PersistedStateNotifier { + PlaybackHistoryNotifier() + : super(const PlaybackHistoryState(), "playback_history"); + + @override + FutureOr fromJson(Map json) => + PlaybackHistoryState.fromJson(json); + + @override + Map toJson() { + return state.toJson(); + } + + void addPlaylists(List playlists) { + state = state.copyWith( + items: [ + ...state.items, + for (final playlist in playlists) + PlaybackHistoryPlaylist(date: DateTime.now(), playlist: playlist), + ], + ); + } + + void addAlbums(List albums) { + state = state.copyWith( + items: [ + ...state.items, + for (final album in albums) + PlaybackHistoryAlbum(date: DateTime.now(), album: album), + ], + ); + } + + void addTracks(List tracks) { + state = state.copyWith( + items: [ + ...state.items, + for (final track in tracks) + PlaybackHistoryTrack(date: DateTime.now(), track: track), + ], + ); + } + + void clear() { + state = state.copyWith(items: []); + } +} + +final playbackHistoryProvider = + StateNotifierProvider( + (ref) => PlaybackHistoryNotifier(), +); diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart new file mode 100644 index 000000000..a011c2b0d --- /dev/null +++ b/lib/provider/history/state.dart @@ -0,0 +1,70 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; + +part 'state.g.dart'; + +@JsonSerializable() +class PlaybackHistoryBase { + final DateTime date; + + const PlaybackHistoryBase({required this.date}); + + factory PlaybackHistoryBase.fromJson(Map json) { + if (json.containsKey("playlist")) { + return PlaybackHistoryPlaylist.fromJson(json); + } else if (json.containsKey("album")) { + return PlaybackHistoryAlbum.fromJson(json); + } else if (json.containsKey("track")) { + return PlaybackHistoryTrack.fromJson(json); + } + + return _$PlaybackHistoryBaseFromJson(json); + } + + Map toJson() => _$PlaybackHistoryBaseToJson(this); +} + +@JsonSerializable() +class PlaybackHistoryPlaylist extends PlaybackHistoryBase { + final PlaylistSimple playlist; + PlaybackHistoryPlaylist({ + required super.date, + required this.playlist, + }); + + factory PlaybackHistoryPlaylist.fromJson(Map json) => + _$PlaybackHistoryPlaylistFromJson(json); + + @override + Map toJson() => _$PlaybackHistoryPlaylistToJson(this); +} + +@JsonSerializable() +class PlaybackHistoryAlbum extends PlaybackHistoryBase { + final AlbumSimple album; + PlaybackHistoryAlbum({ + required super.date, + required this.album, + }); + + factory PlaybackHistoryAlbum.fromJson(Map json) => + _$PlaybackHistoryAlbumFromJson(json); + + @override + Map toJson() => _$PlaybackHistoryAlbumToJson(this); +} + +@JsonSerializable() +class PlaybackHistoryTrack extends PlaybackHistoryBase { + final TrackSimple track; + PlaybackHistoryTrack({ + required super.date, + required this.track, + }); + + factory PlaybackHistoryTrack.fromJson(Map json) => + _$PlaybackHistoryTrackFromJson(json); + + @override + Map toJson() => _$PlaybackHistoryTrackToJson(this); +} diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart new file mode 100644 index 000000000..f8aa98854 --- /dev/null +++ b/lib/provider/history/state.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlaybackHistoryBase _$PlaybackHistoryBaseFromJson(Map json) => + PlaybackHistoryBase( + date: DateTime.parse(json['date'] as String), + ); + +Map _$PlaybackHistoryBaseToJson( + PlaybackHistoryBase instance) => + { + 'date': instance.date.toIso8601String(), + }; + +PlaybackHistoryPlaylist _$PlaybackHistoryPlaylistFromJson( + Map json) => + PlaybackHistoryPlaylist( + date: DateTime.parse(json['date'] as String), + playlist: + PlaylistSimple.fromJson(json['playlist'] as Map), + ); + +Map _$PlaybackHistoryPlaylistToJson( + PlaybackHistoryPlaylist instance) => + { + 'date': instance.date.toIso8601String(), + 'playlist': instance.playlist, + }; + +PlaybackHistoryAlbum _$PlaybackHistoryAlbumFromJson( + Map json) => + PlaybackHistoryAlbum( + date: DateTime.parse(json['date'] as String), + album: AlbumSimple.fromJson(json['album'] as Map), + ); + +Map _$PlaybackHistoryAlbumToJson( + PlaybackHistoryAlbum instance) => + { + 'date': instance.date.toIso8601String(), + 'album': instance.album, + }; + +PlaybackHistoryTrack _$PlaybackHistoryTrackFromJson( + Map json) => + PlaybackHistoryTrack( + date: DateTime.parse(json['date'] as String), + track: TrackSimple.fromJson(json['track'] as Map), + ); + +Map _$PlaybackHistoryTrackToJson( + PlaybackHistoryTrack instance) => + { + 'date': instance.date.toIso8601String(), + 'track': instance.track, + }; diff --git a/pubspec.lock b/pubspec.lock index 8d19f604c..68bad095f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2105,11 +2105,12 @@ packages: spotify: dependency: "direct main" description: - name: spotify - sha256: "2308a84511c18ec1e72515a57e28abb1467389549d571c460732b4538c2e34de" - url: "https://pub.dev" - source: hosted - version: "0.13.3" + path: "." + ref: "feat/to-json" + resolved-ref: "05ace91cdfe64db23d8c62077069e7c25b3645cb" + url: "https://github.com/KRTirtho/spotify-dart.git" + source: git + version: "0.13.5" sqflite: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 16f51981d..ee86c67cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,7 +122,10 @@ dependencies: flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 - spotify: ^0.13.3 + spotify: + git: + url: https://github.com/KRTirtho/spotify-dart.git + ref: feat/to-json bonsoir: ^5.1.9 shelf: ^1.4.1 shelf_router: ^1.1.4 From ddadb0edc5d11a83057c5dad5d9e99963ce120c5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 28 Apr 2024 11:12:17 +0600 Subject: [PATCH 02/14] feat: implement recently played section --- build.yaml | 7 +- lib/collections/fake.dart | 1 - lib/components/album/album_card.dart | 4 +- lib/components/home/sections/recent.dart | 32 + lib/components/playlist/playlist_card.dart | 4 +- .../horizontal_playbutton_card_view.dart | 2 +- .../sections/body/track_view_body.dart | 25 +- .../sections/body/track_view_options.dart | 15 + .../sections/header/header_actions.dart | 10 + .../sections/header/header_buttons.dart | 40 +- .../shared/tracks_view/track_view_props.dart | 12 +- lib/models/connect/connect.freezed.dart | 498 ++++++++++++-- lib/models/connect/connect.g.dart | 44 +- lib/models/connect/load.dart | 19 +- lib/models/source_match.g.dart | 2 +- lib/models/spotify/home_feed.g.dart | 70 +- .../spotify/recommendation_seeds.g.dart | 3 +- lib/models/spotify_friends.g.dart | 39 +- lib/pages/album/album.dart | 2 +- lib/pages/artist/section/top_tracks.dart | 3 +- lib/pages/home/home.dart | 3 + lib/pages/playlist/liked_playlist.dart | 2 +- lib/pages/playlist/playlist.dart | 2 +- lib/pages/search/sections/tracks.dart | 2 +- lib/provider/connect/server.dart | 13 +- lib/provider/history/history.dart | 21 +- lib/provider/history/recent.dart | 40 ++ lib/provider/history/state.dart | 84 +-- lib/provider/history/state.freezed.dart | 644 ++++++++++++++++++ lib/provider/history/state.g.dart | 61 +- .../proxy_playlist/player_listeners.dart | 55 +- .../proxy_playlist_provider.dart | 28 +- .../user_preferences_provider.dart | 1 + .../user_preferences_state.g.dart | 3 +- lib/services/audio_player/audio_player.dart | 1 - lib/services/song_link/song_link.g.dart | 3 +- .../sourced_track/models/source_info.g.dart | 2 +- .../sourced_track/models/source_map.g.dart | 15 +- pubspec.lock | 14 +- pubspec.yaml | 2 +- 40 files changed, 1512 insertions(+), 316 deletions(-) create mode 100644 lib/components/home/sections/recent.dart create mode 100644 lib/provider/history/recent.dart create mode 100644 lib/provider/history/state.freezed.dart diff --git a/build.yaml b/build.yaml index f074d6e15..d83d6a202 100644 --- a/build.yaml +++ b/build.yaml @@ -2,4 +2,9 @@ targets: $default: sources: exclude: - - bin/*.dart \ No newline at end of file + - bin/*.dart + builders: + json_serializable: + options: + any_map: true + explicit_to_json: true diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 4df19dfc9..7391d3a06 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,5 +1,4 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 4931b162a..b7093b60f 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -81,9 +81,9 @@ class AlbumCard extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.album( tracks: fetchedTracks, - collectionId: album.id!, + collection: album, ), ); } else { diff --git a/lib/components/home/sections/recent.dart b/lib/components/home/sections/recent.dart new file mode 100644 index 000000000..0fc5fadf9 --- /dev/null +++ b/lib/components/home/sections/recent.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/provider/history/recent.dart'; +import 'package:spotube/provider/history/state.dart'; + +class HomeRecentlyPlayedSection extends HookConsumerWidget { + const HomeRecentlyPlayedSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final history = ref.watch(recentlyPlayedItems); + + if (history.isEmpty) { + return const SizedBox(); + } + + return HorizontalPlaybuttonCardView( + title: const Text('Recently Played'), + items: [ + for (final item in history) + if (item is PlaybackHistoryPlaylist) + item.playlist + else if (item is PlaybackHistoryAlbum) + item.album + ], + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + ); + } +} diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 98309aefd..8aaf4b61d 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -81,9 +81,9 @@ class PlaylistCard extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: fetchedTracks, - collectionId: playlist.id!, + collection: playlist, ), ); } else { diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index e142cb35c..291950bb5 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -96,7 +96,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { return switch (item) { PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - AlbumSimple() => AlbumCard(item as Album), + AlbumSimple() => AlbumCard(item as AlbumSimple), Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index f576ba0a1..c3605f33a 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -17,6 +17,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -28,6 +29,7 @@ class TrackViewBodySection extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); @@ -146,11 +148,17 @@ class TrackViewBodySection extends HookConsumerWidget { } else { final tracks = await props.pagination.onFetchAll(); await remotePlayback.load( - WebSocketLoadEventData( - tracks: tracks, - collectionId: props.collectionId, - initialIndex: index, - ), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: props.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: props.collection as PlaylistSimple, + initialIndex: index, + ), ); } } else { @@ -164,6 +172,13 @@ class TrackViewBodySection extends HookConsumerWidget { autoPlay: true, ); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier + .addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } } } }, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index ff92b6638..c2adf38bb 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; @@ -8,6 +9,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -23,6 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); @@ -72,6 +75,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracksAtFirst(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } @@ -79,6 +88,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracks(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index f6880485d..8c1c8e153 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; @@ -9,6 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class TrackViewHeaderActions extends HookConsumerWidget { @@ -20,6 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -61,6 +64,13 @@ class TrackViewHeaderActions extends HookConsumerWidget { final tracks = await props.pagination.onFetchAll(); await playlistNotifier.addTracks(tracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier + .addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } }, ), if (props.onHeart != null && auth != null) diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 50eeb7470..5ffff5122 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -5,12 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -28,6 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final props = InheritedTrackView.of(context); final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -52,10 +55,16 @@ class TrackViewHeaderButtons extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( - tracks: allTracks, - collectionId: props.collectionId, - initialIndex: Random().nextInt(allTracks.length)), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + initialIndex: Random().nextInt(allTracks.length)) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.collection as PlaylistSimple, + initialIndex: Random().nextInt(allTracks.length), + ), ); await remotePlayback.setShuffle(true); } else { @@ -66,6 +75,11 @@ class TrackViewHeaderButtons extends HookConsumerWidget { ); await audioPlayer.setShuffle(true); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } } } finally { isLoading.value = false; @@ -84,14 +98,24 @@ class TrackViewHeaderButtons extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( - tracks: allTracks, - collectionId: props.collectionId, - ), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + ) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.collection as PlaylistSimple, + ), ); } else { await playlistNotifier.load(allTracks, autoPlay: true); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } } } finally { isLoading.value = false; diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart index a1a07f84d..b0a00ae29 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -39,7 +39,7 @@ class PaginationProps { } class InheritedTrackView extends InheritedWidget { - final String collectionId; + final Object collection; final String title; final String? description; final String image; @@ -55,7 +55,7 @@ class InheritedTrackView extends InheritedWidget { const InheritedTrackView({ super.key, required super.child, - required this.collectionId, + required this.collection, required this.title, this.description, required this.image, @@ -65,7 +65,11 @@ class InheritedTrackView extends InheritedWidget { required this.shareUrl, this.isLiked = false, this.onHeart, - }); + }) : assert(collection is AlbumSimple || collection is PlaylistSimple); + + String get collectionId => collection is AlbumSimple + ? (collection as AlbumSimple).id! + : (collection as PlaylistSimple).id!; @override bool updateShouldNotify(InheritedTrackView oldWidget) { @@ -78,7 +82,7 @@ class InheritedTrackView extends InheritedWidget { oldWidget.onHeart != onHeart || oldWidget.shareUrl != shareUrl || oldWidget.routePath != routePath || - oldWidget.collectionId != collectionId || + oldWidget.collection != collection || oldWidget.child != child; } diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart index face800e6..088cfbd1a 100644 --- a/lib/models/connect/connect.freezed.dart +++ b/lib/models/connect/connect.freezed.dart @@ -16,16 +16,89 @@ final _privateConstructorUsedError = UnsupportedError( WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( Map json) { - return _WebSocketLoadEventData.fromJson(json); + switch (json['runtimeType']) { + case 'playlist': + return WebSocketLoadEventDataPlaylist.fromJson(json); + case 'album': + return WebSocketLoadEventDataAlbum.fromJson(json); + + default: + throw CheckedFromJsonException( + json, + 'runtimeType', + 'WebSocketLoadEventData', + 'Invalid union type "${json['runtimeType']}"!'); + } } /// @nodoc mixin _$WebSocketLoadEventData { @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks => throw _privateConstructorUsedError; - String? get collectionId => throw _privateConstructorUsedError; + Object? get collection => throw _privateConstructorUsedError; int? get initialIndex => throw _privateConstructorUsedError; - + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) $WebSocketLoadEventDataCopyWith get copyWith => @@ -40,7 +113,6 @@ abstract class $WebSocketLoadEventDataCopyWith<$Res> { @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - String? collectionId, int? initialIndex}); } @@ -59,7 +131,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, @override $Res call({ Object? tracks = null, - Object? collectionId = freezed, Object? initialIndex = freezed, }) { return _then(_value.copyWith( @@ -67,10 +138,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, ? _value.tracks : tracks // ignore: cast_nullable_to_non_nullable as List, - collectionId: freezed == collectionId - ? _value.collectionId - : collectionId // ignore: cast_nullable_to_non_nullable - as String?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -80,46 +147,279 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, } /// @nodoc -abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> +abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> + implements $WebSocketLoadEventDataCopyWith<$Res> { + factory _$$WebSocketLoadEventDataPlaylistImplCopyWith( + _$WebSocketLoadEventDataPlaylistImpl value, + $Res Function(_$WebSocketLoadEventDataPlaylistImpl) then) = + __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex}); +} + +/// @nodoc +class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res> + extends _$WebSocketLoadEventDataCopyWithImpl<$Res, + _$WebSocketLoadEventDataPlaylistImpl> + implements _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> { + __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl( + _$WebSocketLoadEventDataPlaylistImpl _value, + $Res Function(_$WebSocketLoadEventDataPlaylistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collection = freezed, + Object? initialIndex = freezed, + }) { + return _then(_$WebSocketLoadEventDataPlaylistImpl( + tracks: null == tracks + ? _value._tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collection: freezed == collection + ? _value.collection + : collection // ignore: cast_nullable_to_non_nullable + as PlaylistSimple?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataPlaylistImpl + extends WebSocketLoadEventDataPlaylist { + _$WebSocketLoadEventDataPlaylistImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + this.collection, + this.initialIndex, + final String? $type}) + : _tracks = tracks, + $type = $type ?? 'playlist', + super._(); + + factory _$WebSocketLoadEventDataPlaylistImpl.fromJson( + Map json) => + _$$WebSocketLoadEventDataPlaylistImplFromJson(json); + + final List _tracks; + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final PlaylistSimple? collection; + @override + final int? initialIndex; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'WebSocketLoadEventData.playlist(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WebSocketLoadEventDataPlaylistImpl && + const DeepCollectionEquality().equals(other._tracks, _tracks) && + (identical(other.collection, collection) || + other.collection == collection) && + (identical(other.initialIndex, initialIndex) || + other.initialIndex == initialIndex)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$WebSocketLoadEventDataPlaylistImplCopyWith< + _$WebSocketLoadEventDataPlaylistImpl> + get copyWith => __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl< + _$WebSocketLoadEventDataPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return playlist(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return playlist?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(tracks, collection, initialIndex); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$WebSocketLoadEventDataPlaylistImplToJson( + this, + ); + } +} + +abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData { + factory WebSocketLoadEventDataPlaylist( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final PlaylistSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl; + WebSocketLoadEventDataPlaylist._() : super._(); + + factory WebSocketLoadEventDataPlaylist.fromJson(Map json) = + _$WebSocketLoadEventDataPlaylistImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + PlaylistSimple? get collection; + @override + int? get initialIndex; + @override + @JsonKey(ignore: true) + _$$WebSocketLoadEventDataPlaylistImplCopyWith< + _$WebSocketLoadEventDataPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> implements $WebSocketLoadEventDataCopyWith<$Res> { - factory _$$WebSocketLoadEventDataImplCopyWith( - _$WebSocketLoadEventDataImpl value, - $Res Function(_$WebSocketLoadEventDataImpl) then) = - __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; + factory _$$WebSocketLoadEventDataAlbumImplCopyWith( + _$WebSocketLoadEventDataAlbumImpl value, + $Res Function(_$WebSocketLoadEventDataAlbumImpl) then) = + __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>; @override @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - String? collectionId, + AlbumSimple? collection, int? initialIndex}); } /// @nodoc -class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> +class __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res> extends _$WebSocketLoadEventDataCopyWithImpl<$Res, - _$WebSocketLoadEventDataImpl> - implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { - __$$WebSocketLoadEventDataImplCopyWithImpl( - _$WebSocketLoadEventDataImpl _value, - $Res Function(_$WebSocketLoadEventDataImpl) _then) + _$WebSocketLoadEventDataAlbumImpl> + implements _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> { + __$$WebSocketLoadEventDataAlbumImplCopyWithImpl( + _$WebSocketLoadEventDataAlbumImpl _value, + $Res Function(_$WebSocketLoadEventDataAlbumImpl) _then) : super(_value, _then); @pragma('vm:prefer-inline') @override $Res call({ Object? tracks = null, - Object? collectionId = freezed, + Object? collection = freezed, Object? initialIndex = freezed, }) { - return _then(_$WebSocketLoadEventDataImpl( + return _then(_$WebSocketLoadEventDataAlbumImpl( tracks: null == tracks ? _value._tracks : tracks // ignore: cast_nullable_to_non_nullable as List, - collectionId: freezed == collectionId - ? _value.collectionId - : collectionId // ignore: cast_nullable_to_non_nullable - as String?, + collection: freezed == collection + ? _value.collection + : collection // ignore: cast_nullable_to_non_nullable + as AlbumSimple?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -130,16 +430,20 @@ class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { - _$WebSocketLoadEventDataImpl( +class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum { + _$WebSocketLoadEventDataAlbumImpl( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - this.collectionId, - this.initialIndex}) - : _tracks = tracks; + this.collection, + this.initialIndex, + final String? $type}) + : _tracks = tracks, + $type = $type ?? 'album', + super._(); - factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => - _$$WebSocketLoadEventDataImplFromJson(json); + factory _$WebSocketLoadEventDataAlbumImpl.fromJson( + Map json) => + _$$WebSocketLoadEventDataAlbumImplFromJson(json); final List _tracks; @override @@ -151,23 +455,26 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { } @override - final String? collectionId; + final AlbumSimple? collection; @override final int? initialIndex; + @JsonKey(name: 'runtimeType') + final String $type; + @override String toString() { - return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; + return 'WebSocketLoadEventData.album(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$WebSocketLoadEventDataImpl && + other is _$WebSocketLoadEventDataAlbumImpl && const DeepCollectionEquality().equals(other._tracks, _tracks) && - (identical(other.collectionId, collectionId) || - other.collectionId == collectionId) && + (identical(other.collection, collection) || + other.collection == collection) && (identical(other.initialIndex, initialIndex) || other.initialIndex == initialIndex)); } @@ -175,42 +482,129 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { @JsonKey(ignore: true) @override int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); @JsonKey(ignore: true) @override @pragma('vm:prefer-inline') - _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> - get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< - _$WebSocketLoadEventDataImpl>(this, _$identity); + _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> + get copyWith => __$$WebSocketLoadEventDataAlbumImplCopyWithImpl< + _$WebSocketLoadEventDataAlbumImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return album(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return album?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) { + if (album != null) { + return album(tracks, collection, initialIndex); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } @override Map toJson() { - return _$$WebSocketLoadEventDataImplToJson( + return _$$WebSocketLoadEventDataAlbumImplToJson( this, ); } } -abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { - factory _WebSocketLoadEventData( +abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData { + factory WebSocketLoadEventDataAlbum( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - final String? collectionId, - final int? initialIndex}) = _$WebSocketLoadEventDataImpl; + final AlbumSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl; + WebSocketLoadEventDataAlbum._() : super._(); - factory _WebSocketLoadEventData.fromJson(Map json) = - _$WebSocketLoadEventDataImpl.fromJson; + factory WebSocketLoadEventDataAlbum.fromJson(Map json) = + _$WebSocketLoadEventDataAlbumImpl.fromJson; @override @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks; @override - String? get collectionId; + AlbumSimple? get collection; @override int? get initialIndex; @override @JsonKey(ignore: true) - _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart index f636e0350..f297024b9 100644 --- a/lib/models/connect/connect.g.dart +++ b/lib/models/connect/connect.g.dart @@ -6,20 +6,48 @@ part of 'connect.dart'; // JsonSerializableGenerator // ************************************************************************** -_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( - Map json) => - _$WebSocketLoadEventDataImpl( +_$WebSocketLoadEventDataPlaylistImpl + _$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) => + _$WebSocketLoadEventDataPlaylistImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(Map.from(e as Map))) + .toList(), + collection: json['collection'] == null + ? null + : PlaylistSimple.fromJson( + Map.from(json['collection'] as Map)), + initialIndex: json['initialIndex'] as int?, + $type: json['runtimeType'] as String?, + ); + +Map _$$WebSocketLoadEventDataPlaylistImplToJson( + _$WebSocketLoadEventDataPlaylistImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collection': instance.collection?.toJson(), + 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, + }; + +_$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson( + Map json) => + _$WebSocketLoadEventDataAlbumImpl( tracks: (json['tracks'] as List) - .map((e) => Track.fromJson(e as Map)) + .map((e) => Track.fromJson(Map.from(e as Map))) .toList(), - collectionId: json['collectionId'] as String?, + collection: json['collection'] == null + ? null + : AlbumSimple.fromJson( + Map.from(json['collection'] as Map)), initialIndex: json['initialIndex'] as int?, + $type: json['runtimeType'] as String?, ); -Map _$$WebSocketLoadEventDataImplToJson( - _$WebSocketLoadEventDataImpl instance) => +Map _$$WebSocketLoadEventDataAlbumImplToJson( + _$WebSocketLoadEventDataAlbumImpl instance) => { 'tracks': _tracksJson(instance.tracks), - 'collectionId': instance.collectionId, + 'collection': instance.collection?.toJson(), 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart index d750cddd2..bf0e164db 100644 --- a/lib/models/connect/load.dart +++ b/lib/models/connect/load.dart @@ -6,14 +6,27 @@ List> _tracksJson(List tracks) { @freezed class WebSocketLoadEventData with _$WebSocketLoadEventData { - factory WebSocketLoadEventData({ + const WebSocketLoadEventData._(); + + factory WebSocketLoadEventData.playlist({ @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, - String? collectionId, + PlaylistSimple? collection, int? initialIndex, - }) = _WebSocketLoadEventData; + }) = WebSocketLoadEventDataPlaylist; + + factory WebSocketLoadEventData.album({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + AlbumSimple? collection, + int? initialIndex, + }) = WebSocketLoadEventDataAlbum; factory WebSocketLoadEventData.fromJson(Map json) => _$WebSocketLoadEventDataFromJson(json); + + String? get collectionId => when( + playlist: (tracks, collection, _) => collection?.id, + album: (tracks, collection, _) => collection?.id, + ); } class WebSocketLoadEvent extends WebSocketEvent { diff --git a/lib/models/source_match.g.dart b/lib/models/source_match.g.dart index 11f34bf34..3b469694a 100644 --- a/lib/models/source_match.g.dart +++ b/lib/models/source_match.g.dart @@ -97,7 +97,7 @@ class SourceTypeAdapter extends TypeAdapter { // JsonSerializableGenerator // ************************************************************************** -SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( +SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( id: json['id'] as String, sourceId: json['sourceId'] as String, sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), diff --git a/lib/models/spotify/home_feed.g.dart b/lib/models/spotify/home_feed.g.dart index 73a4f9093..fceb3db42 100644 --- a/lib/models/spotify/home_feed.g.dart +++ b/lib/models/spotify/home_feed.g.dart @@ -6,14 +6,13 @@ part of 'home_feed.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson( - Map json) => +_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(Map json) => _$SpotifySectionPlaylistImpl( description: json['description'] as String, format: json['format'] as String, images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), name: json['name'] as String, owner: json['owner'] as String, @@ -25,20 +24,19 @@ Map _$$SpotifySectionPlaylistImplToJson( { 'description': instance.description, 'format': instance.format, - 'images': instance.images, + 'images': instance.images.map((e) => e.toJson()).toList(), 'name': instance.name, 'owner': instance.owner, 'uri': instance.uri, }; -_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson( - Map json) => +_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(Map json) => _$SpotifySectionArtistImpl( name: json['name'] as String, uri: json['uri'] as String, images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), ); @@ -47,19 +45,18 @@ Map _$$SpotifySectionArtistImplToJson( { 'name': instance.name, 'uri': instance.uri, - 'images': instance.images, + 'images': instance.images.map((e) => e.toJson()).toList(), }; -_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( - Map json) => +_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(Map json) => _$SpotifySectionAlbumImpl( artists: (json['artists'] as List) - .map((e) => - SpotifySectionAlbumArtist.fromJson(e as Map)) + .map((e) => SpotifySectionAlbumArtist.fromJson( + Map.from(e as Map))) .toList(), images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), name: json['name'] as String, uri: json['uri'] as String, @@ -68,14 +65,14 @@ _$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( Map _$$SpotifySectionAlbumImplToJson( _$SpotifySectionAlbumImpl instance) => { - 'artists': instance.artists, - 'images': instance.images, + 'artists': instance.artists.map((e) => e.toJson()).toList(), + 'images': instance.images.map((e) => e.toJson()).toList(), 'name': instance.name, 'uri': instance.uri, }; _$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson( - Map json) => + Map json) => _$SpotifySectionAlbumArtistImpl( name: json['name'] as String, uri: json['uri'] as String, @@ -89,7 +86,7 @@ Map _$$SpotifySectionAlbumArtistImplToJson( }; _$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson( - Map json) => + Map json) => _$SpotifySectionItemImageImpl( height: json['height'] as num?, url: json['url'] as String, @@ -105,40 +102,40 @@ Map _$$SpotifySectionItemImageImplToJson( }; _$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson( - Map json) => + Map json) => _$SpotifyHomeFeedSectionItemImpl( typename: json['typename'] as String, playlist: json['playlist'] == null ? null : SpotifySectionPlaylist.fromJson( - json['playlist'] as Map), + Map.from(json['playlist'] as Map)), artist: json['artist'] == null ? null : SpotifySectionArtist.fromJson( - json['artist'] as Map), + Map.from(json['artist'] as Map)), album: json['album'] == null ? null - : SpotifySectionAlbum.fromJson(json['album'] as Map), + : SpotifySectionAlbum.fromJson( + Map.from(json['album'] as Map)), ); Map _$$SpotifyHomeFeedSectionItemImplToJson( _$SpotifyHomeFeedSectionItemImpl instance) => { 'typename': instance.typename, - 'playlist': instance.playlist, - 'artist': instance.artist, - 'album': instance.album, + 'playlist': instance.playlist?.toJson(), + 'artist': instance.artist?.toJson(), + 'album': instance.album?.toJson(), }; -_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson( - Map json) => +_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(Map json) => _$SpotifyHomeFeedSectionImpl( typename: json['typename'] as String, title: json['title'] as String?, uri: json['uri'] as String, items: (json['items'] as List) - .map((e) => - SpotifyHomeFeedSectionItem.fromJson(e as Map)) + .map((e) => SpotifyHomeFeedSectionItem.fromJson( + Map.from(e as Map))) .toList(), ); @@ -148,16 +145,15 @@ Map _$$SpotifyHomeFeedSectionImplToJson( 'typename': instance.typename, 'title': instance.title, 'uri': instance.uri, - 'items': instance.items, + 'items': instance.items.map((e) => e.toJson()).toList(), }; -_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson( - Map json) => +_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(Map json) => _$SpotifyHomeFeedImpl( greeting: json['greeting'] as String, sections: (json['sections'] as List) - .map( - (e) => SpotifyHomeFeedSection.fromJson(e as Map)) + .map((e) => SpotifyHomeFeedSection.fromJson( + Map.from(e as Map))) .toList(), ); @@ -165,5 +161,5 @@ Map _$$SpotifyHomeFeedImplToJson( _$SpotifyHomeFeedImpl instance) => { 'greeting': instance.greeting, - 'sections': instance.sections, + 'sections': instance.sections.map((e) => e.toJson()).toList(), }; diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart index bdfa3a074..accb2ed1d 100644 --- a/lib/models/spotify/recommendation_seeds.g.dart +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -6,8 +6,7 @@ part of 'recommendation_seeds.dart'; // JsonSerializableGenerator // ************************************************************************** -_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( - Map json) => +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(Map json) => _$RecommendationSeedsImpl( acousticness: json['acousticness'] as num?, danceability: json['danceability'] as num?, diff --git a/lib/models/spotify_friends.g.dart b/lib/models/spotify_friends.g.dart index 4a32dd094..a1248429e 100644 --- a/lib/models/spotify_friends.g.dart +++ b/lib/models/spotify_friends.g.dart @@ -6,60 +6,55 @@ part of 'spotify_friends.dart'; // JsonSerializableGenerator // ************************************************************************** -SpotifyFriend _$SpotifyFriendFromJson(Map json) => - SpotifyFriend( +SpotifyFriend _$SpotifyFriendFromJson(Map json) => SpotifyFriend( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, ); -SpotifyActivityArtist _$SpotifyActivityArtistFromJson( - Map json) => +SpotifyActivityArtist _$SpotifyActivityArtistFromJson(Map json) => SpotifyActivityArtist( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson( - Map json) => +SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson(Map json) => SpotifyActivityAlbum( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityContext _$SpotifyActivityContextFromJson( - Map json) => +SpotifyActivityContext _$SpotifyActivityContextFromJson(Map json) => SpotifyActivityContext( uri: json['uri'] as String, name: json['name'] as String, index: json['index'] as num, ); -SpotifyActivityTrack _$SpotifyActivityTrackFromJson( - Map json) => +SpotifyActivityTrack _$SpotifyActivityTrackFromJson(Map json) => SpotifyActivityTrack( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, artist: SpotifyActivityArtist.fromJson( - json['artist'] as Map), - album: - SpotifyActivityAlbum.fromJson(json['album'] as Map), + Map.from(json['artist'] as Map)), + album: SpotifyActivityAlbum.fromJson( + Map.from(json['album'] as Map)), context: SpotifyActivityContext.fromJson( - json['context'] as Map), + Map.from(json['context'] as Map)), ); -SpotifyFriendActivity _$SpotifyFriendActivityFromJson( - Map json) => +SpotifyFriendActivity _$SpotifyFriendActivityFromJson(Map json) => SpotifyFriendActivity( - user: SpotifyFriend.fromJson(json['user'] as Map), - track: - SpotifyActivityTrack.fromJson(json['track'] as Map), + user: SpotifyFriend.fromJson( + Map.from(json['user'] as Map)), + track: SpotifyActivityTrack.fromJson( + Map.from(json['track'] as Map)), ); -SpotifyFriends _$SpotifyFriendsFromJson(Map json) => - SpotifyFriends( +SpotifyFriends _$SpotifyFriendsFromJson(Map json) => SpotifyFriends( friends: (json['friends'] as List) - .map((e) => SpotifyFriendActivity.fromJson(e as Map)) + .map((e) => SpotifyFriendActivity.fromJson( + Map.from(e as Map))) .toList(), ); diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index b24b69f43..8461b1f14 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -22,7 +22,7 @@ class AlbumPage extends HookConsumerWidget { final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); return InheritedTrackView( - collectionId: album.id!, + collection: album, image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 9d4078997..595ac5109 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -52,8 +52,9 @@ class ArtistPageTopTracks extends HookConsumerWidget { if (!isPlaylistPlaying) { await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: tracks, + collection: null, initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), ), ); diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index a4a71146b..1872f0309 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -10,6 +10,7 @@ import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/genres.dart'; import 'package:spotube/components/home/sections/made_for_user.dart'; import 'package:spotube/components/home/sections/new_releases.dart'; +import 'package:spotube/components/home/sections/recent.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -72,6 +73,8 @@ class HomePage extends HookConsumerWidget { else if (kIsMacOS) const SliverGap(10), const HomeGenresSection(), + const SliverGap(10), + const SliverToBoxAdapter(child: HomeRecentlyPlayedSection()), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), const SliverToBoxAdapter(child: HomeNewReleasesSection()), diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 72983518a..8477a2152 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -18,7 +18,7 @@ class LikedPlaylistPage extends HookConsumerWidget { final tracks = likedTracks.asData?.value ?? []; return InheritedTrackView( - collectionId: playlist.id!, + collection: playlist, image: "assets/liked-tracks.jpg", pagination: PaginationProps( hasNextPage: false, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index d9d224e04..879090613 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -29,7 +29,7 @@ class PlaylistPage extends HookConsumerWidget { final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); return InheritedTrackView( - collectionId: playlist.id!, + collection: playlist, image: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 7fb58759f..bd7f3c88e 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -76,7 +76,7 @@ class SearchTracksSection extends HookConsumerWidget { if (shouldPlay) { await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: [track], ), ); diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index ebf53e437..23fbada86 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -9,9 +9,11 @@ import 'package:shelf/shelf_io.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -32,6 +34,7 @@ final connectServerProvider = FutureProvider((ref) async { final resolvedService = await ref .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); if (!enabled || resolvedService != null) { return null; @@ -146,8 +149,14 @@ final connectServerProvider = FutureProvider((ref) async { initialIndex: event.data.initialIndex ?? 0, ); - if (event.data.collectionId != null) { - playbackNotifier.addCollection(event.data.collectionId!); + if (event.data.collectionId == null) return; + playbackNotifier.addCollection(event.data.collectionId!); + if (event.data.collection is AlbumSimple) { + historyNotifier + .addAlbums([event.data.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists( + [event.data.collection as PlaylistSimple]); } }); diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart index d2ff89a20..9983cfae5 100644 --- a/lib/provider/history/history.dart +++ b/lib/provider/history/history.dart @@ -6,13 +6,19 @@ import 'package:spotube/provider/history/state.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; class PlaybackHistoryState { - final List items; + final List items; const PlaybackHistoryState({this.items = const []}); factory PlaybackHistoryState.fromJson(Map json) { return PlaybackHistoryState( - items: - json["items"]?.map((json) => PlaybackHistoryBase.fromJson(json))); + items: json["items"] + ?.map( + (json) => PlaybackHistoryItem.fromJson(json), + ) + .toList() + .cast() ?? + [], + ); } Map toJson() { @@ -22,7 +28,7 @@ class PlaybackHistoryState { } PlaybackHistoryState copyWith({ - List? items, + List? items, }) { return PlaybackHistoryState(items: items ?? this.items); } @@ -47,7 +53,8 @@ class PlaybackHistoryNotifier items: [ ...state.items, for (final playlist in playlists) - PlaybackHistoryPlaylist(date: DateTime.now(), playlist: playlist), + PlaybackHistoryItem.playlist( + date: DateTime.now(), playlist: playlist), ], ); } @@ -57,7 +64,7 @@ class PlaybackHistoryNotifier items: [ ...state.items, for (final album in albums) - PlaybackHistoryAlbum(date: DateTime.now(), album: album), + PlaybackHistoryItem.album(date: DateTime.now(), album: album), ], ); } @@ -67,7 +74,7 @@ class PlaybackHistoryNotifier items: [ ...state.items, for (final track in tracks) - PlaybackHistoryTrack(date: DateTime.now(), track: track), + PlaybackHistoryItem.track(date: DateTime.now(), track: track), ], ); } diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart new file mode 100644 index 000000000..9953858d8 --- /dev/null +++ b/lib/provider/history/recent.dart @@ -0,0 +1,40 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; + +final recentlyPlayedItems = Provider((ref) { + return ref.watch( + playbackHistoryProvider.select( + (s) => s.items + .toSet() + // unique items + .whereIndexed( + (index, item) => + index == + s.items.lastIndexWhere( + (e) => switch ((e, item)) { + ( + PlaybackHistoryPlaylist(:final playlist), + PlaybackHistoryPlaylist(playlist: final playlist2) + ) => + playlist.id == playlist2.id, + ( + PlaybackHistoryAlbum(:final album), + PlaybackHistoryAlbum(album: final album2) + ) => + album.id == album2.id, + _ => false, + }, + ), + ) + .where( + (s) => s is PlaybackHistoryPlaylist || s is PlaybackHistoryAlbum, + ) + .take(10) + .sortedBy((s) => s.date) + .reversed + .toList(), + ), + ); +}); diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart index a011c2b0d..ae7dba950 100644 --- a/lib/provider/history/state.dart +++ b/lib/provider/history/state.dart @@ -1,70 +1,26 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spotify/spotify.dart'; +part 'state.freezed.dart'; part 'state.g.dart'; -@JsonSerializable() -class PlaybackHistoryBase { - final DateTime date; - - const PlaybackHistoryBase({required this.date}); - - factory PlaybackHistoryBase.fromJson(Map json) { - if (json.containsKey("playlist")) { - return PlaybackHistoryPlaylist.fromJson(json); - } else if (json.containsKey("album")) { - return PlaybackHistoryAlbum.fromJson(json); - } else if (json.containsKey("track")) { - return PlaybackHistoryTrack.fromJson(json); - } - - return _$PlaybackHistoryBaseFromJson(json); - } - - Map toJson() => _$PlaybackHistoryBaseToJson(this); -} - -@JsonSerializable() -class PlaybackHistoryPlaylist extends PlaybackHistoryBase { - final PlaylistSimple playlist; - PlaybackHistoryPlaylist({ - required super.date, - required this.playlist, - }); - - factory PlaybackHistoryPlaylist.fromJson(Map json) => - _$PlaybackHistoryPlaylistFromJson(json); - - @override - Map toJson() => _$PlaybackHistoryPlaylistToJson(this); -} - -@JsonSerializable() -class PlaybackHistoryAlbum extends PlaybackHistoryBase { - final AlbumSimple album; - PlaybackHistoryAlbum({ - required super.date, - required this.album, - }); - - factory PlaybackHistoryAlbum.fromJson(Map json) => - _$PlaybackHistoryAlbumFromJson(json); - - @override - Map toJson() => _$PlaybackHistoryAlbumToJson(this); -} - -@JsonSerializable() -class PlaybackHistoryTrack extends PlaybackHistoryBase { - final TrackSimple track; - PlaybackHistoryTrack({ - required super.date, - required this.track, - }); - - factory PlaybackHistoryTrack.fromJson(Map json) => - _$PlaybackHistoryTrackFromJson(json); - - @override - Map toJson() => _$PlaybackHistoryTrackToJson(this); +@freezed +class PlaybackHistoryItem with _$PlaybackHistoryItem { + factory PlaybackHistoryItem.playlist({ + required DateTime date, + required PlaylistSimple playlist, + }) = PlaybackHistoryPlaylist; + + factory PlaybackHistoryItem.album({ + required DateTime date, + required AlbumSimple album, + }) = PlaybackHistoryAlbum; + + factory PlaybackHistoryItem.track({ + required DateTime date, + required TrackSimple track, + }) = PlaybackHistoryTrack; + + factory PlaybackHistoryItem.fromJson(Map json) => + _$PlaybackHistoryItemFromJson(json); } diff --git a/lib/provider/history/state.freezed.dart b/lib/provider/history/state.freezed.dart new file mode 100644 index 000000000..634bf496e --- /dev/null +++ b/lib/provider/history/state.freezed.dart @@ -0,0 +1,644 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +PlaybackHistoryItem _$PlaybackHistoryItemFromJson(Map json) { + switch (json['runtimeType']) { + case 'playlist': + return PlaybackHistoryPlaylist.fromJson(json); + case 'album': + return PlaybackHistoryAlbum.fromJson(json); + case 'track': + return PlaybackHistoryTrack.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'PlaybackHistoryItem', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$PlaybackHistoryItem { + DateTime get date => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, TrackSimple track) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, TrackSimple track)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, TrackSimple track)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $PlaybackHistoryItemCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlaybackHistoryItemCopyWith<$Res> { + factory $PlaybackHistoryItemCopyWith( + PlaybackHistoryItem value, $Res Function(PlaybackHistoryItem) then) = + _$PlaybackHistoryItemCopyWithImpl<$Res, PlaybackHistoryItem>; + @useResult + $Res call({DateTime date}); +} + +/// @nodoc +class _$PlaybackHistoryItemCopyWithImpl<$Res, $Val extends PlaybackHistoryItem> + implements $PlaybackHistoryItemCopyWith<$Res> { + _$PlaybackHistoryItemCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + }) { + return _then(_value.copyWith( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$PlaybackHistoryPlaylistImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryPlaylistImplCopyWith( + _$PlaybackHistoryPlaylistImpl value, + $Res Function(_$PlaybackHistoryPlaylistImpl) then) = + __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, PlaylistSimple playlist}); +} + +/// @nodoc +class __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, + _$PlaybackHistoryPlaylistImpl> + implements _$$PlaybackHistoryPlaylistImplCopyWith<$Res> { + __$$PlaybackHistoryPlaylistImplCopyWithImpl( + _$PlaybackHistoryPlaylistImpl _value, + $Res Function(_$PlaybackHistoryPlaylistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? playlist = null, + }) { + return _then(_$PlaybackHistoryPlaylistImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + playlist: null == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as PlaylistSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { + _$PlaybackHistoryPlaylistImpl( + {required this.date, required this.playlist, final String? $type}) + : $type = $type ?? 'playlist'; + + factory _$PlaybackHistoryPlaylistImpl.fromJson(Map json) => + _$$PlaybackHistoryPlaylistImplFromJson(json); + + @override + final DateTime date; + @override + final PlaylistSimple playlist; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.playlist(date: $date, playlist: $playlist)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryPlaylistImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.playlist, playlist) || + other.playlist == playlist)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, playlist); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => __$$PlaybackHistoryPlaylistImplCopyWithImpl< + _$PlaybackHistoryPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, TrackSimple track) track, + }) { + return playlist(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, TrackSimple track)? track, + }) { + return playlist?.call(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, TrackSimple track)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(date, this.playlist); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryPlaylistImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem { + factory PlaybackHistoryPlaylist( + {required final DateTime date, + required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl; + + factory PlaybackHistoryPlaylist.fromJson(Map json) = + _$PlaybackHistoryPlaylistImpl.fromJson; + + @override + DateTime get date; + PlaylistSimple get playlist; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryAlbumImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryAlbumImplCopyWith(_$PlaybackHistoryAlbumImpl value, + $Res Function(_$PlaybackHistoryAlbumImpl) then) = + __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, AlbumSimple album}); +} + +/// @nodoc +class __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryAlbumImpl> + implements _$$PlaybackHistoryAlbumImplCopyWith<$Res> { + __$$PlaybackHistoryAlbumImplCopyWithImpl(_$PlaybackHistoryAlbumImpl _value, + $Res Function(_$PlaybackHistoryAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? album = null, + }) { + return _then(_$PlaybackHistoryAlbumImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + album: null == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as AlbumSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { + _$PlaybackHistoryAlbumImpl( + {required this.date, required this.album, final String? $type}) + : $type = $type ?? 'album'; + + factory _$PlaybackHistoryAlbumImpl.fromJson(Map json) => + _$$PlaybackHistoryAlbumImplFromJson(json); + + @override + final DateTime date; + @override + final AlbumSimple album; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.album(date: $date, album: $album)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryAlbumImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.album, album) || other.album == album)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, album); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => + __$$PlaybackHistoryAlbumImplCopyWithImpl<_$PlaybackHistoryAlbumImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, TrackSimple track) track, + }) { + return album(date, this.album); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, TrackSimple track)? track, + }) { + return album?.call(date, this.album); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, TrackSimple track)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(date, this.album); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryAlbumImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem { + factory PlaybackHistoryAlbum( + {required final DateTime date, + required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl; + + factory PlaybackHistoryAlbum.fromJson(Map json) = + _$PlaybackHistoryAlbumImpl.fromJson; + + @override + DateTime get date; + AlbumSimple get album; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryTrackImplCopyWith(_$PlaybackHistoryTrackImpl value, + $Res Function(_$PlaybackHistoryTrackImpl) then) = + __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, TrackSimple track}); +} + +/// @nodoc +class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryTrackImpl> + implements _$$PlaybackHistoryTrackImplCopyWith<$Res> { + __$$PlaybackHistoryTrackImplCopyWithImpl(_$PlaybackHistoryTrackImpl _value, + $Res Function(_$PlaybackHistoryTrackImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? track = null, + }) { + return _then(_$PlaybackHistoryTrackImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + track: null == track + ? _value.track + : track // ignore: cast_nullable_to_non_nullable + as TrackSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { + _$PlaybackHistoryTrackImpl( + {required this.date, required this.track, final String? $type}) + : $type = $type ?? 'track'; + + factory _$PlaybackHistoryTrackImpl.fromJson(Map json) => + _$$PlaybackHistoryTrackImplFromJson(json); + + @override + final DateTime date; + @override + final TrackSimple track; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.track(date: $date, track: $track)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryTrackImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.track, track) || other.track == track)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, track); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => + __$$PlaybackHistoryTrackImplCopyWithImpl<_$PlaybackHistoryTrackImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, TrackSimple track) track, + }) { + return track(date, this.track); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, TrackSimple track)? track, + }) { + return track?.call(date, this.track); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, TrackSimple track)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(date, this.track); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return track(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return track?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryTrackImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { + factory PlaybackHistoryTrack( + {required final DateTime date, + required final TrackSimple track}) = _$PlaybackHistoryTrackImpl; + + factory PlaybackHistoryTrack.fromJson(Map json) = + _$PlaybackHistoryTrackImpl.fromJson; + + @override + DateTime get date; + TrackSimple get track; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart index f8aa98854..57d2ece7d 100644 --- a/lib/provider/history/state.g.dart +++ b/lib/provider/history/state.g.dart @@ -6,56 +6,51 @@ part of 'state.dart'; // JsonSerializableGenerator // ************************************************************************** -PlaybackHistoryBase _$PlaybackHistoryBaseFromJson(Map json) => - PlaybackHistoryBase( +_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson( + Map json) => + _$PlaybackHistoryPlaylistImpl( date: DateTime.parse(json['date'] as String), + playlist: PlaylistSimple.fromJson( + Map.from(json['playlist'] as Map)), + $type: json['runtimeType'] as String?, ); -Map _$PlaybackHistoryBaseToJson( - PlaybackHistoryBase instance) => +Map _$$PlaybackHistoryPlaylistImplToJson( + _$PlaybackHistoryPlaylistImpl instance) => { 'date': instance.date.toIso8601String(), + 'playlist': instance.playlist.toJson(), + 'runtimeType': instance.$type, }; -PlaybackHistoryPlaylist _$PlaybackHistoryPlaylistFromJson( - Map json) => - PlaybackHistoryPlaylist( +_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) => + _$PlaybackHistoryAlbumImpl( date: DateTime.parse(json['date'] as String), - playlist: - PlaylistSimple.fromJson(json['playlist'] as Map), + album: + AlbumSimple.fromJson(Map.from(json['album'] as Map)), + $type: json['runtimeType'] as String?, ); -Map _$PlaybackHistoryPlaylistToJson( - PlaybackHistoryPlaylist instance) => +Map _$$PlaybackHistoryAlbumImplToJson( + _$PlaybackHistoryAlbumImpl instance) => { 'date': instance.date.toIso8601String(), - 'playlist': instance.playlist, + 'album': instance.album.toJson(), + 'runtimeType': instance.$type, }; -PlaybackHistoryAlbum _$PlaybackHistoryAlbumFromJson( - Map json) => - PlaybackHistoryAlbum( +_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => + _$PlaybackHistoryTrackImpl( date: DateTime.parse(json['date'] as String), - album: AlbumSimple.fromJson(json['album'] as Map), + track: + TrackSimple.fromJson(Map.from(json['track'] as Map)), + $type: json['runtimeType'] as String?, ); -Map _$PlaybackHistoryAlbumToJson( - PlaybackHistoryAlbum instance) => +Map _$$PlaybackHistoryTrackImplToJson( + _$PlaybackHistoryTrackImpl instance) => { 'date': instance.date.toIso8601String(), - 'album': instance.album, - }; - -PlaybackHistoryTrack _$PlaybackHistoryTrackFromJson( - Map json) => - PlaybackHistoryTrack( - date: DateTime.parse(json['date'] as String), - track: TrackSimple.fromJson(json['track'] as Map), - ); - -Map _$PlaybackHistoryTrackToJson( - PlaybackHistoryTrack instance) => - { - 'date': instance.date.toIso8601String(), - 'track': instance.track, + 'track': instance.track.toJson(), + 'runtimeType': instance.$type, }; diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index f86ad3d47..d3faf9ced 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -3,24 +3,50 @@ import 'dart:async'; import 'package:catcher_2/catcher_2.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension ProxyPlaylistListeners on ProxyPlaylistNotifier { + Future updatePalette() async { + final palette = ref.read(paletteProvider); + if (!preferences.albumColorSync) { + if (palette != null) ref.read(paletteProvider.notifier).state = null; + return; + } + return Future.microtask(() async { + if (playlist.activeTrack == null) return; + + final palette = await PaletteGenerator.fromImageProvider( + UniversalImage.imageProvider( + (playlist.activeTrack?.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + height: 50, + width: 50, + ), + ); + ref.read(paletteProvider.notifier).state = palette; + }); + } + StreamSubscription subscribeToPlaylist() { - return audioPlayer.playlistStream.listen((playlist) { - state = state.copyWith( - tracks: playlist.medias + return audioPlayer.playlistStream.listen((mpvPlaylist) { + state = playlist.copyWith( + tracks: mpvPlaylist.medias .map((media) => SpotubeMedia.fromMedia(media).track) .toSet(), - active: playlist.index, + active: mpvPlaylist.index, ); - notificationService.addTrack(state.activeTrack!); - discord.updatePresence(state.activeTrack!); + notificationService.addTrack(playlist.activeTrack!); + discord.updatePresence(playlist.activeTrack!); updatePalette(); }); } @@ -46,17 +72,18 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String? lastScrobbled; return audioPlayer.positionStream.listen((position) { try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; + final uid = playlist.activeTrack is LocalTrack + ? (playlist.activeTrack as LocalTrack).path + : playlist.activeTrack?.id; - if (state.activeTrack == null || + if (playlist.activeTrack == null || lastScrobbled == uid || position.inSeconds < 30) { return; } - scrobbler.scrobble(state.activeTrack!); + scrobbler.scrobble(playlist.activeTrack!); + history.addTracks([playlist.activeTrack!]); lastScrobbled = uid; } catch (e, stack) { Catcher2.reportCheckedError(e, stack); @@ -68,9 +95,9 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String lastTrack = ""; // used to prevent multiple calls to the same track return audioPlayer.positionStream.listen((event) async { if (event < const Duration(seconds: 3) || - state.active == null || - state.active == state.tracks.length - 1) return; - final nextTrack = state.tracks.elementAt(state.active! + 1); + playlist.active == null || + playlist.active == playlist.tracks.length - 1) return; + final nextTrack = playlist.tracks.elementAt(playlist.active! + 1); if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 9811a1f8e..c8eb3657a 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -2,14 +2,12 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; @@ -32,6 +30,8 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); Discord get discord => ref.read(discordProvider); + PlaybackHistoryNotifier get history => + ref.read(playbackHistoryProvider.notifier); List _subscriptions = []; @@ -167,28 +167,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { discord.clear(); } - Future updatePalette() async { - final palette = ref.read(paletteProvider); - if (!preferences.albumColorSync) { - if (palette != null) ref.read(paletteProvider.notifier).state = null; - return; - } - return Future.microtask(() async { - if (state.activeTrack == null) return; - - final palette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - (state.activeTrack?.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - height: 50, - width: 50, - ), - ); - ref.read(paletteProvider.notifier).state = palette; - }); - } - @override set state(state) { super.state = state; diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a537038e2..1cc6702d1 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/provider/palette_provider.dart'; +import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 930b1dd17..0bc01c76f 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -6,8 +6,7 @@ part of 'user_preferences_state.dart'; // JsonSerializableGenerator // ************************************************************************** -_$UserPreferencesImpl _$$UserPreferencesImplFromJson( - Map json) => +_$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => _$UserPreferencesImpl( audioQuality: $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 92de192ba..035b91d36 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/audio_player/custom_player.dart'; diff --git a/lib/services/song_link/song_link.g.dart b/lib/services/song_link/song_link.g.dart index 911849e3a..7658a74c8 100644 --- a/lib/services/song_link/song_link.g.dart +++ b/lib/services/song_link/song_link.g.dart @@ -6,8 +6,7 @@ part of 'song_link.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => - _$SongLinkImpl( +_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => _$SongLinkImpl( displayName: json['displayName'] as String, linkId: json['linkId'] as String, platform: json['platform'] as String, diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart index 1ec9f75f7..5fe136cee 100644 --- a/lib/services/sourced_track/models/source_info.g.dart +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -6,7 +6,7 @@ part of 'source_info.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( +SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( id: json['id'] as String, title: json['title'] as String, artist: json['artist'] as String, diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart index e1085aa81..a581cc672 100644 --- a/lib/services/sourced_track/models/source_map.g.dart +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -6,8 +6,7 @@ part of 'source_map.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceQualityMap _$SourceQualityMapFromJson(Map json) => - SourceQualityMap( +SourceQualityMap _$SourceQualityMapFromJson(Map json) => SourceQualityMap( high: json['high'] as String, medium: json['medium'] as String, low: json['low'] as String, @@ -20,16 +19,18 @@ Map _$SourceQualityMapToJson(SourceQualityMap instance) => 'low': instance.low, }; -SourceMap _$SourceMapFromJson(Map json) => SourceMap( +SourceMap _$SourceMapFromJson(Map json) => SourceMap( weba: json['weba'] == null ? null - : SourceQualityMap.fromJson(json['weba'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['weba'] as Map)), m4a: json['m4a'] == null ? null - : SourceQualityMap.fromJson(json['m4a'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['m4a'] as Map)), ); Map _$SourceMapToJson(SourceMap instance) => { - 'weba': instance.weba, - 'm4a': instance.m4a, + 'weba': instance.weba?.toJson(), + 'm4a': instance.m4a?.toJson(), }; diff --git a/pubspec.lock b/pubspec.lock index ebb6d2c41..f1a2a69db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2048,11 +2048,19 @@ packages: dependency: "direct main" description: path: "." - ref: "feat/to-json" - resolved-ref: "05ace91cdfe64db23d8c62077069e7c25b3645cb" + ref: "fix/explicit-to-json" + resolved-ref: c4b37c599413ac7bfd78993e416a56105c62b634 url: "https://github.com/KRTirtho/spotify-dart.git" source: git - version: "0.13.5" + version: "0.13.6" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dcb212598..7e3d05670 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -118,7 +118,7 @@ dependencies: spotify: git: url: https://github.com/KRTirtho/spotify-dart.git - ref: feat/to-json + ref: fix/explicit-to-json bonsoir: ^5.1.9 shelf: ^1.4.1 shelf_router: ^1.1.4 From 5f442a1ff7bfd551255f91d07e33ea65dd1682f1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 29 Apr 2024 12:02:28 +0600 Subject: [PATCH 03/14] refactor: use route names --- lib/collections/intents.dart | 12 +- lib/collections/routes.dart | 71 +++++-- lib/collections/side_bar_tiles.dart | 64 +++++- lib/collections/spotube_icons.dart | 1 + lib/components/album/album_card.dart | 10 +- lib/components/artist/artist_card.dart | 9 +- lib/components/connect/connect_device.dart | 7 +- lib/components/home/sections/feed.dart | 10 +- .../home/sections/friends/friend_item.dart | 22 +- lib/components/home/sections/genres.dart | 12 +- lib/components/playlist/playlist_card.dart | 8 +- lib/components/root/sidebar.dart | 95 ++++----- .../root/spotube_navigation_bar.dart | 30 +-- .../shared/fallbacks/anonymous_fallback.dart | 3 +- lib/components/shared/links/artist_link.dart | 8 +- lib/l10n/app_en.arb | 3 +- lib/pages/album/album.dart | 2 + lib/pages/artist/artist.dart | 2 + lib/pages/connect/connect.dart | 7 +- lib/pages/connect/control/control.dart | 11 +- lib/pages/desktop_login/desktop_login.dart | 2 + lib/pages/desktop_login/login_tutorial.dart | 4 +- .../getting_started/getting_started.dart | 2 + .../getting_started/sections/support.dart | 3 +- lib/pages/home/feed/feed_section.dart | 2 + lib/pages/home/genres/genre_playlists.dart | 2 + lib/pages/home/genres/genres.dart | 10 +- lib/pages/home/home.dart | 13 +- lib/pages/lastfm_login/lastfm_login.dart | 1 + lib/pages/library/library.dart | 2 + .../playlist_generate/playlist_generate.dart | 2 + .../playlist_generate_result.dart | 10 +- lib/pages/lyrics/lyrics.dart | 2 + lib/pages/lyrics/mini_lyrics.dart | 2 + lib/pages/mobile_login/mobile_login.dart | 1 + lib/pages/playlist/liked_playlist.dart | 3 + lib/pages/playlist/playlist.dart | 2 + lib/pages/profile/profile.dart | 2 + lib/pages/root/root_app.dart | 38 +--- lib/pages/search/search.dart | 193 ++++++++++-------- lib/pages/settings/about.dart | 2 + lib/pages/settings/blacklist.dart | 2 + lib/pages/settings/logs.dart | 2 + lib/pages/settings/settings.dart | 2 + lib/pages/stats/stats.dart | 13 ++ lib/pages/track/track.dart | 2 + lib/utils/service_utils.dart | 46 +++++ untranslated_messages.json | 90 +++++++- 48 files changed, 590 insertions(+), 252 deletions(-) create mode 100644 lib/pages/stats/stats.dart diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 5f60959ed..579aff185 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -7,6 +7,10 @@ import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/models/logger.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; @@ -67,16 +71,16 @@ class HomeTabAction extends Action { final router = intent.ref.read(routerProvider); switch (intent.tab) { case HomeTabs.browse: - router.go("/"); + router.goNamed(HomePage.name); break; case HomeTabs.search: - router.go("/search"); + router.goNamed(SearchPage.name); break; case HomeTabs.library: - router.go("/library"); + router.goNamed(LibraryPage.name); break; case HomeTabs.lyrics: - router.go("/lyrics"); + router.goNamed(LyricsPage.name); break; } return null; diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 080cbd8a2..2ce29b408 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -24,6 +24,7 @@ import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; +import 'package:spotube/pages/stats/stats.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; @@ -50,6 +51,7 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "/", + name: HomePage.name, redirect: (context, state) async { final authNotifier = ref.read(authenticationProvider.notifier); final json = await authNotifier.box.get(authNotifier.cacheKey); @@ -66,11 +68,13 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "genres", + name: GenrePage.name, pageBuilder: (context, state) => const SpotubePage(child: GenrePage()), ), GoRoute( path: "genre/:categoryId", + name: GenrePlaylistsPage.name, pageBuilder: (context, state) => SpotubePage( child: GenrePlaylistsPage( category: state.extra as Category, @@ -79,6 +83,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "feeds/:feedId", + name: HomeFeedSectionPage.name, pageBuilder: (context, state) => SpotubePage( child: HomeFeedSectionPage( sectionUri: state.pathParameters["feedId"] as String, @@ -89,45 +94,50 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/search", - name: "Search", + name: SearchPage.name, pageBuilder: (context, state) => const SpotubePage(child: SearchPage()), ), GoRoute( - path: "/library", - name: "Library", - pageBuilder: (context, state) => - const SpotubePage(child: LibraryPage()), - routes: [ - GoRoute( - path: "generate", - pageBuilder: (context, state) => - const SpotubePage(child: PlaylistGeneratorPage()), - routes: [ - GoRoute( - path: "result", - pageBuilder: (context, state) => SpotubePage( - child: PlaylistGenerateResultPage( - state: state.extra as GeneratePlaylistProviderInput, - ), + path: "/library", + name: LibraryPage.name, + pageBuilder: (context, state) => + const SpotubePage(child: LibraryPage()), + routes: [ + GoRoute( + path: "generate", + name: PlaylistGeneratorPage.name, + pageBuilder: (context, state) => + const SpotubePage(child: PlaylistGeneratorPage()), + routes: [ + GoRoute( + path: "result", + name: PlaylistGenerateResultPage.name, + pageBuilder: (context, state) => SpotubePage( + child: PlaylistGenerateResultPage( + state: state.extra as GeneratePlaylistProviderInput, ), ), - ]), - ]), + ), + ]), + ], + ), GoRoute( path: "/lyrics", - name: "Lyrics", + name: LyricsPage.name, pageBuilder: (context, state) => const SpotubePage(child: LyricsPage()), ), GoRoute( path: "/settings", + name: SettingsPage.name, pageBuilder: (context, state) => const SpotubePage( child: SettingsPage(), ), routes: [ GoRoute( path: "blacklist", + name: BlackListPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const BlackListPage(), ), @@ -135,12 +145,14 @@ final routerProvider = Provider((ref) { if (!kIsWeb) GoRoute( path: "logs", + name: LogsPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const LogsPage(), ), ), GoRoute( path: "about", + name: AboutSpotube.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const AboutSpotube(), ), @@ -149,6 +161,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/album/:id", + name: AlbumPage.name, pageBuilder: (context, state) { assert(state.extra is AlbumSimple); return SpotubePage( @@ -158,6 +171,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/artist/:id", + name: ArtistPage.name, pageBuilder: (context, state) { assert(state.pathParameters["id"] != null); return SpotubePage( @@ -166,6 +180,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/playlist/:id", + name: PlaylistPage.name, pageBuilder: (context, state) { assert(state.extra is PlaylistSimple); return SpotubePage( @@ -177,6 +192,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/track/:id", + name: TrackPage.name, pageBuilder: (context, state) { final id = state.pathParameters["id"]!; return SpotubePage( @@ -186,12 +202,14 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/connect", + name: ConnectPage.name, pageBuilder: (context, state) => const SpotubePage( child: ConnectPage(), ), routes: [ GoRoute( path: "control", + name: ConnectControlPage.name, pageBuilder: (context, state) { return const SpotubePage( child: ConnectControlPage(), @@ -202,13 +220,22 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/profile", + name: ProfilePage.name, pageBuilder: (context, state) => const SpotubePage(child: ProfilePage()), + ), + GoRoute( + path: "/stats", + name: StatsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPage(), + ), ) ], ), GoRoute( path: "/mini-player", + name: MiniLyricsPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: MiniLyricsPage(prevSize: state.extra as Size), @@ -216,6 +243,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/getting-started", + name: GettingStarting.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: GettingStarting(), @@ -223,6 +251,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login", + name: WebViewLogin.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), @@ -230,6 +259,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login-tutorial", + name: LoginTutorial.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: LoginTutorial(), @@ -237,6 +267,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/lastfm-login", + name: LastFMLoginPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage(child: LastFMLoginPage()), diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index 551d70d72..b52227e84 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -1,32 +1,82 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.dart'; +import 'package:spotube/pages/settings/settings.dart'; +import 'package:spotube/pages/stats/stats.dart'; class SideBarTiles { final IconData icon; final String title; final String id; - SideBarTiles({required this.icon, required this.title, required this.id}); + final String name; + + SideBarTiles({ + required this.icon, + required this.title, + required this.id, + required this.name, + }); } List getSidebarTileList(AppLocalizations l10n) => [ - SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), SideBarTiles( - id: "library", icon: SpotubeIcons.library, title: l10n.library), - SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics), + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), + SideBarTiles( + id: "library", + name: LibraryPage.name, + icon: SpotubeIcons.library, + title: l10n.library, + ), + SideBarTiles( + id: "lyrics", + name: LyricsPage.name, + icon: SpotubeIcons.music, + title: l10n.lyrics, + ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), ]; List getNavbarTileList(AppLocalizations l10n) => [ - SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), + SideBarTiles( + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), SideBarTiles( id: "library", + name: LibraryPage.name, icon: SpotubeIcons.library, title: l10n.library, ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), SideBarTiles( id: "settings", + name: SettingsPage.name, icon: SpotubeIcons.settings, title: l10n.settings, ) diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6de212840..096a1a5f0 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -121,4 +121,5 @@ abstract class SpotubeIcons { static const monitor = FeatherIcons.monitor; static const power = FeatherIcons.power; static const bluetooth = FeatherIcons.bluetooth; + static const chart = FeatherIcons.barChart2; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index b7093b60f..7212a5741 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -9,6 +9,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/album/album.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -64,7 +65,14 @@ class AlbumCard extends HookConsumerWidget { description: "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { - ServiceUtils.push(context, "/album/${album.id}", extra: album); + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.id!, + }, + extra: album, + ); }, onPlaybuttonPressed: () async { updating.value = true; diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index cc8485d5c..57971ada9 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -9,6 +9,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -63,7 +64,13 @@ class ArtistCard extends HookConsumerWidget { ), child: InkWell( onTap: () { - ServiceUtils.push(context, "/artist/${artist.id}"); + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: { + "id": artist.id!, + }, + ); }, borderRadius: radius, child: Padding( diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart index 3ac585df5..f48885344 100644 --- a/lib/components/connect/connect_device.dart +++ b/lib/components/connect/connect_device.dart @@ -3,6 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/connect/connect.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -22,7 +23,7 @@ class ConnectDeviceButton extends HookConsumerWidget { width: double.infinity, child: TextButton( onPressed: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, style: FilledButton.styleFrom( shape: RoundedRectangleBorder( @@ -59,7 +60,7 @@ class ConnectDeviceButton extends HookConsumerWidget { clipBehavior: Clip.hardEdge, child: InkWell( onTap: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, borderRadius: BorderRadius.circular(50), child: Ink( @@ -111,7 +112,7 @@ class ConnectDeviceButton extends HookConsumerWidget { foregroundColor: colorScheme.onPrimary, ), onPressed: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, ), ), diff --git a/lib/components/home/sections/feed.dart b/lib/components/home/sections/feed.dart index 793cd2c3b..f3f632cee 100644 --- a/lib/components/home/sections/feed.dart +++ b/lib/components/home/sections/feed.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/pages/home/feed/feed_section.dart'; import 'package:spotube/provider/spotify/views/home.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -41,8 +42,13 @@ class HomePageFeedSection extends HookConsumerWidget { child: TextButton.icon( label: const Text("Browse More"), icon: const Icon(SpotubeIcons.angleRight), - onPressed: () => - ServiceUtils.push(context, "/feeds/${section.uri}"), + onPressed: () => ServiceUtils.pushNamed( + context, + HomeFeedSectionPage.name, + pathParameters: { + "feedId": section.uri, + }, + ), ), ), ); diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index b883e2cc4..2b5757565 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -6,6 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { @@ -57,7 +60,9 @@ class FriendItem extends HookConsumerWidget { text: friend.track.name, recognizer: TapGestureRecognizer() ..onTap = () { - context.push("/track/${friend.track.id}"); + context.pushNamed(TrackPage.name, pathParameters: { + "id": friend.track.id, + }); }, ), const TextSpan(text: " • "), @@ -71,8 +76,12 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.artist.name}", recognizer: TapGestureRecognizer() ..onTap = () { - context.push( - "/artist/${friend.track.artist.id}", + context.pushNamed( + ArtistPage.name, + pathParameters: { + "id": friend.track.artist.id, + }, + extra: friend.track.artist, ); }, ), @@ -105,8 +114,11 @@ class FriendItem extends HookConsumerWidget { final album = await spotify.albums.get(friend.track.album.id); if (context.mounted) { - context.push( - "/album/${friend.track.album.id}", + context.pushNamed( + AlbumPage.name, + pathParameters: { + "id": friend.track.album.id, + }, extra: album, ); } diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index ac2644f0b..edab6db20 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -13,6 +13,8 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/genres/genre_playlists.dart'; +import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { @@ -50,7 +52,7 @@ class HomeGenresSection extends HookConsumerWidget { textDirection: TextDirection.rtl, child: TextButton.icon( onPressed: () { - context.push('/genres'); + context.pushNamed(GenrePage.name); }, icon: const Icon(SpotubeIcons.angleRight), label: Text( @@ -110,7 +112,13 @@ class HomeGenresSection extends HookConsumerWidget { return InkWell( onTap: () { - context.push('/genre/${category.id}', extra: category); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, borderRadius: BorderRadius.circular(8), child: Ink( diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 8aaf4b61d..72e13b26b 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -6,6 +6,7 @@ import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -58,9 +59,12 @@ class PlaylistCard extends HookConsumerWidget { isOwner: playlist.owner?.id == me.asData?.value.id && me.asData?.value.id != null, onTap: () { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/playlist/${playlist.id}", + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, extra: playlist, ); }, diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index a100ca8e9..00f45daea 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -16,6 +16,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; +import 'package:spotube/pages/profile/profile.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -26,13 +28,9 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class Sidebar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; final Widget child; const Sidebar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, required this.child, super.key, }); @@ -47,12 +45,9 @@ class Sidebar extends HookConsumerWidget { ); } - static void goToSettings(BuildContext context) { - GoRouter.of(context).go("/settings"); - } - @override Widget build(BuildContext context, WidgetRef ref) { + final routerState = GoRouterState.of(context); final mediaQuery = MediaQuery.of(context); final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; @@ -60,8 +55,17 @@ class Sidebar extends HookConsumerWidget { final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final sidebarTileList = useMemoized( + () => getSidebarTileList(context.l10n), + [context.l10n], + ); + + final selectedIndex = sidebarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); + final controller = useSidebarXController( - selectedIndex: selectedIndex ?? 0, + selectedIndex: selectedIndex, extended: mediaQuery.lgAndUp, ); @@ -73,29 +77,6 @@ class Sidebar extends HookConsumerWidget { Color.lerp(bg, Colors.black, 0.45)!, ); - final sidebarTileList = useMemoized( - () => getSidebarTileList(context.l10n), - [context.l10n], - ); - - useEffect(() { - if (controller.selectedIndex != selectedIndex && selectedIndex != null) { - controller.selectIndex(selectedIndex!); - } - return null; - }, [selectedIndex]); - - useEffect(() { - void listener() { - onSelectedIndexChanged(controller.selectedIndex); - } - - controller.addListener(listener); - return () { - controller.removeListener(listener); - }; - }, [controller]); - useEffect(() { if (!context.mounted) return; if (mediaQuery.lgAndUp && !controller.extended) { @@ -106,6 +87,13 @@ class Sidebar extends HookConsumerWidget { return null; }, [mediaQuery, controller]); + useEffect(() { + if (controller.selectedIndex != selectedIndex) { + controller.selectIndex(selectedIndex); + } + return null; + }, [selectedIndex]); + if (layoutMode == LayoutMode.compact || (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { return Scaffold(body: child); @@ -119,23 +107,28 @@ class Sidebar extends HookConsumerWidget { items: sidebarTileList.mapIndexed( (index, e) { return SidebarXItem( - iconWidget: Badge( - backgroundColor: theme.colorScheme.primary, - isLabelVisible: e.title == "Library" && downloadCount > 0, - label: Text( - downloadCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, + onTap: () { + context.goNamed(e.name); + }, + iconBuilder: (selected, hovered) { + return Badge( + backgroundColor: theme.colorScheme.primary, + isLabelVisible: e.title == "Library" && downloadCount > 0, + label: Text( + downloadCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), ), - ), - child: Icon( - e.icon, - color: selectedIndex == index - ? theme.colorScheme.primary - : null, - ), - ), + child: Icon( + e.icon, + color: selected || hovered + ? theme.colorScheme.primary + : null, + ), + ); + }, label: e.title, ); }, @@ -257,7 +250,7 @@ class SidebarFooter extends HookConsumerWidget { if (mediaQuery.mdAndDown) { return IconButton( icon: const Icon(SpotubeIcons.settings), - onPressed: () => Sidebar.goToSettings(context), + onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name), ); } @@ -278,7 +271,7 @@ class SidebarFooter extends HookConsumerWidget { Flexible( child: InkWell( onTap: () { - ServiceUtils.push(context, "/profile"); + ServiceUtils.pushNamed(context, ProfilePage.name); }, borderRadius: BorderRadius.circular(30), child: Row( @@ -310,7 +303,7 @@ class SidebarFooter extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.settings), onPressed: () { - Sidebar.goToSettings(context); + ServiceUtils.navigateNamed(context, SettingsPage.name); }, ), ], diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 489399e5d..0601a37a6 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -3,39 +3,35 @@ import 'dart:ui'; import 'package:curved_navigation_bar/curved_navigation_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/utils/service_utils.dart'; final navigationPanelHeight = StateProvider((ref) => 50); class SpotubeNavigationBar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; - const SpotubeNavigationBar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, super.key, }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final routerState = GoRouterState.of(context); + final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final mediaQuery = MediaQuery.of(context); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final insideSelectedIndex = useState(selectedIndex ?? 0); - final buttonColor = useBrightnessValue( theme.colorScheme.inversePrimary, theme.colorScheme.primary.withOpacity(0.2), @@ -46,12 +42,9 @@ class SpotubeNavigationBar extends HookConsumerWidget { final panelHeight = ref.watch(navigationPanelHeight); - useEffect(() { - if (selectedIndex != null) { - insideSelectedIndex.value = selectedIndex!; - } - return null; - }, [selectedIndex]); + final selectedIndex = navbarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); if (layoutMode == LayoutMode.extended || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || @@ -91,14 +84,9 @@ class SpotubeNavigationBar extends HookConsumerWidget { }); }, ).toList(), - index: insideSelectedIndex.value, + index: selectedIndex, onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); + ServiceUtils.navigateNamed(context, navbarTileList[i].name); }, ), ), diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index 2f06b0b6f..5ced6bb6d 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -25,7 +26,7 @@ class AnonymousFallback extends ConsumerWidget { const SizedBox(height: 10), FilledButton( child: Text(context.l10n.login_with_spotify), - onPressed: () => ServiceUtils.push(context, "/settings"), + onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name), ) ], ), diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart index af8b186af..5236a0611 100644 --- a/lib/components/shared/links/artist_link.dart +++ b/lib/components/shared/links/artist_link.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/utils/service_utils.dart'; class ArtistLink extends StatelessWidget { @@ -40,9 +41,12 @@ class ArtistLink extends StatelessWidget { if (onRouteChange != null) { onRouteChange?.call("/artist/${artist.value.id}"); } else { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/artist/${artist.value.id}", + ArtistPage.name, + pathParameters: { + "id": artist.value.id!, + }, ); } }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 832862c01..01539ece6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -320,5 +320,6 @@ "select": "Select", "connect_client_alert": "You're being controlled by {client}", "this_device": "This Device", - "remote": "Remote" + "remote": "Remote", + "stats": "Stats" } \ No newline at end of file diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 8461b1f14..aea890a0d 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -8,6 +8,8 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class AlbumPage extends HookConsumerWidget { + static const name = "album"; + final AlbumSimple album; const AlbumPage({ super.key, diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index c3b046910..498909491 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -15,6 +15,8 @@ import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPage extends HookConsumerWidget { + static const name = "artist"; + final String artistId; final logger = getLogger(ArtistPage); ArtistPage(this.artistId, {super.key}); diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index cbdb446e7..c7cb493a6 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -5,10 +5,13 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/local_devices.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; class ConnectPage extends HookConsumerWidget { + static const name = "connect"; + const ConnectPage({super.key}); @override @@ -65,9 +68,9 @@ class ConnectPage extends HookConsumerWidget { selected: selected, onTap: () { if (selected) { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/connect/control", + ConnectControlPage.name, ); } else { connectClientsNotifier.resolveService(device); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index b78f0ed32..639a9dd97 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -13,6 +13,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; @@ -46,6 +47,8 @@ class RemotePlayerQueue extends ConsumerWidget { } class ConnectControlPage extends HookConsumerWidget { + static const name = "connect_control"; + const ConnectControlPage({super.key}); @override @@ -125,9 +128,13 @@ class ConnectControlPage extends HookConsumerWidget { playlist.activeTrack?.name ?? "", style: textTheme.titleLarge!, onTap: () { - ServiceUtils.push( + if (playlist.activeTrack == null) return; + ServiceUtils.pushNamed( context, - "/track/${playlist.activeTrack?.id}", + TrackPage.name, + pathParameters: { + "id": playlist.activeTrack!.id!, + }, ); }, ), diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index 9c0610911..9c9bdddba 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -7,8 +7,10 @@ import 'package:spotube/components/desktop_login/login_form.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/mobile_login/mobile_login.dart'; class DesktopLoginPage extends HookConsumerWidget { + static const name = WebViewLogin.name; const DesktopLoginPage({super.key}); @override diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index 83b04af18..dbec28dc9 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -8,10 +8,12 @@ import 'package:spotube/components/desktop_login/login_form.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { + static const name = "login_tutorial"; const LoginTutorial({super.key}); @override @@ -53,7 +55,7 @@ class LoginTutorial extends ConsumerWidget { overrideDone: FilledButton( onPressed: authenticationNotifier.isLoggedIn ? () { - ServiceUtils.push(context, "/"); + ServiceUtils.pushNamed(context, HomePage.name); } : null, child: Center(child: Text(context.l10n.done)), diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index cbab03b9c..fa2054038 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -12,6 +12,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/themes/theme.dart'; class GettingStarting extends HookConsumerWidget { + static const name = "getting_started"; + const GettingStarting({super.key}); @override diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 468234257..ec29de244 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -120,7 +121,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.push("/login"); + context.pushNamed(WebViewLogin.name); } }, ), diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index c945251c2..d31b8256d 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -10,6 +10,8 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; class HomeFeedSectionPage extends HookConsumerWidget { + static const name = "home_feed_section"; + final String sectionUri; const HomeFeedSectionPage({super.key, required this.sectionUri}); diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index ca4e7238d..531ea889a 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -15,6 +15,8 @@ import 'package:collection/collection.dart'; import 'package:spotube/utils/platform.dart'; class GenrePlaylistsPage extends HookConsumerWidget { + static const name = "genre_playlists"; + final Category category; const GenrePlaylistsPage({super.key, required this.category}); diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 291ce737b..bb84fc168 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -9,9 +9,11 @@ import 'package:spotube/collections/gradients.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class GenrePage extends HookConsumerWidget { + static const name = "genre"; const GenrePage({super.key}); @override @@ -47,7 +49,13 @@ class GenrePage extends HookConsumerWidget { return InkWell( borderRadius: BorderRadius.circular(8), onTap: () { - context.push("/genre/${category.id}", extra: category); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, child: Ink( padding: const EdgeInsets.all(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 1872f0309..dd4c6f730 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/feed.dart'; @@ -15,12 +16,15 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/profile/profile.dart'; +import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class HomePage extends HookConsumerWidget { + static const name = "home"; const HomePage({super.key}); @override @@ -63,11 +67,18 @@ class HomePage extends HookConsumerWidget { padding: EdgeInsets.zero, ), onPressed: () { - ServiceUtils.push(context, "/profile"); + ServiceUtils.pushNamed(context, ProfilePage.name); }, ); }), const Gap(10), + IconButton( + icon: const Icon(SpotubeIcons.search), + onPressed: () { + ServiceUtils.pushNamed(context, SearchPage.name); + }, + ), + const Gap(10), ], ) else if (kIsMacOS) diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index b6aeef2ec..2baeaad94 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -10,6 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class LastFMLoginPage extends HookConsumerWidget { + static const name = "lastfm_login"; const LastFMLoginPage({super.key}); @override diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index ccdb6a352..89b223418 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -12,6 +12,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { + static const name = "library"; + const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 5044090d2..648e85284 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -24,6 +24,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); class PlaylistGeneratorPage extends HookConsumerWidget { + static const name = "playlist_generator"; + const PlaylistGeneratorPage({super.key}); @override diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 01b73267f..5ee7ab368 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -10,10 +10,13 @@ import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { + static const name = "playlist_generate_result"; + final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ @@ -123,8 +126,11 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ); if (playlist != null) { - router.go( - '/playlist/${playlist.id}', + router.goNamed( + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, extra: playlist, ); } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index ca13864a3..850eccfa0 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -23,6 +23,8 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { + static const name = "lyrics"; + final bool isModal; const LyricsPage({super.key, this.isModal = false}); diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 6d6f75a92..996e190df 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -20,6 +20,8 @@ import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; class MiniLyricsPage extends HookConsumerWidget { + static const name = "mini_lyrics"; + final Size prevSize; const MiniLyricsPage({super.key, required this.prevSize}); diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 0a1ff8b35..1f2df95a0 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -7,6 +7,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { + static const name = "login"; const WebViewLogin({super.key}); @override diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 8477a2152..44e99aeae 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -3,9 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LikedPlaylistPage extends HookConsumerWidget { + static const name = PlaylistPage.name; + final PlaylistSimple playlist; const LikedPlaylistPage({ super.key, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 879090613..8fb224581 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -10,6 +10,8 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistPage extends HookConsumerWidget { + static const name = "playlist"; + final PlaylistSimple playlist; const PlaylistPage({ super.key, diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index 52b69835d..d77ae98d8 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -14,6 +14,8 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; class ProfilePage extends HookConsumerWidget { + static const name = "profile"; + const ProfilePage({super.key}); @override diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index f3ed6571e..423312a96 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -22,13 +22,6 @@ import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; -const rootPaths = { - "/": 0, - "/search": 1, - "/library": 2, - "/lyrics": 3, -}; - class RootApp extends HookConsumerWidget { final Widget child; const RootApp({ @@ -42,7 +35,6 @@ class RootApp extends HookConsumerWidget { final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); - final location = GoRouterState.of(context).matchedLocation; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -178,32 +170,17 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); - void onSelectIndexChanged(int d) { - final invertedRouteMap = - rootPaths.map((key, value) => MapEntry(value, key)); - - if (context.mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).go(invertedRouteMap[d]!); - }); - } - } - // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { - if (rootPaths[location] != 0) { - onSelectIndexChanged(0); - return false; - } + // if (rootPaths[location] != 0) { + // onSelectIndexChanged(0); + // return false; + // } return true; }, child: Scaffold( - body: Sidebar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - child: child, - ), + body: Sidebar(child: child), extendBody: true, drawerScrimColor: Colors.transparent, endDrawer: kIsDesktop @@ -237,10 +214,7 @@ class RootApp extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ BottomPlayer(), - SpotubeNavigationBar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - ), + const SpotubeNavigationBar(), ], ), ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index e9ada2365..d5374786d 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -26,6 +27,8 @@ import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; class SearchPage extends HookConsumerWidget { + static const name = "search"; + const SearchPage({super.key}); @override @@ -85,99 +88,117 @@ class SearchPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar() : null, + appBar: kIsDesktop && !kIsMacOS + ? const PageWindowTitleBar(automaticallyImplyLeading: true) + : null, body: !authenticationNotifier.isLoggedIn ? const AnonymousFallback() : Column( children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - color: theme.scaffoldBackgroundColor, - child: SearchAnchor( - searchController: controller, - viewBuilder: (_) => HookBuilder(builder: (context) { - final searchController = useListenable(controller); - final update = useForceUpdate(); - final suggestions = searchController.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - searchController.text.toLowerCase(), - ) > - 50, - ) - .toList(); - - return ListView.builder( - itemCount: suggestions.length, - itemBuilder: (context, index) { - final suggestion = suggestions[index]; - - return ListTile( - leading: const Icon(SpotubeIcons.history), - title: Text(suggestion), - trailing: IconButton( - icon: const Icon(SpotubeIcons.trash), - onPressed: () { + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if ((kIsMobile || kIsMacOS) && context.canPop()) + const BackButton() + else + const Gap(20), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + right: 20, + top: 20, + bottom: 20, + ), + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = + useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text + .toLowerCase(), + ) > + 50, + ) + .toList(); + + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { + KVStoreService.setRecentSearches( + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), + ); + update(); + }, + ), + onTap: () { + controller.closeView(suggestion); + ref + .read( + searchTermStateProvider.notifier) + .state = suggestion; + }, + ); + }, + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); + Timer( + const Duration(milliseconds: 50), + () { + ref + .read(searchTermStateProvider.notifier) + .state = value; + if (value.trim().isEmpty) { + return; + } KVStoreService.setRecentSearches( - KVStoreService.recentSearches - .where((s) => s != suggestion) - .toList(), + { + value, + ...KVStoreService.recentSearches, + }.toList(), ); - update(); }, - ), - onTap: () { - controller.closeView(suggestion); - ref - .read(searchTermStateProvider.notifier) - .state = suggestion; - }, - ); - }, - ); - }), - suggestionsBuilder: (context, controller) { - return []; - }, - viewOnSubmitted: (value) async { - controller.closeView(value); - Timer( - const Duration(milliseconds: 50), - () { - ref.read(searchTermStateProvider.notifier).state = - value; - if (value.trim().isEmpty) { - return; - } - KVStoreService.setRecentSearches( - { - value, - ...KVStoreService.recentSearches, - }.toList(), - ); - }, - ); - }, - builder: (context, controller) { - return SearchBar( - autoFocus: queries.none((s) => - s.asData?.value != null && !s.hasError) && - !kIsMobile, - controller: controller, - leading: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - onTap: controller.openView, - onChanged: (_) => controller.openView(), - ); - }, - ), + ); + }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none((s) => + s.asData?.value != null && + !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); + }, + ), + ), + ), + ], ), Expanded( child: AnimatedSwitcher( diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 21b8117b1..da8eaaa08 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -16,6 +16,8 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { + static const name = "about"; + const AboutSpotube({super.key}); @override diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 9dd85c507..6eccab073 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; class BlackListPage extends HookConsumerWidget { + static const name = "blacklist"; + const BlackListPage({super.key}); @override diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index b07ebbb1a..8b6f7312d 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; class LogsPage extends HookWidget { + static const name = "logs"; + const LogsPage({super.key}); List<({DateTime? date, String body})> parseLogs(String raw) { diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index d293518d8..2949a0d3f 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -16,6 +16,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/utils/platform.dart'; class SettingsPage extends HookConsumerWidget { + static const name = "settings"; + const SettingsPage({super.key}); @override diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart new file mode 100644 index 000000000..13fe5771e --- /dev/null +++ b/lib/pages/stats/stats.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class StatsPage extends HookConsumerWidget { + static const name = "stats"; + + const StatsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + return Container(); + } +} diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index fc90d19a9..2109fe6ef 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -21,6 +21,8 @@ import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { + static const name = "track"; + final String trackId; const TrackPage({ super.key, diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 88c528966..2d29d93e2 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -262,6 +262,22 @@ abstract class ServiceUtils { GoRouter.of(context).go(location, extra: extra); } + static void navigateNamed( + BuildContext context, + String name, { + Object? extra, + Map? pathParameters, + Map? queryParameters, + }) { + if (GoRouterState.of(context).matchedLocation == name) return; + GoRouter.of(context).goNamed( + name, + pathParameters: pathParameters ?? const {}, + queryParameters: queryParameters ?? const {}, + extra: extra, + ); + } + static void push(BuildContext context, String location, {Object? extra}) { final router = GoRouter.of(context); final routerState = GoRouterState.of(context); @@ -273,6 +289,36 @@ abstract class ServiceUtils { router.push(location, extra: extra); } + static void pushNamed( + BuildContext context, + String name, { + Object? extra, + Map pathParameters = const {}, + Map queryParameters = const {}, + }) { + final router = GoRouter.of(context); + final routerState = GoRouterState.of(context); + final routerStack = router.routerDelegate.currentConfiguration.matches + .map((e) => e.matchedLocation); + + final nameLocation = routerState.namedLocation( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + ); + + if (routerState.matchedLocation == nameLocation || + routerStack.contains(nameLocation)) { + return; + } + router.pushNamed( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + extra: extra, + ); + } + static DateTime parseSpotifyAlbumDate(AlbumSimple? album) { if (album == null || album.releaseDate == null) { return DateTime.parse("1975-01-01"); diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfeeb..eedce4f1b 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,89 @@ -{} \ No newline at end of file +{ + "ar": [ + "stats" + ], + + "bn": [ + "stats" + ], + + "ca": [ + "stats" + ], + + "cs": [ + "stats" + ], + + "de": [ + "stats" + ], + + "es": [ + "stats" + ], + + "fa": [ + "stats" + ], + + "fr": [ + "stats" + ], + + "hi": [ + "stats" + ], + + "it": [ + "stats" + ], + + "ja": [ + "stats" + ], + + "ko": [ + "stats" + ], + + "ne": [ + "stats" + ], + + "nl": [ + "stats" + ], + + "pl": [ + "stats" + ], + + "pt": [ + "stats" + ], + + "ru": [ + "stats" + ], + + "th": [ + "stats" + ], + + "tr": [ + "stats" + ], + + "uk": [ + "stats" + ], + + "vi": [ + "stats" + ], + + "zh": [ + "stats" + ] +} From c9bd42c84733d04b074cda9027fad027b996d41f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 29 Apr 2024 14:50:05 +0600 Subject: [PATCH 04/14] feat: add stats summary and top tracks/artists/albums --- lib/collections/formatters.dart | 3 + .../shared/themed_button_tab_bar.dart | 4 +- lib/components/stats/summary/summary.dart | 74 +++++++++++++++++++ .../stats/summary/summary_card.dart | 67 +++++++++++++++++ lib/components/stats/top/albums.dart | 67 +++++++++++++++++ lib/components/stats/top/artists.dart | 48 ++++++++++++ lib/components/stats/top/top.dart | 57 ++++++++++++++ lib/components/stats/top/tracks.dart | 56 ++++++++++++++ lib/pages/root/root_app.dart | 10 ++- lib/pages/stats/stats.dart | 24 +++++- lib/provider/history/history.dart | 49 ++++++++++-- lib/provider/history/state.dart | 2 +- lib/provider/history/state.freezed.dart | 34 ++++----- lib/provider/history/state.g.dart | 3 +- lib/provider/history/summary.dart | 53 +++++++++++++ lib/provider/history/top.dart | 47 ++++++++++++ .../proxy_playlist/player_listeners.dart | 2 +- .../proxy_playlist/proxy_playlist.dart | 1 - 18 files changed, 568 insertions(+), 33 deletions(-) create mode 100644 lib/collections/formatters.dart create mode 100644 lib/components/stats/summary/summary.dart create mode 100644 lib/components/stats/summary/summary_card.dart create mode 100644 lib/components/stats/top/albums.dart create mode 100644 lib/components/stats/top/artists.dart create mode 100644 lib/components/stats/top/top.dart create mode 100644 lib/components/stats/top/tracks.dart create mode 100644 lib/provider/history/summary.dart create mode 100644 lib/provider/history/top.dart diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart new file mode 100644 index 000000000..2f823f56c --- /dev/null +++ b/lib/collections/formatters.dart @@ -0,0 +1,3 @@ +import 'package:intl/intl.dart'; + +final compactNumberFormatter = NumberFormat.compact(); diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index 017f04aa8..b21ca9924 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; - const ThemedButtonsTabBar({super.key, required this.tabs}); + final TabController? controller; + const ThemedButtonsTabBar({super.key, required this.tabs, this.controller}); @override Widget build(BuildContext context) { @@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { bottom: 8, ), child: ButtonsTabBar( + controller: controller, radius: 100, decoration: BoxDecoration( color: bgColor, diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart new file mode 100644 index 000000000..9d735a57a --- /dev/null +++ b/lib/components/stats/summary/summary.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/stats/summary/summary_card.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/provider/history/summary.dart'; + +class StatsPageSummarySection extends HookConsumerWidget { + const StatsPageSummarySection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final summary = ref.watch(playbackHistorySummaryProvider); + + return SliverPadding( + padding: const EdgeInsets.all(10), + sliver: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constrains.isXs + ? 2 + : constrains.smAndDown + ? 3 + : constrains.mdAndDown + ? 4 + : 5, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: constrains.isXs ? 1.3 : 1.5, + ), + delegate: SliverChildListDelegate([ + switch (summary.duration) { + >= const Duration(hours: 1) => SummaryCard( + title: summary.duration.inHours.toDouble(), + unit: "hours", + description: 'Listened to music', + color: Colors.green, + ), + _ => SummaryCard( + title: summary.duration.inMinutes.toDouble(), + unit: "minutes", + description: 'Listened to music', + color: Colors.green, + ), + }, + SummaryCard( + title: summary.tracks.toDouble(), + unit: "songs", + description: 'Streamed overall', + color: Colors.lightBlue, + ), + SummaryCard( + title: summary.artists.toDouble(), + unit: "artist's", + description: 'Music reached you', + color: Colors.yellow, + ), + SummaryCard( + title: summary.albums.toDouble(), + unit: "full albums", + description: 'Got your love', + color: Colors.pink, + ), + SummaryCard( + title: summary.playlists.toDouble(), + unit: "playlists", + description: 'Were on repeat', + color: Colors.teal, + ), + ]), + ); + }), + ); + } +} diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart new file mode 100644 index 000000000..2601f9dd3 --- /dev/null +++ b/lib/components/stats/summary/summary_card.dart @@ -0,0 +1,67 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/formatters.dart'; + +class SummaryCard extends StatelessWidget { + final double title; + final String unit; + final String description; + + final MaterialColor color; + + const SummaryCard({ + super.key, + required this.title, + required this.unit, + required this.description, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme, :brightness) = Theme.of(context); + + return Card( + color: brightness == Brightness.dark ? color.shade100 : color.shade50, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AutoSizeText.rich( + TextSpan( + children: [ + TextSpan( + text: compactNumberFormatter.format(title), + style: textTheme.headlineLarge?.copyWith( + color: color.shade900, + ), + ), + TextSpan( + text: " $unit", + style: textTheme.titleMedium?.copyWith( + color: color.shade900, + ), + ), + ], + ), + maxLines: 1, + ), + const Gap(5), + AutoSizeText( + description, + maxLines: 1, + minFontSize: 9, + style: textTheme.labelMedium!.copyWith( + color: color.shade900, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart new file mode 100644 index 000000000..38d97dc3f --- /dev/null +++ b/lib/components/stats/top/albums.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class TopAlbums extends HookConsumerWidget { + const TopAlbums({super.key}); + + @override + Widget build(BuildContext context, ref) { + final albums = + ref.watch(playbackHistoryTopProvider.select((value) => value.albums)); + + return SliverList.builder( + itemCount: albums.length, + itemBuilder: (context, index) { + final album = albums[index]; + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (album.album.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(album.album.name!), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${album.album.albumType?.formatted} • "), + Flexible( + child: ArtistLink( + artists: album.album.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + trailing: Text( + "${compactNumberFormatter.format(album.count)} plays", + ), + onTap: () { + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.album.id!, + }, + extra: album.album, + ); + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart new file mode 100644 index 000000000..8b4941b59 --- /dev/null +++ b/lib/components/stats/top/artists.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class TopArtists extends HookConsumerWidget { + const TopArtists({super.key}); + + @override + Widget build(BuildContext context, ref) { + final artists = + ref.watch(playbackHistoryTopProvider.select((value) => value.artists)); + + return SliverList.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return ListTile( + title: Text(artist.artist.name!), + horizontalTitleGap: 8, + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (artist.artist.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + trailing: Text( + "${compactNumberFormatter.format(artist.count)} plays", + ), + onTap: () { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: { + "id": artist.artist.id!, + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/top/top.dart b/lib/components/stats/top/top.dart new file mode 100644 index 000000000..bb20ed1d2 --- /dev/null +++ b/lib/components/stats/top/top.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/themed_button_tab_bar.dart'; +import 'package:spotube/components/stats/top/albums.dart'; +import 'package:spotube/components/stats/top/artists.dart'; +import 'package:spotube/components/stats/top/tracks.dart'; + +class StatsPageTopSection extends HookConsumerWidget { + const StatsPageTopSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final tabController = useTabController(initialLength: 3); + + return SliverMainAxisGroup( + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: ThemedButtonsTabBar( + controller: tabController, + tabs: const [ + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Tracks"), + ), + ), + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Artists"), + ), + ), + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Albums"), + ), + ), + ], + ), + ), + ListenableBuilder( + listenable: tabController, + builder: (context, _) { + return switch (tabController.index) { + 1 => const TopArtists(), + 2 => const TopAlbums(), + _ => const TopTracks(), + }; + }, + ), + ], + ); + } +} diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart new file mode 100644 index 000000000..3dc888928 --- /dev/null +++ b/lib/components/stats/top/tracks.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class TopTracks extends HookConsumerWidget { + const TopTracks({super.key}); + + @override + Widget build(BuildContext context, ref) { + final tracks = + ref.watch(playbackHistoryTopProvider.select((value) => value.tracks)); + + return SliverList.builder( + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (track.track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(track.track.name!), + subtitle: ArtistLink( + artists: track.track.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + trailing: Text( + "${compactNumberFormatter.format(track.count)} plays", + ), + onTap: () { + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.track.id!, + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 423312a96..c1b148fdf 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -173,10 +174,11 @@ class RootApp extends HookConsumerWidget { // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { - // if (rootPaths[location] != 0) { - // onSelectIndexChanged(0); - // return false; - // } + final routerState = GoRouterState.of(context); + if (routerState.matchedLocation != "/") { + context.goNamed(HomePage.name); + return false; + } return true; }, child: Scaffold( diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart index 13fe5771e..95493591f 100644 --- a/lib/pages/stats/stats.dart +++ b/lib/pages/stats/stats.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/summary/summary.dart'; +import 'package:spotube/components/stats/top/top.dart'; +import 'package:spotube/utils/platform.dart'; class StatsPage extends HookConsumerWidget { static const name = "stats"; @@ -8,6 +13,23 @@ class StatsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - return Container(); + return SafeArea( + bottom: false, + child: Scaffold( + appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(), + body: CustomScrollView( + slivers: [ + if (kIsMacOS) const SliverGap(20), + const StatsPageSummarySection(), + const StatsPageTopSection(), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), + ) + ], + ), + ), + ); } } diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart index 9983cfae5..4436626d5 100644 --- a/lib/provider/history/history.dart +++ b/lib/provider/history/history.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; class PlaybackHistoryState { @@ -36,9 +38,12 @@ class PlaybackHistoryState { class PlaybackHistoryNotifier extends PersistedStateNotifier { - PlaybackHistoryNotifier() + final Ref ref; + PlaybackHistoryNotifier(this.ref) : super(const PlaybackHistoryState(), "playback_history"); + SpotifyApi get spotify => ref.read(spotifyProvider); + @override FutureOr fromJson(Map json) => PlaybackHistoryState.fromJson(json); @@ -69,12 +74,18 @@ class PlaybackHistoryNotifier ); } - void addTracks(List tracks) { + void addTrack(Track track) async { + // For some reason Track's artists images are `null` + // so we need to fetch them from the API + final artists = + await spotify.artists.list(track.artists!.map((e) => e.id!).toList()); + + track.artists = artists.toList(); + state = state.copyWith( items: [ ...state.items, - for (final track in tracks) - PlaybackHistoryItem.track(date: DateTime.now(), track: track), + PlaybackHistoryItem.track(date: DateTime.now(), track: track), ], ); } @@ -86,5 +97,33 @@ class PlaybackHistoryNotifier final playbackHistoryProvider = StateNotifierProvider( - (ref) => PlaybackHistoryNotifier(), + (ref) => PlaybackHistoryNotifier(ref), ); + +typedef PlaybackHistoryGrouped = ({ + List tracks, + List albums, + List playlists, +}); + +final playbackHistoryGroupedProvider = Provider((ref) { + final history = ref.watch(playbackHistoryProvider); + final tracks = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final albums = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final playlists = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + + return ( + tracks: tracks, + albums: albums, + playlists: playlists, + ); +}); diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart index ae7dba950..ca2714acf 100644 --- a/lib/provider/history/state.dart +++ b/lib/provider/history/state.dart @@ -18,7 +18,7 @@ class PlaybackHistoryItem with _$PlaybackHistoryItem { factory PlaybackHistoryItem.track({ required DateTime date, - required TrackSimple track, + required Track track, }) = PlaybackHistoryTrack; factory PlaybackHistoryItem.fromJson(Map json) => diff --git a/lib/provider/history/state.freezed.dart b/lib/provider/history/state.freezed.dart index 634bf496e..e2ee94210 100644 --- a/lib/provider/history/state.freezed.dart +++ b/lib/provider/history/state.freezed.dart @@ -36,21 +36,21 @@ mixin _$PlaybackHistoryItem { TResult when({ required TResult Function(DateTime date, PlaylistSimple playlist) playlist, required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, TrackSimple track) track, + required TResult Function(DateTime date, Track track) track, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull({ TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, TrackSimple track)? track, + TResult? Function(DateTime date, Track track)? track, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ TResult Function(DateTime date, PlaylistSimple playlist)? playlist, TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, TrackSimple track)? track, + TResult Function(DateTime date, Track track)? track, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -205,7 +205,7 @@ class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { TResult when({ required TResult Function(DateTime date, PlaylistSimple playlist) playlist, required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, TrackSimple track) track, + required TResult Function(DateTime date, Track track) track, }) { return playlist(date, this.playlist); } @@ -215,7 +215,7 @@ class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { TResult? whenOrNull({ TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, TrackSimple track)? track, + TResult? Function(DateTime date, Track track)? track, }) { return playlist?.call(date, this.playlist); } @@ -225,7 +225,7 @@ class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { TResult maybeWhen({ TResult Function(DateTime date, PlaylistSimple playlist)? playlist, TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, TrackSimple track)? track, + TResult Function(DateTime date, Track track)? track, required TResult orElse(), }) { if (playlist != null) { @@ -380,7 +380,7 @@ class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { TResult when({ required TResult Function(DateTime date, PlaylistSimple playlist) playlist, required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, TrackSimple track) track, + required TResult Function(DateTime date, Track track) track, }) { return album(date, this.album); } @@ -390,7 +390,7 @@ class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { TResult? whenOrNull({ TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, TrackSimple track)? track, + TResult? Function(DateTime date, Track track)? track, }) { return album?.call(date, this.album); } @@ -400,7 +400,7 @@ class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { TResult maybeWhen({ TResult Function(DateTime date, PlaylistSimple playlist)? playlist, TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, TrackSimple track)? track, + TResult Function(DateTime date, Track track)? track, required TResult orElse(), }) { if (album != null) { @@ -476,7 +476,7 @@ abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; @override @useResult - $Res call({DateTime date, TrackSimple track}); + $Res call({DateTime date, Track track}); } /// @nodoc @@ -501,7 +501,7 @@ class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> track: null == track ? _value.track : track // ignore: cast_nullable_to_non_nullable - as TrackSimple, + as Track, )); } } @@ -519,7 +519,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { @override final DateTime date; @override - final TrackSimple track; + final Track track; @JsonKey(name: 'runtimeType') final String $type; @@ -555,7 +555,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { TResult when({ required TResult Function(DateTime date, PlaylistSimple playlist) playlist, required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, TrackSimple track) track, + required TResult Function(DateTime date, Track track) track, }) { return track(date, this.track); } @@ -565,7 +565,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { TResult? whenOrNull({ TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, TrackSimple track)? track, + TResult? Function(DateTime date, Track track)? track, }) { return track?.call(date, this.track); } @@ -575,7 +575,7 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { TResult maybeWhen({ TResult Function(DateTime date, PlaylistSimple playlist)? playlist, TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, TrackSimple track)? track, + TResult Function(DateTime date, Track track)? track, required TResult orElse(), }) { if (track != null) { @@ -629,14 +629,14 @@ class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { factory PlaybackHistoryTrack( {required final DateTime date, - required final TrackSimple track}) = _$PlaybackHistoryTrackImpl; + required final Track track}) = _$PlaybackHistoryTrackImpl; factory PlaybackHistoryTrack.fromJson(Map json) = _$PlaybackHistoryTrackImpl.fromJson; @override DateTime get date; - TrackSimple get track; + Track get track; @override @JsonKey(ignore: true) _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart index 57d2ece7d..dfd01c2cd 100644 --- a/lib/provider/history/state.g.dart +++ b/lib/provider/history/state.g.dart @@ -42,8 +42,7 @@ Map _$$PlaybackHistoryAlbumImplToJson( _$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => _$PlaybackHistoryTrackImpl( date: DateTime.parse(json['date'] as String), - track: - TrackSimple.fromJson(Map.from(json['track'] as Map)), + track: Track.fromJson(Map.from(json['track'] as Map)), $type: json['runtimeType'] as String?, ); diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart new file mode 100644 index 000000000..1cc316a72 --- /dev/null +++ b/lib/provider/history/summary.dart @@ -0,0 +1,53 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/history/history.dart'; + +final playbackHistorySummaryProvider = Provider((ref) { + final (:tracks, :albums, :playlists) = + ref.watch(playbackHistoryGroupedProvider); + + final totalDurationListened = tracks.fold( + Duration.zero, + (previousValue, element) => previousValue + element.track.duration!, + ); + + final totalTracksListened = tracks + .whereIndexed( + (i, track) => + i == tracks.lastIndexWhere((e) => e.track.id == track.track.id), + ) + .length; + + final artists = + tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList(); + + final totalArtistsListened = artists + .whereIndexed( + (i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id), + ) + .length; + + final totalAlbumsListened = albums + .whereIndexed( + (i, album) => + i == albums.lastIndexWhere((e) => e.album.id == album.album.id), + ) + .length; + + final totalPlaylistsListened = playlists + .whereIndexed( + (i, playlist) => + i == + playlists + .lastIndexWhere((e) => e.playlist.id == playlist.playlist.id), + ) + .length; + + return ( + duration: totalDurationListened, + tracks: totalTracksListened, + artists: totalArtistsListened, + albums: totalAlbumsListened, + playlists: totalPlaylistsListened, + ); +}); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart new file mode 100644 index 000000000..f27a82da5 --- /dev/null +++ b/lib/provider/history/top.dart @@ -0,0 +1,47 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/history/history.dart'; + +final playbackHistoryTopProvider = Provider((ref) { + final (:tracks, :albums, playlists: _) = + ref.watch(playbackHistoryGroupedProvider); + + final tracksWithCount = groupBy(tracks, (track) => track.track.id!) + .entries + .map((entry) { + return (count: entry.value.length, track: entry.value.first.track); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final albumsWithTrackAlbums = [ + for (final historicAlbum in albums) historicAlbum.album, + for (final track in tracks) track.track.album! + ]; + + final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!) + .entries + .map((entry) { + return (count: entry.value.length, album: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final artists = + tracks.map((track) => track.track.artists).expand((e) => e ?? []); + + final artistsWithCount = groupBy(artists, (artist) => artist.id!) + .entries + .map((entry) { + return (count: entry.value.length, artist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + return ( + tracks: tracksWithCount, + albums: albumsWithCount, + artists: artistsWithCount + ); +}); diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index d3faf9ced..e10e7253e 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -83,7 +83,7 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { } scrobbler.scrobble(playlist.activeTrack!); - history.addTracks([playlist.activeTrack!]); + history.addTrack(playlist.activeTrack!); lastScrobbled = uid; } catch (e, stack) { Catcher2.reportCheckedError(e, stack); diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index f70301ff4..97dfaa549 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,6 +1,5 @@ import 'package:collection/collection.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; From 1500fff9ce5c332758a392384b2b03b800d633ee Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 4 May 2024 23:00:08 +0600 Subject: [PATCH 05/14] feat: add top date based filtering --- lib/components/stats/summary/summary.dart | 20 +++------ lib/components/stats/top/albums.dart | 6 +-- lib/components/stats/top/artists.dart | 5 ++- lib/components/stats/top/top.dart | 49 +++++++++++++++++++++++ lib/components/stats/top/tracks.dart | 5 ++- lib/provider/history/state.dart | 9 +++++ lib/provider/history/top.dart | 37 +++++++++++++++-- 7 files changed, 106 insertions(+), 25 deletions(-) diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart index 9d735a57a..41b4c8728 100644 --- a/lib/components/stats/summary/summary.dart +++ b/lib/components/stats/summary/summary.dart @@ -28,20 +28,12 @@ class StatsPageSummarySection extends HookConsumerWidget { 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.duration.inMinutes.toDouble(), + unit: "minutes", + description: 'Listened to music', + color: Colors.green, + ), SummaryCard( title: summary.tracks.toDouble(), unit: "songs", diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart index 38d97dc3f..8d8d2b5cd 100644 --- a/lib/components/stats/top/albums.dart +++ b/lib/components/stats/top/albums.dart @@ -1,5 +1,4 @@ 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'; @@ -15,8 +14,9 @@ class TopAlbums extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final albums = - ref.watch(playbackHistoryTopProvider.select((value) => value.albums)); + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final albums = ref.watch(playbackHistoryTopProvider(historyDuration) + .select((value) => value.albums)); return SliverList.builder( itemCount: albums.length, diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart index 8b4941b59..05b5b0820 100644 --- a/lib/components/stats/top/artists.dart +++ b/lib/components/stats/top/artists.dart @@ -12,8 +12,9 @@ class TopArtists extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final artists = - ref.watch(playbackHistoryTopProvider.select((value) => value.artists)); + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final artists = ref.watch(playbackHistoryTopProvider(historyDuration) + .select((value) => value.artists)); return SliverList.builder( itemCount: artists.length, diff --git a/lib/components/stats/top/top.dart b/lib/components/stats/top/top.dart index bb20ed1d2..df1275e83 100644 --- a/lib/components/stats/top/top.dart +++ b/lib/components/stats/top/top.dart @@ -5,6 +5,8 @@ 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'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; class StatsPageTopSection extends HookConsumerWidget { const StatsPageTopSection({super.key}); @@ -12,6 +14,9 @@ class StatsPageTopSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final tabController = useTabController(initialLength: 3); + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final historyDurationNotifier = + ref.watch(playbackHistoryTopDurationProvider.notifier); return SliverMainAxisGroup( slivers: [ @@ -41,6 +46,50 @@ class StatsPageTopSection extends HookConsumerWidget { ], ), ), + SliverToBoxAdapter( + child: Align( + alignment: Alignment.centerRight, + child: DropdownButton( + style: Theme.of(context).textTheme.bodySmall!, + isDense: true, + padding: const EdgeInsets.all(4), + borderRadius: BorderRadius.circular(4), + underline: const SizedBox(), + value: historyDuration, + onChanged: (value) { + if (value == null) return; + historyDurationNotifier.update((_) => value); + }, + icon: const Icon(Icons.arrow_drop_down), + items: const [ + DropdownMenuItem( + value: HistoryDuration.days7, + child: Text("This week"), + ), + DropdownMenuItem( + value: HistoryDuration.days30, + child: Text("This month"), + ), + DropdownMenuItem( + value: HistoryDuration.months6, + child: Text("Last 6 months"), + ), + DropdownMenuItem( + value: HistoryDuration.year, + child: Text("This year"), + ), + DropdownMenuItem( + value: HistoryDuration.years2, + child: Text("Last 2 years"), + ), + DropdownMenuItem( + value: HistoryDuration.allTime, + child: Text("All time"), + ), + ], + ), + ), + ), ListenableBuilder( listenable: tabController, builder: (context, _) { diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart index 3dc888928..26a5df16f 100644 --- a/lib/components/stats/top/tracks.dart +++ b/lib/components/stats/top/tracks.dart @@ -13,8 +13,9 @@ class TopTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final tracks = - ref.watch(playbackHistoryTopProvider.select((value) => value.tracks)); + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final tracks = ref.watch(playbackHistoryTopProvider(historyDuration) + .select((value) => value.tracks)); return SliverList.builder( itemCount: tracks.length, diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart index ca2714acf..67658502f 100644 --- a/lib/provider/history/state.dart +++ b/lib/provider/history/state.dart @@ -4,6 +4,15 @@ import 'package:spotify/spotify.dart'; part 'state.freezed.dart'; part 'state.g.dart'; +enum HistoryDuration { + allTime, + days7, + days30, + months6, + year, + years2, +} + @freezed class PlaybackHistoryItem with _$PlaybackHistoryItem { factory PlaybackHistoryItem.playlist({ diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index f27a82da5..46c860339 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -2,12 +2,41 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; -final playbackHistoryTopProvider = Provider((ref) { - final (:tracks, :albums, playlists: _) = - ref.watch(playbackHistoryGroupedProvider); +final playbackHistoryTopDurationProvider = + StateProvider((ref) => HistoryDuration.days7); +final playbackHistoryTopProvider = + Provider.family((ref, HistoryDuration durationState) { + final grouped = ref.watch(playbackHistoryGroupedProvider); - final tracksWithCount = groupBy(tracks, (track) => track.track.id!) + final duration = switch (durationState) { + HistoryDuration.allTime => const Duration(days: 365 * 2003), + HistoryDuration.days7 => const Duration(days: 7), + HistoryDuration.days30 => const Duration(days: 30), + HistoryDuration.months6 => const Duration(days: 30 * 6), + HistoryDuration.year => const Duration(days: 365), + HistoryDuration.years2 => const Duration(days: 365 * 2), + }; + final tracks = grouped.tracks + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + final albums = grouped.albums + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + + final tracksWithCount = groupBy( + tracks, + (track) => track.track.id!, + ) .entries .map((entry) { return (count: entry.value.length, track: entry.value.first.track); From dcb4c0a01822a29425c031cceb025b763a72f83f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 9 May 2024 21:46:40 +0600 Subject: [PATCH 06/14] feat: add stream money calculation --- lib/collections/formatters.dart | 5 +++++ lib/components/stats/summary/summary.dart | 9 ++++++++- lib/components/stats/summary/summary_card.dart | 14 +++++++++++--- lib/models/connect/connect.dart | 1 - lib/models/current_playlist.dart | 1 - lib/provider/connect/server.dart | 2 +- lib/provider/history/summary.dart | 9 +++++++++ lib/provider/history/top.dart | 1 + .../audio_services/mobile_audio_service.dart | 4 ++-- 9 files changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart index 2f823f56c..0aed9e9f8 100644 --- a/lib/collections/formatters.dart +++ b/lib/collections/formatters.dart @@ -1,3 +1,8 @@ import 'package:intl/intl.dart'; final compactNumberFormatter = NumberFormat.compact(); +final usdFormatter = NumberFormat.compactCurrency( + locale: 'en-US', + symbol: r"$", + decimalDigits: 2, +); diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart index 41b4c8728..b793d2a74 100644 --- a/lib/components/stats/summary/summary.dart +++ b/lib/components/stats/summary/summary.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/history/summary.dart'; @@ -32,7 +33,7 @@ class StatsPageSummarySection extends HookConsumerWidget { title: summary.duration.inMinutes.toDouble(), unit: "minutes", description: 'Listened to music', - color: Colors.green, + color: Colors.purple, ), SummaryCard( title: summary.tracks.toDouble(), @@ -40,6 +41,12 @@ class StatsPageSummarySection extends HookConsumerWidget { description: 'Streamed overall', color: Colors.lightBlue, ), + SummaryCard.unformatted( + title: usdFormatter.format(summary.fees.toDouble()), + unit: "", + description: 'Worth of streams', + color: Colors.green, + ), SummaryCard( title: summary.artists.toDouble(), unit: "artist's", diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart index 2601f9dd3..b9ef5448b 100644 --- a/lib/components/stats/summary/summary_card.dart +++ b/lib/components/stats/summary/summary_card.dart @@ -4,13 +4,21 @@ import 'package:gap/gap.dart'; import 'package:spotube/collections/formatters.dart'; class SummaryCard extends StatelessWidget { - final double title; + final String title; final String unit; final String description; final MaterialColor color; - const SummaryCard({ + SummaryCard({ + super.key, + required double title, + required this.unit, + required this.description, + required this.color, + }) : title = compactNumberFormatter.format(title); + + const SummaryCard.unformatted({ super.key, required this.title, required this.unit, @@ -35,7 +43,7 @@ class SummaryCard extends StatelessWidget { TextSpan( children: [ TextSpan( - text: compactNumberFormatter.format(title), + text: title, style: textTheme.headlineLarge?.copyWith( color: color.shade900, ), diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart index efb373150..283860505 100644 --- a/lib/models/connect/connect.dart +++ b/lib/models/connect/connect.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; diff --git a/lib/models/current_playlist.dart b/lib/models/current_playlist.dart index 53ea2799b..7e55e3939 100644 --- a/lib/models/current_playlist.dart +++ b/lib/models/current_playlist.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; class CurrentPlaylist { diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index 23fbada86..9c4e6466c 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -82,7 +82,7 @@ final connectServerProvider = FutureProvider((ref) async { .toJson(), ); channel.sink.add( - WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), + WebSocketShuffleEvent(audioPlayer.isShuffled).toJson(), ); channel.sink.add( WebSocketLoopEvent(audioPlayer.loopMode).toJson(), diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart index 1cc316a72..2aa86ac9f 100644 --- a/lib/provider/history/summary.dart +++ b/lib/provider/history/summary.dart @@ -1,6 +1,8 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; final playbackHistorySummaryProvider = Provider((ref) { final (:tracks, :albums, :playlists) = @@ -43,10 +45,17 @@ final playbackHistorySummaryProvider = Provider((ref) { ) .length; + final tracksThisMonth = ref.watch( + playbackHistoryTopProvider(HistoryDuration.days30).select((s) => s.tracks), + ); + + final streams = tracksThisMonth.fold(0, (acc, el) => acc + el.count); + return ( duration: totalDurationListened, tracks: totalTracksListened, artists: totalArtistsListened, + fees: streams * 0.005, // Spotify pays $0.003 to $0.005 albums: totalAlbumsListened, playlists: totalPlaylistsListened, ); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index 46c860339..bee2bf29b 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -6,6 +6,7 @@ import 'package:spotube/provider/history/state.dart'; final playbackHistoryTopDurationProvider = StateProvider((ref) => HistoryDuration.days7); + final playbackHistoryTopProvider = Provider.family((ref, HistoryDuration durationState) { final grouped = ref.watch(playbackHistoryGroupedProvider); diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 3bb884475..62cc85520 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -11,7 +11,7 @@ class MobileAudioService extends BaseAudioHandler { AudioSession? session; final ProxyPlaylistNotifier playlistNotifier; - // ignore: invalid_use_of_protected_member + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member ProxyPlaylist get playlist => playlistNotifier.state; MobileAudioService(this.playlistNotifier) { @@ -135,7 +135,7 @@ class MobileAudioService extends BaseAudioHandler { playing: audioPlayer.isPlaying, updatePosition: position, bufferedPosition: await audioPlayer.bufferedPosition ?? Duration.zero, - shuffleMode: await audioPlayer.isShuffled == true + shuffleMode: audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), From 4d5beb19febd03a3f756efdff2631cf49f313d8a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 9 May 2024 22:18:25 +0600 Subject: [PATCH 07/14] refactor: place search in mobile navbar and settings in home appbar --- lib/collections/side_bar_tiles.dart | 13 +++---- lib/components/root/sidebar.dart | 2 +- .../root/spotube_navigation_bar.dart | 16 +++++--- lib/components/stats/summary/summary.dart | 2 +- .../stats/summary/summary_card.dart | 6 ++- lib/pages/home/home.dart | 39 ++----------------- lib/pages/settings/sections/accounts.dart | 28 ++++++++++++- lib/pages/settings/settings.dart | 1 + lib/pages/stats/albums/albums.dart | 0 lib/pages/stats/artists/artists.dart | 0 lib/pages/stats/fees/fees.dart | 0 lib/pages/stats/minutes/minutes.dart | 0 lib/pages/stats/playlists/playlists.dart | 0 lib/pages/stats/streams/streams.dart | 0 14 files changed, 56 insertions(+), 51 deletions(-) create mode 100644 lib/pages/stats/albums/albums.dart create mode 100644 lib/pages/stats/artists/artists.dart create mode 100644 lib/pages/stats/fees/fees.dart create mode 100644 lib/pages/stats/minutes/minutes.dart create mode 100644 lib/pages/stats/playlists/playlists.dart create mode 100644 lib/pages/stats/streams/streams.dart diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index b52227e84..4f23c049a 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -5,7 +5,6 @@ import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/stats/stats.dart'; class SideBarTiles { @@ -62,6 +61,12 @@ List getNavbarTileList(AppLocalizations l10n) => [ icon: SpotubeIcons.home, title: l10n.browse, ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), SideBarTiles( id: "library", name: LibraryPage.name, @@ -74,10 +79,4 @@ List getNavbarTileList(AppLocalizations l10n) => [ icon: SpotubeIcons.chart, title: l10n.stats, ), - SideBarTiles( - id: "settings", - name: SettingsPage.name, - icon: SpotubeIcons.settings, - title: l10n.settings, - ) ]; diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 00f45daea..0e644a898 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -303,7 +303,7 @@ class SidebarFooter extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.settings), onPressed: () { - ServiceUtils.navigateNamed(context, SettingsPage.name); + ServiceUtils.pushNamed(context, SettingsPage.name); }, ), ], diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 0601a37a6..e16ad1a89 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -37,14 +37,20 @@ class SpotubeNavigationBar extends HookConsumerWidget { theme.colorScheme.primary.withOpacity(0.2), ); - final navbarTileList = - useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); + final navbarTileList = useMemoized( + () => getNavbarTileList(context.l10n), + [context.l10n], + ); final panelHeight = ref.watch(navigationPanelHeight); - final selectedIndex = navbarTileList.indexWhere( - (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, - ); + final selectedIndex = useMemoized(() { + final index = navbarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); + + return index == -1 ? 0 : index; + }, [navbarTileList, routerState.matchedLocation]); if (layoutMode == LayoutMode.extended || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart index b793d2a74..ba4c76416 100644 --- a/lib/components/stats/summary/summary.dart +++ b/lib/components/stats/summary/summary.dart @@ -44,7 +44,7 @@ class StatsPageSummarySection extends HookConsumerWidget { SummaryCard.unformatted( title: usdFormatter.format(summary.fees.toDouble()), unit: "", - description: 'Worth of streams', + description: 'Owed to artists\nthis month', color: Colors.green, ), SummaryCard( diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart index b9ef5448b..7ab974a64 100644 --- a/lib/components/stats/summary/summary_card.dart +++ b/lib/components/stats/summary/summary_card.dart @@ -30,6 +30,8 @@ class SummaryCard extends StatelessWidget { Widget build(BuildContext context) { final ThemeData(:textTheme, :brightness) = Theme.of(context); + final descriptionNewLines = description.split("").where((s) => s == "\n"); + return Card( color: brightness == Brightness.dark ? color.shade100 : color.shade50, child: Padding( @@ -61,7 +63,9 @@ class SummaryCard extends StatelessWidget { const Gap(5), AutoSizeText( description, - maxLines: 1, + maxLines: description.contains("\n") + ? descriptionNewLines.length + 1 + : 1, minFontSize: 9, style: textTheme.labelMedium!.copyWith( color: color.shade900, diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index dd4c6f730..d4e2d94e6 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -12,14 +12,9 @@ import 'package:spotube/components/home/sections/genres.dart'; import 'package:spotube/components/home/sections/made_for_user.dart'; import 'package:spotube/components/home/sections/new_releases.dart'; import 'package:spotube/components/home/sections/recent.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/profile/profile.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -39,43 +34,17 @@ class HomePage extends HookConsumerWidget { body: CustomScrollView( controller: controller, slivers: [ - if (mediaQuery.mdAndDown) + if (mediaQuery.smAndDown) SliverAppBar( floating: true, title: Assets.spotubeLogoPng.image(height: 45), actions: [ const ConnectDeviceButton(), const Gap(10), - Consumer(builder: (context, ref, _) { - final auth = ref.watch(authenticationProvider); - final me = ref.watch(meProvider); - final meData = me.asData?.value; - - if (auth == null) { - return const SizedBox(); - } - - return IconButton( - icon: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - (meData?.images).asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ), - ), - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - ), - onPressed: () { - ServiceUtils.pushNamed(context, ProfilePage.name); - }, - ); - }), - const Gap(10), IconButton( - icon: const Icon(SpotubeIcons.search), + icon: const Icon(SpotubeIcons.settings, size: 20), onPressed: () { - ServiceUtils.pushNamed(context, SearchPage.name); + ServiceUtils.pushNamed(context, SettingsPage.name); }, ), const Gap(10), diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index ab3a7c92f..6162aa3d3 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -4,10 +4,15 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/service_utils.dart'; class SettingsAccountSection extends HookConsumerWidget { const SettingsAccountSection({super.key}); @@ -15,9 +20,12 @@ class SettingsAccountSection extends HookConsumerWidget { @override Widget build(context, ref) { final theme = Theme.of(context); + final router = GoRouter.of(context); + final auth = ref.watch(authenticationProvider); final scrobbler = ref.watch(scrobblerProvider); - final router = GoRouter.of(context); + final me = ref.watch(meProvider); + final meData = me.asData?.value; final logoutBtnStyle = FilledButton.styleFrom( backgroundColor: Colors.red, @@ -27,6 +35,24 @@ class SettingsAccountSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.account, children: [ + if (auth != null) + ListTile( + leading: const Icon(SpotubeIcons.user), + title: const Text("User Profile"), + trailing: Padding( + padding: const EdgeInsets.all(8.0), + child: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (meData?.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + ), + onTap: () { + ServiceUtils.pushNamed(context, ProfilePage.name); + }, + ), if (auth == null) LayoutBuilder(builder: (context, constrains) { return ListTile( diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 2949a0d3f..af0fc0959 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -31,6 +31,7 @@ class SettingsPage extends HookConsumerWidget { appBar: PageWindowTitleBar( title: Text(context.l10n.settings), centerTitle: true, + automaticallyImplyLeading: true, ), body: Scrollbar( controller: controller, diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart new file mode 100644 index 000000000..e69de29bb From f0b6d660e2e24d3ffd139b7e3e551d46aee8056c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 9 May 2024 23:44:43 +0600 Subject: [PATCH 08/14] feat: add individual minutes and streams page --- lib/collections/routes.dart | 18 +++++ lib/components/stats/common/album_item.dart | 53 +++++++++++++ lib/components/stats/common/artist_item.dart | 39 ++++++++++ lib/components/stats/common/track_item.dart | 49 ++++++++++++ lib/components/stats/summary/summary.dart | 9 +++ .../stats/summary/summary_card.dart | 75 ++++++++++--------- lib/components/stats/top/albums.dart | 46 +----------- lib/components/stats/top/artists.dart | 30 +------- lib/components/stats/top/tracks.dart | 42 ++--------- lib/pages/stats/minutes/minutes.dart | 44 +++++++++++ lib/pages/stats/streams/streams.dart | 44 +++++++++++ 11 files changed, 313 insertions(+), 136 deletions(-) create mode 100644 lib/components/stats/common/album_item.dart create mode 100644 lib/components/stats/common/artist_item.dart create mode 100644 lib/components/stats/common/track_item.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 2ce29b408..702aca9f0 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -24,7 +24,9 @@ import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; import 'package:spotube/pages/stats/stats.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; @@ -230,6 +232,22 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => const SpotubePage( child: StatsPage(), ), + routes: [ + GoRoute( + path: "minutes", + name: StatsMinutesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsMinutesPage(), + ), + ), + GoRoute( + path: "streams", + name: StatsStreamsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamsPage(), + ), + ) + ], ) ], ), diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart new file mode 100644 index 000000000..ccc0fa4ec --- /dev/null +++ b/lib/components/stats/common/album_item.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.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/utils/service_utils.dart'; + +class StatsAlbumItem extends StatelessWidget { + final AlbumSimple album; + final Widget info; + const StatsAlbumItem({super.key, required this.album, required this.info}); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (album.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(album.name!), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${album.albumType?.formatted} • "), + Flexible( + child: ArtistLink( + artists: album.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: {"id": album.id!}, + extra: album, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/artist_item.dart b/lib/components/stats/common/artist_item.dart new file mode 100644 index 000000000..9282d4e1b --- /dev/null +++ b/lib/components/stats/common/artist_item.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.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/utils/service_utils.dart'; + +class StatsArtistItem extends StatelessWidget { + final Artist artist; + final Widget info; + const StatsArtistItem({ + super.key, + required this.artist, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(artist.name!), + horizontalTitleGap: 8, + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (artist.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: {"id": artist.id!}, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/track_item.dart b/lib/components/stats/common/track_item.dart new file mode 100644 index 000000000..6ba6b8866 --- /dev/null +++ b/lib/components/stats/common/track_item.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.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/utils/service_utils.dart'; + +class StatsTrackItem extends StatelessWidget { + final Track track; + final Widget info; + const StatsTrackItem({ + super.key, + required this.track, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(track.name!), + subtitle: ArtistLink( + artists: track.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart index ba4c76416..69524376e 100644 --- a/lib/components/stats/summary/summary.dart +++ b/lib/components/stats/summary/summary.dart @@ -3,7 +3,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/provider/history/summary.dart'; +import 'package:spotube/utils/service_utils.dart'; class StatsPageSummarySection extends HookConsumerWidget { const StatsPageSummarySection({super.key}); @@ -34,12 +37,18 @@ class StatsPageSummarySection extends HookConsumerWidget { unit: "minutes", description: 'Listened to music', color: Colors.purple, + onTap: () { + ServiceUtils.pushNamed(context, StatsMinutesPage.name); + }, ), SummaryCard( title: summary.tracks.toDouble(), unit: "songs", description: 'Streamed overall', color: Colors.lightBlue, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamsPage.name); + }, ), SummaryCard.unformatted( title: usdFormatter.format(summary.fees.toDouble()), diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart index 7ab974a64..243c50e87 100644 --- a/lib/components/stats/summary/summary_card.dart +++ b/lib/components/stats/summary/summary_card.dart @@ -7,6 +7,7 @@ class SummaryCard extends StatelessWidget { final String title; final String unit; final String description; + final VoidCallback? onTap; final MaterialColor color; @@ -16,6 +17,7 @@ class SummaryCard extends StatelessWidget { required this.unit, required this.description, required this.color, + this.onTap, }) : title = compactNumberFormatter.format(title); const SummaryCard.unformatted({ @@ -24,6 +26,7 @@ class SummaryCard extends StatelessWidget { required this.unit, required this.description, required this.color, + this.onTap, }); @override @@ -34,44 +37,48 @@ class SummaryCard extends StatelessWidget { 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: title, - style: textTheme.headlineLarge?.copyWith( - color: color.shade900, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + 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: title, + style: textTheme.headlineLarge?.copyWith( + color: color.shade900, + ), ), - ), - TextSpan( - text: " $unit", - style: textTheme.titleMedium?.copyWith( - color: color.shade900, + TextSpan( + text: " $unit", + style: textTheme.titleMedium?.copyWith( + color: color.shade900, + ), ), - ), - ], + ], + ), + maxLines: 1, ), - maxLines: 1, - ), - const Gap(5), - AutoSizeText( - description, - maxLines: description.contains("\n") - ? descriptionNewLines.length + 1 - : 1, - minFontSize: 9, - style: textTheme.labelMedium!.copyWith( - color: color.shade900, + const Gap(5), + AutoSizeText( + description, + maxLines: description.contains("\n") + ? descriptionNewLines.length + 1 + : 1, + minFontSize: 9, + style: textTheme.labelMedium!.copyWith( + color: color.shade900, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart index 8d8d2b5cd..51bcf5b07 100644 --- a/lib/components/stats/top/albums.dart +++ b/lib/components/stats/top/albums.dart @@ -1,13 +1,8 @@ import 'package:flutter/material.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/components/stats/common/album_item.dart'; import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/utils/service_utils.dart'; class TopAlbums extends HookConsumerWidget { const TopAlbums({super.key}); @@ -22,44 +17,11 @@ class TopAlbums extends HookConsumerWidget { 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( + return StatsAlbumItem( + album: album.album, + info: Text( "${compactNumberFormatter.format(album.count)} plays", ), - onTap: () { - ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: { - "id": album.album.id!, - }, - extra: album.album, - ); - }, ); }, ); diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart index 05b5b0820..d6d0c98d3 100644 --- a/lib/components/stats/top/artists.dart +++ b/lib/components/stats/top/artists.dart @@ -1,11 +1,8 @@ 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/components/stats/common/artist_item.dart'; import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/utils/service_utils.dart'; class TopArtists extends HookConsumerWidget { const TopArtists({super.key}); @@ -20,28 +17,9 @@ class TopArtists extends HookConsumerWidget { 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!, - }, - ); - }, + return StatsArtistItem( + artist: artist.artist, + info: Text("${compactNumberFormatter.format(artist.count)} plays"), ); }, ); diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart index 26a5df16f..bffa4ecd5 100644 --- a/lib/components/stats/top/tracks.dart +++ b/lib/components/stats/top/tracks.dart @@ -1,12 +1,8 @@ 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/components/stats/common/track_item.dart'; import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/utils/service_utils.dart'; class TopTracks extends HookConsumerWidget { const TopTracks({super.key}); @@ -14,42 +10,20 @@ class TopTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final tracks = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.tracks)); + final tracks = ref.watch( + playbackHistoryTopProvider(historyDuration) + .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( + return StatsTrackItem( + track: track.track, + info: Text( "${compactNumberFormatter.format(track.count)} plays", ), - onTap: () { - ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.track.id!, - }, - ); - }, ); }, ); diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index e69de29bb..b22f9a4f1 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -0,0 +1,44 @@ +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/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsMinutesPage extends HookConsumerWidget { + static const name = "stats_minutes"; + + const StatsMinutesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Minutes listened"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index e69de29bb..33480709a 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -0,0 +1,44 @@ +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/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsStreamsPage extends HookConsumerWidget { + static const name = "stats_streams"; + + const StatsStreamsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Streamed songs"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count)} streams", + ), + ); + }, + ), + ); + } +} From 9393ed75d739dc5bd78cdd289bad268fa2af5f82 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 10 May 2024 17:40:50 +0600 Subject: [PATCH 09/14] feat(stats): add individual minutes and streams page --- lib/collections/routes.dart | 34 +++++++++++++- .../stats/common/playlist_item.dart | 46 +++++++++++++++++++ lib/components/stats/summary/summary.dart | 20 +++++++- lib/pages/stats/albums/albums.dart | 38 +++++++++++++++ lib/pages/stats/artists/artists.dart | 38 +++++++++++++++ lib/pages/stats/fees/fees.dart | 39 ++++++++++++++++ lib/pages/stats/playlists/playlists.dart | 39 ++++++++++++++++ lib/provider/history/top.dart | 20 +++++++- 8 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 lib/components/stats/common/playlist_item.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 702aca9f0..42e31e952 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -24,7 +24,11 @@ import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; +import 'package:spotube/pages/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; import 'package:spotube/pages/stats/stats.dart'; import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; @@ -246,7 +250,35 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => const SpotubePage( child: StatsStreamsPage(), ), - ) + ), + GoRoute( + path: "fees", + name: StatsStreamFeesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamFeesPage(), + ), + ), + GoRoute( + path: "artists", + name: StatsArtistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsArtistsPage(), + ), + ), + GoRoute( + path: "albums", + name: StatsAlbumsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsAlbumsPage(), + ), + ), + GoRoute( + path: "playlists", + name: StatsPlaylistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPlaylistsPage(), + ), + ), ], ) ], diff --git a/lib/components/stats/common/playlist_item.dart b/lib/components/stats/common/playlist_item.dart new file mode 100644 index 000000000..b07311ab4 --- /dev/null +++ b/lib/components/stats/common/playlist_item.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsPlaylistItem extends StatelessWidget { + final PlaylistSimple playlist; + final Widget info; + const StatsPlaylistItem( + {super.key, required this.playlist, required this.info}); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (playlist.images).asUrlString( + placeholder: ImagePlaceholder.collection, + ), + width: 40, + height: 40, + ), + ), + title: Text(playlist.name!), + subtitle: Text( + playlist.description!.replaceAll(htmlTagRegexp, ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + PlaylistPage.name, + pathParameters: {"id": playlist.id!}, + extra: playlist, + ); + }, + ); + } +} diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart index 69524376e..61f3bd6c8 100644 --- a/lib/components/stats/summary/summary.dart +++ b/lib/components/stats/summary/summary.dart @@ -3,7 +3,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/pages/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/provider/history/summary.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -26,7 +30,9 @@ class StatsPageSummarySection extends HookConsumerWidget { ? 3 : constrains.mdAndDown ? 4 - : 5, + : constrains.lgAndDown + ? 5 + : 6, mainAxisSpacing: 10, crossAxisSpacing: 10, childAspectRatio: constrains.isXs ? 1.3 : 1.5, @@ -55,24 +61,36 @@ class StatsPageSummarySection extends HookConsumerWidget { unit: "", description: 'Owed to artists\nthis month', color: Colors.green, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); + }, ), SummaryCard( title: summary.artists.toDouble(), unit: "artist's", description: 'Music reached you', color: Colors.yellow, + onTap: () { + ServiceUtils.pushNamed(context, StatsArtistsPage.name); + }, ), SummaryCard( title: summary.albums.toDouble(), unit: "full albums", description: 'Got your love', color: Colors.pink, + onTap: () { + ServiceUtils.pushNamed(context, StatsAlbumsPage.name); + }, ), SummaryCard( title: summary.playlists.toDouble(), unit: "playlists", description: 'Were on repeat', color: Colors.teal, + onTap: () { + ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); + }, ), ]), ); diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index e69de29bb..83867f936 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/album_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsAlbumsPage extends HookConsumerWidget { + static const name = "stats_albums"; + const StatsAlbumsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final albums = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.albums), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Albums"), + ), + body: ListView.builder( + itemCount: albums.length, + itemBuilder: (context, index) { + final album = albums[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 e69de29bb..755475aef 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsArtistsPage extends HookConsumerWidget { + static const name = "stats_artists"; + const StatsArtistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final artists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.artists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Artists"), + ), + body: ListView.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[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 e69de29bb..e5bb93307 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsStreamFeesPage extends HookConsumerWidget { + static const name = "stats_stream_fees"; + + const StatsStreamFeesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final artists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.days30) + .select((value) => value.artists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Streaming fees (hypothetical)"), + ), + body: ListView.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(usdFormatter.format(artist.count * 0.005)), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index e69de29bb..cca7febb2 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/playlist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsPlaylistsPage extends HookConsumerWidget { + static const name = "stats_playlists"; + const StatsPlaylistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final playlists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.playlists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Playlists"), + ), + body: ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + return StatsPlaylistItem( + playlist: playlist.playlist.playlist, + info: + Text("${compactNumberFormatter.format(playlist.count)} plays"), + ); + }, + ), + ); + } +} diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index bee2bf29b..0188707e4 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -34,6 +34,14 @@ final playbackHistoryTopProvider = ) .toList(); + final playlists = grouped.playlists + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + final tracksWithCount = groupBy( tracks, (track) => track.track.id!, @@ -69,9 +77,19 @@ final playbackHistoryTopProvider = .sorted((a, b) => b.count.compareTo(a.count)) .toList(); + final playlistsWithCount = + groupBy(playlists, (playlist) => playlist.playlist.id!) + .entries + .map((entry) { + return (count: entry.value.length, playlist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + return ( tracks: tracksWithCount, albums: albumsWithCount, - artists: artistsWithCount + artists: artistsWithCount, + playlists: playlistsWithCount, ); }); From fd4b8a3bbc9aed3fb755aee88c1a2b6b68b02618 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 10 May 2024 17:43:39 +0600 Subject: [PATCH 10/14] chore: default period to 1 month --- lib/provider/history/top.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index 0188707e4..7d4594f08 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -5,7 +5,7 @@ import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/history/state.dart'; final playbackHistoryTopDurationProvider = - StateProvider((ref) => HistoryDuration.days7); + StateProvider((ref) => HistoryDuration.days30); final playbackHistoryTopProvider = Provider.family((ref, HistoryDuration durationState) { From cefda904de254e21d7d0b7ba8d954d515f77c9eb Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 10 May 2024 17:54:43 +0600 Subject: [PATCH 11/14] feat: add text to explain user how hypothetical fees are calculated --- lib/pages/stats/fees/fees.dart | 44 +++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index e5bb93307..228d3243e 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/stats/common/artist_item.dart'; @@ -13,6 +14,8 @@ class StatsStreamFeesPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :hintColor) = Theme.of(context); + final artists = ref.watch( playbackHistoryTopProvider(HistoryDuration.days30) .select((value) => value.artists), @@ -24,15 +27,38 @@ class StatsStreamFeesPage extends HookConsumerWidget { centerTitle: false, title: Text("Streaming fees (hypothetical)"), ), - body: ListView.builder( - itemCount: artists.length, - itemBuilder: (context, index) { - final artist = artists[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(usdFormatter.format(artist.count * 0.005)), - ); - }, + body: CustomScrollView( + slivers: [ + SliverCrossAxisConstrained( + maxCrossAxisExtent: 600, + alignment: -1, + child: SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverToBoxAdapter( + child: Text( + "*This is calculated based on Spotify's per stream " + "payout of \$0.003 to \$0.005. This is a hypothetical " + "calculation to give user insight about how much they " + "would have paid to the artists if they were to listen " + "their song in Spotify.", + style: textTheme.bodySmall?.copyWith( + color: hintColor, + ), + ), + ), + ), + ), + SliverList.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(usdFormatter.format(artist.count * 0.005)), + ); + }, + ), + ], ), ); } From f5a6ce23549137f858e030ad8028dafce8575499 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 23 May 2024 21:52:28 +0600 Subject: [PATCH 12/14] chore: ensure usage of route names instead of direct paths --- .../library/local_folder/local_folder_item.dart | 16 ++++++++-------- lib/pages/getting_started/sections/support.dart | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart index 281cfc2cd..556f09a63 100644 --- a/lib/components/library/local_folder/local_folder_item.dart +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -11,6 +11,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/library/local_folder.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -57,14 +58,13 @@ class LocalFolderItem extends HookConsumerWidget { return InkWell( onTap: () { - if (isDownloadFolder) { - context.go("/library/local?downloads=1", extra: folder); - } else { - context.go( - "/library/local", - extra: folder, - ); - } + context.goNamed( + LocalLibraryPage.name, + queryParameters: { + if (isDownloadFolder) "downloads": 1, + }, + extra: folder, + ); }, borderRadius: BorderRadius.circular(8), child: Ink( diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index ec29de244..7bccfe06e 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -105,7 +106,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.go("/"); + context.go(HomePage.name); } }, ), From 48817e5ffe323cfe78a7d919f3cc05b8b8ff9eb3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 23 May 2024 22:15:28 +0600 Subject: [PATCH 13/14] cd: add cache key --- .github/workflows/spotube-release-binary.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 0fe1f1bac..694dc1ebf 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -66,6 +66,7 @@ jobs: - uses: subosito/flutter-action@v2.12.0 with: cache: true + cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} flutter-version: ${{ env.FLUTTER_VERSION }} - name: Setup Java if: ${{matrix.platform == 'android'}} From 9aff55a772bc3f0508adad506eb232ff406c0a96 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 23 May 2024 22:26:02 +0600 Subject: [PATCH 14/14] cd: remove media_kit_event_loop from git --- pubspec.lock | 11 +++++------ pubspec.yaml | 10 +++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 0ba96ae25..c5688dea4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1455,13 +1455,12 @@ packages: source: hosted version: "1.0.9" media_kit_native_event_loop: - dependency: "direct overridden" + dependency: transitive description: - path: media_kit_native_event_loop - ref: main - resolved-ref: "285f7919bbf4a7d89a62615b14a3766a171ad575" - url: "https://github.com/media-kit/media-kit" - source: git + name: media_kit_native_event_loop + sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e + url: "https://pub.dev" + source: hosted version: "1.0.8" menu_base: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 0729f815c..6ec4a2fce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -159,11 +159,11 @@ dependency_overrides: git: url: https://github.com/antler119/system_tray ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - media_kit_native_event_loop: # to fix "macro name must be an identifier" - git: - url: https://github.com/media-kit/media-kit - path: media_kit_native_event_loop - ref: main + # media_kit_native_event_loop: # to fix "macro name must be an identifier" + # git: + # url: https://github.com/media-kit/media-kit + # path: media_kit_native_event_loop + # ref: main flutter: generate: true