Playlist info editing (#708)

* feat: playlist metadata edit support

* refactor: replace file_picker with file_selector
This commit is contained in:
Kingkor Roy Tirtho 2023-09-10 16:39:21 +06:00 committed by GitHub
parent 0df8d9cace
commit ab0fe5bdfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 750 additions and 227 deletions

View File

@ -1,58 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sptube</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spotube</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sptube</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spotube</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSAllowsArbitraryLoadsForMedia</key>
<true />
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>UIStatusBarHidden</key>
<false />
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
</dict>
</plist>

View File

@ -94,4 +94,6 @@ abstract class SpotubeIcons {
static const noWifi = FeatherIcons.wifiOff;
static const wifi = FeatherIcons.wifi;
static const window = Icons.window_rounded;
static const user = FeatherIcons.user;
static const edit = FeatherIcons.edit;
}

View File

@ -32,6 +32,7 @@ class PlaylistCard extends HookConsumerWidget {
final updating = useState(false);
final spotify = ref.watch(spotifyProvider);
final me = useQueries.user.me(ref);
return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10),
@ -44,6 +45,7 @@ class PlaylistCard extends HookConsumerWidget {
isPlaying: isPlaylistPlaying,
isLoading:
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null,
onTap: () {
ServiceUtils.push(
context,

View File

@ -1,106 +1,228 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_validator/form_validator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/mutations/playlist.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCreateDialog extends HookConsumerWidget {
/// Track ids to add to the playlist
final List<String> trackIds;
const PlaylistCreateDialog({
final String? playlistId;
PlaylistCreateDialog({
Key? key,
this.trackIds = const [],
this.playlistId,
}) : super(key: key);
final formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context, ref) {
final spotify = ref.watch(spotifyProvider);
final playlistName = useTextEditingController();
final description = useTextEditingController();
final public = useState(false);
final collaborative = useState(false);
final client = useQueryClient();
final navigator = Navigator.of(context);
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
body: HookBuilder(builder: (context) {
final userPlaylists = useQueries.playlist.ofMine(ref);
final updatingPlaylist = useMemoized(
() => userPlaylists.pages
.expand((p) => p.items ?? <PlaylistSimple>[])
.firstWhereOrNull((playlist) => playlist.id == playlistId),
[
userPlaylists.pages,
playlistId,
],
);
Future<void> onCreate() async {
if (playlistName.text.isEmpty) return;
final me = await spotify.me.get();
final playlist = await spotify.playlists.createPlaylist(
me.id!,
playlistName.text,
collaborative: collaborative.value,
public: public.value,
description: description.text,
);
if (trackIds.isNotEmpty) {
await spotify.playlists.addTracks(
trackIds.map((id) => "spotify:track:$id").toList(),
playlist.id!,
);
}
await client
.getQuery(
"current-user-playlists",
)
?.refresh();
navigator.pop(playlist);
}
final playlistName = useTextEditingController(
text: updatingPlaylist?.name,
);
final description = useTextEditingController(
text: updatingPlaylist?.description,
);
final public = useState(
updatingPlaylist?.public ?? false,
);
final collaborative = useState(
updatingPlaylist?.collaborative ?? false,
);
final image = useState<XFile?>(null);
return AlertDialog(
title: Text(context.l10n.create_a_playlist),
actions: [
OutlinedButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context);
},
),
FilledButton(
onPressed: onCreate,
child: Text(context.l10n.create),
),
],
content: Container(
width: MediaQuery.of(context).size.width,
constraints: const BoxConstraints(maxWidth: 500),
child: ListView(
shrinkWrap: true,
children: [
TextField(
controller: playlistName,
decoration: InputDecoration(
hintText: context.l10n.name_of_playlist,
labelText: context.l10n.name_of_playlist,
final isUpdatingPlaylist = playlistId != null;
final l10n = context.l10n;
final theme = Theme.of(context);
final scaffold = ScaffoldMessenger.of(context);
final onError = useCallback((error) {
if (error is SpotifyError || error is SpotifyException) {
scaffold.showSnackBar(
SnackBar(
content: Text(
l10n.error(error.message ?? "Epic failure!"),
style: theme.textTheme.bodyMedium!.copyWith(
color: theme.colorScheme.onError,
),
),
backgroundColor: theme.colorScheme.error,
),
);
}
}, [scaffold, l10n, theme]);
final playlistCreateMutation = useMutations.playlist.create(
ref,
trackIds: trackIds,
onData: (value) {
Navigator.pop(context);
},
onError: onError,
);
final playlistUpdateMutation = useMutations.playlist.update(
ref,
playlistId: playlistId,
onData: (value) {
Navigator.pop(context);
},
onError: onError,
);
Future<void> onCreate() async {
if (!formKey.currentState!.validate()) return;
final PlaylistCRUDVariables payload = (
playlistName: playlistName.text,
collaborative: collaborative.value,
public: public.value,
description: description.text,
base64Image: image.value?.path != null
? await image.value!
.readAsBytes()
.then((bytes) => base64Encode(bytes))
: null,
);
if (isUpdatingPlaylist) {
await playlistUpdateMutation.mutate(payload);
} else {
await playlistCreateMutation.mutate(payload);
}
}
return AlertDialog(
title: Text(
isUpdatingPlaylist
? context.l10n.update_playlist
: context.l10n.create_a_playlist,
),
actions: [
OutlinedButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context);
},
),
FilledButton(
onPressed: onCreate,
child: Text(
isUpdatingPlaylist
? context.l10n.update
: context.l10n.create,
),
),
],
insetPadding: const EdgeInsets.all(8),
content: Container(
width: MediaQuery.of(context).size.width,
constraints: const BoxConstraints(maxWidth: 500),
child: Form(
key: formKey,
child: ListView(
shrinkWrap: true,
children: [
Center(
child: Stack(
children: [
UniversalImage(
path: image.value?.path ??
TypeConversionUtils.image_X_UrlString(
updatingPlaylist?.images,
placeholder: ImagePlaceholder.collection,
),
height: 200,
),
Positioned(
bottom: 20,
right: 20,
child: IconButton.filled(
icon: const Icon(SpotubeIcons.edit),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.primary,
elevation: 2,
shadowColor: theme.colorScheme.onSurface,
),
onPressed: () async {
final imageFile = await ImagePicker()
.pickImage(source: ImageSource.gallery);
image.value = imageFile ?? image.value;
},
),
),
],
),
),
const SizedBox(height: 10),
TextFormField(
controller: playlistName,
decoration: InputDecoration(
hintText: context.l10n.name_of_playlist,
labelText: context.l10n.name_of_playlist,
),
validator: ValidationBuilder().required().build(),
),
const SizedBox(height: 10),
TextFormField(
controller: description,
decoration: InputDecoration(
hintText: context.l10n.description,
),
keyboardType: TextInputType.multiline,
maxLines: 5,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.public),
value: public.value,
onChanged: (val) => public.value = val ?? false,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.collaborative),
value: collaborative.value,
onChanged: (val) => collaborative.value = val ?? false,
),
],
),
),
),
const SizedBox(height: 10),
TextField(
controller: description,
decoration: InputDecoration(
hintText: context.l10n.description,
),
keyboardType: TextInputType.multiline,
maxLines: 5,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.public),
value: public.value,
onChanged: (val) => public.value = val ?? false,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.collaborative),
value: collaborative.value,
onChanged: (val) => collaborative.value = val ?? false,
),
],
),
);
}),
),
);
}
@ -112,7 +234,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
showDialog(
context: context,
builder: (context) => const PlaylistCreateDialog(),
builder: (context) => PlaylistCreateDialog(),
);
}
@ -132,11 +254,12 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
}
return FilledButton.tonalIcon(
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_playlist),
onPressed: () => showPlaylistDialog(context, spotify));
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_playlist),
onPressed: () => showPlaylistDialog(context, spotify),
);
}
}

