Bottom player

This commit is contained in:
LittleSheep 2024-08-27 14:35:16 +08:00
parent 41e248f8cc
commit e7ea852725
16 changed files with 328 additions and 66 deletions

View File

@ -28,6 +28,8 @@ android {
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
minSdkVersion 24
} }
buildTypes { buildTypes {

3
devtools_options.yaml Normal file
View File

@ -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:

View File

@ -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/server.dart';
import 'package:rhythm_box/services/server/sourced_track.dart'; import 'package:rhythm_box/services/server/sourced_track.dart';
import 'package:rhythm_box/translations.dart'; import 'package:rhythm_box/translations.dart';
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
void main() { void main() {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
@ -51,9 +52,11 @@ class MyApp extends StatelessWidget {
void _initializeProviders(BuildContext context) async { void _initializeProviders(BuildContext context) async {
Get.lazyPut(() => SpotifyProvider()); Get.lazyPut(() => SpotifyProvider());
Get.lazyPut(() => ActiveSourcedTrackProvider()); Get.lazyPut(() => ActiveSourcedTrackProvider());
Get.lazyPut(() => SourcedTrackProvider());
Get.put(AudioPlayerProvider()); Get.put(AudioPlayerProvider());
Get.put(QueryingTrackInfoProvider());
Get.put(SourcedTrackProvider());
Get.put(ServerPlaybackRoutesProvider()); Get.put(ServerPlaybackRoutesProvider());
Get.put(PlaybackServerProvider()); Get.put(PlaybackServerProvider());
} }

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -13,6 +14,8 @@ import 'package:shared_preferences/shared_preferences.dart';
class AudioPlayerProvider extends GetxController { class AudioPlayerProvider extends GetxController {
late final SharedPreferences _prefs; late final SharedPreferences _prefs;
RxBool isPlaying = false.obs;
Rx<AudioPlayerState> state = Rx(AudioPlayerState( Rx<AudioPlayerState> state = Rx(AudioPlayerState(
playing: false, playing: false,
shuffled: false, shuffled: false,
@ -25,26 +28,12 @@ class AudioPlayerProvider extends GetxController {
@override @override
void onInit() { void onInit() {
SharedPreferences.getInstance().then((ins) { SharedPreferences.getInstance().then((ins) async {
_prefs = ins; _prefs = ins;
_syncSavedState(); final res = await _readSavedState();
}); if (res != null) {
state.value = res;
_subscriptions = [ } else {
audioPlayer.playingStream.listen((playing) async {
state.value = state.value.copyWith(playing: playing);
}),
audioPlayer.loopModeStream.listen((loopMode) async {
state.value = state.value.copyWith(loopMode: loopMode);
}),
audioPlayer.shuffledStream.listen((shuffled) async {
state.value = state.value.copyWith(shuffled: shuffled);
}),
audioPlayer.playlistStream.listen((playlist) async {
state.value = state.value.copyWith(playlist: playlist);
}),
];
state.value = AudioPlayerState( state.value = AudioPlayerState(
loopMode: audioPlayer.loopMode, loopMode: audioPlayer.loopMode,
playing: audioPlayer.isPlaying, playing: audioPlayer.isPlaying,
@ -52,6 +41,31 @@ class AudioPlayerProvider extends GetxController {
shuffled: audioPlayer.isShuffled, shuffled: audioPlayer.isShuffled,
collections: [], 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();
}),
];
audioPlayer.playingStream.listen((playing) {
isPlaying.value = playing;
});
super.onInit(); super.onInit();
} }
@ -66,13 +80,16 @@ class AudioPlayerProvider extends GetxController {
super.dispose(); super.dispose();
} }
Future<void> _syncSavedState() async { Future<AudioPlayerState?> _readSavedState() async {
final data = _prefs.getBool("player_state"); final data = _prefs.getString("player_state");
if (data == null) return; if (data == null) return null;
// TODO Serilize and deserilize this state return AudioPlayerState.fromJson(jsonDecode(data));
}
// TODO Sync saved playlist Future<void> _updateSavedState() async {
final out = jsonEncode(state.value.toJson());
await _prefs.setString("player_state", out);
} }
Future<void> addCollections(List<String> collectionIds) async { Future<void> addCollections(List<String> collectionIds) async {
@ -80,6 +97,8 @@ class AudioPlayerProvider extends GetxController {
...state.value.collections, ...state.value.collections,
...collectionIds, ...collectionIds,
]); ]);
await _updateSavedState();
} }
Future<void> addCollection(String collectionId) async { Future<void> addCollection(String collectionId) async {
@ -92,6 +111,8 @@ class AudioPlayerProvider extends GetxController {
.where((element) => !collectionIds.contains(element)) .where((element) => !collectionIds.contains(element))
.toList(), .toList(),
); );
await _updateSavedState();
} }
Future<void> removeCollection(String collectionId) async { Future<void> removeCollection(String collectionId) async {

View File

@ -13,13 +13,6 @@ final router = GoRouter(routes: [
name: "explore", name: "explore",
builder: (context, state) => const ExploreScreen(), builder: (context, state) => const ExploreScreen(),
), ),
GoRoute(
path: "/settings",
name: "settings",
builder: (context, state) => const SettingsScreen(),
),
],
),
GoRoute( GoRoute(
path: "/playlist/:id", path: "/playlist/:id",
name: "playlistView", name: "playlistView",
@ -27,4 +20,11 @@ final router = GoRouter(routes: [
playlistId: state.pathParameters['id']!, playlistId: state.pathParameters['id']!,
), ),
), ),
GoRoute(
path: "/settings",
name: "settings",
builder: (context, state) => const SettingsScreen(),
),
],
),
]); ]);

View File

@ -22,7 +22,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
Future<void> _pullPlaylist() async { Future<void> _pullPlaylist() async {
_featuredPlaylist = _featuredPlaylist =
(await _spotify.api.playlists.featured.all(10)).toList(); (await _spotify.api.playlists.featured.getPage(20)).items!.toList();
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@ -48,7 +48,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
final item = _featuredPlaylist?[idx]; final item = _featuredPlaylist?[idx];
return ListTile( return ListTile(
leading: ClipRRect( leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: item != null child: item != null
? AutoCacheImage( ? AutoCacheImage(
item.images!.first.url!, item.images!.first.url!,

View File

@ -1,6 +1,12 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
extension ArtistExtension on List<ArtistSimple> { extension ArtistSimpleExtension on List<ArtistSimple> {
String asString() {
return map((e) => e.name?.replaceAll(",", " ")).join(", ");
}
}
extension ArtistExtension on List<Artist> {
String asString() { String asString() {
return map((e) => e.name?.replaceAll(",", " ")).join(", "); return map((e) => e.name?.replaceAll(",", " ")).join(", ");
} }

View File

@ -48,11 +48,11 @@ class AudioServices with WidgetsBindingObserver {
duration: track is SourcedTrack duration: track is SourcedTrack
? track.sourceInfo.duration ? track.sourceInfo.duration
: Duration(milliseconds: track.durationMs ?? 0), : Duration(milliseconds: track.durationMs ?? 0),
artUri: Uri.parse( artUri: track.album?.images != null
(track.album?.images).asUrlString( ? Uri.parse(
placeholder: ImagePlaceholder.albumArt, (track.album?.images).asUrlString()!,
), )
), : null,
playable: true, playable: true,
)); ));
} }

View File

@ -1,6 +1,4 @@
import 'package:rhythm_box/services/primitive.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:rhythm_box/collections/assets.gen.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
enum ImagePlaceholder { enum ImagePlaceholder {
@ -11,24 +9,13 @@ enum ImagePlaceholder {
} }
extension SpotifyImageExtensions on List<Image>? { extension SpotifyImageExtensions on List<Image>? {
String asUrlString({ String? asUrlString({
int index = 1, 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!)); final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!));
return sortedImage != null && sortedImage.isNotEmpty return sortedImage?[
? sortedImage[
index > sortedImage.length - 1 ? sortedImage.length - 1 : index] index > sortedImage.length - 1 ? sortedImage.length - 1 : index]
.url! .url;
: placeholderUrl;
} }
} }

View File

@ -84,9 +84,7 @@ class WindowsAudioService {
albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", albumArtist: track.artists?.firstOrNull?.name ?? "Unknown",
artist: track.artists?.asString() ?? "Unknown", artist: track.artists?.asString() ?? "Unknown",
album: track.album?.name ?? "Unknown", album: track.album?.name ?? "Unknown",
thumbnail: (track.album?.images).asUrlString( thumbnail: (track.album?.images).asUrlString(),
placeholder: ImagePlaceholder.albumArt,
),
), ),
); );
} }

View File

@ -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/audio_player/audio_player.dart';
import 'package:rhythm_box/services/local_track.dart'; import 'package:rhythm_box/services/local_track.dart';
import 'package:rhythm_box/services/sourced_track/sourced_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'; import 'package:spotify/spotify.dart';
class SourcedTrackProvider extends GetxController { class SourcedTrackProvider extends GetxController {
@ -17,6 +18,7 @@ class SourcedTrackProvider extends GetxController {
} }
final AudioPlayerProvider playback = Get.find(); final AudioPlayerProvider playback = Get.find();
final QueryingTrackInfoProvider query = Get.find();
ever(playback.state.value.tracks.obs, (List<Track> tracks) { ever(playback.state.value.tracks.obs, (List<Track> tracks) {
if (tracks.isEmpty || tracks.none((element) => element.id == track.id)) { 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); sourcedTrack.value = await SourcedTrack.fetchFromTrack(track: track);
query.isQueryingTrackInfo.value = false;
return sourcedTrack.value; return sourcedTrack.value;
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; 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/widgets/player/bottom_player.dart';
class Destination { class Destination {
const Destination(this.title, this.page, this.icon); const Destination(this.title, this.page, this.icon);
@ -30,7 +31,15 @@ class _NavShellState extends State<NavShell> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: widget.child, body: widget.child,
bottomNavigationBar: BottomNavigationBar( 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, showUnselectedLabels: false,
currentIndex: _focusDestination, currentIndex: _focusDestination,
items: _allDestinations items: _allDestinations
@ -44,6 +53,9 @@ class _NavShellState extends State<NavShell> {
setState(() => _focusDestination = value); setState(() => _focusDestination = value);
}, },
), ),
],
),
),
); );
} }
} }

