✨ User library
This commit is contained in:
parent
7285eb4959
commit
a063d19952
@ -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!();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user