refactor: put local libraries on separate pages

Signed-off-by: Blake Leonard <me@blakes.dev>
This commit is contained in:
Blake Leonard 2024-05-09 14:36:39 -04:00
parent f297cc0d3e
commit 2abc3b973b
No known key found for this signature in database
GPG Key ID: 3B1965C22D07D9F6
3 changed files with 287 additions and 218 deletions

View File

@ -14,6 +14,7 @@ import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/local_folder.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
@ -113,6 +114,17 @@ final routerProvider = Provider((ref) {
),
),
]),
GoRoute(
path: "local",
pageBuilder: (context, state) {
assert(state.extra is String);
return SpotubePage(
child: LocalLibraryPage(state.extra as String,
isDownloads: state.uri.queryParameters["downloads"] != null
),
);
},
),
]),
GoRoute(
path: "/lyrics",

View File

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:mime/mime.dart';
@ -149,42 +150,9 @@ final localTracksProvider = FutureProvider<Map<String, List<LocalTrack>>>((ref)
class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({super.key});
Future<void> playLocalTracks(
WidgetRef ref,
List<LocalTrack> tracks, {
LocalTrack? currentTrack,
}) async {
final playlist = ref.read(proxyPlaylistProvider);
final playback = ref.read(proxyPlaylistProvider.notifier);
currentTrack ??= tracks.first;
final isPlaylistPlaying = playlist.containsTracks(tracks);
if (!isPlaylistPlaying) {
await playback.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
@override
Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(proxyPlaylistProvider);
final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []);
final searchController = useTextEditingController();
useValueListenable(searchController);
final searchFocus = useFocusNode();
final isFiltering = useState(false);
final controller = useScrollController();
final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
final preferences = ref.watch(userPreferencesProvider);
@ -211,6 +179,10 @@ class UserLocalTracks extends HookConsumerWidget {
preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..remove(location));
}, [preferences.localLibraryLocation]);
// This is just to pre-load the tracks.
// For now, this gets all of them.
ref.watch(localTracksProvider);
return Column(
children: [
Padding(
@ -218,193 +190,42 @@ class UserLocalTracks extends HookConsumerWidget {
child: Row(
children: [
const SizedBox(width: 5),
FilledButton(
onPressed: trackSnapshot.asData?.value != null
? () async {
if (trackSnapshot.asData?.value.isNotEmpty == true) {
if (!isPlaylistPlaying) {
await playLocalTracks(
ref,
trackSnapshot.asData!.value.values.flattened.toList(),
);
}
}
}
: null,
child: Row(
children: [
Text(context.l10n.play),
Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
)
],
),
),
const Spacer(),
ExpandableSearchButton(
isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus,
),
const SizedBox(width: 10),
SortTracksDropdown(
value: sortBy.value,
onChanged: (value) {
sortBy.value = value;
},
),
if (!kIsWeb) ...[
const SizedBox(width: 10),
Tooltip(
message: context.l10n.add_library_location,
child: IconButton(
onPressed: addLocalLibraryLocation,
icon: const Icon(SpotubeIcons.folderAdd),
),
),
],
const SizedBox(width: 5),
FilledButton(
child: const Icon(SpotubeIcons.refresh),
onPressed: () {
ref.invalidate(localTracksProvider);
},
TextButton.icon(
icon: const Icon(SpotubeIcons.folderAdd),
label: Text(context.l10n.add_library_location),
onPressed: addLocalLibraryLocation,
)
],
]
)
),
Expanded(
child: ListView.builder(
itemCount: preferences.localLibraryLocation.length+1,
itemBuilder: (context, index) {
late final String location;
if (index == 0) {
location = preferences.downloadLocation;
} else {
location = preferences.localLibraryLocation[index-1];
}
return ListTile(
title: preferences.downloadLocation != location ? Text(location)
: Text(context.l10n.downloads),
trailing: preferences.downloadLocation != location ? Tooltip(
message: context.l10n.remove_library_location,
child: IconButton(
icon: Icon(SpotubeIcons.folderRemove, color: Colors.red[400]),
onPressed: () => removeLocalLibraryLocation(location),
),
) : null,
onTap: () async {
context.go("/library/local${location == preferences.downloadLocation ? "?downloads=1" : ""}", extra: location);
}
);
}
),
),
ExpandableSearchField(
searchController: searchController,
searchFocus: searchFocus,
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
),
trackSnapshot.when(
data: (groups) => Expanded(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
for (var MapEntry(key: location, value: tracks) in groups.entries) ...[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
sliver: SliverToBoxAdapter(
child: Row(
children: [
Text(preferences.downloadLocation == location ? context.l10n.downloads : location,
style: Theme.of(context).textTheme.titleLarge
),
const Expanded(child: SizedBox()),
if (preferences.downloadLocation != location) Tooltip(
message: context.l10n.remove_library_location,
child: IconButton(
icon: Icon(SpotubeIcons.folderRemove, color: Colors.red[400]),
onPressed: () => removeLocalLibraryLocation(location),
),
),
],
),
),
),
HookBuilder(
key: ValueKey("LocalTracks\$$location"),
builder: (context) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(tracks, sortBy.value);
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return sortedTracks;
}
return sortedTracks
.map((e) => (
weightedRatio(
"${e.name} - ${e.artists?.asString() ?? ""}",
searchController.text,
),
e,
))
.toList()
.sorted(
(a, b) => b.$1.compareTo(a.$1),
)
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList()
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
return const SliverFillRemaining(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
);
}
return SliverSkeletonizer(
enabled: trackSnapshot.isLoading,
child: SliverList.builder(
itemCount:
trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) {
if (trackSnapshot.isLoading) {
return TrackTile(
playlist: playlist,
track: FakeData.track,
index: index,
);
}
final track = filteredTracks[index];
return TrackTile(
index: index,
playlist: playlist,
track: track,
userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
),
);
}
)
]
]
)
),
),
),
loading: () => Expanded(
child: Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
)
],
]
);
}
}

