From be977f10d1f199cd3c048c22f41241fd0ff26019 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 30 Aug 2024 01:38:02 +0800 Subject: [PATCH] :sparkles: Better explore --- lib/main.dart | 2 + lib/providers/recent_played.dart | 51 +++++++++++++ lib/screens/explore.dart | 76 ++++++++++++------- lib/services/album.dart | 21 +++++ .../sourced_track/sources/youtube.dart | 3 +- lib/widgets/album/album_card.dart | 53 +++++++++++++ lib/widgets/lyrics/synced_lyrics.dart | 6 +- lib/widgets/playlist/playlist_card.dart | 52 +++++++++++++ lib/widgets/playlist/playlist_section.dart | 74 ++++++++++++++++++ 9 files changed, 308 insertions(+), 30 deletions(-) create mode 100644 lib/providers/recent_played.dart create mode 100644 lib/services/album.dart create mode 100644 lib/widgets/album/album_card.dart create mode 100644 lib/widgets/playlist/playlist_card.dart create mode 100644 lib/widgets/playlist/playlist_section.dart diff --git a/lib/main.dart b/lib/main.dart index 48e423c..b64e2f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:rhythm_box/providers/database.dart'; import 'package:rhythm_box/providers/endless_playback.dart'; import 'package:rhythm_box/providers/history.dart'; import 'package:rhythm_box/providers/palette.dart'; +import 'package:rhythm_box/providers/recent_played.dart'; import 'package:rhythm_box/providers/scrobbler.dart'; import 'package:rhythm_box/providers/skip_segments.dart'; import 'package:rhythm_box/providers/spotify.dart'; @@ -105,6 +106,7 @@ class MyApp extends StatelessWidget { Get.put(SourcedTrackProvider()); Get.put(EndlessPlaybackProvider()); Get.put(VolumeProvider()); + Get.put(RecentlyPlayedProvider()); Get.put(ServerPlaybackRoutesProvider()); Get.put(PlaybackServerProvider()); diff --git a/lib/providers/recent_played.dart b/lib/providers/recent_played.dart new file mode 100644 index 0000000..637fdae --- /dev/null +++ b/lib/providers/recent_played.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/services/database/database.dart'; + +class RecentlyPlayedProvider extends GetxController { + Future> fetch() async { + final database = Get.find().database; + + final uniqueItemIds = await (database.selectOnly( + database.historyTable, + distinct: true, + ) + ..addColumns([database.historyTable.itemId, database.historyTable.id]) + ..where( + database.historyTable.type.isInValues([ + HistoryEntryType.playlist, + HistoryEntryType.album, + ]), + ) + ..limit(10) + ..orderBy([ + OrderingTerm( + expression: database.historyTable.createdAt, + mode: OrderingMode.desc, + ), + ])) + .map( + (row) => row.read(database.historyTable.id), + ) + .get() + .then((value) => value.whereNotNull().toList()); + + final query = database.select(database.historyTable) + ..where( + (tbl) => tbl.id.isIn(uniqueItemIds), + ) + ..orderBy([ + (tbl) => OrderingTerm( + expression: tbl.createdAt, + mode: OrderingMode.desc, + ), + ]); + + final fetchedItems = await query.get(); + return fetchedItems; + } +} diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index b387aa9..2bccb6a 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:go_router/go_router.dart'; +import 'package:rhythm_box/providers/recent_played.dart'; import 'package:rhythm_box/providers/spotify.dart'; -import 'package:rhythm_box/widgets/playlist/playlist_tile.dart'; -import 'package:rhythm_box/widgets/sized_container.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; +import 'package:rhythm_box/providers/user_preferences.dart'; +import 'package:rhythm_box/services/album.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:rhythm_box/widgets/playlist/playlist_section.dart'; class ExploreScreen extends StatefulWidget { const ExploreScreen({super.key}); @@ -16,15 +16,37 @@ class ExploreScreen extends StatefulWidget { class _ExploreScreenState extends State { late final SpotifyProvider _spotify = Get.find(); + late final RecentlyPlayedProvider _history = Get.find(); - bool _isLoading = true; + final Map _isLoading = { + 'featured': true, + 'recently': true, + 'newReleases': true, + }; - List? _featuredPlaylist; + List? _featuredPlaylist; + List? _recentlyPlaylist; + List? _newReleasesPlaylist; Future _pullPlaylist() async { + final market = Get.find().state.value.market; + _featuredPlaylist = (await _spotify.api.playlists.featured.getPage(20)).items!.toList(); - setState(() => _isLoading = false); + setState(() => _isLoading['featured'] = false); + + _recentlyPlaylist = (await _history.fetch()) + .where((x) => x.playlist != null) + .map((x) => x.playlist!) + .toList(); + setState(() => _isLoading['recently'] = false); + + _newReleasesPlaylist = + (await _spotify.api.browse.newReleases(country: market).getPage(20)) + .items + ?.map((album) => album.toAlbum()) + .toList(); + setState(() => _isLoading['newReleases'] = false); } @override @@ -42,26 +64,26 @@ class _ExploreScreenState extends State { title: Text('explore'.tr), centerTitle: MediaQuery.of(context).size.width >= 720, ), - body: CenteredContainer( - child: Skeletonizer( - enabled: _isLoading, - child: ListView.builder( - itemCount: _featuredPlaylist?.length ?? 20, - itemBuilder: (context, idx) { - final item = _featuredPlaylist?[idx]; - return PlaylistTile( - item: item, - onTap: () { - if (item == null) return; - GoRouter.of(context).pushNamed( - 'playlistView', - pathParameters: {'id': item.id!}, - ); - }, - ); - }, + body: ListView( + children: [ + if (_newReleasesPlaylist?.isNotEmpty ?? false) + PlaylistSection( + isLoading: _isLoading['newReleases']!, + title: 'New Releases', + list: _newReleasesPlaylist, + ), + if (_recentlyPlaylist?.isNotEmpty ?? false) + PlaylistSection( + isLoading: _isLoading['recently']!, + title: 'Recent Played', + list: _recentlyPlaylist, + ), + PlaylistSection( + isLoading: _isLoading['featured']!, + title: 'Featured', + list: _featuredPlaylist, ), - ), + ], ), ), ); diff --git a/lib/services/album.dart b/lib/services/album.dart new file mode 100644 index 0000000..5678390 --- /dev/null +++ b/lib/services/album.dart @@ -0,0 +1,21 @@ +import 'package:spotify/spotify.dart'; + +extension AlbumExtensions on AlbumSimple { + Album toAlbum() { + Album album = Album(); + album.albumType = albumType; + album.artists = artists; + album.availableMarkets = availableMarkets; + album.externalUrls = externalUrls; + album.href = href; + album.id = id; + album.images = images; + album.name = name; + album.releaseDate = releaseDate; + album.releaseDatePrecision = releaseDatePrecision; + album.tracks = tracks; + album.type = type; + album.uri = uri; + return album; + } +} diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 712bd95..dc615b6 100755 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -250,8 +250,7 @@ class YoutubeSourcedTrack extends SourcedTrack { final searchResults = await youtubeClient.search.search( query, - // '$query - Topic', - filter: TypeFilters.video, + filter: const SearchFilter('CAMSAhAB'), ); if (ServiceUtils.onlyContainsEnglish(query)) { diff --git a/lib/widgets/album/album_card.dart b/lib/widgets/album/album_card.dart new file mode 100644 index 0000000..cd99534 --- /dev/null +++ b/lib/widgets/album/album_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/widgets/auto_cache_image.dart'; +import 'package:rhythm_box/services/artist.dart'; +import 'package:spotify/spotify.dart'; + +class AlbumCard extends StatelessWidget { + final AlbumSimple? item; + + final Function? onTap; + + const AlbumCard({super.key, required this.item, this.onTap}); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 1, + child: (item?.images?.isNotEmpty ?? false) + ? AutoCacheImage(item!.images!.first.url!) + : const Center(child: Icon(Icons.image)), + ), + ).paddingSymmetric(vertical: 8), + Text( + item?.name ?? 'Loading...', + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Expanded( + child: Text( + item?.artists?.asString() ?? 'Please stand by...', + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ).paddingSymmetric(horizontal: 8), + onTap: () { + if (onTap != null) return; + onTap!(); + }, + ), + ); + } +} diff --git a/lib/widgets/lyrics/synced_lyrics.dart b/lib/widgets/lyrics/synced_lyrics.dart index e24b3d7..1e59fa6 100644 --- a/lib/widgets/lyrics/synced_lyrics.dart +++ b/lib/widgets/lyrics/synced_lyrics.dart @@ -109,6 +109,7 @@ class _SyncedLyricsState extends State { final size = MediaQuery.of(context).size; return CustomScrollView( + cacheExtent: 10000, controller: _autoScrollController, slivers: [ if (_lyric == null) @@ -164,6 +165,9 @@ class _SyncedLyricsState extends State { ), textAlign: TextAlign.center, child: InkWell( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), onTap: () async { final time = Duration( seconds: lyricSlice.time.inSeconds - @@ -184,7 +188,7 @@ class _SyncedLyricsState extends State { : _unFocusColor, ), duration: 500.ms, - curve: Curves.easeInOut, + curve: Curves.decelerate, child: Text( lyricSlice.text, textAlign: diff --git a/lib/widgets/playlist/playlist_card.dart b/lib/widgets/playlist/playlist_card.dart new file mode 100644 index 0000000..59db49c --- /dev/null +++ b/lib/widgets/playlist/playlist_card.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/widgets/auto_cache_image.dart'; +import 'package:spotify/spotify.dart'; + +class PlaylistCard extends StatelessWidget { + final PlaylistSimple? item; + + final Function? onTap; + + const PlaylistCard({super.key, required this.item, this.onTap}); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 1, + child: (item?.images?.isNotEmpty ?? false) + ? AutoCacheImage(item!.images!.first.url!) + : const Center(child: Icon(Icons.image)), + ), + ).paddingSymmetric(vertical: 8), + Text( + item?.name ?? 'Loading...', + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Expanded( + child: Text( + item?.description ?? 'Please stand by...', + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ).paddingSymmetric(horizontal: 8), + onTap: () { + if (onTap != null) return; + onTap!(); + }, + ), + ); + } +} diff --git a/lib/widgets/playlist/playlist_section.dart b/lib/widgets/playlist/playlist_section.dart new file mode 100644 index 0000000..d634375 --- /dev/null +++ b/lib/widgets/playlist/playlist_section.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; +import 'package:rhythm_box/widgets/album/album_card.dart'; +import 'package:rhythm_box/widgets/playlist/playlist_card.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart'; + +class PlaylistSection extends StatelessWidget { + final bool isLoading; + final String title; + final List? list; + + const PlaylistSection({ + super.key, + required this.isLoading, + required this.title, + required this.list, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ).paddingOnly(left: 32, right: 32, bottom: 4), + SizedBox( + height: 280, + width: double.infinity, + child: Skeletonizer( + enabled: isLoading, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: list?.length ?? 20, + itemBuilder: (context, idx) { + final item = list?[idx]; + return SizedBox( + width: 180, + height: 180, + child: switch (item.runtimeType) { + const (AlbumSimple) || const (Album) => AlbumCard( + item: item as AlbumSimple?, + onTap: () { + if (item == null) return; + GoRouter.of(context).pushNamed( + 'playlistView', + pathParameters: {'id': item.id!}, + ); + }, + ), + _ => PlaylistCard( + item: item as PlaylistSimple?, + onTap: () { + if (item == null) return; + GoRouter.of(context).pushNamed( + 'playlistView', + pathParameters: {'id': item.id!}, + ); + }, + ), + }, + ); + }, + ), + ), + ), + ], + ); + } +}