diff --git a/.gitignore b/.gitignore index 119e42e5..544dbba8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .history .svn/ + # IntelliJ related *.iml *.ipr diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 955ac90d..ec3f50f3 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -39,6 +39,7 @@ class TrackTile extends HookConsumerWidget { final int? index; final SpotubeTrackObject track; final bool selected; + final bool selectionMode; final ValueChanged? onChanged; final Future Function()? onTap; final VoidCallback? onLongPress; @@ -53,6 +54,7 @@ class TrackTile extends HookConsumerWidget { this.index, required this.track, this.selected = false, + this.selectionMode = false, required this.playlist, this.onTap, this.onLongPress, @@ -81,6 +83,12 @@ class TrackTile extends HookConsumerWidget { [track.album.images], ); + // Treat either explicit selectionMode or presence of onChanged as selection + // context. Some lists enable selection by providing `onChanged` without + // toggling a dedicated `selectionMode` flag (e.g. playlists), so we must + // disable inner navigation in both cases. + final effectiveSelection = selectionMode || onChanged != null; + return LayoutBuilder(builder: (context, constrains) { return Listener( onPointerDown: (event) { @@ -222,7 +230,9 @@ class TrackTile extends HookConsumerWidget { children: [ Expanded( flex: 6, - child: switch (track) { + child: AbsorbPointer( + absorbing: selectionMode, + child: switch (track) { SpotubeLocalTrackObject() => Text( track.name, maxLines: 1, @@ -232,15 +242,17 @@ class TrackTile extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: Button( - style: ButtonVariance.link.copyWith( - padding: (context, states, value) => - EdgeInsets.zero, - ), - onPressed: () { - context - .navigateTo(TrackRoute(trackId: track.id)); - }, + child: Button( + style: ButtonVariance.link.copyWith( + padding: (context, states, value) => + EdgeInsets.zero, + ), + onPressed: effectiveSelection + ? null + : () { + context + .navigateTo(TrackRoute(trackId: track.id)); + }, child: Text( track.name, maxLines: 1, @@ -251,6 +263,7 @@ class TrackTile extends HookConsumerWidget { ], ), }, + ), ), if (constrains.mdAndUp) ...[ const SizedBox(width: 8), @@ -281,20 +294,25 @@ class TrackTile extends HookConsumerWidget { ), subtitle: Align( alignment: Alignment.centerLeft, - child: track is SpotubeLocalTrackObject + child: track is SpotubeLocalTrackObject ? Text( track.artists.asString(), ) : ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 40), - child: ArtistLink( - artists: track.artists, - onOverflowArtistClick: () { - context.navigateTo( - TrackRoute(trackId: track.id), - ); - }, + child: AbsorbPointer( + absorbing: effectiveSelection, + child: ArtistLink( + artists: track.artists, + onOverflowArtistClick: effectiveSelection + ? () {} + : () { + context.navigateTo( + TrackRoute(trackId: track.id), + ); + }, + ), ), ), ), diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index c9d5626f..ad70117d 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -2,6 +2,7 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter/material.dart' show showModalBottomSheet, ListTile, SafeArea, Column, Navigator; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,6 +13,7 @@ import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/fallbacks/not_found.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/track_tile/track_tile.dart'; +import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; @@ -55,9 +57,12 @@ class PlayerQueue extends HookConsumerWidget { final controller = useAutoScrollController(); final searchText = useState(''); + final selectionMode = useState(false); + final selectedTrackIds = useState({}); + final isSearching = useState(false); - final tracks = playlist.tracks; + final tracks = playlist.tracks; final filteredTracks = useMemoized( () { @@ -132,50 +137,156 @@ class PlayerQueue extends HookConsumerWidget { child: searchBar, ) else - AppBar( - trailingGap: 0, - backgroundColor: Colors.transparent, - surfaceBlur: 0, - surfaceOpacity: 0, - title: mediaQuery.mdAndUp || !isSearching.value - ? SizedBox( + selectionMode.value + ? AppBar( + backgroundColor: Colors.transparent, + surfaceBlur: 0, + surfaceOpacity: 0, + leading: [ + IconButton.ghost( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + selectedTrackIds.value = {}; + selectionMode.value = false; + }, + ) + ], + title: SizedBox( height: 30, child: AutoSizeText( - context.l10n.tracks_in_queue(tracks.length), + '${selectedTrackIds.value.length} selected', maxLines: 1, ), - ) - : null, - trailing: [ - if (mediaQuery.mdAndUp) - searchBar - else - IconButton.ghost( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, - ), - if (!isSearching.value) ...[ - const SizedBox(width: 10), - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.clear_all)) - .call, - child: IconButton.outline( - icon: const Icon(SpotubeIcons.playlistRemove), - onPressed: () { - onStop(); - closeDrawer(context); - }, ), + trailing: [ + IconButton.ghost( + icon: const Icon(SpotubeIcons.moreHorizontal), + onPressed: () async { + await showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon( + SpotubeIcons.selectionCheck), + title: Text( + context.l10n.select_all), + onTap: () { + selectedTrackIds.value = + filteredTracks + .map((t) => t.id) + .toSet(); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon( + SpotubeIcons.playlistAdd), + title: Text( + context.l10n.add_to_playlist), + onTap: () async { + final selected = filteredTracks + .where((t) => selectedTrackIds + .value + .contains(t.id)) + .toList(); + Navigator.pop(context); + if (selected.isEmpty) return; + final res = await showDialog< + bool?>( + context: context, + builder: (context) => + PlaylistAddTrackDialog( + tracks: selected, + openFromPlaylist: null, + ), + ); + if (res == true) { + selectedTrackIds.value = {}; + selectionMode.value = false; + } + }, + ), + ListTile( + leading: + const Icon(SpotubeIcons.trash), + title: Text( + context.l10n.remove_from_queue), + onTap: () async { + final ids = selectedTrackIds + .value + .toList(); + Navigator.pop(context); + if (ids.isEmpty) return; + await Future.wait(ids + .map((id) => onRemove(id))); + selectedTrackIds.value = {}; + selectionMode.value = false; + }, + ), + ListTile( + leading: const Icon( + SpotubeIcons.close), + title: Text(context.l10n.cancel), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ); + }, + ); + }, + ), + ], + ) + : AppBar( + trailingGap: 0, + backgroundColor: Colors.transparent, + surfaceBlur: 0, + surfaceOpacity: 0, + title: mediaQuery.mdAndUp || !isSearching.value + ? SizedBox( + height: 30, + child: AutoSizeText( + context.l10n.tracks_in_queue(tracks.length), + maxLines: 1, + ), + ) + : null, + trailing: [ + if (mediaQuery.mdAndUp) searchBar + else + IconButton.ghost( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (!isSearching.value) ...[ + const SizedBox(width: 10), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.clear_all)) + .call, + child: IconButton.outline( + icon: const Icon(SpotubeIcons.playlistRemove), + onPressed: () { + onStop(); + closeDrawer(context); + }, + ), + ), + const Gap(5), + if (mediaQuery.smAndDown) + const BackButton(icon: SpotubeIcons.angleDown), + ], + ], ), - const Gap(5), - if (mediaQuery.smAndDown) - const BackButton(icon: SpotubeIcons.angleDown), - ], - ], - ), const Divider(), Expanded( child: InterScrollbar( @@ -195,6 +306,20 @@ class PlayerQueue extends HookConsumerWidget { }, itemBuilder: (context, i) { final track = filteredTracks.elementAt(i); + + void toggleSelection(String id) { + final s = {...selectedTrackIds.value}; + if (s.contains(id)) { + s.remove(id); + } else { + s.add(id); + } + selectedTrackIds.value = s; + if (selectedTrackIds.value.isEmpty) { + selectionMode.value = false; + } + } + return AutoScrollTag( key: ValueKey(i), controller: controller, @@ -203,15 +328,34 @@ class PlayerQueue extends HookConsumerWidget { playlist: playlist, index: i, track: track, + selectionMode: selectionMode.value, + selected: + selectedTrackIds.value.contains(track.id), + onChanged: selectionMode.value + ? (_) => toggleSelection(track.id) + : null, onTap: () async { + if (selectionMode.value) { + toggleSelection(track.id); + return; + } if (playlist.activeTrack?.id == track.id) { return; } await onJump(track); }, + onLongPress: () { + if (!selectionMode.value) { + selectionMode.value = true; + selectedTrackIds.value = {track.id}; + } else { + toggleSelection(track.id); + } + }, leadingActions: [ if (!isSearching.value && - searchText.value.isEmpty) + searchText.value.isEmpty && + !selectionMode.value) Padding( padding: const EdgeInsets.only(left: 8.0), diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 8ac2c1b9..2af899f3 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -64,7 +64,7 @@ class BlackListPage extends HookConsumerWidget { child: TextField( onChanged: (value) => searchText.value = value, placeholder: Text(context.l10n.search), - leading: const Icon(SpotubeIcons.search), + // prefixIcon: const Icon(SpotubeIcons.search), ), ), InterScrollbar( diff --git a/lib/services/metadata/metadata.dart b/lib/services/metadata/metadata.dart index 5860e0d6..22c09458 100644 --- a/lib/services/metadata/metadata.dart +++ b/lib/services/metadata/metadata.dart @@ -50,6 +50,7 @@ class MetadataPlugin { sharedPreferences, config.slug, ), + createYoutubeEngine: () => throw UnimplementedError(), onNavigatorPush: (route) { return rootNavigatorKey.currentContext?.router .pushWidget(Builder(builder: (context) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 63e83265..8f5a71fe 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -12,13 +12,11 @@ #include #include #include -#include #include #include #include #include #include -#include #include #include #include @@ -43,9 +41,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); - g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); - irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); g_autoptr(FlPluginRegistrar) local_notifier_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin"); local_notifier_plugin_register_with_registrar(local_notifier_registrar); @@ -61,9 +56,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); - g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); - super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 541826e6..e174b0ef 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -9,13 +9,11 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux flutter_timezone gtk - irondash_engine_context local_notifier media_kit_libs_linux open_file_linux screen_retriever_linux sqlite3_flutter_libs - super_native_extensions system_theme tray_manager url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d211f518..2931c1b4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -18,7 +18,6 @@ import flutter_inappwebview_macos import flutter_new_pipe_extractor import flutter_secure_storage_macos import flutter_timezone -import irondash_engine_context import local_notifier import media_kit_libs_macos_audio import open_file_mac @@ -28,7 +27,6 @@ import screen_retriever_macos import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs -import super_native_extensions import system_theme import tray_manager import url_launcher_macos @@ -48,7 +46,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterNewPipeExtractorPlugin.register(with: registry.registrar(forPlugin: "FlutterNewPipeExtractorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) - IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) @@ -58,7 +55,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) - SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7e53e91c..a67d7b2b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2863,4 +2863,4 @@ packages: version: "1.0.0" sdks: dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.1" + flutter: ">=3.35.1" \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index ac2fd1e0..95f52491 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -15,13 +15,11 @@ #include #include #include -#include #include #include #include #include #include -#include #include #include #include @@ -46,8 +44,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterTimezonePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); - IrondashEngineContextPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); LocalNotifierPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalNotifierPlugin")); MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar( @@ -58,8 +54,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); - SuperNativeExtensionsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); SystemThemePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemThemePlugin")); TrayManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 53cd3667..abbe763a 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -12,13 +12,11 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_new_pipe_extractor flutter_secure_storage_windows flutter_timezone - irondash_engine_context local_notifier media_kit_libs_windows_audio permission_handler_windows screen_retriever_windows sqlite3_flutter_libs - super_native_extensions system_theme tray_manager url_launcher_windows