feat: personalized stats based on local music history (#1522)

* feat: add playback history provider

* feat: implement recently played section

* refactor: use route names

* feat: add stats summary and top tracks/artists/albums

* feat: add top date based filtering

* feat: add stream money calculation

* refactor: place search in mobile navbar and settings in home appbar

* feat: add individual minutes and streams page

* feat(stats): add individual minutes and streams page

* chore: default period to 1 month

* feat: add text to explain user how hypothetical fees are calculated

* chore: ensure usage of route names instead of direct paths

* cd: add cache key

* cd: remove media_kit_event_loop from git
This commit is contained in:
Kingkor Roy Tirtho 2024-06-01 11:40:01 +06:00 committed by GitHub
parent fc5bfa089c
commit 82307bc030
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
114 changed files with 3372 additions and 613 deletions

View File

@ -66,6 +66,7 @@ jobs:
- uses: subosito/flutter-action@v2.12.0 - uses: subosito/flutter-action@v2.12.0
with: with:
cache: true cache: true
cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }}
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Setup Java - name: Setup Java
if: ${{matrix.platform == 'android'}} if: ${{matrix.platform == 'android'}}

View File

@ -3,3 +3,8 @@ targets:
sources: sources:
exclude: exclude:
- bin/*.dart - bin/*.dart
builders:
json_serializable:
options:
any_map: true
explicit_to_json: true

View File

@ -1,5 +1,4 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify/home_feed.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';

View File

@ -0,0 +1,8 @@
import 'package:intl/intl.dart';
final compactNumberFormatter = NumberFormat.compact();
final usdFormatter = NumberFormat.compactCurrency(
locale: 'en-US',
symbol: r"$",
decimalDigits: 2,
);

View File

@ -7,6 +7,10 @@ import 'package:go_router/go_router.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/models/logger.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/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -67,16 +71,16 @@ class HomeTabAction extends Action<HomeTabIntent> {
final router = intent.ref.read(routerProvider); final router = intent.ref.read(routerProvider);
switch (intent.tab) { switch (intent.tab) {
case HomeTabs.browse: case HomeTabs.browse:
router.go("/"); router.goNamed(HomePage.name);
break; break;
case HomeTabs.search: case HomeTabs.search:
router.go("/search"); router.goNamed(SearchPage.name);
break; break;
case HomeTabs.library: case HomeTabs.library:
router.go("/library"); router.goNamed(LibraryPage.name);
break; break;
case HomeTabs.lyrics: case HomeTabs.lyrics:
router.go("/lyrics"); router.goNamed(LyricsPage.name);
break; break;
} }
return null; return null;

View File

@ -25,6 +25,13 @@ import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/about.dart';
import 'package:spotube/pages/settings/logs.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'; import 'package:spotube/pages/track/track.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
@ -51,6 +58,7 @@ final routerProvider = Provider((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: "/", path: "/",
name: HomePage.name,
redirect: (context, state) async { redirect: (context, state) async {
final authNotifier = ref.read(authenticationProvider.notifier); final authNotifier = ref.read(authenticationProvider.notifier);
final json = await authNotifier.box.get(authNotifier.cacheKey); final json = await authNotifier.box.get(authNotifier.cacheKey);
@ -67,11 +75,13 @@ final routerProvider = Provider((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: "genres", path: "genres",
name: GenrePage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: GenrePage()), const SpotubePage(child: GenrePage()),
), ),
GoRoute( GoRoute(
path: "genre/:categoryId", path: "genre/:categoryId",
name: GenrePlaylistsPage.name,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: GenrePlaylistsPage( child: GenrePlaylistsPage(
category: state.extra as Category, category: state.extra as Category,
@ -80,6 +90,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "feeds/:feedId", path: "feeds/:feedId",
name: HomeFeedSectionPage.name,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: HomeFeedSectionPage( child: HomeFeedSectionPage(
sectionUri: state.pathParameters["feedId"] as String, sectionUri: state.pathParameters["feedId"] as String,
@ -90,56 +101,62 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/search", path: "/search",
name: "Search", name: SearchPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: SearchPage()), const SpotubePage(child: SearchPage()),
), ),
GoRoute( GoRoute(
path: "/library", path: "/library",
name: "Library", name: LibraryPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LibraryPage()), const SpotubePage(child: LibraryPage()),
routes: [ routes: [
GoRoute( GoRoute(
path: "generate", path: "generate",
name: PlaylistGeneratorPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: PlaylistGeneratorPage()), const SpotubePage(child: PlaylistGeneratorPage()),
routes: [ routes: [
GoRoute( GoRoute(
path: "result", path: "result",
name: PlaylistGenerateResultPage.name,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: PlaylistGenerateResultPage( child: PlaylistGenerateResultPage(
state: state.extra as GeneratePlaylistProviderInput, state: state.extra as GeneratePlaylistProviderInput,
), ),
), ),
)
],
), ),
]),
GoRoute( GoRoute(
path: "local", path: "local",
name: LocalLibraryPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is String); assert(state.extra is String);
return SpotubePage( return SpotubePage(
child: LocalLibraryPage(state.extra as String, child: LocalLibraryPage(state.extra as String,
isDownloads: state.uri.queryParameters["downloads"] != null isDownloads:
), state.uri.queryParameters["downloads"] != null),
); );
}, },
), ),
]), ]),
GoRoute( GoRoute(
path: "/lyrics", path: "/lyrics",
name: "Lyrics", name: LyricsPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LyricsPage()), const SpotubePage(child: LyricsPage()),
), ),
GoRoute( GoRoute(
path: "/settings", path: "/settings",
name: SettingsPage.name,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: SettingsPage(), child: SettingsPage(),
), ),
routes: [ routes: [
GoRoute( GoRoute(
path: "blacklist", path: "blacklist",
name: BlackListPage.name,
pageBuilder: (context, state) => SpotubeSlidePage( pageBuilder: (context, state) => SpotubeSlidePage(
child: const BlackListPage(), child: const BlackListPage(),
), ),
@ -147,12 +164,14 @@ final routerProvider = Provider((ref) {
if (!kIsWeb) if (!kIsWeb)
GoRoute( GoRoute(
path: "logs", path: "logs",
name: LogsPage.name,
pageBuilder: (context, state) => SpotubeSlidePage( pageBuilder: (context, state) => SpotubeSlidePage(
child: const LogsPage(), child: const LogsPage(),
), ),
), ),
GoRoute( GoRoute(
path: "about", path: "about",
name: AboutSpotube.name,
pageBuilder: (context, state) => SpotubeSlidePage( pageBuilder: (context, state) => SpotubeSlidePage(
child: const AboutSpotube(), child: const AboutSpotube(),
), ),
@ -161,6 +180,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/album/:id", path: "/album/:id",
name: AlbumPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is AlbumSimple); assert(state.extra is AlbumSimple);
return SpotubePage( return SpotubePage(
@ -170,6 +190,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/artist/:id", path: "/artist/:id",
name: ArtistPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.pathParameters["id"] != null); assert(state.pathParameters["id"] != null);
return SpotubePage( return SpotubePage(
@ -178,6 +199,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/playlist/:id", path: "/playlist/:id",
name: PlaylistPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is PlaylistSimple); assert(state.extra is PlaylistSimple);
return SpotubePage( return SpotubePage(
@ -189,6 +211,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/track/:id", path: "/track/:id",
name: TrackPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
final id = state.pathParameters["id"]!; final id = state.pathParameters["id"]!;
return SpotubePage( return SpotubePage(
@ -198,12 +221,14 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/connect", path: "/connect",
name: ConnectPage.name,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: ConnectPage(), child: ConnectPage(),
), ),
routes: [ routes: [
GoRoute( GoRoute(
path: "control", path: "control",
name: ConnectControlPage.name,
pageBuilder: (context, state) { pageBuilder: (context, state) {
return const SpotubePage( return const SpotubePage(
child: ConnectControlPage(), child: ConnectControlPage(),
@ -214,13 +239,66 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/profile", path: "/profile",
name: ProfilePage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: ProfilePage()), const SpotubePage(child: ProfilePage()),
),
GoRoute(
path: "/stats",
name: StatsPage.name,
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(),
),
),
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(),
),
),
],
) )
], ],
), ),
GoRoute( GoRoute(
path: "/mini-player", path: "/mini-player",
name: MiniLyricsPage.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: MiniLyricsPage(prevSize: state.extra as Size), child: MiniLyricsPage(prevSize: state.extra as Size),
@ -228,6 +306,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/getting-started", path: "/getting-started",
name: GettingStarting.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: GettingStarting(), child: GettingStarting(),
@ -235,6 +314,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/login", path: "/login",
name: WebViewLogin.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => SpotubePage( pageBuilder: (context, state) => SpotubePage(
child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(),
@ -242,6 +322,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/login-tutorial", path: "/login-tutorial",
name: LoginTutorial.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: LoginTutorial(), child: LoginTutorial(),
@ -249,6 +330,7 @@ final routerProvider = Provider((ref) {
), ),
GoRoute( GoRoute(
path: "/lastfm-login", path: "/lastfm-login",
name: LastFMLoginPage.name,
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LastFMLoginPage()), const SpotubePage(child: LastFMLoginPage()),

View File

@ -1,33 +1,82 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.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/stats/stats.dart';
class SideBarTiles { class SideBarTiles {
final IconData icon; final IconData icon;
final String title; final String title;
final String id; 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<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [ List<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [
SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse),
SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search),
SideBarTiles( SideBarTiles(
id: "library", icon: SpotubeIcons.library, title: l10n.library), id: "browse",
SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics), name: HomePage.name,
]; icon: SpotubeIcons.home,
title: l10n.browse,
List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [ ),
SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), SideBarTiles(
SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), id: "search",
name: SearchPage.name,
icon: SpotubeIcons.search,
title: l10n.search,
),
SideBarTiles( SideBarTiles(
id: "library", id: "library",
name: LibraryPage.name,
icon: SpotubeIcons.library, icon: SpotubeIcons.library,
title: l10n.library, title: l10n.library,
), ),
SideBarTiles( SideBarTiles(
id: "settings", id: "lyrics",
icon: SpotubeIcons.settings, name: LyricsPage.name,
title: l10n.settings, icon: SpotubeIcons.music,
) title: l10n.lyrics,
),
SideBarTiles(
id: "stats",
name: StatsPage.name,
icon: SpotubeIcons.chart,
title: l10n.stats,
),
];
List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [
SideBarTiles(
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: "stats",
name: StatsPage.name,
icon: SpotubeIcons.chart,
title: l10n.stats,
),
]; ];

View File

@ -121,6 +121,7 @@ abstract class SpotubeIcons {
static const monitor = FeatherIcons.monitor; static const monitor = FeatherIcons.monitor;
static const power = FeatherIcons.power; static const power = FeatherIcons.power;
static const bluetooth = FeatherIcons.bluetooth; static const bluetooth = FeatherIcons.bluetooth;
static const chart = FeatherIcons.barChart2;
static const folderAdd = FeatherIcons.folderPlus; static const folderAdd = FeatherIcons.folderPlus;
static const folderRemove = FeatherIcons.folderMinus; static const folderRemove = FeatherIcons.folderMinus;
} }

View File

@ -9,7 +9,9 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/connect/connect.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/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -32,6 +34,7 @@ class AlbumCard extends HookConsumerWidget {
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.read(playbackHistoryProvider.notifier);
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlist.containsCollection(album.id!), () => playlist.containsCollection(album.id!),
@ -62,7 +65,14 @@ class AlbumCard extends HookConsumerWidget {
description: description:
"${album.albumType?.formatted}${album.artists?.asString() ?? ""}", "${album.albumType?.formatted}${album.artists?.asString() ?? ""}",
onTap: () { onTap: () {
ServiceUtils.push(context, "/album/${album.id}", extra: album); ServiceUtils.pushNamed(
context,
AlbumPage.name,
pathParameters: {
"id": album.id!,
},
extra: album,
);
}, },
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
updating.value = true; updating.value = true;
@ -79,14 +89,15 @@ class AlbumCard extends HookConsumerWidget {
if (isRemoteDevice) { if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( WebSocketLoadEventData.album(
tracks: fetchedTracks, tracks: fetchedTracks,
collectionId: album.id!, collection: album,
), ),
); );
} else { } else {
await playlistNotifier.load(fetchedTracks, autoPlay: true); await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
historyNotifier.addAlbums([album]);
} }
} finally { } finally {
updating.value = false; updating.value = false;
@ -104,6 +115,7 @@ class AlbumCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
historyNotifier.addAlbums([album]);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( final snackbar = SnackBar(
content: Text( content: Text(

View File

@ -9,6 +9,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/hooks/utils/use_brightness_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/provider/blacklist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -63,7 +64,13 @@ class ArtistCard extends HookConsumerWidget {
), ),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
ServiceUtils.push(context, "/artist/${artist.id}"); ServiceUtils.pushNamed(
context,
ArtistPage.name,
pathParameters: {
"id": artist.id!,
},
);
}, },
borderRadius: radius, borderRadius: radius,
child: Padding( child: Padding(

View File

@ -3,6 +3,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/connect/connect.dart';
import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -22,7 +23,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
width: double.infinity, width: double.infinity,
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
ServiceUtils.push(context, "/connect"); ServiceUtils.pushNamed(context, ConnectPage.name);
}, },
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -59,7 +60,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
ServiceUtils.push(context, "/connect"); ServiceUtils.pushNamed(context, ConnectPage.name);
}, },
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
child: Ink( child: Ink(
@ -111,7 +112,7 @@ class ConnectDeviceButton extends HookConsumerWidget {
foregroundColor: colorScheme.onPrimary, foregroundColor: colorScheme.onPrimary,
), ),
onPressed: () { onPressed: () {
ServiceUtils.push(context, "/connect"); ServiceUtils.pushNamed(context, ConnectPage.name);
}, },
), ),
), ),

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.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/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/provider/spotify/views/home.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -41,8 +42,13 @@ class HomePageFeedSection extends HookConsumerWidget {
child: TextButton.icon( child: TextButton.icon(
label: const Text("Browse More"), label: const Text("Browse More"),
icon: const Icon(SpotubeIcons.angleRight), icon: const Icon(SpotubeIcons.angleRight),
onPressed: () => onPressed: () => ServiceUtils.pushNamed(
ServiceUtils.push(context, "/feeds/${section.uri}"), context,
HomeFeedSectionPage.name,
pathParameters: {
"feedId": section.uri,
},
),
), ),
), ),
); );

View File

@ -6,6 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/models/spotify_friends.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'; import 'package:spotube/provider/spotify_provider.dart';
class FriendItem extends HookConsumerWidget { class FriendItem extends HookConsumerWidget {
@ -57,7 +60,9 @@ class FriendItem extends HookConsumerWidget {
text: friend.track.name, text: friend.track.name,
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () { ..onTap = () {
context.push("/track/${friend.track.id}"); context.pushNamed(TrackPage.name, pathParameters: {
"id": friend.track.id,
});
}, },
), ),
const TextSpan(text: ""), const TextSpan(text: ""),
@ -71,8 +76,12 @@ class FriendItem extends HookConsumerWidget {
text: " ${friend.track.artist.name}", text: " ${friend.track.artist.name}",
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () { ..onTap = () {
context.push( context.pushNamed(
"/artist/${friend.track.artist.id}", ArtistPage.name,
pathParameters: {
"id": friend.track.artist.id,
},
extra: friend.track.artist,
); );
}, },
), ),
@ -105,8 +114,11 @@ class FriendItem extends HookConsumerWidget {
final album = final album =
await spotify.albums.get(friend.track.album.id); await spotify.albums.get(friend.track.album.id);
if (context.mounted) { if (context.mounted) {
context.push( context.pushNamed(
"/album/${friend.track.album.id}", AlbumPage.name,
pathParameters: {
"id": friend.track.album.id,
},
extra: album, extra: album,
); );
} }

View File

@ -13,6 +13,8 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.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'; import 'package:spotube/provider/spotify/spotify.dart';
class HomeGenresSection extends HookConsumerWidget { class HomeGenresSection extends HookConsumerWidget {
@ -50,7 +52,7 @@ class HomeGenresSection extends HookConsumerWidget {
textDirection: TextDirection.rtl, textDirection: TextDirection.rtl,
child: TextButton.icon( child: TextButton.icon(
onPressed: () { onPressed: () {
context.push('/genres'); context.pushNamed(GenrePage.name);
}, },
icon: const Icon(SpotubeIcons.angleRight), icon: const Icon(SpotubeIcons.angleRight),
label: Text( label: Text(
@ -110,7 +112,13 @@ class HomeGenresSection extends HookConsumerWidget {
return InkWell( return InkWell(
onTap: () { onTap: () {
context.push('/genre/${category.id}', extra: category); context.pushNamed(
GenrePlaylistsPage.name,
pathParameters: {
"categoryId": category.id!,
},
extra: category,
);
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Ink( child: Ink(

View File

@ -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: () {},
);
}
}

View File

@ -11,6 +11,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_brightness_value.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/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
@ -57,14 +58,13 @@ class LocalFolderItem extends HookConsumerWidget {
return InkWell( return InkWell(
onTap: () { onTap: () {
if (isDownloadFolder) { context.goNamed(
context.go("/library/local?downloads=1", extra: folder); LocalLibraryPage.name,
} else { queryParameters: {
context.go( if (isDownloadFolder) "downloads": 1,
"/library/local", },
extra: folder, extra: folder,
); );
}
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Ink( child: Ink(

View File

@ -6,7 +6,9 @@ import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/connect/connect.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/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -22,6 +24,8 @@ class PlaylistCard extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlistQueue = ref.watch(proxyPlaylistProvider); final playlistQueue = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.read(playbackHistoryProvider.notifier);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
@ -55,9 +59,12 @@ class PlaylistCard extends HookConsumerWidget {
isOwner: playlist.owner?.id == me.asData?.value.id && isOwner: playlist.owner?.id == me.asData?.value.id &&
me.asData?.value.id != null, me.asData?.value.id != null,
onTap: () { onTap: () {
ServiceUtils.push( ServiceUtils.pushNamed(
context, context,
"/playlist/${playlist.id}", PlaylistPage.name,
pathParameters: {
"id": playlist.id!,
},
extra: playlist, extra: playlist,
); );
}, },
@ -78,14 +85,15 @@ class PlaylistCard extends HookConsumerWidget {
if (isRemoteDevice) { if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( WebSocketLoadEventData.playlist(
tracks: fetchedTracks, tracks: fetchedTracks,
collectionId: playlist.id!, collection: playlist,
), ),
); );
} else { } else {
await playlistNotifier.load(fetchedTracks, autoPlay: true); await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(playlist.id!); playlistNotifier.addCollection(playlist.id!);
historyNotifier.addPlaylists([playlist]);
} }
} finally { } finally {
if (context.mounted) { if (context.mounted) {
@ -104,6 +112,7 @@ class PlaylistCard extends HookConsumerWidget {
playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(playlist.id!); playlistNotifier.addCollection(playlist.id!);
historyNotifier.addPlaylists([playlist]);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( final snackbar = SnackBar(
content: Text("Added ${fetchedTracks.length} tracks to queue"), content: Text("Added ${fetchedTracks.length} tracks to queue"),

View File

@ -16,6 +16,8 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/hooks/controllers/use_sidebarx_controller.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/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/spotify/spotify.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'; import 'package:spotube/utils/service_utils.dart';
class Sidebar extends HookConsumerWidget { class Sidebar extends HookConsumerWidget {
final int? selectedIndex;
final void Function(int) onSelectedIndexChanged;
final Widget child; final Widget child;
const Sidebar({ const Sidebar({
required this.selectedIndex,
required this.onSelectedIndexChanged,
required this.child, required this.child,
super.key, super.key,
}); });
@ -47,12 +45,9 @@ class Sidebar extends HookConsumerWidget {
); );
} }
static void goToSettings(BuildContext context) {
GoRouter.of(context).go("/settings");
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final routerState = GoRouterState.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final downloadCount = ref.watch(downloadManagerProvider).$downloadCount;
@ -60,8 +55,17 @@ class Sidebar extends HookConsumerWidget {
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.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( final controller = useSidebarXController(
selectedIndex: selectedIndex ?? 0, selectedIndex: selectedIndex,
extended: mediaQuery.lgAndUp, extended: mediaQuery.lgAndUp,
); );
@ -73,29 +77,6 @@ class Sidebar extends HookConsumerWidget {
Color.lerp(bg, Colors.black, 0.45)!, 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(() { useEffect(() {
if (!context.mounted) return; if (!context.mounted) return;
if (mediaQuery.lgAndUp && !controller.extended) { if (mediaQuery.lgAndUp && !controller.extended) {
@ -106,6 +87,13 @@ class Sidebar extends HookConsumerWidget {
return null; return null;
}, [mediaQuery, controller]); }, [mediaQuery, controller]);
useEffect(() {
if (controller.selectedIndex != selectedIndex) {
controller.selectIndex(selectedIndex);
}
return null;
}, [selectedIndex]);
if (layoutMode == LayoutMode.compact || if (layoutMode == LayoutMode.compact ||
(mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) {
return Scaffold(body: child); return Scaffold(body: child);
@ -119,7 +107,11 @@ class Sidebar extends HookConsumerWidget {
items: sidebarTileList.mapIndexed( items: sidebarTileList.mapIndexed(
(index, e) { (index, e) {
return SidebarXItem( return SidebarXItem(
iconWidget: Badge( onTap: () {
context.goNamed(e.name);
},
iconBuilder: (selected, hovered) {
return Badge(
backgroundColor: theme.colorScheme.primary, backgroundColor: theme.colorScheme.primary,
isLabelVisible: e.title == "Library" && downloadCount > 0, isLabelVisible: e.title == "Library" && downloadCount > 0,
label: Text( label: Text(
@ -131,11 +123,12 @@ class Sidebar extends HookConsumerWidget {
), ),
child: Icon( child: Icon(
e.icon, e.icon,
color: selectedIndex == index color: selected || hovered
? theme.colorScheme.primary ? theme.colorScheme.primary
: null, : null,
), ),
), );
},
label: e.title, label: e.title,
); );
}, },
@ -257,7 +250,7 @@ class SidebarFooter extends HookConsumerWidget {
if (mediaQuery.mdAndDown) { if (mediaQuery.mdAndDown) {
return IconButton( return IconButton(
icon: const Icon(SpotubeIcons.settings), icon: const Icon(SpotubeIcons.settings),
onPressed: () => Sidebar.goToSettings(context), onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name),
); );
} }
@ -278,7 +271,7 @@ class SidebarFooter extends HookConsumerWidget {
Flexible( Flexible(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
ServiceUtils.push(context, "/profile"); ServiceUtils.pushNamed(context, ProfilePage.name);
}, },
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(30),
child: Row( child: Row(
@ -310,7 +303,7 @@ class SidebarFooter extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(SpotubeIcons.settings), icon: const Icon(SpotubeIcons.settings),
onPressed: () { onPressed: () {
Sidebar.goToSettings(context); ServiceUtils.pushNamed(context, SettingsPage.name);
}, },
), ),
], ],

View File

@ -3,55 +3,54 @@ import 'dart:ui';
import 'package:curved_navigation_bar/curved_navigation_bar.dart'; import 'package:curved_navigation_bar/curved_navigation_bar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/side_bar_tiles.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/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/provider/download_manager_provider.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_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/utils/service_utils.dart';
final navigationPanelHeight = StateProvider<double>((ref) => 50); final navigationPanelHeight = StateProvider<double>((ref) => 50);
class SpotubeNavigationBar extends HookConsumerWidget { class SpotubeNavigationBar extends HookConsumerWidget {
final int? selectedIndex;
final void Function(int) onSelectedIndexChanged;
const SpotubeNavigationBar({ const SpotubeNavigationBar({
required this.selectedIndex,
required this.onSelectedIndexChanged,
super.key, super.key,
}); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final routerState = GoRouterState.of(context);
final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final downloadCount = ref.watch(downloadManagerProvider).$downloadCount;
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
final insideSelectedIndex = useState<int>(selectedIndex ?? 0);
final buttonColor = useBrightnessValue( final buttonColor = useBrightnessValue(
theme.colorScheme.inversePrimary, theme.colorScheme.inversePrimary,
theme.colorScheme.primary.withOpacity(0.2), theme.colorScheme.primary.withOpacity(0.2),
); );
final navbarTileList = final navbarTileList = useMemoized(
useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); () => getNavbarTileList(context.l10n),
[context.l10n],
);
final panelHeight = ref.watch(navigationPanelHeight); final panelHeight = ref.watch(navigationPanelHeight);
useEffect(() { final selectedIndex = useMemoized(() {
if (selectedIndex != null) { final index = navbarTileList.indexWhere(
insideSelectedIndex.value = selectedIndex!; (e) => routerState.namedLocation(e.name) == routerState.matchedLocation,
} );
return null;
}, [selectedIndex]); return index == -1 ? 0 : index;
}, [navbarTileList, routerState.matchedLocation]);
if (layoutMode == LayoutMode.extended || if (layoutMode == LayoutMode.extended ||
(mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) ||
@ -91,14 +90,9 @@ class SpotubeNavigationBar extends HookConsumerWidget {
}); });
}, },
).toList(), ).toList(),
index: insideSelectedIndex.value, index: selectedIndex,
onTap: (i) { onTap: (i) {
insideSelectedIndex.value = i; ServiceUtils.navigateNamed(context, navbarTileList[i].name);
if (navbarTileList[i].id == "settings") {
Sidebar.goToSettings(context);
return;
}
onSelectedIndexChanged(i);
}, },
), ),
), ),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -25,7 +26,7 @@ class AnonymousFallback extends ConsumerWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
FilledButton( FilledButton(
child: Text(context.l10n.login_with_spotify), child: Text(context.l10n.login_with_spotify),
onPressed: () => ServiceUtils.push(context, "/settings"), onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name),
) )
], ],
), ),

View File

@ -96,7 +96,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
return switch (item) { return switch (item) {
PlaylistSimple() => PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple), PlaylistCard(item as PlaylistSimple),
AlbumSimple() => AlbumCard(item as Album), AlbumSimple() => AlbumCard(item as AlbumSimple),
Artist() => Padding( Artist() => Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12.0), horizontal: 12.0),

View File

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class ArtistLink extends StatelessWidget { class ArtistLink extends StatelessWidget {
@ -40,9 +41,12 @@ class ArtistLink extends StatelessWidget {
if (onRouteChange != null) { if (onRouteChange != null) {
onRouteChange?.call("/artist/${artist.value.id}"); onRouteChange?.call("/artist/${artist.value.id}");
} else { } else {
ServiceUtils.push( ServiceUtils.pushNamed(
context, context,
"/artist/${artist.value.id}", ArtistPage.name,
pathParameters: {
"id": artist.value.id!,
},
); );
} }
}, },

View File

@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List<Widget> tabs; final List<Widget> tabs;
const ThemedButtonsTabBar({super.key, required this.tabs}); final TabController? controller;
const ThemedButtonsTabBar({super.key, required this.tabs, this.controller});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
bottom: 8, bottom: 8,
), ),
child: ButtonsTabBar( child: ButtonsTabBar(
controller: controller,
radius: 100, radius: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor,

View File

@ -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/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.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) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(proxyPlaylistProvider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks)); final trackViewState = ref.watch(trackViewProvider(props.tracks));
@ -146,9 +148,15 @@ class TrackViewBodySection extends HookConsumerWidget {
} else { } else {
final tracks = await props.pagination.onFetchAll(); final tracks = await props.pagination.onFetchAll();
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: tracks, tracks: tracks,
collectionId: props.collectionId, collection: props.collection as AlbumSimple,
initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: props.collection as PlaylistSimple,
initialIndex: index, initialIndex: index,
), ),
); );
@ -164,6 +172,13 @@ class TrackViewBodySection extends HookConsumerWidget {
autoPlay: true, autoPlay: true,
); );
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier
.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
} }
} }
}, },

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.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/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_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/provider/user_preferences/user_preferences_state.dart';
@ -23,6 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget {
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final audioSource = final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource)); ref.watch(userPreferencesProvider.select((s) => s.audioSource));
@ -72,6 +75,12 @@ class TrackViewBodyOptions extends HookConsumerWidget {
{ {
playlistNotifier.addTracksAtFirst(selectedTracks); playlistNotifier.addTracksAtFirst(selectedTracks);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll(); trackViewState.deselectAll();
break; break;
} }
@ -79,6 +88,12 @@ class TrackViewBodyOptions extends HookConsumerWidget {
{ {
playlistNotifier.addTracks(selectedTracks); playlistNotifier.addTracks(selectedTracks);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll(); trackViewState.deselectAll();
break; break;
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/heart_button.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/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.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'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
class TrackViewHeaderActions extends HookConsumerWidget { class TrackViewHeaderActions extends HookConsumerWidget {
@ -20,6 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final playlist = ref.watch(proxyPlaylistProvider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);
@ -61,6 +64,13 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final tracks = await props.pagination.onFetchAll(); final tracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(tracks); await playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(props.collectionId); 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) if (props.onHeart != null && auth != null)

View File

@ -5,12 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.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/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -28,6 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final playlist = ref.watch(proxyPlaylistProvider); final playlist = ref.watch(proxyPlaylistProvider);
final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);
@ -52,10 +55,16 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
if (isRemoteDevice) { if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks, tracks: allTracks,
collectionId: props.collectionId, collection: props.collection as AlbumSimple,
initialIndex: Random().nextInt(allTracks.length)), initialIndex: Random().nextInt(allTracks.length))
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: props.collection as PlaylistSimple,
initialIndex: Random().nextInt(allTracks.length),
),
); );
await remotePlayback.setShuffle(true); await remotePlayback.setShuffle(true);
} else { } else {
@ -66,6 +75,11 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
); );
await audioPlayer.setShuffle(true); await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@ -84,14 +98,24 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
if (isRemoteDevice) { if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier); final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks, tracks: allTracks,
collectionId: props.collectionId, collection: props.collection as AlbumSimple,
)
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: props.collection as PlaylistSimple,
), ),
); );
} else { } else {
await playlistNotifier.load(allTracks, autoPlay: true); await playlistNotifier.load(allTracks, autoPlay: true);
playlistNotifier.addCollection(props.collectionId); playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;

View File

@ -39,7 +39,7 @@ class PaginationProps {
} }
class InheritedTrackView extends InheritedWidget { class InheritedTrackView extends InheritedWidget {
final String collectionId; final Object collection;
final String title; final String title;
final String? description; final String? description;
final String image; final String image;
@ -55,7 +55,7 @@ class InheritedTrackView extends InheritedWidget {
const InheritedTrackView({ const InheritedTrackView({
super.key, super.key,
required super.child, required super.child,
required this.collectionId, required this.collection,
required this.title, required this.title,
this.description, this.description,
required this.image, required this.image,
@ -65,7 +65,11 @@ class InheritedTrackView extends InheritedWidget {
required this.shareUrl, required this.shareUrl,
this.isLiked = false, this.isLiked = false,
this.onHeart, this.onHeart,
}); }) : assert(collection is AlbumSimple || collection is PlaylistSimple);
String get collectionId => collection is AlbumSimple
? (collection as AlbumSimple).id!
: (collection as PlaylistSimple).id!;
@override @override
bool updateShouldNotify(InheritedTrackView oldWidget) { bool updateShouldNotify(InheritedTrackView oldWidget) {
@ -78,7 +82,7 @@ class InheritedTrackView extends InheritedWidget {
oldWidget.onHeart != onHeart || oldWidget.onHeart != onHeart ||
oldWidget.shareUrl != shareUrl || oldWidget.shareUrl != shareUrl ||
oldWidget.routePath != routePath || oldWidget.routePath != routePath ||
oldWidget.collectionId != collectionId || oldWidget.collection != collection ||
oldWidget.child != child; oldWidget.child != child;
} }

View File

@ -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,
);
},
);
}
}

View File

@ -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!},
);
},
);
}
}

View File

@ -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,
);
},
);
}
}

View File

@ -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!,
},
);
},
);
}
}

View File

@ -0,0 +1,100 @@
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/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';
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
: constrains.lgAndDown
? 5
: 6,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: constrains.isXs ? 1.3 : 1.5,
),
delegate: SliverChildListDelegate([
SummaryCard(
title: summary.duration.inMinutes.toDouble(),
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()),
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);
},
),
]),
);
}),
);
}
}

View File

@ -0,0 +1,86 @@
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 String title;
final String unit;
final String description;
final VoidCallback? onTap;
final MaterialColor color;
SummaryCard({
super.key,
required double title,
required this.unit,
required this.description,
required this.color,
this.onTap,
}) : title = compactNumberFormatter.format(title);
const SummaryCard.unformatted({
super.key,
required this.title,
required this.unit,
required this.description,
required this.color,
this.onTap,
});
@override
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: 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,
),
),
],
),
maxLines: 1,
),
const Gap(5),
AutoSizeText(
description,
maxLines: description.contains("\n")
? descriptionNewLines.length + 1
: 1,
minFontSize: 9,
style: textTheme.labelMedium!.copyWith(
color: color.shade900,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/common/album_item.dart';
import 'package:spotube/provider/history/top.dart';
class TopAlbums extends HookConsumerWidget {
const TopAlbums({super.key});
@override
Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final albums = ref.watch(playbackHistoryTopProvider(historyDuration)
.select((value) => value.albums));
return SliverList.builder(
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return StatsAlbumItem(
album: album.album,
info: Text(
"${compactNumberFormatter.format(album.count)} plays",
),
);
},
);
}
}

View File

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/common/artist_item.dart';
import 'package:spotube/provider/history/top.dart';
class TopArtists extends HookConsumerWidget {
const TopArtists({super.key});
@override
Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final artists = ref.watch(playbackHistoryTopProvider(historyDuration)
.select((value) => value.artists));
return SliverList.builder(
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index];
return StatsArtistItem(
artist: artist.artist,
info: Text("${compactNumberFormatter.format(artist.count)} plays"),
);
},
);
}
}

View File

@ -0,0 +1,106 @@
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';
import 'package:spotube/provider/history/state.dart';
import 'package:spotube/provider/history/top.dart';
class StatsPageTopSection extends HookConsumerWidget {
const StatsPageTopSection({super.key});
@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: [
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"),
),
),
],
),
),
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, _) {
return switch (tabController.index) {
1 => const TopArtists(),
2 => const TopAlbums(),
_ => const TopTracks(),
};
},
),
],
);
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/common/track_item.dart';
import 'package:spotube/provider/history/top.dart';
class TopTracks extends HookConsumerWidget {
const TopTracks({super.key});
@override
Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final tracks = ref.watch(
playbackHistoryTopProvider(historyDuration)
.select((value) => value.tracks),
);
return SliverList.builder(
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
return StatsTrackItem(
track: track.track,
info: Text(
"${compactNumberFormatter.format(track.count)} plays",
),
);
},
);
}
}

View File

@ -1,21 +1,6 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
extension AlbumExtensions on AlbumSimple { extension AlbumExtensions on AlbumSimple {
Map<String, dynamic> 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 toAlbum() {
Album album = Album(); Album album = Album();
album.albumType = albumType; album.albumType = albumType;

View File

@ -1,17 +1,5 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
extension ArtistJson on ArtistSimple {
Map<String, dynamic> toJson() {
return {
"href": href,
"id": id,
"name": name,
"type": type,
"uri": uri,
};
}
}
extension ArtistExtension on List<ArtistSimple> { extension ArtistExtension on List<ArtistSimple> {
String asString() { String asString() {
return map((e) => e.name?.replaceAll(",", " ")).join(", "); return map((e) => e.name?.replaceAll(",", " ")).join(", ");

View File

@ -3,8 +3,6 @@ import 'dart:io';
import 'package:metadata_god/metadata_god.dart'; import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:spotify/spotify.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'; import 'package:spotube/services/audio_player/audio_player.dart';
extension TrackExtensions on Track { extension TrackExtensions on Track {
@ -39,33 +37,6 @@ extension TrackExtensions on Track {
return this; return this;
} }
Map<String, dynamic> toJson() {
return TrackExtensions.trackToJson(this);
}
static Map<String, dynamic> 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 { extension TrackSimpleExtensions on TrackSimple {

View File

@ -324,5 +324,6 @@
"select": "Select", "select": "Select",
"connect_client_alert": "You're being controlled by {client}", "connect_client_alert": "You're being controlled by {client}",
"this_device": "This Device", "this_device": "This Device",
"remote": "Remote" "remote": "Remote",
"stats": "Stats"
} }

View File

@ -5,7 +5,6 @@ import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/loop_mode.dart';

View File

@ -16,16 +16,89 @@ final _privateConstructorUsedError = UnsupportedError(
WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( WebSocketLoadEventData _$WebSocketLoadEventDataFromJson(
Map<String, dynamic> json) { Map<String, dynamic> 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 /// @nodoc
mixin _$WebSocketLoadEventData { mixin _$WebSocketLoadEventData {
@JsonKey(name: 'tracks', toJson: _tracksJson) @JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks => throw _privateConstructorUsedError; List<Track> get tracks => throw _privateConstructorUsedError;
String? get collectionId => throw _privateConstructorUsedError; Object? get collection => throw _privateConstructorUsedError;
int? get initialIndex => throw _privateConstructorUsedError; int? get initialIndex => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)
playlist,
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)
album,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
playlist,
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
album,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
playlist,
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
album,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(WebSocketLoadEventDataPlaylist value) playlist,
required TResult Function(WebSocketLoadEventDataAlbum value) album,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist,
TResult? Function(WebSocketLoadEventDataAlbum value)? album,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(WebSocketLoadEventDataPlaylist value)? playlist,
TResult Function(WebSocketLoadEventDataAlbum value)? album,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true) @JsonKey(ignore: true)
$WebSocketLoadEventDataCopyWith<WebSocketLoadEventData> get copyWith => $WebSocketLoadEventDataCopyWith<WebSocketLoadEventData> get copyWith =>
@ -40,7 +113,6 @@ abstract class $WebSocketLoadEventDataCopyWith<$Res> {
@useResult @useResult
$Res call( $Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks, {@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
String? collectionId,
int? initialIndex}); int? initialIndex});
} }
@ -59,7 +131,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res,
@override @override
$Res call({ $Res call({
Object? tracks = null, Object? tracks = null,
Object? collectionId = freezed,
Object? initialIndex = freezed, Object? initialIndex = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
@ -67,10 +138,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res,
? _value.tracks ? _value.tracks
: tracks // ignore: cast_nullable_to_non_nullable : tracks // ignore: cast_nullable_to_non_nullable
as List<Track>, as List<Track>,
collectionId: freezed == collectionId
? _value.collectionId
: collectionId // ignore: cast_nullable_to_non_nullable
as String?,
initialIndex: freezed == initialIndex initialIndex: freezed == initialIndex
? _value.initialIndex ? _value.initialIndex
: initialIndex // ignore: cast_nullable_to_non_nullable : initialIndex // ignore: cast_nullable_to_non_nullable
@ -80,46 +147,46 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res,
} }
/// @nodoc /// @nodoc
abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res>
implements $WebSocketLoadEventDataCopyWith<$Res> { implements $WebSocketLoadEventDataCopyWith<$Res> {
factory _$$WebSocketLoadEventDataImplCopyWith( factory _$$WebSocketLoadEventDataPlaylistImplCopyWith(
_$WebSocketLoadEventDataImpl value, _$WebSocketLoadEventDataPlaylistImpl value,
$Res Function(_$WebSocketLoadEventDataImpl) then) = $Res Function(_$WebSocketLoadEventDataPlaylistImpl) then) =
__$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>;
@override @override
@useResult @useResult
$Res call( $Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks, {@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
String? collectionId, PlaylistSimple? collection,
int? initialIndex}); int? initialIndex});
} }
/// @nodoc /// @nodoc
class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>
extends _$WebSocketLoadEventDataCopyWithImpl<$Res, extends _$WebSocketLoadEventDataCopyWithImpl<$Res,
_$WebSocketLoadEventDataImpl> _$WebSocketLoadEventDataPlaylistImpl>
implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { implements _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> {
__$$WebSocketLoadEventDataImplCopyWithImpl( __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl(
_$WebSocketLoadEventDataImpl _value, _$WebSocketLoadEventDataPlaylistImpl _value,
$Res Function(_$WebSocketLoadEventDataImpl) _then) $Res Function(_$WebSocketLoadEventDataPlaylistImpl) _then)
: super(_value, _then); : super(_value, _then);
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
@override @override
$Res call({ $Res call({
Object? tracks = null, Object? tracks = null,
Object? collectionId = freezed, Object? collection = freezed,
Object? initialIndex = freezed, Object? initialIndex = freezed,
}) { }) {
return _then(_$WebSocketLoadEventDataImpl( return _then(_$WebSocketLoadEventDataPlaylistImpl(
tracks: null == tracks tracks: null == tracks
? _value._tracks ? _value._tracks
: tracks // ignore: cast_nullable_to_non_nullable : tracks // ignore: cast_nullable_to_non_nullable
as List<Track>, as List<Track>,
collectionId: freezed == collectionId collection: freezed == collection
? _value.collectionId ? _value.collection
: collectionId // ignore: cast_nullable_to_non_nullable : collection // ignore: cast_nullable_to_non_nullable
as String?, as PlaylistSimple?,
initialIndex: freezed == initialIndex initialIndex: freezed == initialIndex
? _value.initialIndex ? _value.initialIndex
: initialIndex // ignore: cast_nullable_to_non_nullable : initialIndex // ignore: cast_nullable_to_non_nullable
@ -130,16 +197,21 @@ class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { class _$WebSocketLoadEventDataPlaylistImpl
_$WebSocketLoadEventDataImpl( extends WebSocketLoadEventDataPlaylist {
_$WebSocketLoadEventDataPlaylistImpl(
{@JsonKey(name: 'tracks', toJson: _tracksJson) {@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks, required final List<Track> tracks,
this.collectionId, this.collection,
this.initialIndex}) this.initialIndex,
: _tracks = tracks; final String? $type})
: _tracks = tracks,
$type = $type ?? 'playlist',
super._();
factory _$WebSocketLoadEventDataImpl.fromJson(Map<String, dynamic> json) => factory _$WebSocketLoadEventDataPlaylistImpl.fromJson(
_$$WebSocketLoadEventDataImplFromJson(json); Map<String, dynamic> json) =>
_$$WebSocketLoadEventDataPlaylistImplFromJson(json);
final List<Track> _tracks; final List<Track> _tracks;
@override @override
@ -151,23 +223,26 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData {
} }
@override @override
final String? collectionId; final PlaylistSimple? collection;
@override @override
final int? initialIndex; final int? initialIndex;
@JsonKey(name: 'runtimeType')
final String $type;
@override @override
String toString() { String toString() {
return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; return 'WebSocketLoadEventData.playlist(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)';
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || return identical(this, other) ||
(other.runtimeType == runtimeType && (other.runtimeType == runtimeType &&
other is _$WebSocketLoadEventDataImpl && other is _$WebSocketLoadEventDataPlaylistImpl &&
const DeepCollectionEquality().equals(other._tracks, _tracks) && const DeepCollectionEquality().equals(other._tracks, _tracks) &&
(identical(other.collectionId, collectionId) || (identical(other.collection, collection) ||
other.collectionId == collectionId) && other.collection == collection) &&
(identical(other.initialIndex, initialIndex) || (identical(other.initialIndex, initialIndex) ||
other.initialIndex == initialIndex)); other.initialIndex == initialIndex));
} }
@ -175,42 +250,361 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData {
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
int get hashCode => Object.hash(runtimeType, int get hashCode => Object.hash(runtimeType,
const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); const DeepCollectionEquality().hash(_tracks), collection, initialIndex);
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
_$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> _$$WebSocketLoadEventDataPlaylistImplCopyWith<
get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< _$WebSocketLoadEventDataPlaylistImpl>
_$WebSocketLoadEventDataImpl>(this, _$identity); get copyWith => __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<
_$WebSocketLoadEventDataPlaylistImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)
playlist,
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)
album,
}) {
return playlist(tracks, collection, initialIndex);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
playlist,
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
album,
}) {
return playlist?.call(tracks, collection, initialIndex);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
playlist,
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
album,
required TResult orElse(),
}) {
if (playlist != null) {
return playlist(tracks, collection, initialIndex);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(WebSocketLoadEventDataPlaylist value) playlist,
required TResult Function(WebSocketLoadEventDataAlbum value) album,
}) {
return playlist(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist,
TResult? Function(WebSocketLoadEventDataAlbum value)? album,
}) {
return playlist?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(WebSocketLoadEventDataPlaylist value)? playlist,
TResult Function(WebSocketLoadEventDataAlbum value)? album,
required TResult orElse(),
}) {
if (playlist != null) {
return playlist(this);
}
return orElse();
}
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return _$$WebSocketLoadEventDataImplToJson( return _$$WebSocketLoadEventDataPlaylistImplToJson(
this, this,
); );
} }
} }
abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData {
factory _WebSocketLoadEventData( factory WebSocketLoadEventDataPlaylist(
{@JsonKey(name: 'tracks', toJson: _tracksJson) {@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks, required final List<Track> tracks,
final String? collectionId, final PlaylistSimple? collection,
final int? initialIndex}) = _$WebSocketLoadEventDataImpl; final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl;
WebSocketLoadEventDataPlaylist._() : super._();
factory _WebSocketLoadEventData.fromJson(Map<String, dynamic> json) = factory WebSocketLoadEventDataPlaylist.fromJson(Map<String, dynamic> json) =
_$WebSocketLoadEventDataImpl.fromJson; _$WebSocketLoadEventDataPlaylistImpl.fromJson;
@override @override
@JsonKey(name: 'tracks', toJson: _tracksJson) @JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks; List<Track> get tracks;
@override @override
String? get collectionId; PlaylistSimple? get collection;
@override @override
int? get initialIndex; int? get initialIndex;
@override @override
@JsonKey(ignore: true) @JsonKey(ignore: true)
_$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> _$$WebSocketLoadEventDataPlaylistImplCopyWith<
_$WebSocketLoadEventDataPlaylistImpl>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res>
implements $WebSocketLoadEventDataCopyWith<$Res> {
factory _$$WebSocketLoadEventDataAlbumImplCopyWith(
_$WebSocketLoadEventDataAlbumImpl value,
$Res Function(_$WebSocketLoadEventDataAlbumImpl) then) =
__$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex});
}
/// @nodoc
class __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>
extends _$WebSocketLoadEventDataCopyWithImpl<$Res,
_$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? collection = freezed,
Object? initialIndex = freezed,
}) {
return _then(_$WebSocketLoadEventDataAlbumImpl(
tracks: null == tracks
? _value._tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<Track>,
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
as int?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum {
_$WebSocketLoadEventDataAlbumImpl(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
this.collection,
this.initialIndex,
final String? $type})
: _tracks = tracks,
$type = $type ?? 'album',
super._();
factory _$WebSocketLoadEventDataAlbumImpl.fromJson(
Map<String, dynamic> json) =>
_$$WebSocketLoadEventDataAlbumImplFromJson(json);
final List<Track> _tracks;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks {
if (_tracks is EqualUnmodifiableListView) return _tracks;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tracks);
}
@override
final AlbumSimple? collection;
@override
final int? initialIndex;
@JsonKey(name: 'runtimeType')
final String $type;
@override
String toString() {
return 'WebSocketLoadEventData.album(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$WebSocketLoadEventDataAlbumImpl &&
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')
_$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl>
get copyWith => __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<
_$WebSocketLoadEventDataAlbumImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)
playlist,
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)
album,
}) {
return album(tracks, collection, initialIndex);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
playlist,
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
album,
}) {
return album?.call(tracks, collection, initialIndex);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
playlist,
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
album,
required TResult orElse(),
}) {
if (album != null) {
return album(tracks, collection, initialIndex);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(WebSocketLoadEventDataPlaylist value) playlist,
required TResult Function(WebSocketLoadEventDataAlbum value) album,
}) {
return album(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist,
TResult? Function(WebSocketLoadEventDataAlbum value)? album,
}) {
return album?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(WebSocketLoadEventDataPlaylist value)? playlist,
TResult Function(WebSocketLoadEventDataAlbum value)? album,
required TResult orElse(),
}) {
if (album != null) {
return album(this);
}
return orElse();
}
@override
Map<String, dynamic> toJson() {
return _$$WebSocketLoadEventDataAlbumImplToJson(
this,
);
}
}
abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData {
factory WebSocketLoadEventDataAlbum(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
final AlbumSimple? collection,
final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl;
WebSocketLoadEventDataAlbum._() : super._();
factory WebSocketLoadEventDataAlbum.fromJson(Map<String, dynamic> json) =
_$WebSocketLoadEventDataAlbumImpl.fromJson;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks;
@override
AlbumSimple? get collection;
@override
int? get initialIndex;
@override
@JsonKey(ignore: true)
_$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl>
get copyWith => throw _privateConstructorUsedError; get copyWith => throw _privateConstructorUsedError;
} }

View File

@ -6,20 +6,48 @@ part of 'connect.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( _$WebSocketLoadEventDataPlaylistImpl
Map<String, dynamic> json) => _$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) =>
_$WebSocketLoadEventDataImpl( _$WebSocketLoadEventDataPlaylistImpl(
tracks: (json['tracks'] as List<dynamic>) tracks: (json['tracks'] as List<dynamic>)
.map((e) => Track.fromJson(e as Map<String, dynamic>)) .map((e) => Track.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
collectionId: json['collectionId'] as String?, collection: json['collection'] == null
? null
: PlaylistSimple.fromJson(
Map<String, dynamic>.from(json['collection'] as Map)),
initialIndex: json['initialIndex'] as int?, initialIndex: json['initialIndex'] as int?,
$type: json['runtimeType'] as String?,
); );
Map<String, dynamic> _$$WebSocketLoadEventDataImplToJson( Map<String, dynamic> _$$WebSocketLoadEventDataPlaylistImplToJson(
_$WebSocketLoadEventDataImpl instance) => _$WebSocketLoadEventDataPlaylistImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'tracks': _tracksJson(instance.tracks), 'tracks': _tracksJson(instance.tracks),
'collectionId': instance.collectionId, 'collection': instance.collection?.toJson(),
'initialIndex': instance.initialIndex, 'initialIndex': instance.initialIndex,
'runtimeType': instance.$type,
};
_$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson(
Map json) =>
_$WebSocketLoadEventDataAlbumImpl(
tracks: (json['tracks'] as List<dynamic>)
.map((e) => Track.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
collection: json['collection'] == null
? null
: AlbumSimple.fromJson(
Map<String, dynamic>.from(json['collection'] as Map)),
initialIndex: json['initialIndex'] as int?,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$WebSocketLoadEventDataAlbumImplToJson(
_$WebSocketLoadEventDataAlbumImpl instance) =>
<String, dynamic>{
'tracks': _tracksJson(instance.tracks),
'collection': instance.collection?.toJson(),
'initialIndex': instance.initialIndex,
'runtimeType': instance.$type,
}; };

View File

@ -6,14 +6,27 @@ List<Map<String, dynamic>> _tracksJson(List<Track> tracks) {
@freezed @freezed
class WebSocketLoadEventData with _$WebSocketLoadEventData { class WebSocketLoadEventData with _$WebSocketLoadEventData {
factory WebSocketLoadEventData({ const WebSocketLoadEventData._();
factory WebSocketLoadEventData.playlist({
@JsonKey(name: 'tracks', toJson: _tracksJson) required List<Track> tracks, @JsonKey(name: 'tracks', toJson: _tracksJson) required List<Track> tracks,
String? collectionId, PlaylistSimple? collection,
int? initialIndex, int? initialIndex,
}) = _WebSocketLoadEventData; }) = WebSocketLoadEventDataPlaylist;
factory WebSocketLoadEventData.album({
@JsonKey(name: 'tracks', toJson: _tracksJson) required List<Track> tracks,
AlbumSimple? collection,
int? initialIndex,
}) = WebSocketLoadEventDataAlbum;
factory WebSocketLoadEventData.fromJson(Map<String, dynamic> json) => factory WebSocketLoadEventData.fromJson(Map<String, dynamic> json) =>
_$WebSocketLoadEventDataFromJson(json); _$WebSocketLoadEventDataFromJson(json);
String? get collectionId => when(
playlist: (tracks, collection, _) => collection?.id,
album: (tracks, collection, _) => collection?.id,
);
} }
class WebSocketLoadEvent extends WebSocketEvent<WebSocketLoadEventData> { class WebSocketLoadEvent extends WebSocketEvent<WebSocketLoadEventData> {

View File

@ -1,6 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
class CurrentPlaylist { class CurrentPlaylist {

View File

@ -1,5 +1,4 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
class LocalTrack extends Track { class LocalTrack extends Track {
final String path; final String path;
@ -35,9 +34,10 @@ class LocalTrack extends Track {
); );
} }
@override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
...TrackExtensions.trackToJson(this), ...super.toJson(),
'path': path, 'path': path,
}; };
} }

View File

@ -97,7 +97,7 @@ class SourceTypeAdapter extends TypeAdapter<SourceType> {
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
SourceMatch _$SourceMatchFromJson(Map<String, dynamic> json) => SourceMatch( SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch(
id: json['id'] as String, id: json['id'] as String,
sourceId: json['sourceId'] as String, sourceId: json['sourceId'] as String,
sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']),

View File

@ -6,14 +6,13 @@ part of 'home_feed.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson( _$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(Map json) =>
Map<String, dynamic> json) =>
_$SpotifySectionPlaylistImpl( _$SpotifySectionPlaylistImpl(
description: json['description'] as String, description: json['description'] as String,
format: json['format'] as String, format: json['format'] as String,
images: (json['images'] as List<dynamic>) images: (json['images'] as List<dynamic>)
.map((e) => .map((e) => SpotifySectionItemImage.fromJson(
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>)) Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
name: json['name'] as String, name: json['name'] as String,
owner: json['owner'] as String, owner: json['owner'] as String,
@ -25,20 +24,19 @@ Map<String, dynamic> _$$SpotifySectionPlaylistImplToJson(
<String, dynamic>{ <String, dynamic>{
'description': instance.description, 'description': instance.description,
'format': instance.format, 'format': instance.format,
'images': instance.images, 'images': instance.images.map((e) => e.toJson()).toList(),
'name': instance.name, 'name': instance.name,
'owner': instance.owner, 'owner': instance.owner,
'uri': instance.uri, 'uri': instance.uri,
}; };
_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson( _$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(Map json) =>
Map<String, dynamic> json) =>
_$SpotifySectionArtistImpl( _$SpotifySectionArtistImpl(
name: json['name'] as String, name: json['name'] as String,
uri: json['uri'] as String, uri: json['uri'] as String,
images: (json['images'] as List<dynamic>) images: (json['images'] as List<dynamic>)
.map((e) => .map((e) => SpotifySectionItemImage.fromJson(
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>)) Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
); );
@ -47,19 +45,18 @@ Map<String, dynamic> _$$SpotifySectionArtistImplToJson(
<String, dynamic>{ <String, dynamic>{
'name': instance.name, 'name': instance.name,
'uri': instance.uri, 'uri': instance.uri,
'images': instance.images, 'images': instance.images.map((e) => e.toJson()).toList(),
}; };
_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( _$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(Map json) =>
Map<String, dynamic> json) =>
_$SpotifySectionAlbumImpl( _$SpotifySectionAlbumImpl(
artists: (json['artists'] as List<dynamic>) artists: (json['artists'] as List<dynamic>)
.map((e) => .map((e) => SpotifySectionAlbumArtist.fromJson(
SpotifySectionAlbumArtist.fromJson(e as Map<String, dynamic>)) Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
images: (json['images'] as List<dynamic>) images: (json['images'] as List<dynamic>)
.map((e) => .map((e) => SpotifySectionItemImage.fromJson(
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>)) Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
name: json['name'] as String, name: json['name'] as String,
uri: json['uri'] as String, uri: json['uri'] as String,
@ -68,14 +65,14 @@ _$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(
Map<String, dynamic> _$$SpotifySectionAlbumImplToJson( Map<String, dynamic> _$$SpotifySectionAlbumImplToJson(
_$SpotifySectionAlbumImpl instance) => _$SpotifySectionAlbumImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'artists': instance.artists, 'artists': instance.artists.map((e) => e.toJson()).toList(),
'images': instance.images, 'images': instance.images.map((e) => e.toJson()).toList(),
'name': instance.name, 'name': instance.name,
'uri': instance.uri, 'uri': instance.uri,
}; };
_$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson( _$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson(
Map<String, dynamic> json) => Map json) =>
_$SpotifySectionAlbumArtistImpl( _$SpotifySectionAlbumArtistImpl(
name: json['name'] as String, name: json['name'] as String,
uri: json['uri'] as String, uri: json['uri'] as String,
@ -89,7 +86,7 @@ Map<String, dynamic> _$$SpotifySectionAlbumArtistImplToJson(
}; };
_$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson( _$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson(
Map<String, dynamic> json) => Map json) =>
_$SpotifySectionItemImageImpl( _$SpotifySectionItemImageImpl(
height: json['height'] as num?, height: json['height'] as num?,
url: json['url'] as String, url: json['url'] as String,
@ -105,40 +102,40 @@ Map<String, dynamic> _$$SpotifySectionItemImageImplToJson(
}; };
_$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson( _$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson(
Map<String, dynamic> json) => Map json) =>
_$SpotifyHomeFeedSectionItemImpl( _$SpotifyHomeFeedSectionItemImpl(
typename: json['typename'] as String, typename: json['typename'] as String,
playlist: json['playlist'] == null playlist: json['playlist'] == null
? null ? null
: SpotifySectionPlaylist.fromJson( : SpotifySectionPlaylist.fromJson(
json['playlist'] as Map<String, dynamic>), Map<String, dynamic>.from(json['playlist'] as Map)),
artist: json['artist'] == null artist: json['artist'] == null
? null ? null
: SpotifySectionArtist.fromJson( : SpotifySectionArtist.fromJson(
json['artist'] as Map<String, dynamic>), Map<String, dynamic>.from(json['artist'] as Map)),
album: json['album'] == null album: json['album'] == null
? null ? null
: SpotifySectionAlbum.fromJson(json['album'] as Map<String, dynamic>), : SpotifySectionAlbum.fromJson(
Map<String, dynamic>.from(json['album'] as Map)),
); );
Map<String, dynamic> _$$SpotifyHomeFeedSectionItemImplToJson( Map<String, dynamic> _$$SpotifyHomeFeedSectionItemImplToJson(
_$SpotifyHomeFeedSectionItemImpl instance) => _$SpotifyHomeFeedSectionItemImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'typename': instance.typename, 'typename': instance.typename,
'playlist': instance.playlist, 'playlist': instance.playlist?.toJson(),
'artist': instance.artist, 'artist': instance.artist?.toJson(),
'album': instance.album, 'album': instance.album?.toJson(),
}; };
_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson( _$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(Map json) =>
Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionImpl( _$SpotifyHomeFeedSectionImpl(
typename: json['typename'] as String, typename: json['typename'] as String,
title: json['title'] as String?, title: json['title'] as String?,
uri: json['uri'] as String, uri: json['uri'] as String,
items: (json['items'] as List<dynamic>) items: (json['items'] as List<dynamic>)
.map((e) => .map((e) => SpotifyHomeFeedSectionItem.fromJson(
SpotifyHomeFeedSectionItem.fromJson(e as Map<String, dynamic>)) Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
); );
@ -148,16 +145,15 @@ Map<String, dynamic> _$$SpotifyHomeFeedSectionImplToJson(
'typename': instance.typename, 'typename': instance.typename,
'title': instance.title, 'title': instance.title,
'uri': instance.uri, 'uri': instance.uri,
'items': instance.items, 'items': instance.items.map((e) => e.toJson()).toList(),
}; };
_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson( _$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(Map json) =>
Map<String, dynamic> json) =>
_$SpotifyHomeFeedImpl( _$SpotifyHomeFeedImpl(
greeting: json['greeting'] as String, greeting: json['greeting'] as String,
sections: (json['sections'] as List<dynamic>) sections: (json['sections'] as List<dynamic>)
.map( .map((e) => SpotifyHomeFeedSection.fromJson(
(e) => SpotifyHomeFeedSection.fromJson(e as Map<String, dynamic>)) Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
); );
@ -165,5 +161,5 @@ Map<String, dynamic> _$$SpotifyHomeFeedImplToJson(
_$SpotifyHomeFeedImpl instance) => _$SpotifyHomeFeedImpl instance) =>
<String, dynamic>{ <String, dynamic>{
'greeting': instance.greeting, 'greeting': instance.greeting,
'sections': instance.sections, 'sections': instance.sections.map((e) => e.toJson()).toList(),
}; };

View File

@ -6,8 +6,7 @@ part of 'recommendation_seeds.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( _$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(Map json) =>
Map<String, dynamic> json) =>
_$RecommendationSeedsImpl( _$RecommendationSeedsImpl(
acousticness: json['acousticness'] as num?, acousticness: json['acousticness'] as num?,
danceability: json['danceability'] as num?, danceability: json['danceability'] as num?,

View File

@ -6,60 +6,55 @@ part of 'spotify_friends.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
SpotifyFriend _$SpotifyFriendFromJson(Map<String, dynamic> json) => SpotifyFriend _$SpotifyFriendFromJson(Map json) => SpotifyFriend(
SpotifyFriend(
uri: json['uri'] as String, uri: json['uri'] as String,
name: json['name'] as String, name: json['name'] as String,
imageUrl: json['imageUrl'] as String, imageUrl: json['imageUrl'] as String,
); );
SpotifyActivityArtist _$SpotifyActivityArtistFromJson( SpotifyActivityArtist _$SpotifyActivityArtistFromJson(Map json) =>
Map<String, dynamic> json) =>
SpotifyActivityArtist( SpotifyActivityArtist(
uri: json['uri'] as String, uri: json['uri'] as String,
name: json['name'] as String, name: json['name'] as String,
); );
SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson( SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson(Map json) =>
Map<String, dynamic> json) =>
SpotifyActivityAlbum( SpotifyActivityAlbum(
uri: json['uri'] as String, uri: json['uri'] as String,
name: json['name'] as String, name: json['name'] as String,
); );
SpotifyActivityContext _$SpotifyActivityContextFromJson( SpotifyActivityContext _$SpotifyActivityContextFromJson(Map json) =>
Map<String, dynamic> json) =>
SpotifyActivityContext( SpotifyActivityContext(
uri: json['uri'] as String, uri: json['uri'] as String,
name: json['name'] as String, name: json['name'] as String,
index: json['index'] as num, index: json['index'] as num,
); );
SpotifyActivityTrack _$SpotifyActivityTrackFromJson( SpotifyActivityTrack _$SpotifyActivityTrackFromJson(Map json) =>
Map<String, dynamic> json) =>
SpotifyActivityTrack( SpotifyActivityTrack(
uri: json['uri'] as String, uri: json['uri'] as String,
name: json['name'] as String, name: json['name'] as String,
imageUrl: json['imageUrl'] as String, imageUrl: json['imageUrl'] as String,
artist: SpotifyActivityArtist.fromJson( artist: SpotifyActivityArtist.fromJson(
json['artist'] as Map<String, dynamic>), Map<String, dynamic>.from(json['artist'] as Map)),
album: album: SpotifyActivityAlbum.fromJson(
SpotifyActivityAlbum.fromJson(json['album'] as Map<String, dynamic>), Map<String, dynamic>.from(json['album'] as Map)),
context: SpotifyActivityContext.fromJson( context: SpotifyActivityContext.fromJson(
json['context'] as Map<String, dynamic>), Map<String, dynamic>.from(json['context'] as Map)),
); );
SpotifyFriendActivity _$SpotifyFriendActivityFromJson( SpotifyFriendActivity _$SpotifyFriendActivityFromJson(Map json) =>
Map<String, dynamic> json) =>
SpotifyFriendActivity( SpotifyFriendActivity(
user: SpotifyFriend.fromJson(json['user'] as Map<String, dynamic>), user: SpotifyFriend.fromJson(
track: Map<String, dynamic>.from(json['user'] as Map)),
SpotifyActivityTrack.fromJson(json['track'] as Map<String, dynamic>), track: SpotifyActivityTrack.fromJson(
Map<String, dynamic>.from(json['track'] as Map)),
); );
SpotifyFriends _$SpotifyFriendsFromJson(Map<String, dynamic> json) => SpotifyFriends _$SpotifyFriendsFromJson(Map json) => SpotifyFriends(
SpotifyFriends(
friends: (json['friends'] as List<dynamic>) friends: (json['friends'] as List<dynamic>)
.map((e) => SpotifyFriendActivity.fromJson(e as Map<String, dynamic>)) .map((e) => SpotifyFriendActivity.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(), .toList(),
); );

View File

@ -8,6 +8,8 @@ import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class AlbumPage extends HookConsumerWidget { class AlbumPage extends HookConsumerWidget {
static const name = "album";
final AlbumSimple album; final AlbumSimple album;
const AlbumPage({ const AlbumPage({
super.key, super.key,
@ -22,7 +24,7 @@ class AlbumPage extends HookConsumerWidget {
final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!));
return InheritedTrackView( return InheritedTrackView(
collectionId: album.id!, collection: album,
image: album.images.asUrlString( image: album.images.asUrlString(
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
), ),

View File

@ -15,6 +15,8 @@ import 'package:spotube/pages/artist/section/top_tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class ArtistPage extends HookConsumerWidget { class ArtistPage extends HookConsumerWidget {
static const name = "artist";
final String artistId; final String artistId;
final logger = getLogger(ArtistPage); final logger = getLogger(ArtistPage);
ArtistPage(this.artistId, {super.key}); ArtistPage(this.artistId, {super.key});

View File

@ -52,8 +52,9 @@ class ArtistPageTopTracks extends HookConsumerWidget {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( WebSocketLoadEventData.playlist(
tracks: tracks, tracks: tracks,
collection: null,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
), ),
); );

View File

@ -5,10 +5,13 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/connect/local_devices.dart'; import 'package:spotube/components/connect/local_devices.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.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/provider/connect/clients.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class ConnectPage extends HookConsumerWidget { class ConnectPage extends HookConsumerWidget {
static const name = "connect";
const ConnectPage({super.key}); const ConnectPage({super.key});
@override @override
@ -65,9 +68,9 @@ class ConnectPage extends HookConsumerWidget {
selected: selected, selected: selected,
onTap: () { onTap: () {
if (selected) { if (selected) {
ServiceUtils.push( ServiceUtils.pushNamed(
context, context,
"/connect/control", ConnectControlPage.name,
); );
} else { } else {
connectClientsNotifier.resolveService(device); connectClientsNotifier.resolveService(device);

View File

@ -13,6 +13,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.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/clients.dart';
import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/loop_mode.dart';
@ -46,6 +47,8 @@ class RemotePlayerQueue extends ConsumerWidget {
} }
class ConnectControlPage extends HookConsumerWidget { class ConnectControlPage extends HookConsumerWidget {
static const name = "connect_control";
const ConnectControlPage({super.key}); const ConnectControlPage({super.key});
@override @override
@ -125,9 +128,13 @@ class ConnectControlPage extends HookConsumerWidget {
playlist.activeTrack?.name ?? "", playlist.activeTrack?.name ?? "",
style: textTheme.titleLarge!, style: textTheme.titleLarge!,
onTap: () { onTap: () {
ServiceUtils.push( if (playlist.activeTrack == null) return;
ServiceUtils.pushNamed(
context, context,
"/track/${playlist.activeTrack?.id}", TrackPage.name,
pathParameters: {
"id": playlist.activeTrack!.id!,
},
); );
}, },
), ),

View File

@ -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/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/mobile_login/mobile_login.dart';
class DesktopLoginPage extends HookConsumerWidget { class DesktopLoginPage extends HookConsumerWidget {
static const name = WebViewLogin.name;
const DesktopLoginPage({super.key}); const DesktopLoginPage({super.key});
@override @override

View File

@ -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/links/hyper_link.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class LoginTutorial extends ConsumerWidget { class LoginTutorial extends ConsumerWidget {
static const name = "login_tutorial";
const LoginTutorial({super.key}); const LoginTutorial({super.key});
@override @override
@ -53,7 +55,7 @@ class LoginTutorial extends ConsumerWidget {
overrideDone: FilledButton( overrideDone: FilledButton(
onPressed: authenticationNotifier.isLoggedIn onPressed: authenticationNotifier.isLoggedIn
? () { ? () {
ServiceUtils.push(context, "/"); ServiceUtils.pushNamed(context, HomePage.name);
} }
: null, : null,
child: Center(child: Text(context.l10n.done)), child: Center(child: Text(context.l10n.done)),

View File

@ -12,6 +12,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/themes/theme.dart'; import 'package:spotube/themes/theme.dart';
class GettingStarting extends HookConsumerWidget { class GettingStarting extends HookConsumerWidget {
static const name = "getting_started";
const GettingStarting({super.key}); const GettingStarting({super.key});
@override @override

View File

@ -5,6 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/components/getting_started/blur_card.dart';
import 'package:spotube/extensions/context.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:spotube/services/kv_store/kv_store.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -104,7 +106,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
await KVStoreService.setDoneGettingStarted(true); await KVStoreService.setDoneGettingStarted(true);
if (context.mounted) { if (context.mounted) {
context.go("/"); context.go(HomePage.name);
} }
}, },
), ),
@ -120,7 +122,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
await KVStoreService.setDoneGettingStarted(true); await KVStoreService.setDoneGettingStarted(true);
if (context.mounted) { if (context.mounted) {
context.push("/login"); context.pushNamed(WebViewLogin.name);
} }
}, },
), ),

View File

@ -10,6 +10,8 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/spotify/views/home_section.dart'; import 'package:spotube/provider/spotify/views/home_section.dart';
class HomeFeedSectionPage extends HookConsumerWidget { class HomeFeedSectionPage extends HookConsumerWidget {
static const name = "home_feed_section";
final String sectionUri; final String sectionUri;
const HomeFeedSectionPage({super.key, required this.sectionUri}); const HomeFeedSectionPage({super.key, required this.sectionUri});

View File

@ -15,6 +15,8 @@ import 'package:collection/collection.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class GenrePlaylistsPage extends HookConsumerWidget { class GenrePlaylistsPage extends HookConsumerWidget {
static const name = "genre_playlists";
final Category category; final Category category;
const GenrePlaylistsPage({super.key, required this.category}); const GenrePlaylistsPage({super.key, required this.category});

View File

@ -9,9 +9,11 @@ import 'package:spotube/collections/gradients.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class GenrePage extends HookConsumerWidget { class GenrePage extends HookConsumerWidget {
static const name = "genre";
const GenrePage({super.key}); const GenrePage({super.key});
@override @override
@ -47,7 +49,13 @@ class GenrePage extends HookConsumerWidget {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () { onTap: () {
context.push("/genre/${category.id}", extra: category); context.pushNamed(
GenrePlaylistsPage.name,
pathParameters: {
"categoryId": category.id!,
},
extra: category,
);
}, },
child: Ink( child: Ink(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),

View File

@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.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/connect/connect_device.dart';
import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/featured.dart';
import 'package:spotube/components/home/sections/feed.dart'; import 'package:spotube/components/home/sections/feed.dart';
@ -10,16 +11,15 @@ import 'package:spotube/components/home/sections/friends.dart';
import 'package:spotube/components/home/sections/genres.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/made_for_user.dart';
import 'package:spotube/components/home/sections/new_releases.dart'; import 'package:spotube/components/home/sections/new_releases.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/home/sections/recent.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/settings/settings.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/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
static const name = "home";
const HomePage({super.key}); const HomePage({super.key});
@override @override
@ -34,44 +34,27 @@ class HomePage extends HookConsumerWidget {
body: CustomScrollView( body: CustomScrollView(
controller: controller, controller: controller,
slivers: [ slivers: [
if (mediaQuery.mdAndDown) if (mediaQuery.smAndDown)
SliverAppBar( SliverAppBar(
floating: true, floating: true,
title: Assets.spotubeLogoPng.image(height: 45), title: Assets.spotubeLogoPng.image(height: 45),
actions: [ actions: [
const ConnectDeviceButton(), const ConnectDeviceButton(),
const Gap(10), const Gap(10),
Consumer(builder: (context, ref, _) { IconButton(
final auth = ref.watch(authenticationProvider); icon: const Icon(SpotubeIcons.settings, size: 20),
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: () { onPressed: () {
ServiceUtils.push(context, "/profile"); ServiceUtils.pushNamed(context, SettingsPage.name);
}, },
); ),
}),
const Gap(10), const Gap(10),
], ],
) )
else if (kIsMacOS) else if (kIsMacOS)
const SliverGap(10), const SliverGap(10),
const HomeGenresSection(), const HomeGenresSection(),
const SliverGap(10),
const SliverToBoxAdapter(child: HomeRecentlyPlayedSection()),
const SliverToBoxAdapter(child: HomeFeaturedSection()), const SliverToBoxAdapter(child: HomeFeaturedSection()),
const HomePageFriendsSection(), const HomePageFriendsSection(),
const SliverToBoxAdapter(child: HomeNewReleasesSection()), const SliverToBoxAdapter(child: HomeNewReleasesSection()),

View File

@ -10,6 +10,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart';
class LastFMLoginPage extends HookConsumerWidget { class LastFMLoginPage extends HookConsumerWidget {
static const name = "lastfm_login";
const LastFMLoginPage({super.key}); const LastFMLoginPage({super.key});
@override @override

View File

@ -12,6 +12,8 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
class LibraryPage extends HookConsumerWidget { class LibraryPage extends HookConsumerWidget {
static const name = "library";
const LibraryPage({super.key}); const LibraryPage({super.key});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {

View File

@ -21,6 +21,8 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class LocalLibraryPage extends HookConsumerWidget { class LocalLibraryPage extends HookConsumerWidget {
static const name = "local_library_page";
final String location; final String location;
final bool isDownloads; final bool isDownloads;
const LocalLibraryPage(this.location, {super.key, this.isDownloads = false}); const LocalLibraryPage(this.location, {super.key, this.isDownloads = false});

View File

@ -24,6 +24,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0);
class PlaylistGeneratorPage extends HookConsumerWidget { class PlaylistGeneratorPage extends HookConsumerWidget {
static const name = "playlist_generator";
const PlaylistGeneratorPage({super.key}); const PlaylistGeneratorPage({super.key});
@override @override

View File

@ -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/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotify/recommendation_seeds.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class PlaylistGenerateResultPage extends HookConsumerWidget { class PlaylistGenerateResultPage extends HookConsumerWidget {
static const name = "playlist_generate_result";
final GeneratePlaylistProviderInput state; final GeneratePlaylistProviderInput state;
const PlaylistGenerateResultPage({ const PlaylistGenerateResultPage({
@ -123,8 +126,11 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
); );
if (playlist != null) { if (playlist != null) {
router.go( router.goNamed(
'/playlist/${playlist.id}', PlaylistPage.name,
pathParameters: {
"id": playlist.id!,
},
extra: playlist, extra: playlist,
); );
} }

View File

@ -23,6 +23,8 @@ import 'package:spotube/utils/platform.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class LyricsPage extends HookConsumerWidget { class LyricsPage extends HookConsumerWidget {
static const name = "lyrics";
final bool isModal; final bool isModal;
const LyricsPage({super.key, this.isModal = false}); const LyricsPage({super.key, this.isModal = false});

View File

@ -20,6 +20,8 @@ import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
class MiniLyricsPage extends HookConsumerWidget { class MiniLyricsPage extends HookConsumerWidget {
static const name = "mini_lyrics";
final Size prevSize; final Size prevSize;
const MiniLyricsPage({super.key, required this.prevSize}); const MiniLyricsPage({super.key, required this.prevSize});

View File

@ -7,6 +7,7 @@ import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class WebViewLogin extends HookConsumerWidget { class WebViewLogin extends HookConsumerWidget {
static const name = "login";
const WebViewLogin({super.key}); const WebViewLogin({super.key});
@override @override

View File

@ -3,9 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.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.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.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'; import 'package:spotube/provider/spotify/spotify.dart';
class LikedPlaylistPage extends HookConsumerWidget { class LikedPlaylistPage extends HookConsumerWidget {
static const name = PlaylistPage.name;
final PlaylistSimple playlist; final PlaylistSimple playlist;
const LikedPlaylistPage({ const LikedPlaylistPage({
super.key, super.key,
@ -18,7 +21,7 @@ class LikedPlaylistPage extends HookConsumerWidget {
final tracks = likedTracks.asData?.value ?? <Track>[]; final tracks = likedTracks.asData?.value ?? <Track>[];
return InheritedTrackView( return InheritedTrackView(
collectionId: playlist.id!, collection: playlist,
image: "assets/liked-tracks.jpg", image: "assets/liked-tracks.jpg",
pagination: PaginationProps( pagination: PaginationProps(
hasNextPage: false, hasNextPage: false,

View File

@ -10,6 +10,8 @@ import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
class PlaylistPage extends HookConsumerWidget { class PlaylistPage extends HookConsumerWidget {
static const name = "playlist";
final PlaylistSimple playlist; final PlaylistSimple playlist;
const PlaylistPage({ const PlaylistPage({
super.key, super.key,
@ -29,7 +31,7 @@ class PlaylistPage extends HookConsumerWidget {
final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!);
return InheritedTrackView( return InheritedTrackView(
collectionId: playlist.id!, collection: playlist,
image: playlist.images.asUrlString( image: playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),

View File

@ -14,6 +14,8 @@ import 'package:spotube/provider/spotify/spotify.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class ProfilePage extends HookConsumerWidget { class ProfilePage extends HookConsumerWidget {
static const name = "profile";
const ProfilePage({super.key}); const ProfilePage({super.key});
@override @override

View File

@ -14,6 +14,7 @@ import 'package:spotube/components/root/sidebar.dart';
import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/components/root/spotube_navigation_bar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/connect/server.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
@ -22,13 +23,6 @@ import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
const rootPaths = {
"/": 0,
"/search": 1,
"/library": 2,
"/lyrics": 3,
};
class RootApp extends HookConsumerWidget { class RootApp extends HookConsumerWidget {
final Widget child; final Widget child;
const RootApp({ const RootApp({
@ -42,7 +36,6 @@ class RootApp extends HookConsumerWidget {
final downloader = ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider);
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final location = GoRouterState.of(context).matchedLocation;
useEffect(() { useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
@ -179,32 +172,18 @@ class RootApp extends HookConsumerWidget {
return null; return null;
}, [backgroundColor]); }, [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 // ignore: deprecated_member_use
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
if (rootPaths[location] != 0) { final routerState = GoRouterState.of(context);
onSelectIndexChanged(0); if (routerState.matchedLocation != "/") {
context.goNamed(HomePage.name);
return false; return false;
} }
return true; return true;
}, },
child: Scaffold( child: Scaffold(
body: Sidebar( body: Sidebar(child: child),
selectedIndex: rootPaths[location],
onSelectedIndexChanged: onSelectIndexChanged,
child: child,
),
extendBody: true, extendBody: true,
drawerScrimColor: Colors.transparent, drawerScrimColor: Colors.transparent,
endDrawer: kIsDesktop endDrawer: kIsDesktop
@ -238,10 +217,7 @@ class RootApp extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomPlayer(), BottomPlayer(),
SpotubeNavigationBar( const SpotubeNavigationBar(),
selectedIndex: rootPaths[location],
onSelectedIndexChanged: onSelectIndexChanged,
),
], ],
), ),
), ),

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
@ -26,6 +27,8 @@ import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class SearchPage extends HookConsumerWidget { class SearchPage extends HookConsumerWidget {
static const name = "search";
const SearchPage({super.key}); const SearchPage({super.key});
@override @override
@ -85,21 +88,32 @@ class SearchPage extends HookConsumerWidget {
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar() : null, appBar: kIsDesktop && !kIsMacOS
? const PageWindowTitleBar(automaticallyImplyLeading: true)
: null,
body: !authenticationNotifier.isLoggedIn body: !authenticationNotifier.isLoggedIn
? const AnonymousFallback() ? const AnonymousFallback()
: Column( : Column(
children: [ children: [
Container( Row(
padding: const EdgeInsets.symmetric( crossAxisAlignment: CrossAxisAlignment.center,
horizontal: 20, children: [
vertical: 10, if ((kIsMobile || kIsMacOS) && context.canPop())
const BackButton()
else
const Gap(20),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
right: 20,
top: 20,
bottom: 20,
), ),
color: theme.scaffoldBackgroundColor,
child: SearchAnchor( child: SearchAnchor(
searchController: controller, searchController: controller,
viewBuilder: (_) => HookBuilder(builder: (context) { viewBuilder: (_) => HookBuilder(builder: (context) {
final searchController = useListenable(controller); final searchController =
useListenable(controller);
final update = useForceUpdate(); final update = useForceUpdate();
final suggestions = searchController.text.isEmpty final suggestions = searchController.text.isEmpty
? KVStoreService.recentSearches ? KVStoreService.recentSearches
@ -108,7 +122,8 @@ class SearchPage extends HookConsumerWidget {
(s) => (s) =>
weightedRatio( weightedRatio(
s.toLowerCase(), s.toLowerCase(),
searchController.text.toLowerCase(), searchController.text
.toLowerCase(),
) > ) >
50, 50,
) )
@ -136,7 +151,8 @@ class SearchPage extends HookConsumerWidget {
onTap: () { onTap: () {
controller.closeView(suggestion); controller.closeView(suggestion);
ref ref
.read(searchTermStateProvider.notifier) .read(
searchTermStateProvider.notifier)
.state = suggestion; .state = suggestion;
}, },
); );
@ -151,8 +167,9 @@ class SearchPage extends HookConsumerWidget {
Timer( Timer(
const Duration(milliseconds: 50), const Duration(milliseconds: 50),
() { () {
ref.read(searchTermStateProvider.notifier).state = ref
value; .read(searchTermStateProvider.notifier)
.state = value;
if (value.trim().isEmpty) { if (value.trim().isEmpty) {
return; return;
} }
@ -168,7 +185,8 @@ class SearchPage extends HookConsumerWidget {
builder: (context, controller) { builder: (context, controller) {
return SearchBar( return SearchBar(
autoFocus: queries.none((s) => autoFocus: queries.none((s) =>
s.asData?.value != null && !s.hasError) && s.asData?.value != null &&
!s.hasError) &&
!kIsMobile, !kIsMobile,
controller: controller, controller: controller,
leading: const Icon(SpotubeIcons.search), leading: const Icon(SpotubeIcons.search),
@ -179,6 +197,9 @@ class SearchPage extends HookConsumerWidget {
}, },
), ),
), ),
),
],
),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),

View File

@ -76,7 +76,7 @@ class SearchTracksSection extends HookConsumerWidget {
if (shouldPlay) { if (shouldPlay) {
await remotePlayback.load( await remotePlayback.load(
WebSocketLoadEventData( WebSocketLoadEventData.playlist(
tracks: [track], tracks: [track],
), ),
); );

View File

@ -17,6 +17,8 @@ final _licenseProvider = FutureProvider<String>((ref) async {
}); });
class AboutSpotube extends HookConsumerWidget { class AboutSpotube extends HookConsumerWidget {
static const name = "about";
const AboutSpotube({super.key}); const AboutSpotube({super.key});
@override @override

View File

@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
class BlackListPage extends HookConsumerWidget { class BlackListPage extends HookConsumerWidget {
static const name = "blacklist";
const BlackListPage({super.key}); const BlackListPage({super.key});
@override @override

View File

@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
class LogsPage extends HookWidget { class LogsPage extends HookWidget {
static const name = "logs";
const LogsPage({super.key}); const LogsPage({super.key});
List<({DateTime? date, String body})> parseLogs(String raw) { List<({DateTime? date, String body})> parseLogs(String raw) {

View File

@ -4,10 +4,15 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.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/constrains.dart';
import 'package:spotube/extensions/context.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/authentication_provider.dart';
import 'package:spotube/provider/scrobbler_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 { class SettingsAccountSection extends HookConsumerWidget {
const SettingsAccountSection({super.key}); const SettingsAccountSection({super.key});
@ -15,9 +20,12 @@ class SettingsAccountSection extends HookConsumerWidget {
@override @override
Widget build(context, ref) { Widget build(context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final router = GoRouter.of(context);
final auth = ref.watch(authenticationProvider); final auth = ref.watch(authenticationProvider);
final scrobbler = ref.watch(scrobblerProvider); final scrobbler = ref.watch(scrobblerProvider);
final router = GoRouter.of(context); final me = ref.watch(meProvider);
final meData = me.asData?.value;
final logoutBtnStyle = FilledButton.styleFrom( final logoutBtnStyle = FilledButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: Colors.red,
@ -27,6 +35,24 @@ class SettingsAccountSection extends HookConsumerWidget {
return SectionCardWithHeading( return SectionCardWithHeading(
heading: context.l10n.account, heading: context.l10n.account,
children: [ 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) if (auth == null)
LayoutBuilder(builder: (context, constrains) { LayoutBuilder(builder: (context, constrains) {
return ListTile( return ListTile(

View File

@ -16,6 +16,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class SettingsPage extends HookConsumerWidget { class SettingsPage extends HookConsumerWidget {
static const name = "settings";
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
@ -29,6 +31,7 @@ class SettingsPage extends HookConsumerWidget {
appBar: PageWindowTitleBar( appBar: PageWindowTitleBar(
title: Text(context.l10n.settings), title: Text(context.l10n.settings),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: true,
), ),
body: Scrollbar( body: Scrollbar(
controller: controller, controller: controller,

View File

@ -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"),
);
},
),
);
}
}

View File

@ -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"),
);
},
),
);
}
}

View File

@ -0,0 +1,65 @@
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';
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 ThemeData(:textTheme, :hintColor) = Theme.of(context);
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: 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)),
);
},
),
],
),
);
}
}

View File

@ -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",
),
);
},
),
);
}
}

View File

@ -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"),
);
},
),
);
}
}

View File

@ -0,0 +1,35 @@
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";
const StatsPage({super.key});
@override
Widget build(BuildContext context, ref) {
return SafeArea(
bottom: false,
child: Scaffold(
appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(),
body: CustomScrollView(
slivers: [
if (kIsMacOS) const SliverGap(20),
const StatsPageSummarySection(),
const StatsPageTopSection(),
const SliverToBoxAdapter(
child: SafeArea(
child: SizedBox(),
),
)
],
),
),
);
}
}

View File

@ -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",
),
);
},
),
);
}
}

View File

@ -21,6 +21,8 @@ import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
class TrackPage extends HookConsumerWidget { class TrackPage extends HookConsumerWidget {
static const name = "track";
final String trackId; final String trackId;
const TrackPage({ const TrackPage({
super.key, super.key,

View File

@ -9,9 +9,11 @@ import 'package:shelf/shelf_io.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
import 'package:shelf_web_socket/shelf_web_socket.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/connect/connect.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/connect/clients.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -32,6 +34,7 @@ final connectServerProvider = FutureProvider((ref) async {
final resolvedService = await ref final resolvedService = await ref
.watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); .watch(connectClientsProvider.selectAsync((s) => s.resolvedService));
final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); final playbackNotifier = ref.read(proxyPlaylistProvider.notifier);
final historyNotifier = ref.read(playbackHistoryProvider.notifier);
if (!enabled || resolvedService != null) { if (!enabled || resolvedService != null) {
return null; return null;
@ -79,7 +82,7 @@ final connectServerProvider = FutureProvider((ref) async {
.toJson(), .toJson(),
); );
channel.sink.add( channel.sink.add(
WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), WebSocketShuffleEvent(audioPlayer.isShuffled).toJson(),
); );
channel.sink.add( channel.sink.add(
WebSocketLoopEvent(audioPlayer.loopMode).toJson(), WebSocketLoopEvent(audioPlayer.loopMode).toJson(),
@ -146,8 +149,14 @@ final connectServerProvider = FutureProvider((ref) async {
initialIndex: event.data.initialIndex ?? 0, initialIndex: event.data.initialIndex ?? 0,
); );
if (event.data.collectionId != null) { if (event.data.collectionId == null) return;
playbackNotifier.addCollection(event.data.collectionId!); 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]);
} }
}); });

View File

@ -0,0 +1,129 @@
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 {
final List<PlaybackHistoryItem> items;
const PlaybackHistoryState({this.items = const []});
factory PlaybackHistoryState.fromJson(Map<String, dynamic> json) {
return PlaybackHistoryState(
items: json["items"]
?.map(
(json) => PlaybackHistoryItem.fromJson(json),
)
.toList()
.cast<PlaybackHistoryItem>() ??
<PlaybackHistoryItem>[],
);
}
Map<String, dynamic> toJson() {
return {
"items": items.map((s) => s.toJson()).toList(),
};
}
PlaybackHistoryState copyWith({
List<PlaybackHistoryItem>? items,
}) {
return PlaybackHistoryState(items: items ?? this.items);
}
}
class PlaybackHistoryNotifier
extends PersistedStateNotifier<PlaybackHistoryState> {
final Ref ref;
PlaybackHistoryNotifier(this.ref)
: super(const PlaybackHistoryState(), "playback_history");
SpotifyApi get spotify => ref.read(spotifyProvider);
@override
FutureOr<PlaybackHistoryState> fromJson(Map<String, dynamic> json) =>
PlaybackHistoryState.fromJson(json);
@override
Map<String, dynamic> toJson() {
return state.toJson();
}
void addPlaylists(List<PlaylistSimple> playlists) {
state = state.copyWith(
items: [
...state.items,
for (final playlist in playlists)
PlaybackHistoryItem.playlist(
date: DateTime.now(), playlist: playlist),
],
);
}
void addAlbums(List<AlbumSimple> albums) {
state = state.copyWith(
items: [
...state.items,
for (final album in albums)
PlaybackHistoryItem.album(date: DateTime.now(), album: album),
],
);
}
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,
PlaybackHistoryItem.track(date: DateTime.now(), track: track),
],
);
}
void clear() {
state = state.copyWith(items: []);
}
}
final playbackHistoryProvider =
StateNotifierProvider<PlaybackHistoryNotifier, PlaybackHistoryState>(
(ref) => PlaybackHistoryNotifier(ref),
);
typedef PlaybackHistoryGrouped = ({
List<PlaybackHistoryTrack> tracks,
List<PlaybackHistoryAlbum> albums,
List<PlaybackHistoryPlaylist> playlists,
});
final playbackHistoryGroupedProvider = Provider<PlaybackHistoryGrouped>((ref) {
final history = ref.watch(playbackHistoryProvider);
final tracks = history.items
.whereType<PlaybackHistoryTrack>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
final albums = history.items
.whereType<PlaybackHistoryAlbum>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
final playlists = history.items
.whereType<PlaybackHistoryPlaylist>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
return (
tracks: tracks,
albums: albums,
playlists: playlists,
);
});

View File

@ -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(),
),
);
});

View File

@ -0,0 +1,35 @@
import 'package:freezed_annotation/freezed_annotation.dart';
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({
required DateTime date,
required PlaylistSimple playlist,
}) = PlaybackHistoryPlaylist;
factory PlaybackHistoryItem.album({
required DateTime date,
required AlbumSimple album,
}) = PlaybackHistoryAlbum;
factory PlaybackHistoryItem.track({
required DateTime date,
required Track track,
}) = PlaybackHistoryTrack;
factory PlaybackHistoryItem.fromJson(Map<String, dynamic> json) =>
_$PlaybackHistoryItemFromJson(json);
}

View File

@ -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>(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<String, dynamic> 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<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, Track track) track,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, Track track)? track,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(PlaybackHistoryPlaylist value) playlist,
required TResult Function(PlaybackHistoryAlbum value) album,
required TResult Function(PlaybackHistoryTrack value) track,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(PlaybackHistoryPlaylist value)? playlist,
TResult? Function(PlaybackHistoryAlbum value)? album,
TResult? Function(PlaybackHistoryTrack value)? track,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(PlaybackHistoryPlaylist value)? playlist,
TResult Function(PlaybackHistoryAlbum value)? album,
TResult Function(PlaybackHistoryTrack value)? track,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$PlaybackHistoryItemCopyWith<PlaybackHistoryItem> 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<String, dynamic> 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<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, Track track) track,
}) {
return playlist(date, this.playlist);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, Track track)? track,
}) {
return playlist?.call(date, this.playlist);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) {
if (playlist != null) {
return playlist(date, this.playlist);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
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 extends Object?>({
TResult? Function(PlaybackHistoryPlaylist value)? playlist,
TResult? Function(PlaybackHistoryAlbum value)? album,
TResult? Function(PlaybackHistoryTrack value)? track,
}) {
return playlist?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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<String, dynamic> toJson() {
return _$$PlaybackHistoryPlaylistImplToJson(
this,
);
}
}
abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem {
factory PlaybackHistoryPlaylist(
{required final DateTime date,
required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl;
factory PlaybackHistoryPlaylist.fromJson(Map<String, dynamic> 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<String, dynamic> 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<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, Track track) track,
}) {
return album(date, this.album);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, Track track)? track,
}) {
return album?.call(date, this.album);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) {
if (album != null) {
return album(date, this.album);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
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 extends Object?>({
TResult? Function(PlaybackHistoryPlaylist value)? playlist,
TResult? Function(PlaybackHistoryAlbum value)? album,
TResult? Function(PlaybackHistoryTrack value)? track,
}) {
return album?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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<String, dynamic> toJson() {
return _$$PlaybackHistoryAlbumImplToJson(
this,
);
}
}
abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem {
factory PlaybackHistoryAlbum(
{required final DateTime date,
required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl;
factory PlaybackHistoryAlbum.fromJson(Map<String, dynamic> 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, Track 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 Track,
));
}
}
/// @nodoc
@JsonSerializable()
class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack {
_$PlaybackHistoryTrackImpl(
{required this.date, required this.track, final String? $type})
: $type = $type ?? 'track';
factory _$PlaybackHistoryTrackImpl.fromJson(Map<String, dynamic> json) =>
_$$PlaybackHistoryTrackImplFromJson(json);
@override
final DateTime date;
@override
final Track 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<TResult extends Object?>({
required TResult Function(DateTime date, PlaylistSimple playlist) playlist,
required TResult Function(DateTime date, AlbumSimple album) album,
required TResult Function(DateTime date, Track track) track,
}) {
return track(date, this.track);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult? Function(DateTime date, AlbumSimple album)? album,
TResult? Function(DateTime date, Track track)? track,
}) {
return track?.call(date, this.track);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime date, PlaylistSimple playlist)? playlist,
TResult Function(DateTime date, AlbumSimple album)? album,
TResult Function(DateTime date, Track track)? track,
required TResult orElse(),
}) {
if (track != null) {
return track(date, this.track);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
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 extends Object?>({
TResult? Function(PlaybackHistoryPlaylist value)? playlist,
TResult? Function(PlaybackHistoryAlbum value)? album,
TResult? Function(PlaybackHistoryTrack value)? track,
}) {
return track?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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<String, dynamic> toJson() {
return _$$PlaybackHistoryTrackImplToJson(
this,
);
}
}
abstract class PlaybackHistoryTrack implements PlaybackHistoryItem {
factory PlaybackHistoryTrack(
{required final DateTime date,
required final Track track}) = _$PlaybackHistoryTrackImpl;
factory PlaybackHistoryTrack.fromJson(Map<String, dynamic> json) =
_$PlaybackHistoryTrackImpl.fromJson;
@override
DateTime get date;
Track get track;
@override
@JsonKey(ignore: true)
_$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson(
Map json) =>
_$PlaybackHistoryPlaylistImpl(
date: DateTime.parse(json['date'] as String),
playlist: PlaylistSimple.fromJson(
Map<String, dynamic>.from(json['playlist'] as Map)),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$PlaybackHistoryPlaylistImplToJson(
_$PlaybackHistoryPlaylistImpl instance) =>
<String, dynamic>{
'date': instance.date.toIso8601String(),
'playlist': instance.playlist.toJson(),
'runtimeType': instance.$type,
};
_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) =>
_$PlaybackHistoryAlbumImpl(
date: DateTime.parse(json['date'] as String),
album:
AlbumSimple.fromJson(Map<String, dynamic>.from(json['album'] as Map)),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$PlaybackHistoryAlbumImplToJson(
_$PlaybackHistoryAlbumImpl instance) =>
<String, dynamic>{
'date': instance.date.toIso8601String(),
'album': instance.album.toJson(),
'runtimeType': instance.$type,
};
_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) =>
_$PlaybackHistoryTrackImpl(
date: DateTime.parse(json['date'] as String),
track: Track.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$PlaybackHistoryTrackImplToJson(
_$PlaybackHistoryTrackImpl instance) =>
<String, dynamic>{
'date': instance.date.toIso8601String(),
'track': instance.track.toJson(),
'runtimeType': instance.$type,
};

View File

@ -0,0 +1,62 @@
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) =
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;
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,
);
});

View File

@ -0,0 +1,95 @@
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 playbackHistoryTopDurationProvider =
StateProvider((ref) => HistoryDuration.days30);
final playbackHistoryTopProvider =
Provider.family((ref, HistoryDuration durationState) {
final grouped = ref.watch(playbackHistoryGroupedProvider);
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 playlists = grouped.playlists
.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);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
final albumsWithTrackAlbums = [
for (final historicAlbum in albums) historicAlbum.album,
for (final track in tracks) track.track.album!
];
final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!)
.entries
.map((entry) {
return (count: entry.value.length, album: entry.value.first);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
final artists =
tracks.map((track) => track.track.artists).expand((e) => e ?? <Artist>[]);
final artistsWithCount = groupBy(artists, (artist) => artist.id!)
.entries
.map((entry) {
return (count: entry.value.length, artist: entry.value.first);
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
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,
playlists: playlistsWithCount,
);
});

Some files were not shown because too many files have changed in this diff Show More