✨ Bottom player
This commit is contained in:
parent
41e248f8cc
commit
e7ea852725
@ -28,6 +28,8 @@ android {
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
minSdkVersion 24
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal 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:
|
@ -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());
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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']!,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
@ -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!,
|
||||
|
@ -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(", ");
|
||||
}
|
||||
|
@ -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,
|
||||
));
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
172
lib/widgets/player/bottom_player.dart
Normal file
172
lib/widgets/player/bottom_player.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
49
lib/widgets/player/track_details.dart
Normal file
49
lib/widgets/player/track_details.dart
Normal 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
5
lib/widgets/tracks/querying_track_info.dart
Normal file
5
lib/widgets/tracks/querying_track_info.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class QueryingTrackInfoProvider extends GetxController {
|
||||
RxBool isQueryingTrackInfo = false.obs;
|
||||
}
|
Loading…
Reference in New Issue
Block a user