✨ User library
This commit is contained in:
		| @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; | |||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:rhythm_box/screens/auth/mobile_login.dart'; | import 'package:rhythm_box/screens/auth/mobile_login.dart'; | ||||||
| import 'package:rhythm_box/screens/explore.dart'; | import 'package:rhythm_box/screens/explore.dart'; | ||||||
|  | import 'package:rhythm_box/screens/library/view.dart'; | ||||||
| import 'package:rhythm_box/screens/player/lyrics.dart'; | import 'package:rhythm_box/screens/player/lyrics.dart'; | ||||||
| import 'package:rhythm_box/screens/player/view.dart'; | import 'package:rhythm_box/screens/player/view.dart'; | ||||||
| import 'package:rhythm_box/screens/playlist/view.dart'; | import 'package:rhythm_box/screens/playlist/view.dart'; | ||||||
| @@ -19,6 +20,11 @@ final router = GoRouter(routes: [ | |||||||
|         name: 'explore', |         name: 'explore', | ||||||
|         builder: (context, state) => const ExploreScreen(), |         builder: (context, state) => const ExploreScreen(), | ||||||
|       ), |       ), | ||||||
|  |       GoRoute( | ||||||
|  |         path: '/library', | ||||||
|  |         name: 'library', | ||||||
|  |         builder: (context, state) => const LibraryScreen(), | ||||||
|  |       ), | ||||||
|       GoRoute( |       GoRoute( | ||||||
|         path: '/search', |         path: '/search', | ||||||
|         name: 'search', |         name: 'search', | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; | |||||||
| import 'package:get/get.dart'; | import 'package:get/get.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:rhythm_box/providers/spotify.dart'; | import 'package:rhythm_box/providers/spotify.dart'; | ||||||
| import 'package:rhythm_box/widgets/auto_cache_image.dart'; | import 'package:rhythm_box/widgets/playlist/playlist_tile.dart'; | ||||||
| import 'package:rhythm_box/widgets/sized_container.dart'; | import 'package:rhythm_box/widgets/sized_container.dart'; | ||||||
| import 'package:skeletonizer/skeletonizer.dart'; | import 'package:skeletonizer/skeletonizer.dart'; | ||||||
| import 'package:spotify/spotify.dart'; | import 'package:spotify/spotify.dart'; | ||||||
| @@ -49,29 +49,8 @@ class _ExploreScreenState extends State<ExploreScreen> { | |||||||
|               itemCount: _featuredPlaylist?.length ?? 20, |               itemCount: _featuredPlaylist?.length ?? 20, | ||||||
|               itemBuilder: (context, idx) { |               itemBuilder: (context, idx) { | ||||||
|                 final item = _featuredPlaylist?[idx]; |                 final item = _featuredPlaylist?[idx]; | ||||||
|                 return ListTile( |                 return PlaylistTile( | ||||||
|                   leading: ClipRRect( |                   item: item, | ||||||
|                     borderRadius: const BorderRadius.all(Radius.circular(8)), |  | ||||||
|                     child: item != null |  | ||||||
|                         ? AutoCacheImage( |  | ||||||
|                             item.images!.first.url!, |  | ||||||
|                             width: 64.0, |  | ||||||
|                             height: 64.0, |  | ||||||
|                           ) |  | ||||||
|                         : const SizedBox( |  | ||||||
|                             width: 64, |  | ||||||
|                             height: 64, |  | ||||||
|                             child: Center( |  | ||||||
|                               child: Icon(Icons.image), |  | ||||||
|                             ), |  | ||||||
|                           ), |  | ||||||
|                   ), |  | ||||||
|                   title: Text(item?.name ?? 'Loading...'), |  | ||||||
|                   subtitle: Text( |  | ||||||
|                     item?.description ?? 'Please stand by...', |  | ||||||
|                     maxLines: 2, |  | ||||||
|                     overflow: TextOverflow.ellipsis, |  | ||||||
|                   ), |  | ||||||
|                   onTap: () { |                   onTap: () { | ||||||
|                     if (item == null) return; |                     if (item == null) return; | ||||||
|                     GoRouter.of(context).pushNamed( |                     GoRouter.of(context).pushNamed( | ||||||
|   | |||||||
							
								
								
									
										36
									
								
								lib/screens/library/view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								lib/screens/library/view.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:get/get.dart'; | ||||||
|  | import 'package:rhythm_box/providers/auth.dart'; | ||||||
|  | import 'package:rhythm_box/widgets/no_login_fallback.dart'; | ||||||
|  | import 'package:rhythm_box/widgets/playlist/user_playlist_list.dart'; | ||||||
|  |  | ||||||
|  | class LibraryScreen extends StatefulWidget { | ||||||
|  |   const LibraryScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<LibraryScreen> createState() => _LibraryScreenState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _LibraryScreenState extends State<LibraryScreen> { | ||||||
|  |   late final AuthenticationProvider _authenticate = Get.find(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Material( | ||||||
|  |       color: Theme.of(context).colorScheme.surface, | ||||||
|  |       child: SafeArea( | ||||||
|  |         child: Obx(() { | ||||||
|  |           if (_authenticate.auth.value == null) { | ||||||
|  |             return const NoLoginFallback(); | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           return const Column( | ||||||
|  |             children: [ | ||||||
|  |               Expanded(child: UserPlaylistList()), | ||||||
|  |             ], | ||||||
|  |           ); | ||||||
|  |         }), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:rhythm_box/widgets/lyrics/synced.dart'; | import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart'; | ||||||
| import 'package:rhythm_box/widgets/player/bottom_player.dart'; | import 'package:rhythm_box/widgets/player/bottom_player.dart'; | ||||||
|  |  | ||||||
| class LyricsScreen extends StatelessWidget { | class LyricsScreen extends StatelessWidget { | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ import 'package:rhythm_box/services/audio_player/audio_player.dart'; | |||||||
| import 'package:rhythm_box/services/duration.dart'; | import 'package:rhythm_box/services/duration.dart'; | ||||||
| import 'package:rhythm_box/widgets/auto_cache_image.dart'; | import 'package:rhythm_box/widgets/auto_cache_image.dart'; | ||||||
| import 'package:rhythm_box/services/audio_services/image.dart'; | import 'package:rhythm_box/services/audio_services/image.dart'; | ||||||
| import 'package:rhythm_box/widgets/lyrics/synced.dart'; | import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart'; | ||||||
| import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; | import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; | ||||||
|  |  | ||||||
| class PlayerScreen extends StatefulWidget { | class PlayerScreen extends StatefulWidget { | ||||||
| @@ -75,7 +75,8 @@ class _PlayerScreenState extends State<PlayerScreen> { | |||||||
|                 child: Column( |                 child: Column( | ||||||
|                   mainAxisAlignment: MainAxisAlignment.center, |                   mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                   children: [ |                   children: [ | ||||||
|                     LimitedBox( |                     Obx( | ||||||
|  |                       () => LimitedBox( | ||||||
|                         maxHeight: maxAlbumSize, |                         maxHeight: maxAlbumSize, | ||||||
|                         maxWidth: maxAlbumSize, |                         maxWidth: maxAlbumSize, | ||||||
|                         child: Hero( |                         child: Hero( | ||||||
| @@ -97,23 +98,32 @@ class _PlayerScreenState extends State<PlayerScreen> { | |||||||
|                                           .surfaceContainerHigh, |                                           .surfaceContainerHigh, | ||||||
|                                       width: 64, |                                       width: 64, | ||||||
|                                       height: 64, |                                       height: 64, | ||||||
|                                     child: |                                       child: const Center( | ||||||
|                                         const Center(child: Icon(Icons.image)), |                                           child: Icon(Icons.image)), | ||||||
|                                     ), |                                     ), | ||||||
|                             ), |                             ), | ||||||
|                           ).marginSymmetric(horizontal: 24), |                           ).marginSymmetric(horizontal: 24), | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|                     const Gap(24), |  | ||||||
|                     Text( |  | ||||||
|                       _playback.state.value.activeTrack?.name ?? 'Not playing', |  | ||||||
|                       style: Theme.of(context).textTheme.titleLarge, |  | ||||||
|                     ), |                     ), | ||||||
|                     Text( |                     const Gap(24), | ||||||
|                       _playback.state.value.activeTrack?.artists?.asString() ?? |                     Obx( | ||||||
|  |                       () => Text( | ||||||
|  |                         _playback.state.value.activeTrack?.name ?? | ||||||
|  |                             'Not playing', | ||||||
|  |                         style: Theme.of(context).textTheme.titleLarge, | ||||||
|  |                         textAlign: TextAlign.center, | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     Obx( | ||||||
|  |                       () => Text( | ||||||
|  |                         _playback.state.value.activeTrack?.artists | ||||||
|  |                                 ?.asString() ?? | ||||||
|                             'No author', |                             'No author', | ||||||
|                         style: Theme.of(context).textTheme.bodyMedium, |                         style: Theme.of(context).textTheme.bodyMedium, | ||||||
|                         overflow: TextOverflow.ellipsis, |                         overflow: TextOverflow.ellipsis, | ||||||
|  |                         textAlign: TextAlign.center, | ||||||
|  |                       ), | ||||||
|                     ), |                     ), | ||||||
|                     const Gap(24), |                     const Gap(24), | ||||||
|                     Obx( |                     Obx( | ||||||
| @@ -197,14 +207,17 @@ class _PlayerScreenState extends State<PlayerScreen> { | |||||||
|                             ); |                             ); | ||||||
|                           }, |                           }, | ||||||
|                         ), |                         ), | ||||||
|                         IconButton( |                         Obx( | ||||||
|  |                           () => IconButton( | ||||||
|                             icon: const Icon(Icons.skip_previous), |                             icon: const Icon(Icons.skip_previous), | ||||||
|                             onPressed: _isFetchingActiveTrack |                             onPressed: _isFetchingActiveTrack | ||||||
|                                 ? null |                                 ? null | ||||||
|                                 : audioPlayer.skipToPrevious, |                                 : audioPlayer.skipToPrevious, | ||||||
|                           ), |                           ), | ||||||
|  |                         ), | ||||||
|                         const Gap(8), |                         const Gap(8), | ||||||
|                         SizedBox( |                         Obx( | ||||||
|  |                           () => SizedBox( | ||||||
|                             width: 56, |                             width: 56, | ||||||
|                             height: 56, |                             height: 56, | ||||||
|                             child: IconButton.filled( |                             child: IconButton.filled( | ||||||
| @@ -228,13 +241,16 @@ class _PlayerScreenState extends State<PlayerScreen> { | |||||||
|                                   : _togglePlayState, |                                   : _togglePlayState, | ||||||
|                             ), |                             ), | ||||||
|                           ), |                           ), | ||||||
|  |                         ), | ||||||
|                         const Gap(8), |                         const Gap(8), | ||||||
|                         IconButton( |                         Obx( | ||||||
|  |                           () => IconButton( | ||||||
|                             icon: const Icon(Icons.skip_next), |                             icon: const Icon(Icons.skip_next), | ||||||
|                             onPressed: _isFetchingActiveTrack |                             onPressed: _isFetchingActiveTrack | ||||||
|                                 ? null |                                 ? null | ||||||
|                                 : audioPlayer.skipToNext, |                                 : audioPlayer.skipToNext, | ||||||
|                           ), |                           ), | ||||||
|  |                         ), | ||||||
|                         Obx( |                         Obx( | ||||||
|                           () => IconButton( |                           () => IconButton( | ||||||
|                             icon: Icon( |                             icon: Icon( | ||||||
|   | |||||||
| @@ -37,19 +37,46 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> { | |||||||
|       : false; |       : false; | ||||||
|  |  | ||||||
|   bool _isLoading = true; |   bool _isLoading = true; | ||||||
|  |   bool _isLoadingTracks = true; | ||||||
|   bool _isUpdating = false; |   bool _isUpdating = false; | ||||||
|  |  | ||||||
|   Playlist? _playlist; |   Playlist? _playlist; | ||||||
|  |   List<Track>? _tracks; | ||||||
|  |  | ||||||
|   Future<void> _pullPlaylist() async { |   Future<void> _pullPlaylist() async { | ||||||
|  |     if (widget.playlistId == 'user-liked-tracks') { | ||||||
|  |       _playlist = Playlist() | ||||||
|  |         ..name = 'Liked Music' | ||||||
|  |         ..description = 'Your favorite music' | ||||||
|  |         ..type = 'playlist' | ||||||
|  |         ..collaborative = false | ||||||
|  |         ..public = false | ||||||
|  |         ..id = 'user-liked-tracks'; | ||||||
|  |     } else { | ||||||
|       _playlist = await _spotify.api.playlists.get(widget.playlistId); |       _playlist = await _spotify.api.playlists.get(widget.playlistId); | ||||||
|  |     } | ||||||
|     setState(() => _isLoading = false); |     setState(() => _isLoading = false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   Future<void> _pullTracks() async { | ||||||
|  |     if (widget.playlistId == 'user-liked-tracks') { | ||||||
|  |       _tracks = (await _spotify.api.tracks.me.saved.all()) | ||||||
|  |           .map((x) => x.track!) | ||||||
|  |           .toList(); | ||||||
|  |     } else { | ||||||
|  |       _tracks = (await _spotify.api.playlists | ||||||
|  |               .getTracksByPlaylistId(widget.playlistId) | ||||||
|  |               .all()) | ||||||
|  |           .toList(); | ||||||
|  |     } | ||||||
|  |     setState(() => _isLoadingTracks = false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _pullPlaylist(); |     _pullPlaylist(); | ||||||
|  |     _pullTracks(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -86,13 +113,16 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> { | |||||||
|                               elevation: 2, |                               elevation: 2, | ||||||
|                               child: ClipRRect( |                               child: ClipRRect( | ||||||
|                                 borderRadius: radius, |                                 borderRadius: radius, | ||||||
|                                 child: Hero( |                                 child: (_playlist?.images?.isNotEmpty ?? false) | ||||||
|                                   tag: Key('playlist-cover-${_playlist!.id}'), |                                     ? AutoCacheImage( | ||||||
|                                   child: AutoCacheImage( |  | ||||||
|                                         _playlist!.images!.first.url!, |                                         _playlist!.images!.first.url!, | ||||||
|                                         width: 160.0, |                                         width: 160.0, | ||||||
|                                         height: 160.0, |                                         height: 160.0, | ||||||
|                                   ), |                                       ) | ||||||
|  |                                     : const SizedBox( | ||||||
|  |                                         width: 160, | ||||||
|  |                                         height: 160, | ||||||
|  |                                         child: Icon(Icons.image), | ||||||
|                                       ), |                                       ), | ||||||
|                               ), |                               ), | ||||||
|                             ), |                             ), | ||||||
| @@ -116,7 +146,7 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> { | |||||||
|                                   ), |                                   ), | ||||||
|                                   const Gap(8), |                                   const Gap(8), | ||||||
|                                   Text( |                                   Text( | ||||||
|                                     "${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers!.total!)} saves", |                                     "${NumberFormat.compactCurrency(symbol: '', decimalDigits: 2).format(_playlist!.followers?.total! ?? 0)} saves", | ||||||
|                                   ), |                                   ), | ||||||
|                                   Text( |                                   Text( | ||||||
|                                     '#${_playlist!.id}', |                                     '#${_playlist!.id}', | ||||||
| @@ -153,14 +183,7 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> { | |||||||
|  |  | ||||||
|                                         setState(() => _isUpdating = true); |                                         setState(() => _isUpdating = true); | ||||||
|  |  | ||||||
|                                         final tracks = (await _spotify |                                         await _playback.load(_tracks!, | ||||||
|                                                 .api.playlists |  | ||||||
|                                                 .getTracksByPlaylistId( |  | ||||||
|                                                     widget.playlistId) |  | ||||||
|                                                 .all()) |  | ||||||
|                                             .toList(); |  | ||||||
|  |  | ||||||
|                                         await _playback.load(tracks, |  | ||||||
|                                             autoPlay: true); |                                             autoPlay: true); | ||||||
|                                         _playback.addCollection(_playlist!.id!); |                                         _playback.addCollection(_playlist!.id!); | ||||||
|                                         Get.find<PlaybackHistoryProvider>() |                                         Get.find<PlaybackHistoryProvider>() | ||||||
| @@ -180,18 +203,11 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> { | |||||||
|  |  | ||||||
|                                       audioPlayer.setShuffle(true); |                                       audioPlayer.setShuffle(true); | ||||||
|  |  | ||||||
|                                       final tracks = (await _spotify |  | ||||||
|                                               .api.playlists |  | ||||||
|                                               .getTracksByPlaylistId( |  | ||||||
|                                                   widget.playlistId) |  | ||||||
|                                               .all()) |  | ||||||
|                                           .toList(); |  | ||||||
|  |  | ||||||
|                                       await _playback.load( |                                       await _playback.load( | ||||||
|                                         tracks, |                                         _tracks!, | ||||||
|                                         autoPlay: true, |                                         autoPlay: true, | ||||||
|                                         initialIndex: |                                         initialIndex: | ||||||
|                                             Random().nextInt(tracks.length), |                                             Random().nextInt(_tracks!.length), | ||||||
|                                       ); |                                       ); | ||||||
|                                       _playback.addCollection(_playlist!.id!); |                                       _playback.addCollection(_playlist!.id!); | ||||||
|                                       Get.find<PlaybackHistoryProvider>() |                                       Get.find<PlaybackHistoryProvider>() | ||||||
| @@ -208,11 +224,15 @@ class _PlaylistViewScreenState extends State<PlaylistViewScreen> { | |||||||
|                   ), |                   ), | ||||||
|                   SliverToBoxAdapter( |                   SliverToBoxAdapter( | ||||||
|                     child: Text( |                     child: Text( | ||||||
|                       'Songs (${_playlist!.tracks!.total})', |                       'Songs (${_playlist!.tracks?.total ?? (_tracks?.length ?? 0)})', | ||||||
|                       style: Theme.of(context).textTheme.titleLarge, |                       style: Theme.of(context).textTheme.titleLarge, | ||||||
|                     ).paddingOnly(left: 28, right: 28, bottom: 4), |                     ).paddingOnly(left: 28, right: 28, bottom: 4), | ||||||
|                   ), |                   ), | ||||||
|                   PlaylistTrackList(playlistId: widget.playlistId), |                   PlaylistTrackList( | ||||||
|  |                     isLoading: _isLoadingTracks, | ||||||
|  |                     playlistId: widget.playlistId, | ||||||
|  |                     tracks: _tracks, | ||||||
|  |                   ), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|             ); |             ); | ||||||
|   | |||||||
| @@ -45,8 +45,6 @@ class _SettingsScreenState extends State<SettingsScreen> { | |||||||
|               return FutureBuilder( |               return FutureBuilder( | ||||||
|                 future: _spotify.api.me.get(), |                 future: _spotify.api.me.get(), | ||||||
|                 builder: (context, snapshot) { |                 builder: (context, snapshot) { | ||||||
|                   print(snapshot.data); |  | ||||||
|                   print(snapshot.error); |  | ||||||
|                   if (!snapshot.hasData) { |                   if (!snapshot.hasData) { | ||||||
|                     return const ListTile( |                     return const ListTile( | ||||||
|                       contentPadding: |                       contentPadding: | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ class _NavShellState extends State<NavShell> { | |||||||
|  |  | ||||||
|   final List<Destination> _allDestinations = <Destination>[ |   final List<Destination> _allDestinations = <Destination>[ | ||||||
|     Destination('explore'.tr, 'explore', Icons.explore), |     Destination('explore'.tr, 'explore', Icons.explore), | ||||||
|  |     Destination('library'.tr, 'library', Icons.video_library), | ||||||
|     Destination('search'.tr, 'search', Icons.search), |     Destination('search'.tr, 'search', Icons.search), | ||||||
|     Destination('settings'.tr, 'settings', Icons.settings), |     Destination('settings'.tr, 'settings', Icons.settings), | ||||||
|   ]; |   ]; | ||||||
| @@ -40,6 +41,7 @@ class _NavShellState extends State<NavShell> { | |||||||
|             const BottomPlayer(key: Key('app-wide-bottom-player')), |             const BottomPlayer(key: Key('app-wide-bottom-player')), | ||||||
|             const Divider(height: 0.3, thickness: 0.3), |             const Divider(height: 0.3, thickness: 0.3), | ||||||
|             BottomNavigationBar( |             BottomNavigationBar( | ||||||
|  |               type: BottomNavigationBarType.fixed, | ||||||
|               landscapeLayout: BottomNavigationBarLandscapeLayout.centered, |               landscapeLayout: BottomNavigationBarLandscapeLayout.centered, | ||||||
|               elevation: 0, |               elevation: 0, | ||||||
|               showUnselectedLabels: false, |               showUnselectedLabels: false, | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| const i18nEnglish = { | const i18nEnglish = { | ||||||
|   'appName': 'RhythmBox', |   'appName': 'RhythmBox', | ||||||
|   'explore': 'Explore', |   'explore': 'Explore', | ||||||
|  |   'library': 'Library', | ||||||
|   'settings': 'Settings', |   'settings': 'Settings', | ||||||
|   'search': 'Search', |   'search': 'Search', | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| const i18nSimplifiedChinese = { | const i18nSimplifiedChinese = { | ||||||
|   'appName': '韵律盒', |   'appName': '韵律盒', | ||||||
|   'explore': '探索', |   'explore': '探索', | ||||||
|  |   'library': '资料库', | ||||||
|   'settings': '设置', |   'settings': '设置', | ||||||
|   'search': '搜索', |   'search': '搜索', | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -28,15 +28,16 @@ class _SyncedLyricsState extends State<SyncedLyrics> { | |||||||
|   final AutoScrollController _autoScrollController = AutoScrollController(); |   final AutoScrollController _autoScrollController = AutoScrollController(); | ||||||
| 
 | 
 | ||||||
|   late final int _textZoomLevel = widget.defaultTextZoom; |   late final int _textZoomLevel = widget.defaultTextZoom; | ||||||
|   late Duration _durationCurrent = audioPlayer.position; |  | ||||||
| 
 | 
 | ||||||
|   SubtitleSimple? _lyric; |   SubtitleSimple? _lyric; | ||||||
|  |   String? _activeTrackId; | ||||||
| 
 | 
 | ||||||
|   bool get _isLyricSynced => |   bool get _isLyricSynced => | ||||||
|       _lyric == null ? false : _lyric!.lyrics.any((x) => x.time.inSeconds > 0); |       _lyric == null ? false : _lyric!.lyrics.any((x) => x.time.inSeconds > 0); | ||||||
| 
 | 
 | ||||||
|   Future<void> _pullLyrics() async { |   Future<void> _pullLyrics() async { | ||||||
|     if (_playback.state.value.activeTrack == null) return; |     if (_playback.state.value.activeTrack == null) return; | ||||||
|  |     _activeTrackId = _playback.state.value.activeTrack!.id; | ||||||
|     final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!); |     final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!); | ||||||
|     setState(() => _lyric = out); |     setState(() => _lyric = out); | ||||||
|   } |   } | ||||||
| @@ -49,11 +50,15 @@ class _SyncedLyricsState extends State<SyncedLyrics> { | |||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _subscriptions = [ |  | ||||||
|       audioPlayer.positionStream |  | ||||||
|           .listen((dur) => setState(() => _durationCurrent = dur)), |  | ||||||
|     ]; |  | ||||||
|     _pullLyrics(); |     _pullLyrics(); | ||||||
|  |     _subscriptions = [ | ||||||
|  |       _playback.state.listen((value) { | ||||||
|  |         if (value.activeTrack == null) return; | ||||||
|  |         if (value.activeTrack!.id != _activeTrackId) { | ||||||
|  |           _pullLyrics(); | ||||||
|  |         } | ||||||
|  |       }), | ||||||
|  |     ]; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
| @@ -77,18 +82,19 @@ class _SyncedLyricsState extends State<SyncedLyrics> { | |||||||
|         if (_lyric != null && _lyric!.lyrics.isNotEmpty) |         if (_lyric != null && _lyric!.lyrics.isNotEmpty) | ||||||
|           SliverList.builder( |           SliverList.builder( | ||||||
|             itemCount: _lyric!.lyrics.length, |             itemCount: _lyric!.lyrics.length, | ||||||
|             itemBuilder: (context, idx) { |             itemBuilder: (context, idx) => Obx(() { | ||||||
|               final lyricSlice = _lyric!.lyrics[idx]; |               final lyricSlice = _lyric!.lyrics[idx]; | ||||||
|               final lyricNextSlice = idx + 1 < _lyric!.lyrics.length |               final lyricNextSlice = idx + 1 < _lyric!.lyrics.length | ||||||
|                   ? _lyric!.lyrics[idx + 1] |                   ? _lyric!.lyrics[idx + 1] | ||||||
|                   : null; |                   : null; | ||||||
|               final isActive = |               final isActive = _playback.durationCurrent.value.inSeconds >= | ||||||
|                   _durationCurrent.inSeconds >= lyricSlice.time.inSeconds && |                       lyricSlice.time.inSeconds && | ||||||
|                   (lyricNextSlice == null || |                   (lyricNextSlice == null || | ||||||
|                       lyricNextSlice.time.inSeconds > |                       lyricNextSlice.time.inSeconds > | ||||||
|                               _durationCurrent.inSeconds); |                           _playback.durationCurrent.value.inSeconds); | ||||||
| 
 | 
 | ||||||
|               if (_durationCurrent.inSeconds == lyricSlice.time.inSeconds && |               if (_playback.durationCurrent.value.inSeconds == | ||||||
|  |                       lyricSlice.time.inSeconds && | ||||||
|                   _isLyricSynced) { |                   _isLyricSynced) { | ||||||
|                 _autoScrollController.scrollToIndex( |                 _autoScrollController.scrollToIndex( | ||||||
|                   idx, |                   idx, | ||||||
| @@ -150,7 +156,7 @@ class _SyncedLyricsState extends State<SyncedLyrics> { | |||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|               ); |               ); | ||||||
|             }, |             }), | ||||||
|           ), |           ), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
							
								
								
									
										32
									
								
								lib/widgets/no_login_fallback.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								lib/widgets/no_login_fallback.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:rhythm_box/widgets/sized_container.dart'; | ||||||
|  |  | ||||||
|  | class NoLoginFallback extends StatelessWidget { | ||||||
|  |   const NoLoginFallback({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return CenteredContainer( | ||||||
|  |       maxWidth: 280, | ||||||
|  |       child: Column( | ||||||
|  |         mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |         children: [ | ||||||
|  |           const Icon( | ||||||
|  |             Icons.login, | ||||||
|  |             size: 48, | ||||||
|  |           ), | ||||||
|  |           const Gap(12), | ||||||
|  |           Text( | ||||||
|  |             'Connect with your Spotify', | ||||||
|  |             style: Theme.of(context).textTheme.titleLarge, | ||||||
|  |           ), | ||||||
|  |           const Text( | ||||||
|  |             'You need to connect RhythmBox with your spotify account in settings page, so that we can access your library.', | ||||||
|  |             textAlign: TextAlign.center, | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										43
									
								
								lib/widgets/playlist/playlist_tile.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								lib/widgets/playlist/playlist_tile.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:rhythm_box/widgets/auto_cache_image.dart'; | ||||||
|  | import 'package:spotify/spotify.dart'; | ||||||
|  |  | ||||||
|  | class PlaylistTile extends StatelessWidget { | ||||||
|  |   final PlaylistSimple? item; | ||||||
|  |  | ||||||
|  |   final Function? onTap; | ||||||
|  |  | ||||||
|  |   const PlaylistTile({super.key, required this.item, this.onTap}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return ListTile( | ||||||
|  |       leading: ClipRRect( | ||||||
|  |         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|  |         child: (item?.images?.isNotEmpty ?? false) | ||||||
|  |             ? AutoCacheImage( | ||||||
|  |                 item!.images!.first.url!, | ||||||
|  |                 width: 64.0, | ||||||
|  |                 height: 64.0, | ||||||
|  |               ) | ||||||
|  |             : const SizedBox( | ||||||
|  |                 width: 64, | ||||||
|  |                 height: 64, | ||||||
|  |                 child: Center( | ||||||
|  |                   child: Icon(Icons.image), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |       ), | ||||||
|  |       title: Text(item?.name ?? 'Loading...'), | ||||||
|  |       subtitle: Text( | ||||||
|  |         item?.description ?? 'Please stand by...', | ||||||
|  |         maxLines: 2, | ||||||
|  |         overflow: TextOverflow.ellipsis, | ||||||
|  |       ), | ||||||
|  |       onTap: () { | ||||||
|  |         if (onTap == null) return; | ||||||
|  |         onTap!(); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										64
									
								
								lib/widgets/playlist/user_playlist_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								lib/widgets/playlist/user_playlist_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:get/get.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:rhythm_box/providers/spotify.dart'; | ||||||
|  | import 'package:rhythm_box/widgets/playlist/playlist_tile.dart'; | ||||||
|  | import 'package:skeletonizer/skeletonizer.dart'; | ||||||
|  | import 'package:spotify/spotify.dart'; | ||||||
|  |  | ||||||
|  | class UserPlaylistList extends StatefulWidget { | ||||||
|  |   const UserPlaylistList({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<UserPlaylistList> createState() => _UserPlaylistListState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _UserPlaylistListState extends State<UserPlaylistList> { | ||||||
|  |   late final SpotifyProvider _spotify = Get.find(); | ||||||
|  |  | ||||||
|  |   PlaylistSimple get _userLikedPlaylist => PlaylistSimple() | ||||||
|  |     ..name = 'Liked Music' | ||||||
|  |     ..description = 'Your favorite music' | ||||||
|  |     ..type = 'playlist' | ||||||
|  |     ..collaborative = false | ||||||
|  |     ..public = false | ||||||
|  |     ..id = 'user-liked-tracks'; | ||||||
|  |  | ||||||
|  |   bool _isLoading = true; | ||||||
|  |  | ||||||
|  |   List<PlaylistSimple>? _playlist; | ||||||
|  |  | ||||||
|  |   Future<void> _pullPlaylist() async { | ||||||
|  |     _playlist = [_userLikedPlaylist, ...await _spotify.api.playlists.me.all()]; | ||||||
|  |     setState(() => _isLoading = false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _pullPlaylist(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Skeletonizer( | ||||||
|  |       enabled: _isLoading, | ||||||
|  |       child: ListView.builder( | ||||||
|  |         itemCount: _playlist?.length ?? 3, | ||||||
|  |         itemBuilder: (context, idx) { | ||||||
|  |           final item = _playlist?[idx]; | ||||||
|  |           return PlaylistTile( | ||||||
|  |             item: item, | ||||||
|  |             onTap: () { | ||||||
|  |               if (item == null) return; | ||||||
|  |               GoRouter.of(context).pushNamed( | ||||||
|  |                 'playlistView', | ||||||
|  |                 pathParameters: {'id': item.id!}, | ||||||
|  |               ); | ||||||
|  |             }, | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,82 +1,42 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:get/get.dart'; | import 'package:get/get.dart'; | ||||||
| import 'package:rhythm_box/providers/audio_player.dart'; | import 'package:rhythm_box/providers/audio_player.dart'; | ||||||
| import 'package:rhythm_box/providers/spotify.dart'; | import 'package:rhythm_box/widgets/tracks/track_tile.dart'; | ||||||
| import 'package:rhythm_box/widgets/auto_cache_image.dart'; |  | ||||||
| import 'package:skeletonizer/skeletonizer.dart'; | import 'package:skeletonizer/skeletonizer.dart'; | ||||||
| import 'package:spotify/spotify.dart'; | import 'package:spotify/spotify.dart'; | ||||||
| import 'package:rhythm_box/services/artist.dart'; |  | ||||||
|  |  | ||||||
| class PlaylistTrackList extends StatefulWidget { | class PlaylistTrackList extends StatelessWidget { | ||||||
|   final String playlistId; |   final String playlistId; | ||||||
|  |   final List<Track>? tracks; | ||||||
|  |  | ||||||
|   const PlaylistTrackList({super.key, required this.playlistId}); |   final bool isLoading; | ||||||
|  |  | ||||||
|   @override |   const PlaylistTrackList({ | ||||||
|   State<PlaylistTrackList> createState() => _PlaylistTrackListState(); |     super.key, | ||||||
| } |     this.isLoading = false, | ||||||
|  |     required this.playlistId, | ||||||
| class _PlaylistTrackListState extends State<PlaylistTrackList> { |     required this.tracks, | ||||||
|   late final SpotifyProvider _spotify = Get.find(); |   }); | ||||||
|  |  | ||||||
|   bool _isLoading = true; |  | ||||||
|  |  | ||||||
|   List<Track>? _tracks; |  | ||||||
|  |  | ||||||
|   Future<void> _pullTracks() async { |  | ||||||
|     _tracks = (await _spotify.api.playlists |  | ||||||
|             .getTracksByPlaylistId(widget.playlistId) |  | ||||||
|             .all()) |  | ||||||
|         .toList(); |  | ||||||
|     setState(() => _isLoading = false); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void initState() { |  | ||||||
|     _pullTracks(); |  | ||||||
|     super.initState(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Skeletonizer.sliver( |     return Skeletonizer.sliver( | ||||||
|       enabled: _isLoading, |       enabled: isLoading, | ||||||
|       child: SliverList.builder( |       child: SliverList.builder( | ||||||
|         itemCount: _tracks?.length ?? 3, |         itemCount: tracks?.length ?? 3, | ||||||
|         itemBuilder: (context, idx) { |         itemBuilder: (context, idx) { | ||||||
|           final item = _tracks?[idx]; |           final item = tracks?[idx]; | ||||||
|           return ListTile( |           return TrackTile( | ||||||
|             leading: ClipRRect( |             item: item, | ||||||
|               borderRadius: const BorderRadius.all(Radius.circular(8)), |  | ||||||
|               child: item != null |  | ||||||
|                   ? AutoCacheImage( |  | ||||||
|                       item.album!.images!.first.url!, |  | ||||||
|                       width: 64.0, |  | ||||||
|                       height: 64.0, |  | ||||||
|                     ) |  | ||||||
|                   : const SizedBox( |  | ||||||
|                       width: 64, |  | ||||||
|                       height: 64, |  | ||||||
|                       child: Center( |  | ||||||
|                         child: Icon(Icons.image), |  | ||||||
|                       ), |  | ||||||
|                     ), |  | ||||||
|             ), |  | ||||||
|             title: Text(item?.name ?? 'Loading...'), |  | ||||||
|             subtitle: Text( |  | ||||||
|               item?.artists?.asString() ?? 'Please stand by...', |  | ||||||
|               maxLines: 1, |  | ||||||
|               overflow: TextOverflow.ellipsis, |  | ||||||
|             ), |  | ||||||
|             onTap: () { |             onTap: () { | ||||||
|               if (item == null) return; |               if (item == null) return; | ||||||
|               Get.find<AudioPlayerProvider>() |               Get.find<AudioPlayerProvider>() | ||||||
|                 ..load( |                 ..load( | ||||||
|                   _tracks!, |                   tracks!, | ||||||
|                   initialIndex: idx, |                   initialIndex: idx, | ||||||
|                   autoPlay: true, |                   autoPlay: true, | ||||||
|                 ) |                 ) | ||||||
|                 ..addCollection(widget.playlistId); |                 ..addCollection(playlistId); | ||||||
|             }, |             }, | ||||||
|           ); |           ); | ||||||
|         }, |         }, | ||||||
|   | |||||||
| @@ -1,9 +1,8 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:get/get.dart'; | import 'package:get/get.dart'; | ||||||
| import 'package:rhythm_box/providers/audio_player.dart'; | import 'package:rhythm_box/providers/audio_player.dart'; | ||||||
| import 'package:rhythm_box/widgets/auto_cache_image.dart'; | import 'package:rhythm_box/widgets/tracks/track_tile.dart'; | ||||||
| import 'package:spotify/spotify.dart'; | import 'package:spotify/spotify.dart'; | ||||||
| import 'package:rhythm_box/services/artist.dart'; |  | ||||||
|  |  | ||||||
| class TrackSliverList extends StatelessWidget { | class TrackSliverList extends StatelessWidget { | ||||||
|   final List<Track> tracks; |   final List<Track> tracks; | ||||||
| @@ -19,21 +18,8 @@ class TrackSliverList extends StatelessWidget { | |||||||
|       itemCount: tracks.length, |       itemCount: tracks.length, | ||||||
|       itemBuilder: (context, idx) { |       itemBuilder: (context, idx) { | ||||||
|         final item = tracks[idx]; |         final item = tracks[idx]; | ||||||
|         return ListTile( |         return TrackTile( | ||||||
|           leading: ClipRRect( |           item: item, | ||||||
|             borderRadius: const BorderRadius.all(Radius.circular(8)), |  | ||||||
|             child: AutoCacheImage( |  | ||||||
|               item.album!.images!.first.url!, |  | ||||||
|               width: 64.0, |  | ||||||
|               height: 64.0, |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           title: Text(item.name ?? 'Loading...'), |  | ||||||
|           subtitle: Text( |  | ||||||
|             item.artists?.asString() ?? 'Please stand by...', |  | ||||||
|             maxLines: 1, |  | ||||||
|             overflow: TextOverflow.ellipsis, |  | ||||||
|           ), |  | ||||||
|           onTap: () { |           onTap: () { | ||||||
|             Get.find<AudioPlayerProvider>().load( |             Get.find<AudioPlayerProvider>().load( | ||||||
|               [item], |               [item], | ||||||
|   | |||||||
							
								
								
									
										44
									
								
								lib/widgets/tracks/track_tile.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								lib/widgets/tracks/track_tile.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:rhythm_box/widgets/auto_cache_image.dart'; | ||||||
|  | import 'package:spotify/spotify.dart'; | ||||||
|  | import 'package:rhythm_box/services/artist.dart'; | ||||||
|  |  | ||||||
|  | class TrackTile extends StatelessWidget { | ||||||
|  |   final Track? item; | ||||||
|  |  | ||||||
|  |   final Function? onTap; | ||||||
|  |  | ||||||
|  |   const TrackTile({super.key, required this.item, this.onTap}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return ListTile( | ||||||
|  |       leading: ClipRRect( | ||||||
|  |         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|  |         child: (item?.album?.images?.isNotEmpty ?? false) | ||||||
|  |             ? AutoCacheImage( | ||||||
|  |                 item!.album!.images!.first.url!, | ||||||
|  |                 width: 64.0, | ||||||
|  |                 height: 64.0, | ||||||
|  |               ) | ||||||
|  |             : const SizedBox( | ||||||
|  |                 width: 64, | ||||||
|  |                 height: 64, | ||||||
|  |                 child: Center( | ||||||
|  |                   child: Icon(Icons.image), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |       ), | ||||||
|  |       title: Text(item?.name ?? 'Loading...'), | ||||||
|  |       subtitle: Text( | ||||||
|  |         item?.artists?.asString() ?? 'Please stand by...', | ||||||
|  |         maxLines: 1, | ||||||
|  |         overflow: TextOverflow.ellipsis, | ||||||
|  |       ), | ||||||
|  |       onTap: () { | ||||||
|  |         if (onTap == null) return; | ||||||
|  |         onTap!(); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user