View File

@ -159,10 +159,14 @@ class TrackHeartButton extends HookConsumerWidget {
class PlaylistHeartButton extends HookConsumerWidget {
final PlaylistSimple playlist;
final IconData? icon;
final ValueChanged<bool>? onData;
const PlaylistHeartButton({
required this.playlist,
Key? key,
this.icon,
this.onData,
}) : super(key: key);
@override
@ -181,6 +185,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
refreshQueries: [
isLikedQuery.key,
],
onData: onData,
);
if (me.isLoading || !me.hasData) {
@ -193,6 +198,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
color: Colors.white,
icon: icon,
onPressed: isLikedQuery.hasData
? () {
togglePlaylistLike.mutate(isLikedQuery.data!);

View File

@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/hooks/use_breakpoint_value.dart';
import 'package:spotube/hooks/use_brightness_value.dart';
@ -28,6 +29,7 @@ class PlaybuttonCard extends HookWidget {
final bool isPlaying;
final bool isLoading;
final String title;
final bool isOwner;
const PlaybuttonCard({
required this.imageUrl,
@ -39,6 +41,7 @@ class PlaybuttonCard extends HookWidget {
this.onPlaybuttonPressed,
this.onAddToQueuePressed,
this.onTap,
this.isOwner = false,
Key? key,
}) : super(key: key);
@ -153,6 +156,42 @@ class PlaybuttonCard extends HookWidget {
),
),
),
if (isOwner)
Positioned(
top: 15,
left: 25,
child: AnimatedSize(
duration: const Duration(milliseconds: 150),
alignment: Alignment.centerLeft,
curve: Curves.easeInExpo,
child: HoverBuilder(builder: (context, isHovered) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
SpotubeIcons.user,
color: Colors.white,
size: 16,
),
if (isHovered)
Text(
"Owned by you",
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
),
],
),
);
}),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
right: end,