View File

@ -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<BottomPlayer> createState() => _BottomPlayerState();
}
class _BottomPlayerState extends State<BottomPlayer>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
late final Animation<double> _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<StreamSubscription>? _subscriptions;
Future<void> _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<double>(
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),
],
),
),
);
}
}

View File

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

View File

@ -5,6 +5,7 @@ import 'package:rhythm_box/providers/spotify.dart';
import 'package:rhythm_box/widgets/auto_cache_image.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 StatefulWidget {
final String playlistId; final String playlistId;
@ -46,7 +47,7 @@ class _PlaylistTrackListState extends State<PlaylistTrackList> {
final item = _tracks?[idx]; final item = _tracks?[idx];
return ListTile( return ListTile(
leading: ClipRRect( leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: item != null child: item != null
? AutoCacheImage( ? AutoCacheImage(
item.album!.images!.first.url!, item.album!.images!.first.url!,
@ -63,8 +64,7 @@ class _PlaylistTrackListState extends State<PlaylistTrackList> {
), ),
title: Text(item?.name ?? 'Loading...'), title: Text(item?.name ?? 'Loading...'),
subtitle: Text( subtitle: Text(
item?.artists!.map((x) => x.name!).join(', ') ?? item?.artists?.asString() ?? 'Please stand by...',
'Please stand by...',
), ),
onTap: () { onTap: () {
if (item == null) return; if (item == null) return;

View File

@ -0,0 +1,5 @@
import 'package:get/get.dart';
class QueryingTrackInfoProvider extends GetxController {
RxBool isQueryingTrackInfo = false.obs;
}