View File

@ -0,0 +1,236 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart';
class LocalLibraryPage extends HookConsumerWidget {
final String location;
final bool isDownloads;
const LocalLibraryPage(this.location, {super.key, this.isDownloads = false});
Future<void> playLocalTracks(
WidgetRef ref,
List<LocalTrack> tracks, {
LocalTrack? currentTrack,
}) async {
final playlist = ref.read(proxyPlaylistProvider);
final playback = ref.read(proxyPlaylistProvider.notifier);
currentTrack ??= tracks.first;
final isPlaylistPlaying = playlist.containsTracks(tracks);
if (!isPlaylistPlaying) {
await playback.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
@override
Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(proxyPlaylistProvider);
final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []);
final searchController = useTextEditingController();
useValueListenable(searchController);
final searchFocus = useFocusNode();
final isFiltering = useState(false);
final controller = useScrollController();
return SafeArea(
bottom: false,
child: Scaffold(
appBar: PageWindowTitleBar(
leading: const BackButton(),
centerTitle: true,
title: Text(isDownloads ? context.l10n.downloads : location),
backgroundColor: Colors.transparent,
),
extendBodyBehindAppBar: true,
body: Column(
children: [
const SizedBox(height: 56),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const SizedBox(width: 5),
FilledButton(
onPressed: trackSnapshot.asData?.value != null
? () async {
if (trackSnapshot.asData?.value.isNotEmpty == true) {
if (!isPlaylistPlaying) {
await playLocalTracks(
ref,
trackSnapshot.asData!.value[location] ?? [],
);
}
}
}
: null,
child: Row(
children: [
Text(context.l10n.play),
Icon(
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
)
],
),
),
const Spacer(),
ExpandableSearchButton(
isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus,
),
const SizedBox(width: 10),
SortTracksDropdown(
value: sortBy.value,
onChanged: (value) {
sortBy.value = value;
},
),
const SizedBox(width: 5),
FilledButton(
child: const Icon(SpotubeIcons.refresh),
onPressed: () {
ref.invalidate(localTracksProvider);
},
)
],
),
),
ExpandableSearchField(
searchController: searchController,
searchFocus: searchFocus,
isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
),
trackSnapshot.when(
data: (tracks) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(tracks[location] ?? <LocalTrack>[], sortBy.value);
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return sortedTracks;
}
return sortedTracks
.map((e) => (
weightedRatio(
"${e.name} - ${e.artists?.asString() ?? ""}",
searchController.text,
),
e,
))
.toList()
.sorted(
(a, b) => b.$1.compareTo(a.$1),
)
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList()
.toList();
}, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
return const Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
);
}
return Expanded(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
child: Skeletonizer(
enabled: trackSnapshot.isLoading,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount:
trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) {
if (trackSnapshot.isLoading) {
return TrackTile(
playlist: playlist,
track: FakeData.track,
index: index,
);
}
final track = filteredTracks[index];
return TrackTile(
index: index,
playlist: playlist,
track: track,
userPlaylist: false,
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,
);
},
);
},
),
),
),
),
);
},
loading: () => Expanded(
child: Skeletonizer(
enabled: true,
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()),
)
],
)
),
);
}
}