View File

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
@ -71,6 +72,18 @@ class TrackCollectionView<T> extends HookConsumerWidget {
icon: const Icon(SpotubeIcons.share),
onPressed: onShare,
),
if (isOwned)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(playlistId: id);
},
);
},
),
if (heartBtn != null && auth != null) heartBtn!,
IconButton(
onPressed: playingState == PlayButtonState.playing

View File

@ -8,7 +8,6 @@
import 'package:audio_service_web/audio_service_web.dart';
import 'package:audio_session/audio_session_web.dart';
import 'package:file_picker/_internal/file_picker_web.dart';
import 'package:shared_preferences_web/shared_preferences_web.dart';
import 'package:url_launcher_web/url_launcher_web.dart';
@ -18,7 +17,6 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart';
void registerPlugins(Registrar registrar) {
AudioServiceWeb.registerWith(registrar);
AudioSessionWeb.registerWith(registrar);
FilePickerWeb.registerWith(registrar);
SharedPreferencesPlugin.registerWith(registrar);
UrlLauncherPlugin.registerWith(registrar);
registrar.registerMessageHandler();

View File

@ -24,8 +24,10 @@
"liked_tracks_description": "All your liked tracks",
"create_playlist": "Create Playlist",
"create_a_playlist": "Create a playlist",
"update_playlist": "Update playlist",
"create": "Create",
"cancel": "Cancel",
"update": "update",
"playlist_name": "Playlist Name",
"name_of_playlist": "Name of the playlist",
"description": "Description",

View File

@ -1,6 +1,8 @@
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
@ -18,34 +20,8 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistView extends HookConsumerWidget {
final logger = getLogger(PlaylistView);
final PlaylistSimple playlist;
PlaylistView(this.playlist, {Key? key}) : super(key: key);
Future<void> playPlaylist(
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
}) async {
final proxyPlaylist = ref.read(ProxyPlaylistNotifier.provider);
final playback = ref.read(ProxyPlaylistNotifier.notifier);
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
if (!isPlaylistPlaying) {
playback.addCollection(playlist.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
playback.addCollection(playlist.id!);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != proxyPlaylist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
final PlaylistSimple playlistSimple;
PlaylistView(this.playlistSimple, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
@ -55,6 +31,10 @@ class PlaylistView extends HookConsumerWidget {
final mediaQuery = MediaQuery.of(context);
final meSnapshot = useQueries.user.me(ref);
final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!);
final playlist = playlistQuery.data ?? playlistSimple;
final playlistTrackSnapshot =
useQueries.playlist.tracksOfQuery(ref, playlist.id!);
final likedTracksSnapshot = useQueries.playlist.likedTracksQuery(ref);
@ -83,6 +63,35 @@ class PlaylistView extends HookConsumerWidget {
[proxyPlaylist.activeTrack, tracksSnapshot.data],
);
final playPlaylist = useCallback((
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
}) async {
final playback = ref.read(ProxyPlaylistNotifier.notifier);
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
if (!isPlaylistPlaying) {
playback.addCollection(playlist.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex:
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
playback.addCollection(playlist.id!);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != proxyPlaylist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}, [proxyPlaylist, playlist]);
final ownPlaylist =
playlist.owner?.id != null && playlist.owner?.id == meSnapshot.data?.id;
return TrackCollectionView(
id: playlist.id!,
playingState: isPlaylistPlaying && playlistTrackPlaying
@ -94,8 +103,7 @@ class PlaylistView extends HookConsumerWidget {
titleImage: titleImage,
tracksSnapshot: tracksSnapshot,
description: playlist.description,
isOwned: playlist.owner?.id != null &&
playlist.owner!.id == meSnapshot.data?.id,
isOwned: ownPlaylist,
onPlay: ([track]) {
if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) {
@ -142,7 +150,13 @@ class PlaylistView extends HookConsumerWidget {
);
});
},
heartBtn: PlaylistHeartButton(playlist: playlist),
heartBtn: PlaylistHeartButton(
playlist: playlist,
icon: ownPlaylist ? SpotubeIcons.trash : null,
onData: (data) {
GoRouter.of(context).pop();
},
),
onShuffledPlay: ([track]) {
final tracks = [...?tracksSnapshot.data]..shuffle();

View File

@ -1,6 +1,6 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
@ -11,7 +11,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/collections/language_codes.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart';
@ -47,8 +46,8 @@ class SettingsPage extends HookConsumerWidget {
}, []);
final pickDownloadLocation = useCallback(() async {
final dirStr = await FilePicker.platform.getDirectoryPath(
dialogTitle: context.l10n.download_location,
final dirStr = await getDirectoryPath(
initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
preferences.setDownloadLocation(dirStr);

View File

@ -1,7 +1,17 @@
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/hooks/use_spotify_mutation.dart';
import 'package:spotube/services/queries/queries.dart';
typedef PlaylistCRUDVariables = ({
String playlistName,
bool? public,
bool? collaborative,
String? description,
String? base64Image,
});
class PlaylistMutations {
const PlaylistMutations();
@ -11,8 +21,8 @@ class PlaylistMutations {
String playlistId, {
List<String>? refreshQueries,
List<String>? refreshInfiniteQueries,
ValueChanged<bool>? onData,
}) {
final queryClient = useQueryClient();
return useSpotifyMutation<bool, dynamic, bool, dynamic>(
"toggle-playlist-like/$playlistId",
(isLiked, spotify) async {
@ -25,10 +35,12 @@ class PlaylistMutations {
},
ref: ref,
refreshQueries: refreshQueries,
refreshInfiniteQueries: refreshInfiniteQueries,
onData: (data, recoveryData) async {
await queryClient
.refreshInfiniteQueryAllPages("current-user-playlists");
refreshInfiniteQueries: [
...?refreshInfiniteQueries,
"current-user-playlists",
],
onData: (data, recoveryData) {
onData?.call(data);
},
);
}
@ -47,4 +59,91 @@ class PlaylistMutations {
refreshQueries: ["playlist-tracks/$playlistId"],
);
}
Mutation<Playlist, dynamic, PlaylistCRUDVariables> create(
WidgetRef ref, {
List<String>? trackIds,
ValueChanged<dynamic>? onError,
ValueChanged<Playlist>? onData,
}) {
final me = useQueries.user.me(ref);
return useSpotifyMutation<Playlist, dynamic, PlaylistCRUDVariables, void>(
"create-playlist",
(variable, spotify) async {
final playlist = await spotify.playlists.createPlaylist(
me.data!.id!,
variable.playlistName,
collaborative: variable.collaborative,
description: variable.description,
public: variable.public,
);
if (variable.base64Image != null) {
await spotify.playlists.updatePlaylistImage(
playlist.id!,
variable.base64Image!,
);
}
if (trackIds != null && trackIds.isNotEmpty) {
await spotify.playlists.addTracks(
trackIds.map((id) => "spotify:track:$id").toList(),
playlist.id!,
);
}
return playlist;
},
refreshInfiniteQueries: [
"current-user-playlists",
],
ref: ref,
onError: (error, recoveryData) {
onError?.call(error);
},
onData: (data, recoveryData) {
onData?.call(data);
},
);
}
Mutation<void, dynamic, PlaylistCRUDVariables> update(
WidgetRef ref, {
String? playlistId,
ValueChanged<dynamic>? onError,
ValueChanged<void>? onData,
}) {
return useSpotifyMutation<void, dynamic, PlaylistCRUDVariables, void>(
"update-playlist/$playlistId",
(variable, spotify) async {
if (playlistId == null) return;
await spotify.playlists.updatePlaylist(
playlistId,
variable.playlistName,
collaborative: variable.collaborative,
description: variable.description,
public: variable.public,
);
if (variable.base64Image != null) {
await spotify.playlists.updatePlaylistImage(
playlistId,
variable.base64Image!,
);
}
},
refreshQueries: [
"playlist/$playlistId",
],
refreshInfiniteQueries: [
"current-user-playlists",
],
ref: ref,
onError: (error, recoveryData) {
onError?.call(error);
},
onData: (data, recoveryData) {
onData?.call(data);
},
);
}
}

View File

@ -203,6 +203,16 @@ class PlaylistQueries {
);
}
Query<Playlist, dynamic> byId(WidgetRef ref, String id) {
return useSpotifyQuery<Playlist, dynamic>(
"playlist/$id",
(spotify) async {
return await spotify.playlists.get(id);
},
ref: ref,
);
}
InfiniteQuery<Page<PlaylistSimple>, dynamic, int> featured(
WidgetRef ref,
) {

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <catcher/catcher_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <local_notifier/local_notifier_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) catcher_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "CatcherPlugin");
catcher_plugin_register_with_registrar(catcher_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
catcher
file_selector_linux
flutter_secure_storage_linux
local_notifier
media_kit_libs_linux

View File

@ -9,6 +9,7 @@ import audio_service
import audio_session
import catcher
import device_info_plus
import file_selector_macos
import flutter_secure_storage_macos
import local_notifier
import media_kit_libs_macos_audio
@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
CatcherPlugin.register(with: registry.registrar(forPlugin: "CatcherPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))

View File

@ -19,5 +19,8 @@
<!-- Requires Certification -->
<!-- <key>keychain-access-groups</key>
<array /> -->
<!-- FilePicker -->
<key>com.apple.security.files.user-selected.read-write</key>
<true />
</dict>
</plist>

View File

@ -1,39 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
</dict>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSAllowsArbitraryLoadsForMedia</key>
<true />
</dict>
</dict>
</plist>

View File

@ -17,5 +17,8 @@
<!-- Requires Certification -->
<!-- <key>keychain-access-groups</key>
<array /> -->
<!-- FilePicker -->
<key>com.apple.security.files.user-selected.read-write</key>
<true />
</dict>
</plist>

View File

@ -338,6 +338,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb
url: "https://pub.dev"
source: hosted
version: "0.3.3+5"
crypto:
dependency: transitive
description:
@ -498,14 +506,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.4"
file_picker:
file_selector:
dependency: "direct main"
description:
name: file_picker
sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877
name: file_selector
sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b"
url: "https://pub.dev"
source: hosted
version: "5.3.0"
version: "1.0.1"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7
url: "https://pub.dev"
source: hosted
version: "0.5.0+3"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2
url: "https://pub.dev"
source: hosted
version: "0.5.1+6"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492"
url: "https://pub.dev"
source: hosted
version: "0.9.2+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72"
url: "https://pub.dev"
source: hosted
version: "0.9.3+2"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1
url: "https://pub.dev"
source: hosted
version: "0.9.2+1"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0
url: "https://pub.dev"
source: hosted
version: "0.9.3+1"
fixnum:
dependency: transitive
description:
@ -788,6 +852,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.1.3"
form_validator:
dependency: "direct main"
description:
name: form_validator
sha256: "8cbe91b7d5260870d6fb9e23acd55d5d1d1fdf2397f0279a4931ac3c0c7bf8fb"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
freezed_annotation:
dependency: transitive
description:
@ -945,6 +1017,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.17"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: d32a997bcc4ee135aebca8e272b7c517927aa65a74b9c60a81a2764ef1a0462d
url: "https://pub.dev"
source: hosted
version: "0.8.7+5"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497
url: "https://pub.dev"
source: hosted
version: "0.8.8+2"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514
url: "https://pub.dev"
source: hosted
version: "2.9.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
integration_test:
dependency: "direct dev"
description: flutter

View File

@ -33,7 +33,6 @@ dependencies:
disable_battery_optimization: ^1.1.0+1
duration: ^3.0.12
envied: ^0.3.0
file_picker: ^5.2.2
fl_query: ^1.0.0-alpha.4
fl_query_hooks: ^1.0.0-alpha.4+1
fl_query_devtools: ^0.1.0-alpha.2
@ -55,6 +54,7 @@ dependencies:
flutter_riverpod: ^2.1.1
flutter_secure_storage: ^8.0.0
flutter_svg: ^1.1.6
form_validator: ^2.1.1
fuzzywuzzy: ^0.2.0
google_fonts: ^5.1.0
go_router: ^10.0.0
@ -100,6 +100,8 @@ dependencies:
path: plugins/window_size
youtube_explode_dart: ^2.0.1
stroke_text: ^0.0.2
image_picker: ^1.0.4
file_selector: ^1.0.1
dev_dependencies:
build_runner: ^2.3.2

View File

@ -1 +1,56 @@
{}
{
"bn": [
"update_playlist",
"update"
],
"ca": [
"update_playlist",
"update"
],
"de": [
"update_playlist",
"update"
],
"es": [
"update_playlist",
"update"
],
"fr": [
"update_playlist",
"update"
],
"hi": [
"update_playlist",
"update"
],
"ja": [
"update_playlist",
"update"
],
"pl": [
"update_playlist",
"update"
],
"pt": [
"update_playlist",
"update"
],
"ru": [
"update_playlist",
"update"
],
"zh": [
"update_playlist",
"update"
]
}

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <catcher/catcher_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_notifier/local_notifier_plugin.h>
#include <media_kit_libs_windows_audio/media_kit_libs_windows_audio_plugin_c_api.h>
@ -21,6 +22,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
CatcherPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CatcherPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalNotifierPluginRegisterWithRegistrar(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
catcher
file_selector_windows
flutter_secure_storage_windows
local_notifier
media_kit_libs_windows_audio