diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 85c26e01..12a2f99b 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -22,12 +22,12 @@ jobs: runs-on: ubuntu-22.04 if: contains(inputs.jobs, 'flathub') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: repository: KRTirtho/com.github.KRTirtho.Spotube token: ${{ secrets.FLATHUB_TOKEN }} - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: path: spotube @@ -50,7 +50,7 @@ jobs: runs-on: ubuntu-22.04 if: contains(inputs.jobs, 'aur') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dsaltares/fetch-gh-release-asset@master with: diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 31b0ebb6..cd94ef67 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -32,7 +32,7 @@ jobs: windows: runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.10.0 with: cache: true @@ -102,7 +102,7 @@ jobs: linux: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.10.0 with: cache: true @@ -191,7 +191,7 @@ jobs: android: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.10.0 with: cache: true @@ -266,7 +266,7 @@ jobs: macos: runs-on: macos-12 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.10.0 with: cache: true 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..4781050d 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -94,4 +94,7 @@ 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; + static const web = FeatherIcons.globe; } diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index a36be283..16692462 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -131,7 +131,7 @@ final localTracksProvider = FutureProvider>((ref) async { class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({Key? key}) : super(key: key); - void playLocalTracks( + Future playLocalTracks( WidgetRef ref, List tracks, { LocalTrack? currentTrack, @@ -203,10 +203,10 @@ class UserLocalTracks extends HookConsumerWidget { const SizedBox(width: 10), FilledButton( onPressed: trackSnapshot.value != null - ? () { + ? () async { if (trackSnapshot.value?.isNotEmpty == true) { if (!isPlaylistPlaying) { - playLocalTracks( + await playLocalTracks( ref, trackSnapshot.value!, ); @@ -295,8 +295,8 @@ class UserLocalTracks extends HookConsumerWidget { index: index, track: track, userPlaylist: false, - onTap: () { - playLocalTracks( + onTap: () async { + await playLocalTracks( ref, sortedTracks, currentTrack: track, diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 7ae4fa82..07a6b7ba 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -7,12 +7,12 @@ import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/duration.dart'; import 'package:spotube/hooks/use_progress.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; -import 'package:spotube/utils/primitive_utils.dart'; class PlayerControls extends HookConsumerWidget { final PaletteGenerator? palette; @@ -113,19 +113,6 @@ class PlayerControls extends HookConsumerWidget { :progressStatic ) = useProgress(ref); - final totalMinutes = PrimitiveUtils.zeroPadNumStr( - duration.inMinutes.remainder(60), - ); - final totalSeconds = PrimitiveUtils.zeroPadNumStr( - duration.inSeconds.remainder(60), - ); - final currentMinutes = PrimitiveUtils.zeroPadNumStr( - position.inMinutes.remainder(60), - ); - final currentSeconds = PrimitiveUtils.zeroPadNumStr( - position.inSeconds.remainder(60), - ); - final progress = useState( useMemoized(() => progressStatic, []), ); @@ -173,8 +160,8 @@ class PlayerControls extends HookConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("$currentMinutes:$currentSeconds"), - Text("$totalMinutes:$totalSeconds"), + Text(position.toHumanReadableString()), + Text(duration.toHumanReadableString()), ], ), ), diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index f4984ad2..889e6609 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -100,9 +100,13 @@ class PlayerOverlay extends HookConsumerWidget { child: GestureDetector( onTap: () => GoRouter.of(context).push("/player"), - child: PlayerTrackDetails( - albumArt: albumArt, - color: textColor, + child: Container( + width: double.infinity, + color: Colors.transparent, + child: PlayerTrackDetails( + albumArt: albumArt, + color: textColor, + ), ), ), ), @@ -114,7 +118,9 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipBack, color: textColor, ), - onPressed: playlistNotifier.previous, + onPressed: playlist.isFetching + ? null + : playlistNotifier.previous, ), Consumer( builder: (context, ref, _) { @@ -143,7 +149,9 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipForward, color: textColor, ), - onPressed: playlistNotifier.next, + onPressed: playlist.isFetching + ? null + : playlistNotifier.next, ), ], ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 599da26e..a5dee8c9 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -1,16 +1,21 @@ import 'dart:ui'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/use_auto_scroll_controller.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; @@ -24,12 +29,11 @@ class PlayerQueue extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final controller = useAutoScrollController(); + final searchText = useState(''); + + final isSearching = useState(false); + final tracks = playlist.tracks; - - if (tracks.isEmpty) { - return const NotFound(vertical: true); - } - final borderRadius = floating ? BorderRadius.circular(10) : const BorderRadius.only( @@ -39,6 +43,27 @@ class PlayerQueue extends HookConsumerWidget { final theme = Theme.of(context); final headlineColor = theme.textTheme.headlineSmall?.color; + final filteredTracks = useMemoized( + () { + if (searchText.value.isEmpty) { + return tracks; + } + return tracks + .map((e) => ( + weightedRatio( + '${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}', + searchText.value, + ), + e + )) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList(); + }, + [tracks, searchText.value], + ); + useEffect(() { if (playlist.active == null) return null; @@ -50,6 +75,10 @@ class PlayerQueue extends HookConsumerWidget { return null; }, []); + if (tracks.isEmpty) { + return const NotFound(vertical: true); + } + return BackdropFilter( filter: ImageFilter.blur( sigmaX: 12.0, @@ -64,89 +93,172 @@ class PlayerQueue extends HookConsumerWidget { color: theme.scaffoldBackgroundColor.withOpacity(0.5), borderRadius: borderRadius, ), - child: Column( - children: [ - Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - Row( + child: CallbackShortcuts( + bindings: { + LogicalKeySet(LogicalKeyboardKey.escape): () { + if (!isSearching.value) { + Navigator.of(context).pop(); + } + isSearching.value = false; + searchText.value = ''; + } + }, + child: LayoutBuilder(builder: (context, constraints) { + return Column( children: [ - const SizedBox(width: 10), - Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( + Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, + borderRadius: BorderRadius.circular(20), ), ), - const Spacer(), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: - theme.scaffoldBackgroundColor.withOpacity(0.5), - foregroundColor: theme.textTheme.headlineSmall?.color, - ), - child: Row( - children: [ - const Icon(SpotubeIcons.playlistRemove), - const SizedBox(width: 5), - Text(context.l10n.clear_all), - ], - ), - onPressed: () { - playlistNotifier.stop(); - Navigator.of(context).pop(); - }, - ), - const SizedBox(width: 10), - ], - ), - const SizedBox(height: 10), - Flexible( - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - itemBuilder: (context, i) { - final track = tracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - ReorderableDragStartListener( - index: i, - child: const Icon(SpotubeIcons.dragHandle), - ), - ], + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (constraints.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + Text( + context.l10n.tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, ), ), - ); - }), - ), - ], + const Spacer(), + ], + if (constraints.mdAndUp || isSearching.value) + TextField( + onChanged: (value) { + searchText.value = value; + }, + decoration: InputDecoration( + hintText: context.l10n.search, + isDense: true, + prefixIcon: constraints.smAndDown + ? IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, + ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size.square(20), + ), + ) + : const Icon(SpotubeIcons.filter), + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: constraints.smAndDown + ? constraints.maxWidth - 20 + : 300, + ), + ), + ) + else + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (constraints.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: + theme.scaffoldBackgroundColor.withOpacity(0.5), + foregroundColor: theme.textTheme.headlineSmall?.color, + ), + child: Row( + children: [ + const Icon(SpotubeIcons.playlistRemove), + const SizedBox(width: 5), + Text(context.l10n.clear_all), + ], + ), + onPressed: () { + playlistNotifier.stop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], + ), + const SizedBox(height: 10), + if (!isSearching.value && searchText.value.isEmpty) + Flexible( + child: ReorderableListView.builder( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + scrollController: controller, + itemCount: tracks.length, + shrinkWrap: true, + buildDefaultDragHandles: false, + itemBuilder: (context, i) { + final track = tracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + ReorderableDragStartListener( + index: i, + child: const Icon(SpotubeIcons.dragHandle), + ), + ], + ), + ), + ); + }, + ), + ) + else + Flexible( + child: ListView.builder( + itemCount: filteredTracks.length, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + ), + ); + }, + ), + ), + ], + ); + }), ), ), ); diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index b7d802f9..d4857853 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -9,6 +9,7 @@ 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/extensions/duration.dart'; import 'package:spotube/hooks/use_debounce.dart'; import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/spotube_track.dart'; @@ -99,9 +100,7 @@ class SiblingTracksSheet extends HookConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), ), - trailing: Text( - PrimitiveUtils.toReadableDuration(video.duration), - ), + trailing: Text(video.duration.toHumanReadableString()), subtitle: Text(video.channelName), enabled: playlist.isFetching != true, selected: playlist.isFetching != true && diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index be7abfb9..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, @@ -60,11 +62,18 @@ class PlaylistCard extends HookConsumerWidget { return audioPlayer.resume(); } - List fetchedTracks = await queryBowl.fetchQuery( - "playlist-tracks/${playlist.id}", - () => useQueries.playlist.tracksOf(playlist.id!, spotify), - ) ?? - []; + List fetchedTracks = playlist.id == 'user-liked-tracks' + ? await queryBowl.fetchQuery( + "user-liked-tracks", + () => useQueries.playlist.likedTracks(spotify, ref), + ) ?? + [] + : await queryBowl.fetchQuery( + "playlist-tracks/${playlist.id}", + () => useQueries.playlist + .tracksOf(playlist.id!, spotify, ref), + ) ?? + []; if (fetchedTracks.isEmpty) return; @@ -83,7 +92,7 @@ class PlaylistCard extends HookConsumerWidget { if (isPlaylistPlaying) return; List fetchedTracks = await queryBowl.fetchQuery( "playlist-tracks/${playlist.id}", - () => useQueries.playlist.tracksOf(playlist.id!, spotify), + () => useQueries.playlist.tracksOf(playlist.id!, spotify, ref), ) ?? []; diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index b7cee79d..53424914 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -1,106 +1,276 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'dart:convert'; +import 'dart:io'; + +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: [ + FormField( + initialValue: image.value, + onSaved: (newValue) { + image.value = newValue; + }, + validator: (value) { + if (value == null) return null; + final file = File(value.path); + + if (file.lengthSync() > 256000) { + return "Image size should be less than 256kb"; + } + return null; + }, + builder: (field) { + return Center( + child: Stack( + children: [ + UniversalImage( + path: field.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); + + if (imageFile != null) { + field.didChange(imageFile); + field.validate(); + field.save(); + } + }, + ), + ), + if (field.hasError) + Positioned( + bottom: 20, + left: 20, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme.colorScheme.error, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + field.errorText ?? "", + style: theme.textTheme.bodyMedium! + .copyWith( + color: theme.colorScheme.onError, + ), + ), + ), + ), + ], + ), + ); + }), + 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, + validator: ValidationBuilder().required().build(), + 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 +282,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget { showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( context: context, - builder: (context) => const PlaylistCreateDialog(), + builder: (context) => PlaylistCreateDialog(), ); } @@ -132,11 +302,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/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart index 41534cb3..21f56a22 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart @@ -78,6 +78,31 @@ class AdaptivePopSheetList extends StatelessWidget { 'Either icon or child must be provided', ); + Future showPopupMenu(BuildContext context, RelativeRect position) { + final mediaQuery = MediaQuery.of(context); + + return showMenu( + context: context, + useRootNavigator: useRootNavigator, + constraints: BoxConstraints( + maxHeight: mediaQuery.size.height * 0.6, + ), + position: position, + items: children + .map( + (item) => PopupMenuItem( + padding: EdgeInsets.zero, + enabled: false, + child: _AdaptivePopSheetListItem( + item: item, + onSelected: onSelected, + ), + ), + ) + .toList(), + ); + } + @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index 2b877ecf..4a23cc48 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -29,7 +29,7 @@ class HeartButton extends HookConsumerWidget { Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - if (auth == null) return Container(); + if (auth == null) return const SizedBox.shrink(); return IconButton( tooltip: tooltip, @@ -57,18 +57,21 @@ class HeartButton extends HookConsumerWidget { } } -({ +typedef UseTrackToggleLike = ({ bool isLiked, Mutation toggleTrackLike, Query me, -}) useTrackToggleLike(Track track, WidgetRef ref) { +}); + +UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { final me = useQueries.user.me(ref); - final savedTracks = - useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks"); + final savedTracks = useQueries.playlist.likedTracksQuery(ref); - final isLiked = - savedTracks.data?.any((element) => element.id == track.id) ?? false; + final isLiked = useMemoized( + () => savedTracks.data?.any((element) => element.id == track.id) ?? false, + [savedTracks.data, track.id], + ); final mounted = useIsMounted(); @@ -76,28 +79,48 @@ class HeartButton extends HookConsumerWidget { ref, track.id!, onMutate: (isLiked) { - savedTracks.setData( - [ - if (isLiked == true) - ...?savedTracks.data?.where((element) => element.id != track.id) - else - ...?savedTracks.data?..add(track) - ], - ); + print("Toggle Like onMutate: $isLiked"); + + if (isLiked) { + savedTracks.setData( + savedTracks.data + ?.where((element) => element.id != track.id) + .toList() ?? + [], + ); + } else { + savedTracks.setData( + [ + ...?savedTracks.data, + track, + ], + ); + } return isLiked; }, onData: (data, recoveryData) async { + print("Toggle Like onData: $data"); await savedTracks.refresh(); }, onError: (payload, isLiked) { + print("Toggle Like onError: $payload"); if (!mounted()) return; - savedTracks.setData([ - if (isLiked != true) - ...?savedTracks.data?.where((element) => element.id != track.id) - else - ...?savedTracks.data?..add(track), - ]); + if (isLiked != true) { + savedTracks.setData( + savedTracks.data + ?.where((element) => element.id != track.id) + .toList() ?? + [], + ); + } else { + savedTracks.setData( + [ + ...?savedTracks.data, + track, + ], + ); + } }, ); @@ -113,21 +136,21 @@ class TrackHeartButton extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final savedTracks = - useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks"); - final toggler = useTrackToggleLike(track, ref); - if (toggler.me.isLoading || !toggler.me.hasData) { + final savedTracks = useQueries.playlist.likedTracksQuery(ref); + final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); + + if (me.isLoading || !me.hasData) { return const CircularProgressIndicator(); } return HeartButton( - tooltip: toggler.isLiked + tooltip: isLiked ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, - isLiked: toggler.isLiked, + isLiked: isLiked, onPressed: savedTracks.hasData ? () { - toggler.toggleTrackLike.mutate(toggler.isLiked); + toggleTrackLike.mutate(isLiked); } : null, ); @@ -136,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 @@ -158,6 +185,7 @@ class PlaylistHeartButton extends HookConsumerWidget { refreshQueries: [ isLikedQuery.key, ], + onData: onData, ); if (me.isLoading || !me.hasData) { @@ -170,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_heading.dart b/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart index c82b8177..a8a60109 100644 --- a/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart +++ b/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -103,11 +104,19 @@ class TrackCollectionHeading extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ - Text( - title, - style: theme.textTheme.titleLarge!.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constrains.mdAndDown ? 400 : 300, + ), + child: AutoSizeText( + title, + style: theme.textTheme.titleLarge!.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + minFontSize: 16, + overflow: TextOverflow.ellipsis, ), ), if (album != null) @@ -125,11 +134,12 @@ class TrackCollectionHeading extends HookConsumerWidget { constraints: BoxConstraints( maxWidth: constrains.mdAndDown ? 400 : 300, ), - child: Text( + child: AutoSizeText( cleanDescription, style: const TextStyle(color: Colors.white), maxLines: 2, overflow: TextOverflow.fade, + minFontSize: 14, ), ), const SizedBox(height: 10), 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..14d9598f 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 @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:fl_query/fl_query.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/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'; @@ -26,7 +29,7 @@ class TrackCollectionView extends HookConsumerWidget { final Query, T> tracksSnapshot; final String titleImage; final PlayButtonState playingState; - final void Function([Track? currentTrack]) onPlay; + final Future Function([Track? currentTrack]) onPlay; final void Function([Track? currentTrack]) onShuffledPlay; final void Function() onAddToQueue; final void Function() onShare; @@ -71,6 +74,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/components/shared/track_table/track_options.dart b/lib/components/shared/track_table/track_options.dart index a1bc3fef..96bd8b60 100644 --- a/lib/components/shared/track_table/track_options.dart +++ b/lib/components/shared/track_table/track_options.dart @@ -14,7 +14,6 @@ import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -40,9 +39,11 @@ class TrackOptions extends HookConsumerWidget { final Track track; final bool userPlaylist; final String? playlistId; + final ObjectRef?>? showMenuCbRef; const TrackOptions({ Key? key, required this.track, + this.showMenuCbRef, this.userPlaylist = false, this.playlistId, }) : super(key: key); @@ -114,210 +115,216 @@ class TrackOptions extends HookConsumerWidget { return downloadManager.getProgressNotifier(spotubeTrack); }); + final adaptivePopSheetList = AdaptivePopSheetList( + onSelected: (value) async { + switch (value) { + case TrackOptionValue.delete: + await File((track as LocalTrack).path).delete(); + ref.refresh(localTracksProvider); + break; + case TrackOptionValue.addToQueue: + await playback.addTrack(track); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.added_track_to_queue(track.name!), + ), + ), + ); + } + break; + case TrackOptionValue.playNext: + playback.addTracksAtFirst([track]); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.track_will_play_next(track.name!), + ), + ), + ); + break; + case TrackOptionValue.removeFromQueue: + playback.removeTrack(track.id!); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.removed_track_from_queue( + track.name!, + ), + ), + ), + ); + break; + case TrackOptionValue.favorite: + favorites.toggleTrackLike.mutate(favorites.isLiked); + break; + case TrackOptionValue.addToPlaylist: + actionAddToPlaylist(context, track); + break; + case TrackOptionValue.removeFromPlaylist: + removingTrack.value = track.uri; + removeTrack.mutate(track.uri!); + break; + case TrackOptionValue.blacklist: + if (isBlackListed) { + ref.read(BlackListNotifier.provider.notifier).remove( + BlacklistedElement.track(track.id!, track.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.track(track.id!, track.name!), + ); + } + break; + case TrackOptionValue.share: + actionShare(context, track); + break; + case TrackOptionValue.details: + showDialog( + context: context, + builder: (context) => TrackDetailsDialog(track: track), + ); + break; + case TrackOptionValue.download: + await downloadManager.addToQueue(track); + break; + } + }, + icon: const Icon(SpotubeIcons.moreHorizontal), + headings: [ + ListTile( + dense: true, + leading: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString(track.album!.images, + placeholder: ImagePlaceholder.albumArt), + fit: BoxFit.cover, + ), + ), + ), + title: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: TypeConversionUtils.artists_X_ClickableArtists( + track.artists!, + ), + ), + ), + ], + children: switch (track.runtimeType) { + LocalTrack => [ + PopSheetEntry( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ) + ], + _ => [ + if (!playlist.containsTrack(track)) ...[ + PopSheetEntry( + value: TrackOptionValue.addToQueue, + leading: const Icon(SpotubeIcons.queueAdd), + title: Text(context.l10n.add_to_queue), + ), + PopSheetEntry( + value: TrackOptionValue.playNext, + leading: const Icon(SpotubeIcons.lightning), + title: Text(context.l10n.play_next), + ), + ] else + PopSheetEntry( + value: TrackOptionValue.removeFromQueue, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + title: Text(context.l10n.remove_from_queue), + ), + if (favorites.me.hasData) + PopSheetEntry( + value: TrackOptionValue.favorite, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), + title: Text( + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + ), + ), + if (auth != null) + PopSheetEntry( + value: TrackOptionValue.addToPlaylist, + leading: const Icon(SpotubeIcons.playlistAdd), + title: Text(context.l10n.add_to_playlist), + ), + if (userPlaylist && auth != null) + PopSheetEntry( + value: TrackOptionValue.removeFromPlaylist, + leading: (removeTrack.isMutating || !removeTrack.hasData) && + removingTrack.value == track.uri + ? const CircularProgressIndicator() + : const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + PopSheetEntry( + value: TrackOptionValue.download, + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) + : const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_track), + ), + PopSheetEntry( + value: TrackOptionValue.blacklist, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: !isBlackListed ? Colors.red[400] : null, + textColor: !isBlackListed ? Colors.red[400] : null, + title: Text( + isBlackListed + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, + ), + ), + PopSheetEntry( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), + ), + PopSheetEntry( + value: TrackOptionValue.details, + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.details), + ), + ] + }, + ); + + //! This is the most ANTI pattern I've ever done, but it works + showMenuCbRef?.value = (relativeRect) { + adaptivePopSheetList.showPopupMenu(context, relativeRect); + }; + return ListTileTheme( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), - child: AdaptivePopSheetList( - onSelected: (value) async { - switch (value) { - case TrackOptionValue.delete: - await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); - break; - case TrackOptionValue.addToQueue: - await playback.addTrack(track); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.added_track_to_queue(track.name!), - ), - ), - ); - } - break; - case TrackOptionValue.playNext: - playback.addTracksAtFirst([track]); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.track_will_play_next(track.name!), - ), - ), - ); - break; - case TrackOptionValue.removeFromQueue: - playback.removeTrack(track.id!); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.removed_track_from_queue( - track.name!, - ), - ), - ), - ); - break; - case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); - break; - case TrackOptionValue.addToPlaylist: - actionAddToPlaylist(context, track); - break; - case TrackOptionValue.removeFromPlaylist: - removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); - break; - case TrackOptionValue.blacklist: - if (isBlackListed) { - ref.read(BlackListNotifier.provider.notifier).remove( - BlacklistedElement.track(track.id!, track.name!), - ); - } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.track(track.id!, track.name!), - ); - } - break; - case TrackOptionValue.share: - actionShare(context, track); - break; - case TrackOptionValue.details: - showDialog( - context: context, - builder: (context) => TrackDetailsDialog(track: track), - ); - break; - case TrackOptionValue.download: - await downloadManager.addToQueue(track); - break; - } - }, - icon: const Icon(SpotubeIcons.moreHorizontal), - headings: [ - ListTile( - dense: true, - leading: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, - placeholder: ImagePlaceholder.albumArt), - fit: BoxFit.cover, - ), - ), - ), - title: Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, - ), - ), - ), - ], - children: switch (track.runtimeType) { - LocalTrack => [ - PopSheetEntry( - value: TrackOptionValue.delete, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ) - ], - _ => [ - if (!playlist.containsTrack(track)) ...[ - PopSheetEntry( - value: TrackOptionValue.addToQueue, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - PopSheetEntry( - value: TrackOptionValue.playNext, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - PopSheetEntry( - value: TrackOptionValue.removeFromQueue, - enabled: playlist.activeTrack?.id != track.id, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (favorites.me.hasData) - PopSheetEntry( - value: TrackOptionValue.favorite, - leading: favorites.isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - favorites.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - ), - ), - if (auth != null) - PopSheetEntry( - value: TrackOptionValue.addToPlaylist, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - if (userPlaylist && auth != null) - PopSheetEntry( - value: TrackOptionValue.removeFromPlaylist, - leading: (removeTrack.isMutating || !removeTrack.hasData) && - removingTrack.value == track.uri - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - PopSheetEntry( - value: TrackOptionValue.download, - enabled: !isInQueue, - leading: isInQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier!); - return CircularProgressIndicator( - value: progress.value, - ); - }) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), - ), - PopSheetEntry( - value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, - title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, - ), - ), - PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), - ), - PopSheetEntry( - value: TrackOptionValue.details, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ] - }, - ), + child: adaptivePopSheetList, ); } } diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_table/track_tile.dart index 7926f55a..0666b7f9 100644 --- a/lib/components/shared/track_table/track_tile.dart +++ b/lib/components/shared/track_table/track_tile.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -20,7 +23,7 @@ class TrackTile extends HookConsumerWidget { final Track track; final bool selected; final ValueChanged? onChanged; - final VoidCallback? onTap; + final Future Function()? onTap; final VoidCallback? onLongPress; final bool userPlaylist; final String? playlistId; @@ -57,174 +60,203 @@ class TrackTile extends HookConsumerWidget { [blacklist, track], ); + final showOptionCbRef = useRef?>(null); + final isPlaying = track.id == playlist.activeTrack?.id; + final isLoading = useState(false); + + final isSelected = isPlaying || isLoading.value; + return LayoutBuilder(builder: (context, constrains) { - return HoverBuilder( - permanentState: isPlaying || constrains.smAndDown ? true : null, - builder: (context, isHovering) { - return ListTile( - selected: isPlaying, - onTap: onTap, - onLongPress: onLongPress, - enabled: !isBlackListed, - contentPadding: EdgeInsets.zero, - tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, - horizontalTitleGap: 12, - leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - if (index != null && onChanged == null && constrains.mdAndUp) - SizedBox( - width: 34, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '$index', - maxLines: 1, - style: theme.textTheme.bodySmall, - textAlign: TextAlign.center, - ), - ), - ) - else if (constrains.smAndDown) - const SizedBox(width: 16), - if (onChanged != null) - Checkbox( - value: selected, - onChanged: onChanged, - ), - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: AspectRatio( - aspectRatio: 1, - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, - placeholder: ImagePlaceholder.albumArt, - ), - fit: BoxFit.cover, - ), - ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: isHovering - ? Colors.black.withOpacity(0.4) - : Colors.transparent, - ), - ), - ), - Positioned.fill( - child: Center( - child: IconTheme( - data: theme.iconTheme - .copyWith(size: 26, color: Colors.white), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: !isHovering - ? const SizedBox.shrink() - : isPlaying && playlist.isFetching - ? const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: Colors.white, - ), - ) - : isPlaying - ? Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ) - : const Icon(SpotubeIcons.play), - ), - ), - ), - ), - ], - ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), - Expanded( - flex: 4, - child: switch (track.runtimeType) { - LocalTrack => Text( - track.album!.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( - track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, - push: true, - overflow: TextOverflow.ellipsis, - ), - ) - }, - ), - ], - ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is LocalTrack - ? Text( - TypeConversionUtils.artists_X_String( - track.artists ?? [], - ), - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], - ), - ), - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs ?? 0) - .toHumanReadableString(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - ), - ], + return Listener( + onPointerDown: (event) { + if (event.buttons != kSecondaryMouseButton) return; + showOptionCbRef.value?.call( + RelativeRect.fromLTRB( + event.position.dx, + event.position.dy, + constrains.maxWidth - event.position.dx, + constrains.maxHeight - event.position.dy, ), ); }, + child: HoverBuilder( + permanentState: isSelected || constrains.smAndDown ? true : null, + builder: (context, isHovering) { + return ListTile( + selected: isSelected, + onTap: () async { + try { + isLoading.value = true; + await onTap?.call(); + } finally { + isLoading.value = false; + } + }, + onLongPress: onLongPress, + enabled: !isBlackListed, + contentPadding: EdgeInsets.zero, + tileColor: + isBlackListed ? theme.colorScheme.errorContainer : null, + horizontalTitleGap: 12, + leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...?leadingActions, + if (index != null && onChanged == null && constrains.mdAndUp) + SizedBox( + width: 34, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '$index', + maxLines: 1, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ) + else if (constrains.smAndDown) + const SizedBox(width: 16), + if (onChanged != null) + Checkbox( + value: selected, + onChanged: onChanged, + ), + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AspectRatio( + aspectRatio: 1, + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString( + track.album?.images, + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ), + ), + ), + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? Colors.black.withOpacity(0.4) + : Colors.transparent, + ), + ), + ), + Positioned.fill( + child: Center( + child: IconTheme( + data: theme.iconTheme + .copyWith(size: 26, color: Colors.white), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: (isPlaying && playlist.isFetching) || + isLoading.value + ? const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.white, + ), + ) + : isPlaying + ? Icon( + SpotubeIcons.pause, + color: theme.colorScheme.primary, + ) + : !isHovering + ? const SizedBox.shrink() + : const Icon(SpotubeIcons.play), + ), + ), + ), + ), + ], + ), + ], + ), + title: Row( + children: [ + Expanded( + flex: 6, + child: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (constrains.mdAndUp) ...[ + const SizedBox(width: 8), + Expanded( + flex: 4, + child: switch (track.runtimeType) { + LocalTrack => Text( + track.album!.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => Align( + alignment: Alignment.centerLeft, + child: LinkText( + track.album!.name!, + "/album/${track.album?.id}", + extra: track.album, + push: true, + overflow: TextOverflow.ellipsis, + ), + ) + }, + ), + ], + ], + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: track is LocalTrack + ? Text( + TypeConversionUtils.artists_X_String( + track.artists ?? [], + ), + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: TypeConversionUtils.artists_X_ClickableArtists( + track.artists ?? [], + ), + ), + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(padZero: false), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + showMenuCbRef: showOptionCbRef, + ), + ], + ), + ); + }, + ), ); }); } diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index 58d662f4..2ad6d384 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -27,7 +29,7 @@ final trackCollectionSortState = StateProvider.family((ref, _) => SortBy.none); class TracksTableView extends HookConsumerWidget { - final void Function(Track currentTrack)? onTrackPlayButtonPressed; + final Future Function(Track currentTrack)? onTrackPlayButtonPressed; final List tracks; final bool userPlaylist; final String? playlistId; @@ -58,8 +60,7 @@ class TracksTableView extends HookConsumerWidget { final downloader = ref.watch(downloadManagerProvider.notifier); final apiType = ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType)); - final tableHeadStyle = - const TextStyle(fontWeight: FontWeight.bold, fontSize: 16); + const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16); final selected = useState>([]); final showCheck = useState(false); @@ -297,7 +298,7 @@ class TracksTableView extends HookConsumerWidget { selected: selected.value.contains(track.id), userPlaylist: userPlaylist, playlistId: playlistId, - onTap: () { + onTap: () async { if (showCheck.value) { final alreadyChecked = selected.value.contains(track.id); if (alreadyChecked) { @@ -314,9 +315,8 @@ class TracksTableView extends HookConsumerWidget { ), ), ); - if (!isBlackListed) { - onTrackPlayButtonPressed?.call(track); - } + if (isBlackListed) return; + await onTrackPlayButtonPressed?.call(track); } }, onLongPress: () { diff --git a/lib/extensions/duration.dart b/lib/extensions/duration.dart index 183fce5f..c8612425 100644 --- a/lib/extensions/duration.dart +++ b/lib/extensions/duration.dart @@ -1,10 +1,21 @@ import 'package:duration/locale.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:duration/duration.dart'; extension DurationToHumanReadableString on Duration { - String toHumanReadableString() => - "${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}"; + String toHumanReadableString({padZero = true}) { + final mm = inMinutes + .remainder(60) + .toString() + .padLeft(2, !padZero && inHours == 0 ? '' : "0"); + final ss = inSeconds.remainder(60).toString().padLeft(2, "0"); + + if (inHours > 0) { + final hh = inHours.toString().padLeft(2, !padZero ? '' : "0"); + return "$hh:$mm:$ss"; + } + + return "$mm:$ss"; + } String format({ DurationTersity tersity = DurationTersity.second, 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/hooks/use_disable_battery_optimizations.dart b/lib/hooks/use_disable_battery_optimizations.dart index cf1ad0c1..267655b6 100644 --- a/lib/hooks/use_disable_battery_optimizations.dart +++ b/lib/hooks/use_disable_battery_optimizations.dart @@ -4,7 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/hooks/use_async_effect.dart'; bool _asked = false; -void useDisableBatterOptimizations() { +void useDisableBatteryOptimizations() { useAsyncEffect(() async { if (!DesktopTools.platform.isAndroid || _asked) return; final localStorage = await SharedPreferences.getInstance(); diff --git a/lib/hooks/use_progress.dart b/lib/hooks/use_progress.dart index 62dccbce..15a979af 100644 --- a/lib/hooks/use_progress.dart +++ b/lib/hooks/use_progress.dart @@ -40,14 +40,14 @@ import 'package:spotube/services/audio_player/audio_player.dart'; } }); + var lastPosition = position.value; + // audioPlayer.positionStream is fired every 200ms and only 1s delay is // enough. Thus only update the position if the difference is more than 1s // Reduces CPU usage - var lastPosition = position.value; - final positionSubscription = audioPlayer.positionStream.listen((event) { - if (event.inMilliseconds > 1000 && - event.inMilliseconds - lastPosition.inMilliseconds < 1000) return; + final diff = event.inMilliseconds - lastPosition.inMilliseconds; + if (event.inMilliseconds > 1000 && diff < 1000 && diff > 0) return; lastPosition = event; position.value = event; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 74894612..fa81450b 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", @@ -259,5 +261,7 @@ "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", "you_are_offline": "You are currently offline", "connection_restored": "Your internet connection was restored", - "use_system_title_bar": "Use system title bar" + "use_system_title_bar": "Use system title bar", + "crunching_results": "Crunching results...", + "search_to_get_results": "Search to get results" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index a7f37bad..ee6fdf17 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,7 @@ -import 'dart:io'; - -import 'package:args/args.dart'; import 'package:catcher/catcher.dart'; import 'package:device_preview/device_preview.dart'; import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_devtools/fl_query_devtools.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -14,7 +12,6 @@ import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; import 'package:metadata_god/metadata_god.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/intents.dart'; @@ -26,6 +23,7 @@ import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -37,41 +35,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; Future main(List rawArgs) async { - final parser = ArgParser(); - - parser.addFlag( - 'verbose', - abbr: 'v', - help: 'Verbose mode', - defaultsTo: !kReleaseMode, - callback: (verbose) { - if (verbose) { - logEnv['VERBOSE'] = 'true'; - logEnv['DEBUG'] = 'true'; - logEnv['ERROR'] = 'true'; - } - }, - ); - parser.addFlag( - "version", - help: "Print version and exit", - negatable: false, - ); - - parser.addFlag("help", abbr: "h", negatable: false); - - final arguments = parser.parse(rawArgs); - - if (arguments["help"] == true) { - print(parser.usage); - exit(0); - } - - if (arguments["version"] == true) { - final package = await PackageInfo.fromPlatform(); - print("Spotube v${package.version}"); - exit(0); - } + final arguments = await startCLI(rawArgs); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -215,7 +179,7 @@ class SpotubeState extends ConsumerState { }; }, []); - useDisableBatterOptimizations(); + useDisableBatteryOptimizations(); final lightTheme = useMemoized( () => theme(paletteColor ?? accentMaterialColor, Brightness.light), diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 84e23e8b..a585c9e5 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -85,10 +85,10 @@ class AlbumPage extends HookConsumerWidget { album: album, routePath: "/album/${album.id}", bottomSpace: mediaQuery.mdAndDown, - onPlay: ([track]) { + onPlay: ([track]) async { if (tracksSnapshot.hasData) { if (!isAlbumPlaying) { - playPlaylist( + await playPlaylist( tracksSnapshot.data! .map((track) => TypeConversionUtils.simpleTrack_X_Track(track, album)) @@ -96,7 +96,7 @@ class AlbumPage extends HookConsumerWidget { ref, ); } else if (isAlbumPlaying && track != null) { - playPlaylist( + await playPlaylist( tracksSnapshot.data! .map((track) => TypeConversionUtils.simpleTrack_X_Track(track, album)) @@ -105,7 +105,7 @@ class AlbumPage extends HookConsumerWidget { ref, ); } else { - playback + await playback .removeTracks(tracksSnapshot.data!.map((track) => track.id!)); } } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 44e40423..e1bbefcb 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -390,7 +390,7 @@ class ArtistPage extends HookConsumerWidget { return TrackTile( index: i, track: track, - onTap: () { + onTap: () async { playPlaylist( topTracks.toList(), currentTrack: track, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index baee0669..1623195b 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 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,7 +31,16 @@ class PlaylistView extends HookConsumerWidget { final mediaQuery = MediaQuery.of(context); final meSnapshot = useQueries.user.me(ref); - final tracksSnapshot = useQueries.playlist.tracksOfQuery(ref, playlist.id!); + + 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); + final tracksSnapshot = playlist.id! == "user-liked-tracks" + ? likedTracksSnapshot + : playlistTrackSnapshot; final isPlaylistPlaying = useMemoized( () => proxyPlaylist.collections.contains(playlist.id!), @@ -78,6 +63,35 @@ class PlaylistView extends HookConsumerWidget { [proxyPlaylist.activeTrack, tracksSnapshot.data], ); + final playPlaylist = useCallback(( + List 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 @@ -89,24 +103,17 @@ class PlaylistView extends HookConsumerWidget { titleImage: titleImage, tracksSnapshot: tracksSnapshot, description: playlist.description, - isOwned: playlist.owner?.id != null && - playlist.owner!.id == meSnapshot.data?.id, - onPlay: ([track]) { + isOwned: ownPlaylist, + onPlay: ([track]) async { if (tracksSnapshot.hasData) { - if (!isPlaylistPlaying) { - playPlaylist( - tracksSnapshot.data!, - ref, - currentTrack: track, - ); - } else if (isPlaylistPlaying && track != null) { - playPlaylist( + if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) { + await playPlaylist( tracksSnapshot.data!, ref, currentTrack: track, ); } else { - playlistNotifier + await playlistNotifier .removeTracks(tracksSnapshot.data!.map((e) => e.id!)); } } @@ -137,7 +144,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(); diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 9d5e7eed..7ceecd58 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -55,13 +55,295 @@ class SearchPage extends HookConsumerWidget { Future onSearch() async { await Future.wait([ - searchTrack.refreshAll(), - searchAlbum.refreshAll(), - searchPlaylist.refreshAll(), - searchArtist.refreshAll(), - ]); + searchTrack.reset(), + searchAlbum.reset(), + searchPlaylist.reset(), + searchArtist.reset(), + ]).then((_) { + return Future.wait([ + searchTrack.refreshAll(), + searchAlbum.refreshAll(), + searchPlaylist.refreshAll(), + searchArtist.refreshAll(), + ]); + }); } + final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; + final isFetching = queries.every( + (s) => s.isLoadingPage || s.isRefreshingPage || !s.hasPageData, + ) && + searchTerm.isNotEmpty; + + final resultWidget = HookBuilder( + builder: (context) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + List albums = []; + List artists = []; + List tracks = []; + List playlists = []; + final pages = [ + ...searchTrack.pages, + ...searchAlbum.pages, + ...searchPlaylist.pages, + ...searchArtist.pages, + ].expand((page) => page).toList(); + for (MapEntry page in pages.asMap().entries) { + for (var item in page.value.items ?? []) { + if (item is AlbumSimple) { + albums.add(item); + } else if (item is PlaylistSimple) { + playlists.add(item); + } else if (item is Artist) { + artists.add(item); + } else if (item is Track) { + tracks.add(item); + } + } + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (tracks.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.songs, + style: theme.textTheme.titleLarge!, + ), + ), + if (searchTrack.isLoadingPage) + const CircularProgressIndicator() + else if (searchTrack.hasPageError) + Text( + searchTrack.errors.lastOrNull?.toString() ?? "", + ) + else + ...tracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + onTap: () async { + final isTrackPlaying = + playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } + } + }, + ); + }), + if (searchTrack.hasNextPage && tracks.isNotEmpty) + Center( + child: TextButton( + onPressed: searchTrack.isRefreshingPage + ? null + : () => searchTrack.fetchNext(), + child: searchTrack.isRefreshingPage + ? const CircularProgressIndicator() + : Text(context.l10n.load_more), + ), + ), + if (playlists.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.playlists, + style: theme.textTheme.titleLarge!, + ), + ), + const SizedBox(height: 10), + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + scrollbarOrientation: mediaQuery.lgAndUp + ? ScrollbarOrientation.bottom + : ScrollbarOrientation.top, + controller: playlistController, + child: Waypoint( + onTouchEdge: () { + searchPlaylist.fetchNext(); + }, + controller: playlistController, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: playlistController, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + ...playlists.mapIndexed( + (i, playlist) { + if (i == playlists.length - 1 && + searchPlaylist.hasNextPage) { + return const ShimmerPlaybuttonCard( + count: 1); + } + return PlaylistCard(playlist); + }, + ), + ], + ), + ), + ), + ), + ), + if (searchPlaylist.isLoadingPage) + const CircularProgressIndicator(), + if (searchPlaylist.hasPageError) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + searchPlaylist.errors.lastOrNull?.toString() ?? "", + ), + ), + const SizedBox(height: 20), + if (artists.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.artists, + style: theme.textTheme.titleLarge!, + ), + ), + const SizedBox(height: 10), + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + controller: artistController, + child: Waypoint( + controller: artistController, + onTouchEdge: () { + searchArtist.fetchNext(); + }, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: artistController, + child: Row( + children: [ + ...artists.mapIndexed( + (i, artist) { + if (i == artists.length - 1 && + searchArtist.hasNextPage) { + return const ShimmerPlaybuttonCard( + count: 1); + } + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 15), + child: ArtistCard(artist), + ); + }, + ), + ], + ), + ), + ), + ), + ), + if (searchArtist.isLoadingPage) + const CircularProgressIndicator(), + if (searchArtist.hasPageError) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + searchArtist.errors.lastOrNull?.toString() ?? "", + ), + ), + const SizedBox(height: 20), + if (albums.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.albums, + style: theme.textTheme.titleLarge!, + ), + ), + const SizedBox(height: 10), + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + controller: albumController, + child: Waypoint( + controller: albumController, + onTouchEdge: () { + searchAlbum.fetchNext(); + }, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: albumController, + child: Row( + children: [ + ...albums.mapIndexed((i, album) { + if (i == albums.length - 1 && + searchAlbum.hasNextPage) { + return const ShimmerPlaybuttonCard(count: 1); + } + return AlbumCard( + TypeConversionUtils.simpleAlbum_X_Album( + album, + ), + ); + }), + ], + ), + ), + ), + ), + ), + if (searchAlbum.isLoadingPage) + const CircularProgressIndicator(), + if (searchAlbum.hasPageError) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + searchAlbum.errors.lastOrNull?.toString() ?? "", + ), + ), + ], + ), + ), + ), + ); + }, + ); + return SafeArea( bottom: false, child: Scaffold( @@ -77,7 +359,7 @@ class SearchPage extends HookConsumerWidget { ), color: theme.scaffoldBackgroundColor, child: TextField( - autofocus: true, + autofocus: queries.none((s) => s.hasPageData), decoration: InputDecoration( prefixIcon: const Icon(SpotubeIcons.search), hintText: "${context.l10n.search}...", @@ -93,283 +375,64 @@ class SearchPage extends HookConsumerWidget { }, ), ), - HookBuilder( - builder: (context) { - final playlist = - ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = - ref.watch(ProxyPlaylistNotifier.notifier); - List albums = []; - List artists = []; - List tracks = []; - List playlists = []; - final pages = [ - ...searchTrack.pages, - ...searchAlbum.pages, - ...searchPlaylist.pages, - ...searchArtist.pages, - ].expand((page) => page).toList(); - for (MapEntry page in pages.asMap().entries) { - for (var item in page.value.items ?? []) { - if (item is AlbumSimple) { - albums.add(item); - } else if (item is PlaylistSimple) { - playlists.add(item); - } else if (item is Artist) { - artists.add(item); - } else if (item is Track) { - tracks.add(item); - } - } - } - return Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 20, - ), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (tracks.isNotEmpty) - Text( - context.l10n.songs, - style: theme.textTheme.titleLarge!, - ), - if (searchTrack.isLoadingPage) - const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull - ?.toString() ?? - "", - ) - else - ...tracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () async { - final isTrackPlaying = - playlist.activeTrack?.id == - track.id; - if (!isTrackPlaying && - context.mounted) { - final shouldPlay = - (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n - .playing_track( - track.name!, - ), - message: context.l10n - .queue_clear_alert( - playlist - .tracks.length, - ), - ) - : true; - - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); - } - } - }, - ); - }), - if (searchTrack.hasNextPage && - tracks.isNotEmpty) - Center( - child: TextButton( - onPressed: searchTrack.isRefreshingPage - ? null - : () => searchTrack.fetchNext(), - child: searchTrack.isRefreshingPage - ? const CircularProgressIndicator() - : Text(context.l10n.load_more), - ), - ), - if (playlists.isNotEmpty) - Text( - context.l10n.playlists, - style: theme.textTheme.titleLarge!, - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - scrollbarOrientation: mediaQuery.lgAndUp - ? ScrollbarOrientation.bottom - : ScrollbarOrientation.top, - controller: playlistController, - child: Waypoint( - onTouchEdge: () { - searchPlaylist.fetchNext(); - }, - controller: playlistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: playlistController, - child: Row( - children: [ - ...playlists.mapIndexed( - (i, playlist) { - if (i == - playlists.length - - 1 && - searchPlaylist - .hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return PlaylistCard(playlist); - }, - ), - ], - ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: searchTerm.isEmpty + ? Column( + children: [ + SizedBox( + height: mediaQuery.size.height * 0.2, + ), + Icon( + SpotubeIcons.web, + size: 120, + color: theme.colorScheme.onBackground + .withOpacity(0.7), + ), + const SizedBox(height: 20), + Text( + context.l10n.search_to_get_results, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + color: theme.colorScheme.onBackground + .withOpacity(0.5), + ), + ), + ], + ) + : isFetching + ? Container( + constraints: BoxConstraints( + maxWidth: mediaQuery.lgAndUp + ? mediaQuery.size.width * 0.5 + : mediaQuery.size.width, + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Text( + context.l10n.crunching_results, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: theme.colorScheme.onBackground + .withOpacity(0.7), ), ), - ), + const SizedBox(height: 20), + const LinearProgressIndicator(), + ], ), - if (searchPlaylist.isLoadingPage) - const CircularProgressIndicator(), - if (searchPlaylist.hasPageError) - Text( - searchPlaylist.errors.lastOrNull - ?.toString() ?? - "", - ), - const SizedBox(height: 20), - if (artists.isNotEmpty) - Text( - context.l10n.artists, - style: theme.textTheme.titleLarge!, - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: artistController, - child: Waypoint( - controller: artistController, - onTouchEdge: () { - searchArtist.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: artistController, - child: Row( - children: [ - ...artists.mapIndexed( - (i, artist) { - if (i == artists.length - 1 && - searchArtist - .hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return Container( - margin: const EdgeInsets - .symmetric( - horizontal: 15), - child: ArtistCard(artist), - ); - }, - ), - ], - ), - ), - ), - ), - ), - if (searchArtist.isLoadingPage) - const CircularProgressIndicator(), - if (searchArtist.hasPageError) - Text( - searchArtist.errors.lastOrNull - ?.toString() ?? - "", - ), - const SizedBox(height: 20), - if (albums.isNotEmpty) - Text( - context.l10n.albums, - style: theme.textTheme.titleMedium!, - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: albumController, - child: Waypoint( - controller: albumController, - onTouchEdge: () { - searchAlbum.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: albumController, - child: Row( - children: [ - ...albums.mapIndexed((i, album) { - if (i == albums.length - 1 && - searchAlbum.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return AlbumCard( - TypeConversionUtils - .simpleAlbum_X_Album( - album, - ), - ); - }), - ], - ), - ), - ), - ), - ), - if (searchAlbum.isLoadingPage) - const CircularProgressIndicator(), - if (searchAlbum.hasPageError) - Text( - searchAlbum.errors.lastOrNull - ?.toString() ?? - "", - ), - ], - ), - ), - ), - ), - ); - }, - ) + ) + : resultWidget, + ), + ), ], ), ), diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index d4e1e9db..a6efdc8f 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -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); diff --git a/lib/services/cli/cli.dart b/lib/services/cli/cli.dart new file mode 100644 index 00000000..61af710e --- /dev/null +++ b/lib/services/cli/cli.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:spotube/models/logger.dart'; + +Future startCLI(List args) async { + final parser = ArgParser(); + + parser.addFlag( + 'verbose', + abbr: 'v', + help: 'Verbose mode', + defaultsTo: !kReleaseMode, + callback: (verbose) { + if (verbose) { + logEnv['VERBOSE'] = 'true'; + logEnv['DEBUG'] = 'true'; + logEnv['ERROR'] = 'true'; + } + }, + ); + parser.addFlag( + "version", + help: "Print version and exit", + negatable: false, + ); + + parser.addFlag("help", abbr: "h", negatable: false); + + final arguments = parser.parse(args); + + if (arguments["help"] == true) { + print(parser.usage); + exit(0); + } + + if (arguments["version"] == true) { + final package = await PackageInfo.fromPlatform(); + print("Spotube v${package.version}"); + exit(0); + } + + return arguments; +} diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart index ee06ad9d..176b5cd8 100644 --- a/lib/services/mutations/playlist.dart +++ b/lib/services/mutations/playlist.dart @@ -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? refreshQueries, List? refreshInfiniteQueries, + ValueChanged? onData, }) { - final queryClient = useQueryClient(); return useSpotifyMutation( "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 create( + WidgetRef ref, { + List? trackIds, + ValueChanged? onError, + ValueChanged? onData, + }) { + final me = useQueries.user.me(ref); + return useSpotifyMutation( + "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 update( + WidgetRef ref, { + String? playlistId, + ValueChanged? onError, + ValueChanged? onData, + }) { + return useSpotifyMutation( + "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); + }, + ); + } } diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index 25da6199..0204f9b7 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:math'; + import 'package:catcher/catcher.dart'; import 'package:fl_query/fl_query.dart'; import 'package:fl_query_hooks/fl_query_hooks.dart'; @@ -10,6 +13,7 @@ import 'package:spotube/extensions/track.dart'; import 'package:spotube/hooks/use_spotify_infinite_query.dart'; import 'package:spotube/hooks/use_spotify_query.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; +import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; @@ -142,14 +146,49 @@ class PlaylistQueries { ); } - Future> tracksOf(String playlistId, SpotifyApi spotify) { - if (playlistId == "user-liked-tracks") { - return spotify.tracks.me.saved.all().then( - (tracks) => tracks.map((e) => e.track!).toList(), - ); - } + Future> likedTracks( + SpotifyApi spotify, + WidgetRef ref, + ) async { + final tracks = await spotify.tracks.me.saved.all(); + + return tracks.map((e) => e.track!).toList(); + } + + Query, dynamic> likedTracksQuery(WidgetRef ref) { + final query = useCallback((spotify) => likedTracks(spotify, ref), []); + final context = useContext(); + + return useSpotifyQuery, dynamic>( + "user-liked-tracks", + query, + jsonConfig: JsonConfig( + toJson: (tracks) => { + 'tracks': tracks.map((e) => e.toJson()).toList(), + }, + fromJson: (json) => (json['tracks'] as List) + .map( + (e) => Track.fromJson((e as Map).castKeyDeep()), + ) + .toList(), + ), + refreshConfig: RefreshConfig.withDefaults( + context, + // will never make it stale + staleDuration: const Duration(days: 60), + ), + ref: ref, + ); + } + + Future> tracksOf( + String playlistId, + SpotifyApi spotify, + WidgetRef ref, + ) async { + if (playlistId == "user-liked-tracks") return []; return spotify.playlists.getTracksByPlaylistId(playlistId).all().then( - (value) => value.toList(), + (value) => value.where((track) => track.id != null).toList(), ); } @@ -159,19 +198,17 @@ class PlaylistQueries { ) { return useSpotifyQuery, dynamic>( "playlist-tracks/$playlistId", - (spotify) => tracksOf(playlistId, spotify), - jsonConfig: playlistId == "user-liked-tracks" - ? JsonConfig( - toJson: (tracks) => { - 'tracks': tracks.map((e) => e.toJson()).toList() - }, - fromJson: (json) => (json['tracks'] as List) - .map((e) => Track.fromJson( - (e as Map).castKeyDeep(), - )) - .toList(), - ) - : null, + (spotify) => tracksOf(playlistId, spotify, ref), + ref: ref, + ); + } + + Query byId(WidgetRef ref, String id) { + return useSpotifyQuery( + "playlist/$id", + (spotify) async { + return await spotify.playlists.get(id); + }, ref: ref, ); } diff --git a/lib/services/queries/user.dart b/lib/services/queries/user.dart index 63d58afd..89792592 100644 --- a/lib/services/queries/user.dart +++ b/lib/services/queries/user.dart @@ -1,4 +1,5 @@ import 'package:fl_query/fl_query.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/hooks/use_spotify_query.dart'; @@ -8,6 +9,8 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class UserQueries { const UserQueries(); Query me(WidgetRef ref) { + final context = useContext(); + return useSpotifyQuery( "current-user", (spotify) async { @@ -26,6 +29,11 @@ class UserQueries { } return me; }, + refreshConfig: RefreshConfig.withDefaults( + context, + // will never make it stale + staleDuration: const Duration(days: 60), + ), ref: ref, ); } diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 7f107d56..e11f0cc2 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -51,6 +51,7 @@ ThemeData theme(Color seed, Brightness brightness) { sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( constraints: const BoxConstraints(maxWidth: double.infinity), + padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), backgroundColor: MaterialStatePropertyAll( Color.lerp( scheme.surfaceVariant, diff --git a/lib/utils/primitive_utils.dart b/lib/utils/primitive_utils.dart index a0e54430..3843601e 100644 --- a/lib/utils/primitive_utils.dart +++ b/lib/utils/primitive_utils.dart @@ -31,17 +31,6 @@ abstract class PrimitiveUtils { } } - static String zeroPadNumStr(int input) { - return input < 10 ? "0$input" : input.toString(); - } - - static String toReadableDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes % 60; - final seconds = duration.inSeconds % 60; - return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}"; - } - static Future raceMultiple( Future Function() inner, { Duration timeout = const Duration(milliseconds: 2500), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index eef22b2f..9f8f2fd3 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1d7de67a..c7f0e848 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST catcher + file_selector_linux flutter_secure_storage_linux local_notifier media_kit_libs_linux diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index 3d6d75d6..148b9618 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -16,6 +16,7 @@ dependencies: - libsecret-1-0 - libnotify-bin - libjsoncpp25 + - libmpv2 essential: false icon: assets/spotube-logo.png diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3f63519c..1010a6c4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 6fc950ef..0aed55d5 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -19,5 +19,8 @@ + + com.apple.security.files.user-selected.read-write + \ No newline at end of file diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 9728552b..19f1c02a 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -1,39 +1,39 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 4a447fde..dc5df580 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -17,5 +17,8 @@ + + com.apple.security.files.user-selected.read-write + \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 909c7d3b..94eccfdd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -518,18 +582,26 @@ packages: dependency: "direct main" description: name: fl_query - sha256: "64f482fc09eb1166adca232f68772b2b11c616d88bce3208b2753c940ebc9f71" + sha256: "3d71cd1eeb3232efa5e32363a351d74fd9ff07c6eb80aeb672b1970962764945" url: "https://pub.dev" source: hosted - version: "1.0.0-alpha.3" + version: "1.0.0-alpha.4" + fl_query_devtools: + dependency: "direct main" + description: + name: fl_query_devtools + sha256: "72fac45293902b9f99c726609cd5416573566cce0b7c6e27311efde7fdf1b8b1" + url: "https://pub.dev" + source: hosted + version: "0.1.0-alpha.2" fl_query_hooks: dependency: "direct main" description: name: fl_query_hooks - sha256: b0ffc81fb047cbcedd9766776f9c72b95382730ce173226f0695c3f45774b0bc + sha256: "7f0880696666714f77981777509a8aedb765857dcdbdde23e623da20a24c4ae0" url: "https://pub.dev" source: hosted - version: "1.0.0-alpha.3" + version: "1.0.0-alpha.4+1" fluentui_system_icons: dependency: "direct main" description: @@ -694,10 +766,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "0c997763ce06359ee4686553b74def84062e9d6929ac63f61fa02465c1f8e32c" + sha256: "1bd39b04f1bcd217a969589777ca6bd642d116e3e5de65c3e6a8e8bdd8b178ec" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.4.0" flutter_rust_bridge: dependency: transitive description: @@ -780,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: @@ -893,10 +973,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "71695b2e1dfc22a39f1f9c67b798f8f8f1521f2d0349817d13ccdd5c4cd7acba" + sha256: ad7b877c3687e38764633d221a1f65491bc7a540e724101e9a404a84db2a4276 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.4.0" html: dependency: "direct main" description: @@ -937,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 @@ -998,6 +1142,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.2" + json_view: + dependency: transitive + description: + name: json_view + sha256: "905c69f9e69d1eab5406b87ab6c10c3706c04c70c6a4959621bd2b43c2d27374" + url: "https://pub.dev" + source: hosted + version: "0.4.2" jwt_decode: dependency: transitive description: @@ -1474,10 +1626,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "0f43c64f1f79c2112c843305a879a746587fb7c1e388f1d4717737796756e2c4" + sha256: a600120d6f213a9922860eea1abc32597436edd5b2c4e73b91410f8c2af67d22 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.4.0" rxdart: dependency: transitive description: @@ -1610,10 +1762,10 @@ packages: dependency: "direct main" description: name: skeleton_text - sha256: "6e088723b97ddcccfcce45312ce5e385ed1e5139a57afdf574f753d51eaa77f1" + sha256: bacd536bf0664efe1cae53bcbd78c3d4040a120f300f69dc85d83f358471cc6c url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 1055f4c1..c1b427cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,9 +33,9 @@ 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.3 - fl_query_hooks: ^1.0.0-alpha.3 + fl_query: ^1.0.0-alpha.4 + fl_query_hooks: ^1.0.0-alpha.4+1 + fl_query_devtools: ^0.1.0-alpha.2 fluentui_system_icons: ^1.1.189 flutter: sdk: flutter @@ -54,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 @@ -81,7 +82,7 @@ dependencies: scroll_to_index: ^3.0.1 shared_preferences: ^2.0.11 sidebarx: ^0.15.0 - skeleton_text: ^3.0.0 + skeleton_text: ^3.0.1 smtc_windows: ^0.1.0 spotify: ^0.11.0 supabase: ^1.9.9 @@ -99,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 @@ -118,7 +121,6 @@ dev_dependencies: dependency_overrides: http: ^1.1.0 - flutter_hooks: ^0.20.0 flutter: generate: true diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..ec30b430 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,78 @@ -{} \ No newline at end of file +{ + "bn": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "ca": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "de": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "es": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "fr": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "hi": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "ja": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "pl": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "pt": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "ru": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ], + + "zh": [ + "update_playlist", + "update", + "crunching_results", + "search_to_get_results" + ] +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b8983c9c..089930d3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { CatcherPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("CatcherPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalNotifierPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e066d223..b2b08c8e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST catcher + file_selector_windows flutter_secure_storage_windows local_notifier media_kit_libs_windows_audio