diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 59fc0f08..1f0a5e62 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -1,58 +1,64 @@
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- Sptube
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- spotube
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- $(FLUTTER_BUILD_NAME)
- CFBundleSignature
- ????
- CFBundleVersion
- $(FLUTTER_BUILD_NUMBER)
- LSRequiresIPhoneOS
-
- UILaunchStoryboardName
- LaunchScreen
- UIMainStoryboardFile
- Main
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UIViewControllerBasedStatusBarAppearance
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
- NSAllowsArbitraryLoadsForMedia
-
-
- CADisableMinimumFrameDurationOnPhone
-
- UIStatusBarHidden
-
-
-
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Sptube
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ spotube
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIViewControllerBasedStatusBarAppearance
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsArbitraryLoadsForMedia
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIStatusBarHidden
+
+ NSPhotoLibraryUsageDescription
+ This app require access to the photo library
+ NSCameraUsageDescription
+ This app require access to the device camera
+ NSMicrophoneUsageDescription
+ This app does not require access to the device microphone
+
+
\ No newline at end of file
diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart
index 5503ebb3..7b5221b5 100644
--- a/lib/collections/spotube_icons.dart
+++ b/lib/collections/spotube_icons.dart
@@ -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;
}
diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart
index 3fb30952..0438e559 100644
--- a/lib/components/playlist/playlist_card.dart
+++ b/lib/components/playlist/playlist_card.dart
@@ -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,
diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart
index b7cee79d..fa960210 100644
--- a/lib/components/playlist/playlist_create_dialog.dart
+++ b/lib/components/playlist/playlist_create_dialog.dart
@@ -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 trackIds;
- const PlaylistCreateDialog({
+ final String? playlistId;
+ PlaylistCreateDialog({
Key? key,
this.trackIds = const [],
+ this.playlistId,
}) : super(key: key);
+ final formKey = GlobalKey();
+
@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 ?? [])
+ .firstWhereOrNull((playlist) => playlist.id == playlistId),
+ [
+ userPlaylists.pages,
+ playlistId,
+ ],
+ );
- Future 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(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 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),
+ );
}
}
diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart
index c18ca2e4..4a23cc48 100644
--- a/lib/components/shared/heart_button.dart
+++ b/lib/components/shared/heart_button.dart
@@ -159,10 +159,14 @@ class TrackHeartButton extends HookConsumerWidget {
class PlaylistHeartButton extends HookConsumerWidget {
final PlaylistSimple playlist;
+ final IconData? icon;
+ final ValueChanged? 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!);
diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart
index 86c3f046..c9daa267 100644
--- a/lib/components/shared/playbutton_card.dart
+++ b/lib/components/shared/playbutton_card.dart
@@ -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,
diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart
index b4a1314e..1c87d887 100644
--- a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart
+++ b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart
@@ -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 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
diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart
index dc0cbb0b..a25a1f5f 100644
--- a/lib/generated_plugin_registrant.dart
+++ b/lib/generated_plugin_registrant.dart
@@ -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();
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 74894612..bdfb7983 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -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",
diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart
index 722fcb6d..9c852ace 100644
--- a/lib/pages/playlist/playlist.dart
+++ b/lib/pages/playlist/playlist.dart
@@ -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 playPlaylist(
- List