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
versionCode = flutter.versionCode
versionName = flutter.versionName
minSdkVersion 24
}
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/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());
}

View File

@ -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<AudioPlayerState> 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<void> _syncSavedState() async {
final data = _prefs.getBool("player_state");
if (data == null) return;
Future<AudioPlayerState?> _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<void> _updateSavedState() async {
final out = jsonEncode(state.value.toJson());
await _prefs.setString("player_state", out);
}
Future<void> addCollections(List<String> collectionIds) async {
@ -80,6 +97,8 @@ class AudioPlayerProvider extends GetxController {
...state.value.collections,
...collectionIds,
]);
await _updateSavedState();
}
Future<void> addCollection(String collectionId) async {
@ -92,6 +111,8 @@ class AudioPlayerProvider extends GetxController {
.where((element) => !collectionIds.contains(element))
.toList(),
);
await _updateSavedState();
}
Future<void> removeCollection(String collectionId) async {

View File

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

View File

@ -22,7 +22,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
Future<void> _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<ExploreScreen> {
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!,

View File

@ -1,6 +1,12 @@
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() {
return map((e) => e.name?.replaceAll(",", " ")).join(", ");
}

View File

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

View File

@ -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<Image>? {
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;
}
}

View File

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

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/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<Track> 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;
}

View File

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

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: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<PlaylistTrackList> {
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<PlaylistTrackList> {
),
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;

View File

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