feat(playlists): add picker filtering and TV focus

This commit is contained in:
edde746
2026-07-12 08:42:24 +02:00
parent 2a128777f1
commit 80344a630f
5 changed files with 338 additions and 238 deletions
+1
View File
@@ -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",
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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',
+177 -236
View File
@@ -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<MediaContextMenu> {
}
}
/// Dialog to select a playlist or create a new one.
class _PlaylistSelectionDialog extends StatefulWidget {
final MediaServerClient client;
typedef _PickerPageLoader<T> = Future<LibraryPage<T>> Function(int start, int size, AbortController abort);
const _PlaylistSelectionDialog({required this.client});
typedef _PickerItemBuilder<T> = Widget Function(BuildContext context, T item);
/// Shared loading, filtering, and TV focus shell for collection-style pickers.
class _PickerDialogScaffold<T> extends StatefulWidget {
final String title;
final String searchHint;
final String emptyMessage;
final _PickerPageLoader<T> loadPage;
final String Function(T item) itemTitle;
final _PickerItemBuilder<T> 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<T>> createState() => _PickerDialogScaffoldState<T>();
}
class _PlaylistSelectionDialogState extends State<_PlaylistSelectionDialog> {
class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
static const int _pageSize = 100;
static const int _filterThreshold = 10;
final AbortController _abortController = AbortController();
final ScrollController _scrollController = ScrollController();
final List<MediaPlaylist> _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<void> _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<MediaItem> _collections = [];
List<MediaItem> _filteredCollections = [];
final List<T> _items = [];
List<T> _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<MediaPlaylist>(
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<MediaItem>(
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)),
),
],
);
}
}
+154
View File
@@ -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<TextField>(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<TextField>(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<GlobalKey<MediaContextMenuState>> _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<MediaContextMenuState>();
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<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.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<void> _openPlaylistPicker(WidgetTester tester, GlobalKey<MediaContextMenuState> 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<MediaItem> tracks;