diff --git a/android/app/build.gradle b/android/app/build.gradle index 1c202cc..fb3fc76 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -28,6 +28,8 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + minSdkVersion 24 } buildTypes { diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/main.dart b/lib/main.dart index 57d70f8..338c84f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:rhythm_box/services/server/routes/playback.dart'; import 'package:rhythm_box/services/server/server.dart'; import 'package:rhythm_box/services/server/sourced_track.dart'; import 'package:rhythm_box/translations.dart'; +import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; void main() { MediaKit.ensureInitialized(); @@ -51,9 +52,11 @@ class MyApp extends StatelessWidget { void _initializeProviders(BuildContext context) async { Get.lazyPut(() => SpotifyProvider()); Get.lazyPut(() => ActiveSourcedTrackProvider()); - Get.lazyPut(() => SourcedTrackProvider()); Get.put(AudioPlayerProvider()); + Get.put(QueryingTrackInfoProvider()); + Get.put(SourcedTrackProvider()); + Get.put(ServerPlaybackRoutesProvider()); Get.put(PlaybackServerProvider()); } diff --git a/lib/providers/audio_player.dart b/lib/providers/audio_player.dart index 6960c34..57a88f9 100644 --- a/lib/providers/audio_player.dart +++ b/lib/providers/audio_player.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:get/get.dart'; @@ -13,6 +14,8 @@ import 'package:shared_preferences/shared_preferences.dart'; class AudioPlayerProvider extends GetxController { late final SharedPreferences _prefs; + RxBool isPlaying = false.obs; + Rx state = Rx(AudioPlayerState( playing: false, shuffled: false, @@ -25,33 +28,44 @@ class AudioPlayerProvider extends GetxController { @override void onInit() { - SharedPreferences.getInstance().then((ins) { + SharedPreferences.getInstance().then((ins) async { _prefs = ins; - _syncSavedState(); + final res = await _readSavedState(); + if (res != null) { + state.value = res; + } else { + state.value = AudioPlayerState( + loopMode: audioPlayer.loopMode, + playing: audioPlayer.isPlaying, + playlist: audioPlayer.playlist, + shuffled: audioPlayer.isShuffled, + collections: [], + ); + } }); _subscriptions = [ audioPlayer.playingStream.listen((playing) async { state.value = state.value.copyWith(playing: playing); + await _updateSavedState(); }), audioPlayer.loopModeStream.listen((loopMode) async { state.value = state.value.copyWith(loopMode: loopMode); + await _updateSavedState(); }), audioPlayer.shuffledStream.listen((shuffled) async { state.value = state.value.copyWith(shuffled: shuffled); + await _updateSavedState(); }), audioPlayer.playlistStream.listen((playlist) async { state.value = state.value.copyWith(playlist: playlist); + await _updateSavedState(); }), ]; - state.value = AudioPlayerState( - loopMode: audioPlayer.loopMode, - playing: audioPlayer.isPlaying, - playlist: audioPlayer.playlist, - shuffled: audioPlayer.isShuffled, - collections: [], - ); + audioPlayer.playingStream.listen((playing) { + isPlaying.value = playing; + }); super.onInit(); } @@ -66,13 +80,16 @@ class AudioPlayerProvider extends GetxController { super.dispose(); } - Future _syncSavedState() async { - final data = _prefs.getBool("player_state"); - if (data == null) return; + Future _readSavedState() async { + final data = _prefs.getString("player_state"); + if (data == null) return null; - // TODO Serilize and deserilize this state + return AudioPlayerState.fromJson(jsonDecode(data)); + } - // TODO Sync saved playlist + Future _updateSavedState() async { + final out = jsonEncode(state.value.toJson()); + await _prefs.setString("player_state", out); } Future addCollections(List collectionIds) async { @@ -80,6 +97,8 @@ class AudioPlayerProvider extends GetxController { ...state.value.collections, ...collectionIds, ]); + + await _updateSavedState(); } Future addCollection(String collectionId) async { @@ -92,6 +111,8 @@ class AudioPlayerProvider extends GetxController { .where((element) => !collectionIds.contains(element)) .toList(), ); + + await _updateSavedState(); } Future removeCollection(String collectionId) async { diff --git a/lib/router.dart b/lib/router.dart index fb079d1..025e2bd 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -13,6 +13,13 @@ final router = GoRouter(routes: [ name: "explore", builder: (context, state) => const ExploreScreen(), ), + GoRoute( + path: "/playlist/:id", + name: "playlistView", + builder: (context, state) => PlaylistViewScreen( + playlistId: state.pathParameters['id']!, + ), + ), GoRoute( path: "/settings", name: "settings", @@ -20,11 +27,4 @@ final router = GoRouter(routes: [ ), ], ), - GoRoute( - path: "/playlist/:id", - name: "playlistView", - builder: (context, state) => PlaylistViewScreen( - playlistId: state.pathParameters['id']!, - ), - ), ]); diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index edfd76f..c5e6da7 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -22,7 +22,7 @@ class _ExploreScreenState extends State { Future _pullPlaylist() async { _featuredPlaylist = - (await _spotify.api.playlists.featured.all(10)).toList(); + (await _spotify.api.playlists.featured.getPage(20)).items!.toList(); setState(() => _isLoading = false); } @@ -48,7 +48,7 @@ class _ExploreScreenState extends State { final item = _featuredPlaylist?[idx]; return ListTile( leading: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(8)), child: item != null ? AutoCacheImage( item.images!.first.url!, diff --git a/lib/services/artist.dart b/lib/services/artist.dart index 7997355..1f26989 100644 --- a/lib/services/artist.dart +++ b/lib/services/artist.dart @@ -1,6 +1,12 @@ import 'package:spotify/spotify.dart'; -extension ArtistExtension on List { +extension ArtistSimpleExtension on List { + String asString() { + return map((e) => e.name?.replaceAll(",", " ")).join(", "); + } +} + +extension ArtistExtension on List { String asString() { return map((e) => e.name?.replaceAll(",", " ")).join(", "); } diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 7ca20a3..bf5f279 100755 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -48,11 +48,11 @@ class AudioServices with WidgetsBindingObserver { duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), - artUri: Uri.parse( - (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - ), + artUri: track.album?.images != null + ? Uri.parse( + (track.album?.images).asUrlString()!, + ) + : null, playable: true, )); } diff --git a/lib/services/audio_services/image.dart b/lib/services/audio_services/image.dart index 7a34b38..d7dcb6e 100644 --- a/lib/services/audio_services/image.dart +++ b/lib/services/audio_services/image.dart @@ -1,6 +1,4 @@ -import 'package:rhythm_box/services/primitive.dart'; import 'package:spotify/spotify.dart'; -import 'package:rhythm_box/collections/assets.gen.dart'; import 'package:collection/collection.dart'; enum ImagePlaceholder { @@ -11,24 +9,13 @@ enum ImagePlaceholder { } extension SpotifyImageExtensions on List? { - String asUrlString({ + String? asUrlString({ int index = 1, - required ImagePlaceholder placeholder, }) { - final String placeholderUrl = { - ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, - ImagePlaceholder.artist: Assets.userPlaceholder.path, - ImagePlaceholder.collection: Assets.placeholder.path, - ImagePlaceholder.online: - "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", - }[placeholder]!; - final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); - return sortedImage != null && sortedImage.isNotEmpty - ? sortedImage[ - index > sortedImage.length - 1 ? sortedImage.length - 1 : index] - .url! - : placeholderUrl; + return sortedImage?[ + index > sortedImage.length - 1 ? sortedImage.length - 1 : index] + .url; } } diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 062304a..40e13e5 100755 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -84,9 +84,7 @@ class WindowsAudioService { albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", artist: track.artists?.asString() ?? "Unknown", album: track.album?.name ?? "Unknown", - thumbnail: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), + thumbnail: (track.album?.images).asUrlString(), ), ); } diff --git a/lib/services/server/sourced_track.dart b/lib/services/server/sourced_track.dart index 820e2aa..30b0ffd 100755 --- a/lib/services/server/sourced_track.dart +++ b/lib/services/server/sourced_track.dart @@ -4,6 +4,7 @@ import 'package:rhythm_box/providers/audio_player.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/local_track.dart'; import 'package:rhythm_box/services/sourced_track/sourced_track.dart'; +import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; import 'package:spotify/spotify.dart'; class SourcedTrackProvider extends GetxController { @@ -17,6 +18,7 @@ class SourcedTrackProvider extends GetxController { } final AudioPlayerProvider playback = Get.find(); + final QueryingTrackInfoProvider query = Get.find(); ever(playback.state.value.tracks.obs, (List tracks) { if (tracks.isEmpty || tracks.none((element) => element.id == track.id)) { @@ -24,7 +26,9 @@ class SourcedTrackProvider extends GetxController { } }); + query.isQueryingTrackInfo.value = true; sourcedTrack.value = await SourcedTrack.fetchFromTrack(track: track); + query.isQueryingTrackInfo.value = false; return sourcedTrack.value; } diff --git a/lib/shells/nav_shell.dart b/lib/shells/nav_shell.dart index 87e0c33..2a28e62 100644 --- a/lib/shells/nav_shell.dart +++ b/lib/shells/nav_shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; +import 'package:rhythm_box/widgets/player/bottom_player.dart'; class Destination { const Destination(this.title, this.page, this.icon); @@ -30,19 +31,30 @@ class _NavShellState extends State { Widget build(BuildContext context) { return Scaffold( body: widget.child, - bottomNavigationBar: BottomNavigationBar( - showUnselectedLabels: false, - currentIndex: _focusDestination, - items: _allDestinations - .map((x) => BottomNavigationBarItem( - icon: Icon(x.icon), - label: x.title, - )) - .toList(), - onTap: (value) { - GoRouter.of(context).goNamed(_allDestinations[value].page); - setState(() => _focusDestination = value); - }, + bottomNavigationBar: Material( + elevation: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BottomPlayer(), + const Divider(height: 0.3, thickness: 0.3), + BottomNavigationBar( + elevation: 0, + showUnselectedLabels: false, + currentIndex: _focusDestination, + items: _allDestinations + .map((x) => BottomNavigationBarItem( + icon: Icon(x.icon), + label: x.title, + )) + .toList(), + onTap: (value) { + GoRouter.of(context).goNamed(_allDestinations[value].page); + setState(() => _focusDestination = value); + }, + ), + ], + ), ), ); } diff --git a/lib/widgets/player/bottom_player.dart b/lib/widgets/player/bottom_player.dart new file mode 100644 index 0000000..a9461d1 --- /dev/null +++ b/lib/widgets/player/bottom_player.dart @@ -0,0 +1,172 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/audio_player.dart'; +import 'package:rhythm_box/services/audio_player/audio_player.dart'; +import 'package:rhythm_box/services/audio_services/image.dart'; +import 'package:rhythm_box/widgets/auto_cache_image.dart'; +import 'package:rhythm_box/widgets/player/track_details.dart'; +import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; + +class BottomPlayer extends StatefulWidget { + const BottomPlayer({super.key}); + + @override + State createState() => _BottomPlayerState(); +} + +class _BottomPlayerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + late final Animation _animation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + + late final AudioPlayerProvider _playback = Get.find(); + late final QueryingTrackInfoProvider _query = Get.find(); + + String? get _albumArt => + (_playback.state.value.activeTrack?.album?.images).asUrlString( + index: + (_playback.state.value.activeTrack?.album?.images?.length ?? 1) - 1, + ); + + bool get _isPlaying => _playback.isPlaying.value; + bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value; + + Duration _durationCurrent = Duration.zero; + Duration _durationTotal = Duration.zero; + + void _updateDurationCurrent(Duration dur) { + setState(() => _durationCurrent = dur); + } + + void _updateDurationTotal(Duration dur) { + setState(() => _durationTotal = dur); + } + + List? _subscriptions; + + Future _togglePlayState() async { + if (!audioPlayer.isPlaying) { + await audioPlayer.resume(); + } else { + await audioPlayer.pause(); + } + } + + bool _isLifted = false; + + @override + void initState() { + super.initState(); + _subscriptions = [ + audioPlayer.durationStream.listen(_updateDurationTotal), + audioPlayer.positionStream.listen(_updateDurationCurrent), + _playback.isPlaying.listen((value) { + if (value && !_isLifted) { + _animationController.animateTo(1); + _isLifted = true; + } + }), + _query.isQueryingTrackInfo.listen((value) { + if (value && !_isLifted) { + _animationController.animateTo(1); + _isLifted = true; + } + }), + ]; + } + + @override + void dispose() { + if (_subscriptions != null) { + for (final subscription in _subscriptions!) { + subscription.cancel(); + } + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizeTransition( + sizeFactor: _animation, + axis: Axis.vertical, + axisAlignment: -1, + child: Obx( + () => Column( + children: [ + if (_durationCurrent != Duration.zero) + TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: _durationCurrent.inMilliseconds / + max(_durationTotal.inMilliseconds, 1), + ), + duration: const Duration(milliseconds: 100), + builder: (context, value, _) => LinearProgressIndicator( + minHeight: 3, + value: value, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: _albumArt != null + ? AutoCacheImage(_albumArt!, width: 64, height: 64) + : Container( + color: Theme.of(context) + .colorScheme + .surfaceContainerHigh, + width: 64, + height: 64, + child: const Center(child: Icon(Icons.image)), + ), + ), + const Gap(12), + Expanded( + child: PlayerTrackDetails( + track: _playback.state.value.activeTrack, + ), + ), + const Gap(12), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: _isFetchingActiveTrack + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 3, + ), + ) + : Icon( + !_isPlaying ? Icons.play_arrow : Icons.pause, + ), + onPressed: + _isFetchingActiveTrack ? null : _togglePlayState, + ), + ], + ), + const Gap(12), + ], + ).paddingSymmetric(horizontal: 12, vertical: 8), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/player/track_details.dart b/lib/widgets/player/track_details.dart new file mode 100644 index 0000000..79a004a --- /dev/null +++ b/lib/widgets/player/track_details.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/audio_player.dart'; +import 'package:rhythm_box/services/artist.dart'; +import 'package:spotify/spotify.dart'; + +class PlayerTrackDetails extends StatelessWidget { + final Color? color; + final Track? track; + const PlayerTrackDetails({super.key, this.color, this.track}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final AudioPlayerProvider playback = Get.find(); + + return Row( + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + InkWell( + child: Text( + playback.state.value.activeTrack?.name ?? "Not playing", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium!.copyWith( + color: color, + ), + ), + onTap: () { + // TODO Push to track page + }, + ), + Text( + playback.state.value.activeTrack?.artists?.asString() ?? + "No author", + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall!.copyWith(color: color), + ) + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/tracks/playlist_track_list.dart b/lib/widgets/tracks/playlist_track_list.dart index 7be2cc3..6db2205 100644 --- a/lib/widgets/tracks/playlist_track_list.dart +++ b/lib/widgets/tracks/playlist_track_list.dart @@ -5,6 +5,7 @@ import 'package:rhythm_box/providers/spotify.dart'; import 'package:rhythm_box/widgets/auto_cache_image.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; +import 'package:rhythm_box/services/artist.dart'; class PlaylistTrackList extends StatefulWidget { final String playlistId; @@ -46,7 +47,7 @@ class _PlaylistTrackListState extends State { final item = _tracks?[idx]; return ListTile( leading: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(8)), child: item != null ? AutoCacheImage( item.album!.images!.first.url!, @@ -63,8 +64,7 @@ class _PlaylistTrackListState extends State { ), title: Text(item?.name ?? 'Loading...'), subtitle: Text( - item?.artists!.map((x) => x.name!).join(', ') ?? - 'Please stand by...', + item?.artists?.asString() ?? 'Please stand by...', ), onTap: () { if (item == null) return; diff --git a/lib/widgets/tracks/querying_track_info.dart b/lib/widgets/tracks/querying_track_info.dart new file mode 100644 index 0000000..dd1515d --- /dev/null +++ b/lib/widgets/tracks/querying_track_info.dart @@ -0,0 +1,5 @@ +import 'package:get/get.dart'; + +class QueryingTrackInfoProvider extends GetxController { + RxBool isQueryingTrackInfo = false.obs; +}