feat(playlists): add picker filtering and TV focus
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user