diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 47fff5af..e7193acb 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1014,6 +1014,7 @@ "itemAdded": "Added to playlist", "itemRemoved": "Removed from playlist", "selectPlaylist": "Select Playlist", + "searchPlaylists": "Search playlists...", "errorCreating": "Failed to create playlist", "errorDeleting": "Failed to delete playlist", "errorLoading": "Failed to load playlists", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index ab69baae..6e94d0ee 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 22707 (1419 per locale) +/// Strings: 22708 (1419 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 56a04568..7ab6ebed 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2928,6 +2928,9 @@ class TranslationsPlaylistsEn { /// en: 'Select Playlist' String get selectPlaylist => 'Select Playlist'; + /// en: 'Search playlists...' + String get searchPlaylists => 'Search playlists...'; + /// en: 'Failed to create playlist' String get errorCreating => 'Failed to create playlist'; @@ -5897,6 +5900,7 @@ extension on Translations { 'playlists.itemAdded' => 'Added to playlist', 'playlists.itemRemoved' => 'Removed from playlist', 'playlists.selectPlaylist' => 'Select Playlist', + 'playlists.searchPlaylists' => 'Search playlists...', 'playlists.errorCreating' => 'Failed to create playlist', 'playlists.errorDeleting' => 'Failed to delete playlist', 'playlists.errorLoading' => 'Failed to load playlists', @@ -5991,9 +5995,9 @@ extension on Translations { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', - 'downloads.movies' => 'Movies', _ => null, } ?? switch (path) { + 'downloads.movies' => 'Movies', 'downloads.music' => 'Music', 'downloads.tracksQueued' => ({required Object count}) => '${count} tracks queued for download', 'downloads.noDownloads' => 'No downloads yet', diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 0847254a..1df0133a 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -11,11 +11,11 @@ import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; +import '../media/library_query.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; import '../metadata_edit/metadata_edit_adapters.dart'; import '../media/media_version.dart'; -import '../mixins/controller_disposer_mixin.dart'; import '../services/plex_client.dart'; import '../services/media_list_playback_launcher.dart'; import '../services/music/music_playback_service.dart'; @@ -45,6 +45,7 @@ import '../utils/dialogs.dart'; import '../services/external_player_service.dart'; import '../focus/focusable_button.dart'; import '../focus/focusable_text_field.dart'; +import '../focus/key_event_utils.dart'; import '../screens/plex_match_screen.dart'; import '../screens/media_detail_screen.dart'; import '../screens/metadata_edit_screen.dart'; @@ -1762,172 +1763,50 @@ class MediaContextMenuState extends State { } } -/// Dialog to select a playlist or create a new one. -class _PlaylistSelectionDialog extends StatefulWidget { - final MediaServerClient client; +typedef _PickerPageLoader = Future> Function(int start, int size, AbortController abort); - const _PlaylistSelectionDialog({required this.client}); +typedef _PickerItemBuilder = Widget Function(BuildContext context, T item); + +/// Shared loading, filtering, and TV focus shell for collection-style pickers. +class _PickerDialogScaffold extends StatefulWidget { + final String title; + final String searchHint; + final String emptyMessage; + final _PickerPageLoader loadPage; + final String Function(T item) itemTitle; + final _PickerItemBuilder itemBuilder; + + const _PickerDialogScaffold({ + required this.title, + required this.searchHint, + required this.emptyMessage, + required this.loadPage, + required this.itemTitle, + required this.itemBuilder, + }); @override - State<_PlaylistSelectionDialog> createState() => _PlaylistSelectionDialogState(); + State<_PickerDialogScaffold> createState() => _PickerDialogScaffoldState(); } -class _PlaylistSelectionDialogState extends State<_PlaylistSelectionDialog> { +class _PickerDialogScaffoldState extends State<_PickerDialogScaffold> { static const int _pageSize = 100; + static const int _filterThreshold = 10; - final AbortController _abortController = AbortController(); - final ScrollController _scrollController = ScrollController(); - final List _playlists = []; - bool _isLoading = false; - String? _errorMessage; - int? _totalCount; - - bool get _hasMore => _totalCount == null || _playlists.length < _totalCount!; - - @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); - unawaited(_loadNextPage()); - } - - @override - void dispose() { - _abortController.abort(); - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - if (!_scrollController.hasClients || !_hasMore || _isLoading) return; - final position = _scrollController.position; - if (position.pixels >= position.maxScrollExtent - 240) { - unawaited(_loadNextPage()); - } - } - - Future _loadNextPage() async { - if (_isLoading || !_hasMore) return; - setState(() { - _isLoading = true; - _errorMessage = null; - }); - try { - final page = await widget.client.fetchPlaylistsPage( - playlistType: 'video', - smart: false, - start: _playlists.length, - size: _pageSize, - abort: _abortController, - ); - if (!mounted) return; - setState(() { - _playlists.addAll(page.items); - _totalCount = page.totalCount; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _errorMessage = e.toString(); - _isLoading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(t.playlists.selectPlaylist), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - controller: _scrollController, - shrinkWrap: true, - itemCount: _playlists.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0), - itemBuilder: (context, index) { - if (index == 0) { - // Create new playlist option (always shown first) - return ListTile( - leading: const AppIcon(Symbols.add_rounded, fill: 1), - title: Text(t.common.createNew), - onTap: () => Navigator.pop(context, '_create_new'), - ); - } - - if (index > _playlists.length) { - if (_errorMessage != null) { - return ListTile( - leading: const AppIcon(Symbols.error_rounded, fill: 1), - title: Text(t.messages.errorLoading(error: _errorMessage!)), - trailing: TextButton(onPressed: _loadNextPage, child: Text(t.common.retry)), - ); - } - if (_hasMore && !_isLoading) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_loadNextPage()); - }); - } - return const Padding( - padding: .all(16), - child: Center(child: CircularProgressIndicator()), - ); - } - - final playlist = _playlists[index - 1]; - final leafCount = playlist.leafCount; - final subtitleText = leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: leafCount ?? 0); - return ListTile( - leading: playlist.smart - ? const AppIcon(Symbols.auto_awesome_rounded, fill: 1) - : const AppIcon(Symbols.playlist_play_rounded, fill: 1), - title: Text(playlist.title), - subtitle: playlist.leafCount != null ? Text(subtitleText) : null, - onTap: playlist.smart - ? null // Disable smart playlists - : () => Navigator.pop(context, playlist.id), - enabled: !playlist.smart, - ); - }, - ), - ), - actions: [ - FocusableButton( - autofocus: true, - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - ], - ); - } -} - -/// Dialog to select a collection or create a new one -class _CollectionSelectionDialog extends StatefulWidget { - final MediaServerClient client; - final String libraryId; - - const _CollectionSelectionDialog({required this.client, required this.libraryId}); - - @override - State<_CollectionSelectionDialog> createState() => _CollectionSelectionDialogState(); -} - -class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin { - static const int _pageSize = 100; - - late final _filterController = createTextEditingController(); - final _filterFocusNode = FocusNode(debugLabel: 'CollectionFilter'); - final _firstCollectionFocusNode = FocusNode(debugLabel: 'CollectionFirstItem'); - final AbortController _abortController = AbortController(); + final _filterController = TextEditingController(); + final _filterFocusNode = FocusNode(debugLabel: 'PickerFilter'); + final _firstItemFocusNode = FocusNode(debugLabel: 'PickerFirstItem'); + final _abortController = AbortController(); final _scrollController = ScrollController(); - final List _collections = []; - List _filteredCollections = []; + final List _items = []; + List _filteredItems = []; bool _isLoading = false; + bool _initialFocusRequested = false; String? _errorMessage; int? _totalCount; - bool get _hasMore => _totalCount == null || _collections.length < _totalCount!; + bool get _hasMore => _totalCount == null || _items.length < _totalCount!; + bool get _showFilter => _items.length >= _filterThreshold; @override void initState() { @@ -1940,8 +1819,9 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> void dispose() { _abortController.abort(); _scrollController.dispose(); + _filterController.dispose(); _filterFocusNode.dispose(); - _firstCollectionFocusNode.dispose(); + _firstItemFocusNode.dispose(); super.dispose(); } @@ -1961,24 +1841,17 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> }); try { while (mounted && _hasMore) { - final page = await widget.client.fetchCollectionsPage( - widget.libraryId, - start: _collections.length, - size: _pageSize, - abort: _abortController, - ); + final page = await widget.loadPage(_items.length, _pageSize, _abortController); if (!mounted) return; setState(() { - _collections.addAll(page.items); + _items.addAll(page.items); _totalCount = page.totalCount; _applyFilter(_filterController.text); }); if (_filterController.text.isEmpty || page.items.isEmpty) break; } if (!mounted) return; - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); } catch (e) { if (!mounted) return; setState(() { @@ -1986,12 +1859,20 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> _isLoading = false; }); } + _requestInitialFocus(); + } + + void _requestInitialFocus() { + if (_initialFocusRequested) return; + _initialFocusRequested = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + (_showFilter ? _filterFocusNode : _firstItemFocusNode).requestFocus(); + }); } void _onFilterChanged(String query) { - setState(() { - _applyFilter(query); - }); + setState(() => _applyFilter(query)); if (query.isNotEmpty && _hasMore) { unawaited(_loadNextPage()); } @@ -1999,52 +1880,57 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> void _applyFilter(String query) { final lower = query.toLowerCase(); - _filteredCollections = lower.isEmpty - ? List.of(_collections) - : _collections.where((c) => (c.title ?? '').toLowerCase().contains(lower)).toList(); + _filteredItems = lower.isEmpty + ? List.of(_items) + : _items.where((item) => widget.itemTitle(item).toLowerCase().contains(lower)).toList(); } @override Widget build(BuildContext context) { - return AlertDialog( - title: Text(t.collections.selectCollection), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: .min, - children: [ - if (_collections.length >= 10) ...[ - FocusableTextField( - controller: _filterController, - focusNode: _filterFocusNode, - autofocus: true, - onNavigateDown: _firstCollectionFocusNode.requestFocus, - decoration: pillInputDecoration( - context, - hintText: t.collections.searchCollections, - prefixIcon: const Icon(Symbols.search_rounded, size: 20), + final showStatus = _hasMore || _isLoading || _errorMessage != null || _filteredItems.isEmpty; + return Focus( + onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), + child: AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: .min, + children: [ + if (_showFilter) ...[ + FocusableTextField( + controller: _filterController, + focusNode: _filterFocusNode, + tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + onNavigateDown: _firstItemFocusNode.requestFocus, + decoration: pillInputDecoration( + context, + hintText: widget.searchHint, + prefixIcon: const Icon(Symbols.search_rounded, size: 20), + ), + onChanged: _onFilterChanged, ), - onChanged: _onFilterChanged, - ), - const SizedBox(height: 8), - ], - Flexible( - child: ListView.builder( - controller: _scrollController, - shrinkWrap: true, - itemCount: _filteredCollections.length + 1 + (_hasMore || _isLoading || _errorMessage != null ? 1 : 0), - itemBuilder: (context, index) { - if (index == 0) { - return FocusableListTile( - focusNode: _firstCollectionFocusNode, - autofocus: _collections.length < 10, - leading: const AppIcon(Symbols.add_rounded, fill: 1), - title: Text(t.common.createNew), - onTap: () => Navigator.pop(context, '_create_new'), - ); - } + const SizedBox(height: 8), + ], + Flexible( + child: ListView.builder( + controller: _scrollController, + shrinkWrap: true, + itemCount: _filteredItems.length + 1 + (showStatus ? 1 : 0), + itemBuilder: (context, index) { + if (index == 0) { + return FocusableListTile( + focusNode: _firstItemFocusNode, + leading: const AppIcon(Symbols.add_rounded, fill: 1), + title: Text(t.common.createNew), + onTap: () => Navigator.pop(context, '_create_new'), + ); + } + + if (index <= _filteredItems.length) { + return widget.itemBuilder(context, _filteredItems[index - 1]); + } - if (index > _filteredCollections.length) { if (_errorMessage != null) { return FocusableListTile( leading: const AppIcon(Symbols.error_rounded, fill: 1), @@ -2052,38 +1938,93 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> onTap: _loadNextPage, ); } - if (_hasMore && !_isLoading) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_loadNextPage()); - }); + if (_hasMore || _isLoading) { + if (_hasMore && !_isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_loadNextPage()); + }); + } + return const Padding( + padding: .all(16), + child: Center(child: CircularProgressIndicator()), + ); } - return const Padding( - padding: .all(16), - child: Center(child: CircularProgressIndicator()), + return Padding( + padding: const .all(16), + child: Text(widget.emptyMessage, textAlign: TextAlign.center), ); - } - - final collection = _filteredCollections[index - 1]; - return FocusableListTile( - leading: const AppIcon(Symbols.collections_rounded, fill: 1), - title: Text(collection.title ?? ''), - subtitle: collection.childCount != null - ? Text(t.playlists.itemCount(count: collection.childCount!)) - : null, - onTap: () => Navigator.pop(context, collection.id), - ); - }, + }, + ), ), - ), - ], + ], + ), ), + actions: [ + FocusableButton( + onPressed: () => Navigator.pop(context), + child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + ), + ], + ), + ); + } +} + +/// Dialog to select a playlist or create a new one. +class _PlaylistSelectionDialog extends StatelessWidget { + final MediaServerClient client; + + const _PlaylistSelectionDialog({required this.client}); + + @override + Widget build(BuildContext context) { + return _PickerDialogScaffold( + title: t.playlists.selectPlaylist, + searchHint: t.playlists.searchPlaylists, + emptyMessage: t.playlists.noPlaylists, + loadPage: (start, size, abort) => + client.fetchPlaylistsPage(playlistType: 'video', smart: false, start: start, size: size, abort: abort), + itemTitle: (playlist) => playlist.title, + itemBuilder: (context, playlist) { + final leafCount = playlist.leafCount; + final subtitleText = leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: leafCount ?? 0); + return FocusableListTile( + leading: playlist.smart + ? const AppIcon(Symbols.auto_awesome_rounded, fill: 1) + : const AppIcon(Symbols.playlist_play_rounded, fill: 1), + title: Text(playlist.title), + subtitle: playlist.leafCount != null ? Text(subtitleText) : null, + onTap: playlist.smart + ? null // Disable smart playlists + : () => Navigator.pop(context, playlist.id), + enabled: !playlist.smart, + ); + }, + ); + } +} + +/// Dialog to select a collection or create a new one +class _CollectionSelectionDialog extends StatelessWidget { + final MediaServerClient client; + final String libraryId; + + const _CollectionSelectionDialog({required this.client, required this.libraryId}); + + @override + Widget build(BuildContext context) { + return _PickerDialogScaffold( + title: t.collections.selectCollection, + searchHint: t.collections.searchCollections, + emptyMessage: t.libraries.noCollections, + loadPage: (start, size, abort) => client.fetchCollectionsPage(libraryId, start: start, size: size, abort: abort), + itemTitle: (collection) => collection.title ?? '', + itemBuilder: (context, collection) => FocusableListTile( + leading: const AppIcon(Symbols.collections_rounded, fill: 1), + title: Text(collection.title ?? ''), + subtitle: collection.childCount != null ? Text(t.playlists.itemCount(count: collection.childCount!)) : null, + onTap: () => Navigator.pop(context, collection.id), ), - actions: [ - FocusableButton( - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - ], ); } } diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index eea112ac..719004b9 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -17,6 +20,7 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/metadata_edit/metadata_edit_adapters.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; @@ -27,6 +31,8 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -256,9 +262,157 @@ void main() { expect(find.byType(SnackBar), findsOneWidget); expect(find.text('target'), findsOneWidget); }); + + testWidgets('playlist picker filters playlists by title', (tester) async { + final playlists = [ + for (var i = 0; i < 10; i++) (id: '$i', title: 'Alpha $i'), + (id: 'gamma', title: 'Gamma Nights'), + ]; + final menuKey = await _pumpPlexMovieMenu(tester, playlists); + + await _openPlaylistPicker(tester, menuKey); + final textField = tester.widget(find.byType(TextField)); + textField.controller!.text = 'gamma'; + textField.onChanged!('gamma'); + await tester.pumpAndSettle(); + + expect(find.text('Gamma Nights'), findsOneWidget); + expect(find.text('Alpha 0'), findsNothing); + expect(find.text(t.common.createNew), findsOneWidget); + }); + + testWidgets('playlist picker wires TV focus, D-pad down, and back', (tester) async { + final playlists = [for (var i = 0; i < 10; i++) (id: '$i', title: 'Playlist $i')]; + final menuKey = await _pumpPlexMovieMenu(tester, playlists); + + await _openPlaylistPicker(tester, menuKey); + + final textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode!.hasFocus, isTrue); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(Focus.of(tester.element(find.text(t.common.createNew))).hasFocus, isTrue); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text(t.playlists.selectPlaylist), findsNothing); + expect(find.text('picker target'), findsOneWidget); + }); }); } +Future> _pumpPlexMovieMenu( + WidgetTester tester, + List<({String id, String title})> playlists, +) async { + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1', + ), + serverId: ServerId('plex-1'), + httpClient: MockClient((request) async { + if (request.url.path != '/playlists') return http.Response('not found', 404); + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': playlists.length, + 'totalSize': playlists.length, + 'Metadata': [ + for (final playlist in playlists) + { + 'ratingKey': playlist.id, + 'key': '/playlists/${playlist.id}/items', + 'type': 'playlist', + 'playlistType': 'video', + 'title': playlist.title, + 'smart': false, + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: ProfileRegistry(db), + plexHome: plexHome, + connections: connections, + ); + addTearDown(() async { + activeProfileProvider.dispose(); + await plexHome.dispose(); + multiServerProvider.dispose(); + manager.dispose(); + await db.close(); + }); + + final menuKey = GlobalKey(); + final item = MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'plex-1', + ); + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: activeProfileProvider), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: Center( + child: MediaContextMenu( + key: menuKey, + item: item, + child: const SizedBox(width: 120, height: 80, child: Text('picker target')), + ), + ), + ), + ), + ), + ), + ); + return menuKey; +} + +Future _openPlaylistPicker(WidgetTester tester, GlobalKey menuKey) async { + menuKey.currentState!.showContextMenu(tester.element(find.text('picker target'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.common.addTo)); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.playlists.playlist)); + await tester.pumpAndSettle(); + expect(find.text(t.playlists.selectPlaylist), findsOneWidget); +} + class _AudioPlaylistClient implements MediaServerClient { final List tracks;