fix(media): serialize browsing and metadata mutations

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent 43a8fe020d
commit f8bfecf57d
30 changed files with 3088 additions and 465 deletions
+41
View File
@@ -0,0 +1,41 @@
import 'dart:convert';
/// Profile-private Plex fields that must never cross an ownerless metadata
/// namespace. Plex may place these at any depth in a metadata response.
const plexProfilePrivateMetadataFields = {
'lastRatedAt',
'lastViewedAt',
'skipCount',
'userRating',
'viewCount',
'viewOffset',
'viewedLeafCount',
};
/// Returns an in-memory Plex payload safe to retain while a download has no
/// profile owner.
Map<String, dynamic> sanitizePlexMetadataMapForOwnerlessTransfer(Map<String, dynamic> source) {
Object? scrub(Object? value) {
if (value is Map<String, dynamic>) {
return <String, dynamic>{
for (final entry in value.entries)
if (!plexProfilePrivateMetadataFields.contains(entry.key)) entry.key: scrub(entry.value),
};
}
if (value is List) return <Object?>[for (final item in value) scrub(item)];
return value;
}
return scrub(source)! as Map<String, dynamic>;
}
/// Returns a persisted cache payload safe to retain while a legacy download
/// has no profile owner. Invalid/non-object payloads are rejected rather than
/// copied into the adoptable transfer namespace.
String sanitizePlexMetadataForOwnerlessTransfer(String source) {
final decoded = jsonDecode(source);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Invalid Plex metadata cache payload');
}
return jsonEncode(sanitizePlexMetadataMapForOwnerlessTransfer(decoded));
}
@@ -1,9 +1,11 @@
import '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.dart';
import '../utils/language_codes.dart';
import '../utils/media_image_helper.dart';
import 'metadata_edit_models.dart';
@@ -30,10 +32,26 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
fullItem = await client.fetchItem(item.id) ?? item;
}
late final Map<String, String> preferences;
if (fullItem.kind == MediaKind.episode) {
preferences = const {};
} else {
try {
preferences = await client.getMetadataPrefs(fullItem.id);
} catch (error, stackTrace) {
if (error is MediaServerHttpException && error.isCancellation) rethrow;
appLogger.w(
'Failed to load Plex metadata preferences; continuing without advanced values',
error: error,
stackTrace: stackTrace,
);
preferences = const {};
}
}
final values = <String, Object?>{};
_writeCommonValues(values, fullItem);
_writeArtworkValues(values, fullItem);
_writePrefValues(values, fullItem);
_writeAdvancedValues(values, fullItem.kind, preferences);
return MetadataEditDraft(sourceItem: item, currentItem: fullItem, values: values);
}
@@ -172,17 +190,27 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
values['artwork:squareArts'] = item.backgroundSquarePath;
}
void _writePrefValues(Map<String, Object?> values, MediaItem item) {
values['pref:episodeSort'] = '-1';
values['pref:autoDeletionItemPolicyUnwatchedLibrary'] = '0';
values['pref:autoDeletionItemPolicyWatchedLibrary'] = '0';
values['pref:flattenSeasons'] = '-1';
values['pref:showOrdering'] = '';
values['pref:languageOverride'] = '';
values['pref:useOriginalTitle'] = '-1';
values['pref:audioLanguage'] = item.audioLanguage ?? '';
values['pref:subtitleLanguage'] = item is PlexMediaItem ? item.subtitleLanguage ?? '' : '';
values['pref:subtitleMode'] = item is PlexMediaItem ? (item.subtitleMode?.toString() ?? '-1') : '-1';
void _writeAdvancedValues(Map<String, Object?> values, MediaKind kind, Map<String, String> preferences) {
void addPreference(String key) {
values['pref:$key'] = preferences[key];
}
if (kind == MediaKind.show) {
addPreference('episodeSort');
addPreference('autoDeletionItemPolicyUnwatchedLibrary');
addPreference('autoDeletionItemPolicyWatchedLibrary');
addPreference('flattenSeasons');
addPreference('showOrdering');
}
if (kind == MediaKind.show || kind == MediaKind.movie) {
addPreference('languageOverride');
addPreference('useOriginalTitle');
}
if (kind == MediaKind.show || kind == MediaKind.season) {
addPreference('audioLanguage');
addPreference('subtitleLanguage');
addPreference('subtitleMode');
}
}
List<MetadataEditField> _basicFields(MediaKind kind) {
@@ -0,0 +1,39 @@
import '../../utils/json_utils.dart';
/// Preference rows embedded in a Plex metadata response when
/// `includePreferences=1` is requested.
class PlexMetadataPreferences {
final Map<String, String> values;
const PlexMetadataPreferences._(this.values);
static const empty = PlexMetadataPreferences._(<String, String>{});
factory PlexMetadataPreferences.fromMediaContainer(Map<String, dynamic>? container) {
final metadata = firstFlexibleMap(container?['Metadata']);
if (metadata == null) return empty;
final preferences = firstFlexibleMap(metadata['Preferences']);
final settings = flexibleList(preferences?['Setting']) ?? flexibleList(metadata['Setting']) ?? const <dynamic>[];
final values = <String, String>{};
for (final setting in settings) {
if (setting is! Map<String, dynamic>) continue;
final rawId = setting['id'];
final id = rawId is String ? rawId.trim() : null;
final value = _preferenceValue(setting['value']);
if (id == null || id.isEmpty || value == null) continue;
values[id] = value;
}
return values.isEmpty ? empty : PlexMetadataPreferences._(Map.unmodifiable(values));
}
}
String? _preferenceValue(Object? value) => switch (value) {
final String value => value,
final num value => value.toString(),
final bool value => value ? '1' : '0',
_ => null,
};
+84 -19
View File
@@ -4,6 +4,8 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../focus/input_mode_tracker.dart';
import '../../media/media_filter.dart';
import 'state_messages.dart';
import '../../utils/app_logger.dart';
import '../../utils/scroll_utils.dart';
import '../../widgets/bottom_sheet_page_scaffold.dart';
import '../../widgets/focusable_list_tile.dart';
@@ -47,6 +49,8 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
MediaFilter? _currentFilter;
List<MediaFilterValue> _filterValues = [];
bool _isLoadingValues = false;
String? _filterValuesError;
int _filterValuesLoadGeneration = 0;
final Map<String, String> _tempSelectedFilters = {};
static final Map<String, String> _filterDisplayNames = {}; // Cache for display names
static const int _maxCachedDisplayNames = 1000;
@@ -65,8 +69,28 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
_initialFocusNode = FocusNode(debugLabel: 'FiltersBottomSheetInitialFocus');
}
@override
void didUpdateWidget(covariant FiltersBottomSheet oldWidget) {
super.didUpdateWidget(oldWidget);
final ownerChanged = oldWidget.serverId != widget.serverId || oldWidget.libraryKey != widget.libraryKey;
if (ownerChanged) {
_filterValuesLoadGeneration++;
_currentFilter = null;
_filterValues = [];
_isLoadingValues = false;
_filterValuesError = null;
_tempSelectedFilters
..clear()
..addAll(widget.selectedFilters);
}
if (ownerChanged || !identical(oldWidget.filters, widget.filters)) {
_sortFilters();
}
}
@override
void dispose() {
_filterValuesLoadGeneration++;
_valuesScrollController.dispose();
_initialFocusNode.dispose();
super.dispose();
@@ -86,52 +110,80 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
Future<void> _loadFilterValues(MediaFilter filter) async {
final generation = ++_filterValuesLoadGeneration;
final filterKey = filter.filter;
final serverId = widget.serverId;
final libraryKey = widget.libraryKey;
final cachedValues = widget.cachedValues;
final loader = widget.loadFilterValues;
setState(() {
_currentFilter = filter;
_filterValues = [];
_isLoadingValues = true;
_filterValuesError = null;
});
try {
// Cached path (Jellyfin) - `/Items/Filters` returned values inline.
final cached = widget.cachedValues?[filter.filter];
final values = cached ?? await widget.loadFilterValues(filter);
if (!mounted) return;
final cached = cachedValues?[filterKey];
final values = cached ?? await loader(filter);
if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return;
final selectedValue = _tempSelectedFilters[filterKey];
final selectedIndex = selectedValue == null
? -1
: values.indexWhere((value) => _extractFilterValue(value.key, filterKey) == selectedValue);
setState(() {
_filterValues = values;
_isLoadingValues = false;
});
_requestInitialFocus();
// Scroll to selected value if any
final selectedValue = _tempSelectedFilters[filter.filter];
if (selectedValue != null) {
// +1 because index 0 is the "All" row
final idx = values.indexWhere((v) => _extractFilterValue(v.key, filter.filter) == selectedValue) + 1;
if (idx > 0) {
scrollToCurrentItem(_valuesScrollController, _valuesFirstItemKey, idx);
}
_requestInitialFocus(generation, serverId, libraryKey, filterKey);
if (selectedIndex >= 0) {
// +1 because index 0 is the "All" row.
scrollToCurrentItem(
_valuesScrollController,
_valuesFirstItemKey,
selectedIndex + 1,
isCurrent: () => _isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey),
);
}
} catch (e) {
if (!mounted) return;
} catch (e, stackTrace) {
if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return;
appLogger.w('Failed to load values for filter $filterKey', error: e, stackTrace: stackTrace);
setState(() {
_filterValues = [];
_isLoadingValues = false;
_filterValuesError = t.errors.unableToLoad(context: filter.title);
});
_requestInitialFocus();
_requestInitialFocus(generation, serverId, libraryKey, filterKey);
}
}
bool _isCurrentFilterValuesLoad(int generation, String serverId, String libraryKey, String? filterKey) {
return mounted &&
generation == _filterValuesLoadGeneration &&
widget.serverId == serverId &&
widget.libraryKey == libraryKey &&
_currentFilter?.filter == filterKey;
}
void _goBack() {
final generation = ++_filterValuesLoadGeneration;
final serverId = widget.serverId;
final libraryKey = widget.libraryKey;
setState(() {
_currentFilter = null;
_filterValues = [];
_isLoadingValues = false;
_filterValuesError = null;
});
_requestInitialFocus();
_requestInitialFocus(generation, serverId, libraryKey, null);
}
void _requestInitialFocus() {
void _requestInitialFocus(int generation, String serverId, String libraryKey, String? filterKey) {
if (!InputModeTracker.isKeyboardMode(context)) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return;
if (_initialFocusNode.context != null) {
_initialFocusNode.requestFocus();
} else {
@@ -141,6 +193,7 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
void _clearFilters() {
_filterValuesLoadGeneration++;
setState(() {
_tempSelectedFilters.clear();
});
@@ -148,7 +201,8 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
void _applyFilters() {
widget.onFiltersChanged(_tempSelectedFilters);
_filterValuesLoadGeneration++;
widget.onFiltersChanged(Map<String, String>.of(_tempSelectedFilters));
OverlaySheetController.of(context).close();
}
@@ -186,6 +240,17 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
}
Widget _buildFilterValuesView(MediaFilter filter) {
final error = _filterValuesError;
if (error != null) {
return ErrorStateWidget(
message: error,
onRetry: () => _loadFilterValues(filter),
actionFocusNode: _initialFocusNode,
onActionBack: _goBack,
actionAutofocus: InputModeTracker.isKeyboardMode(context),
actionUseBackgroundFocus: true,
);
}
if (_isLoadingValues) {
return Focus(
autofocus: InputModeTracker.isKeyboardMode(context),
+3 -2
View File
@@ -485,14 +485,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Save selected library key and restore saved tab (async — safe after state is consistent)
final storage = await StorageService.getInstance();
if (!mounted) return;
if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return;
await storage.saveSelectedLibraryKey(libraryGlobalKey);
if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return;
// Restore saved tab by name
final savedTabName = storage.getLibraryTab(libraryGlobalKey);
final savedType = LibraryTabType.values.where((t) => t.name == savedTabName).firstOrNull;
final targetTabIndex = savedType != null ? _visibleTabs.indexOf(savedType) : -1;
if (targetTabIndex > 0) {
if (targetTabIndex >= 0 && targetTabIndex != tabController.index) {
// Set flag to prevent _onTabChanged from triggering focus
_isRestoringTab = true;
// Use animateTo with zero duration for instant switch without animation race conditions
@@ -74,6 +74,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _refreshSubscription;
int _loadGeneration = 0;
// Focus management
bool _hasLoadedData = false;
@@ -97,6 +98,22 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
@protected
set hasLoadedData(bool value) => _hasLoadedData = value;
@protected
int get libraryLoadGeneration => _loadGeneration;
@protected
int beginLibraryLoad() => ++_loadGeneration;
@protected
void invalidateLibraryLoad() {
_loadGeneration++;
}
@protected
bool isCurrentLibraryLoad(int generation, String libraryGlobalKey) {
return mounted && generation == _loadGeneration && widget.library.globalKey == libraryGlobalKey;
}
@override
void initState() {
super.initState();
@@ -115,6 +132,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
@override
void dispose() {
invalidateLibraryLoad();
_refreshSubscription?.cancel();
super.dispose();
}
@@ -124,6 +142,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
invalidateLibraryLoad();
// Reset focus state for new library
hasFocused = false;
_hasFocusedChromeFallback = false;
@@ -173,12 +192,14 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
if (!widget.isActive || !_hasLoadedData) return;
final loadGeneration = _loadGeneration;
final libraryGlobalKey = widget.library.globalKey;
if (hasFocusableContent) {
_hasFocusedChromeFallback = false;
if (hasFocused) return;
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
focusFirstItem();
}
});
@@ -188,7 +209,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
if (!_hasFocusedChromeFallback) {
_hasFocusedChromeFallback = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
focusEmptyState();
}
});
@@ -220,6 +241,10 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
/// Load items with error handling and state management
Future<void> loadItems() async {
if (!mounted) return;
final loadGeneration = beginLibraryLoad();
final libraryGlobalKey = widget.library.globalKey;
setState(() {
_isLoading = true;
_errorMessage = null;
@@ -228,27 +253,28 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
try {
final loadedItems = await loadData();
if (!mounted) return;
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
setState(() {
_items = loadedItems;
_isLoading = false;
_hasLoadedData = true;
});
// Mark data as loaded and try to focus
_hasLoadedData = true;
tryFocus();
// Notify parent that data has loaded
if (widget.onDataLoaded != null) {
final onDataLoaded = widget.onDataLoaded;
if (onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
onDataLoaded();
}
});
}
} catch (e, stackTrace) {
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext);
if (!mounted) return;
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
setState(() {
_errorMessage = message;
@@ -9,6 +9,7 @@ import '../../../media/library_query.dart';
import '../../../media/media_backend.dart';
import '../../../media/media_item.dart';
import '../../../media/media_kind.dart';
import '../../../media/media_library.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../focus/dpad_navigator.dart';
@@ -279,10 +280,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
DateTime? _lastAlphaUpdate;
Timer? _alphaUpdateTimer;
/// Generation counter for the filter/sort loading phase of [_loadContent].
/// Separate from the mixin's pagination generation so a filter reload can
/// invalidate in-flight filter/sort fetches without touching item pagination.
int _contentRequestId = 0;
int _firstCharactersRequestId = 0;
static const int _fetchSize = 200;
static const int _jellyfinFetchSize = 72;
@@ -323,8 +320,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final normalized = _normalizeGrouping(_selectedGrouping);
if (normalized != _selectedGrouping) {
_selectedGrouping = normalized;
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
unawaited(_loadItems());
unawaited(_loadFirstCharacters());
});
@@ -385,9 +384,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// from interfering with TabBarView page animations
if (!InputModeTracker.isKeyboardMode(context)) return;
if (widget.isActive && hasLoadedData && !hasFocused) {
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusContentOrChrome();
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
focusContentOrChrome();
}
});
}
}
@@ -426,10 +429,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Focus the first item in the grid/list/folder tree (for tab activation)
@override
void focusFirstItem() {
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
// In folder mode, items list is empty — focus the first folder tree item directly
if (_selectedGrouping == 'folders') {
void request() {
if (mounted && !firstItemFocusNode.hasFocus) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey) && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -443,7 +449,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt.
void request() {
if (mounted && (loadedItems.isNotEmpty || _hasFocusableStateAction) && !firstItemFocusNode.hasFocus) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey) &&
(loadedItems.isNotEmpty || _hasFocusableStateAction) &&
!firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -524,11 +532,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
Future<void> _loadContent() async {
final generation = ++_contentRequestId;
if (!mounted) return;
final library = widget.library;
final libraryGlobalKey = library.globalKey;
final generation = beginLibraryLoad();
final firstCharactersGeneration = ++_firstCharactersRequestId;
_resetForFullReload();
_resetTopOfPageState();
_currentFirstVisibleIndex.value = 0;
@@ -536,34 +546,35 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// `/sorts`; Jellyfin maps `/Items/Filters` into the same shape with
// values pre-cached and a hardcoded client-side sort list. Both flow
// through the unified [MediaServerClient.fetchLibraryFiltersWithValues].
try {
final client = context.getMediaClientForLibrary(widget.library);
final client = context.getMediaClientForLibrary(library);
final loader = LibraryFilterSortLoader(clientFor: (_) => client);
final storage = await StorageService.getInstance();
final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey);
final savedSort = storage.getLibrarySort(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKey);
final savedSort = storage.getLibrarySort(libraryGlobalKey);
final savedGrouping = storage.getLibraryGrouping(libraryGlobalKey);
// Resolve the restored grouping before the sort fetch — music groupings
// (albums/tracks) request their own per-type sort list.
final restoredGrouping = _normalizeGrouping(savedGrouping);
final sortLibraryType = _sortOptionsLibraryType(restoredGrouping);
final LoadedFiltersAndSorts loaded;
if (_isJellyfinLibrary) {
if (library.backend == MediaBackend.jellyfin) {
// `/Items/Filters` can be much slower than the paged `/Items` browse
// request on large Jellyfin libraries. Load only the local sort list
// before page 1, then fill filter values in the background.
final sorts = await client.fetchSortOptions(widget.library.id, libraryType: sortLibraryType);
final sorts = await client.fetchSortOptions(library.id, libraryType: sortLibraryType);
loaded = LoadedFiltersAndSorts(filters: const [], sorts: sorts);
} else {
// Plex filters+sorts must resolve before items so saved-sort restoration
// can match a saved key against the just-loaded sort list, and so the
// first item fetch already includes the restored sort param.
loaded = await loader.load(widget.library, sortLibraryType: sortLibraryType);
loaded = await loader.load(library, sortLibraryType: sortLibraryType);
}
if (generation != _contentRequestId || !mounted) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
setState(() {
_filters = loaded.filters;
_sortOptions = loaded.sorts;
@@ -587,16 +598,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
});
_notifyFiltersActive();
if (_isJellyfinLibrary) {
_loadJellyfinFiltersInBackground(generation);
if (library.backend == MediaBackend.jellyfin) {
_loadJellyfinFiltersInBackground(generation, libraryGlobalKey, library);
}
// Load items and first characters in parallel
// _loadItems manages its own requestId internally
await Future.wait([_loadItems(), _loadFirstCharacters(requestId: firstCharactersGeneration)]);
// Load items and first characters in parallel.
await Future.wait([
_loadItems(loadGeneration: generation, libraryGlobalKey: libraryGlobalKey),
_loadFirstCharacters(requestId: firstCharactersGeneration),
]);
} catch (e, stackTrace) {
if (!mounted) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
setState(() {
errorMessage = message;
isLoading = false;
@@ -604,20 +618,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
}
void _loadJellyfinFiltersInBackground(int generation) {
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
void _loadJellyfinFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
final client = context.tryGetMediaClientForServer(serverIdOrNull(library.serverId));
if (client == null) return;
unawaited(
client
.fetchLibraryFiltersWithValues(widget.library.id, libraryKind: widget.library.kind)
.fetchLibraryFiltersWithValues(library.id, libraryKind: library.kind)
.then((result) {
if (generation != _contentRequestId || !mounted) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
setState(() {
_filters = result.filters;
_jellyfinFilterValues = result.cachedValues;
});
})
.catchError((Object e, StackTrace st) {
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
appLogger.w('Jellyfin library filters failed; browse content remains available', error: e, stackTrace: st);
}),
);
@@ -630,8 +645,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final cb = widget.onFiltersActiveChanged;
if (cb == null) return;
final active = _selectedFilters.isNotEmpty;
final loadGeneration = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) cb(active);
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) cb(active);
});
}
@@ -690,8 +707,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return filterParams;
}
Future<void> _loadItems({bool preserveFocus = false}) async {
final generation = _contentRequestId;
Future<void> _loadItems({bool preserveFocus = false, int? loadGeneration, String? libraryGlobalKey}) async {
final generation = loadGeneration ?? libraryLoadGeneration;
final acceptedLibraryGlobalKey = libraryGlobalKey ?? widget.library.globalKey;
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
setState(() {
isLoading = true;
items = [];
@@ -707,7 +726,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
try {
final initialPage = await loadInitialPageWithStatus(_calculateInitialFetchSize());
if (!initialPage.applied || generation != _contentRequestId || !mounted) return;
if (!initialPage.applied || !isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
setState(() {
isLoading = false;
});
@@ -717,15 +736,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
tryFocus();
}
// Notify parent
if (!preserveFocus && widget.onDataLoaded != null) {
// Notify parent after the accepted library remains current for a frame.
final onDataLoaded = widget.onDataLoaded;
if (!preserveFocus && onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
if (isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) {
onDataLoaded();
}
});
}
} catch (e, stackTrace) {
if (generation != _contentRequestId || !mounted) return;
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
setState(() {
errorMessage = message;
isLoading = false;
@@ -961,16 +984,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// load items. Only called when the grouping switch changed the sort type
/// (artist/album/track on music libraries).
Future<void> _reloadSortOptionsForGrouping() async {
final generation = _contentRequestId;
final generation = libraryLoadGeneration;
final library = widget.library;
final libraryGlobalKey = library.globalKey;
final grouping = _selectedGrouping;
var sorts = const <MediaSort>[];
try {
final client = context.getMediaClientForLibrary(widget.library);
sorts = await client.fetchSortOptions(widget.library.id, libraryType: _sortOptionsLibraryType(grouping));
final client = context.getMediaClientForLibrary(library);
sorts = await client.fetchSortOptions(library.id, libraryType: _sortOptionsLibraryType(grouping));
} catch (e, st) {
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
appLogger.w('Failed to load sort options for grouping $grouping', error: e, stackTrace: st);
}
if (!mounted || generation != _contentRequestId || grouping != _selectedGrouping) return;
if (!isCurrentLibraryLoad(generation, libraryGlobalKey) || grouping != _selectedGrouping) return;
setState(() {
_sortOptions = sorts;
if (_selectedSort != null && sorts.every((s) => s.key != _selectedSort!.key)) {
+29 -15
View File
@@ -113,6 +113,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
List<LiveTvProgram> _programs = [];
Set<String> _scheduledRecordingKeys = const {};
bool _isLoading = true;
int _programLoadGeneration = 0;
late DateTime _gridStart;
late DateTime _gridEnd;
@@ -238,6 +239,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
@override
void dispose() {
_programLoadGeneration++;
WidgetsBinding.instance.removeObserver(this);
_programSelectController.dispose();
_guideFocusNode.dispose();
@@ -327,13 +329,22 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
}
}
bool _isCurrentProgramLoad(int generation) => mounted && generation == _programLoadGeneration;
Future<void> _loadPrograms() async {
if (!mounted) return;
final loadGeneration = ++_programLoadGeneration;
final requestGridStart = _gridStart;
final requestGridEnd = _gridEnd;
final startEpoch = requestGridStart.millisecondsSinceEpoch ~/ 1000;
final endEpoch = requestGridEnd.millisecondsSinceEpoch ~/ 1000;
final from = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true);
final to = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true);
setState(() => _isLoading = true);
try {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final liveTvServers = List<LiveTvServerInfo>.of(multiServer.liveTvServers);
final allPrograms = <LiveTvProgram>[];
final scheduledRecordingKeys = <String>{};
final queriedServers = <String>{};
@@ -344,25 +355,23 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (genericClient == null) continue;
final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000;
final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000;
final fromDt = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true);
final toDt = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true);
final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt);
final programs = await genericClient.liveTv.fetchSchedule(from: from, to: to);
if (!_isCurrentProgramLoad(loadGeneration)) return;
allPrograms.addAll(programs);
await _addScheduledRecordingKeysForServer(
client: genericClient,
serverId: ServerId(serverInfo.serverId),
keys: scheduledRecordingKeys,
isCurrent: () => _isCurrentProgramLoad(loadGeneration),
);
if (!_isCurrentProgramLoad(loadGeneration)) return;
} catch (e) {
if (!_isCurrentProgramLoad(loadGeneration)) return;
appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e);
}
}
if (!mounted) return;
if (!_isCurrentProgramLoad(loadGeneration)) return;
final shouldFocus = _pendingFocus;
setState(() {
@@ -379,18 +388,17 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
}
});
_scrollToNow();
_scrollToNow(loadGeneration: loadGeneration);
if (shouldFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusContent();
if (_isCurrentProgramLoad(loadGeneration)) focusContent();
});
}
} catch (e) {
if (!_isCurrentProgramLoad(loadGeneration)) return;
appLogger.e('Failed to load guide programs', error: e);
if (mounted) {
setState(() => _isLoading = false);
}
setState(() => _isLoading = false);
}
}
@@ -419,26 +427,31 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
required MediaServerClient client,
required ServerId serverId,
required Set<String> keys,
bool Function()? isCurrent,
}) async {
final dvr = client.liveTvDvr;
if (dvr == null) return;
try {
final grabs = await dvr.fetchScheduledRecordings();
if (isCurrent?.call() == false) return;
for (final grab in grabs) {
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
}
} catch (e) {
if (isCurrent?.call() == false) return;
appLogger.d('Failed to load scheduled recordings for $serverId', error: e);
}
try {
final rules = await dvr.fetchRecordingRules(includeGrabs: true, includeStorage: false);
if (isCurrent?.call() == false) return;
for (final rule in rules) {
for (final grab in rule.grabOperations) {
_addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys);
}
}
} catch (e) {
if (isCurrent?.call() == false) return;
appLogger.d('Failed to load active recording grabs for $serverId', error: e);
}
}
@@ -552,8 +565,9 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
return channelIndex * _rowHeight;
}
void _scrollToNow() {
void _scrollToNow({int? loadGeneration}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || (loadGeneration != null && !_isCurrentProgramLoad(loadGeneration))) return;
final now = DateTime.now();
final minutesSinceStart = now.difference(_gridStart).inMinutes;
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
+43 -5
View File
@@ -68,13 +68,16 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
bool _refreshRequested = true;
bool _tickerEnabled = false;
bool _appRefreshActive = true;
int _loadGeneration = 0;
Future<void>? _loadFuture;
bool _loadPending = false;
final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile');
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_load();
unawaited(_load());
}
@override
@@ -104,6 +107,7 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
@override
void dispose() {
_loadGeneration++;
WidgetsBinding.instance.removeObserver(this);
_refreshTimer?.cancel();
_firstTileFocusNode.dispose();
@@ -142,8 +146,39 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
/// Public reload helper for the parent screen's refresh action.
Future<void> reload() => _load();
Future<void> _load() async {
bool _isCurrentLoad(int generation) => mounted && generation == _loadGeneration;
Future<void> _load() {
if (!mounted) return Future.value();
final inFlight = _loadFuture;
if (inFlight != null) {
_loadPending = true;
return inFlight;
}
final completer = Completer<void>();
_loadFuture = completer.future;
unawaited(_drainLoads(completer));
return completer.future;
}
Future<void> _drainLoads(Completer<void> completer) async {
try {
do {
_loadPending = false;
await _loadOnce();
} while (mounted && _loadPending);
completer.complete();
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} finally {
_loadFuture = null;
}
}
Future<void> _loadOnce() async {
if (!mounted) return;
final loadGeneration = ++_loadGeneration;
setState(() {
_isLoading = _serverRecordings.isEmpty;
_error = null;
@@ -155,7 +190,7 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
var anyOtherError = false;
final seenServers = <String>{};
for (final serverInfo in multiServer.liveTvServers) {
for (final serverInfo in List<LiveTvServerInfo>.of(multiServer.liveTvServers)) {
if (!seenServers.add(serverInfo.serverId)) continue;
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue;
@@ -163,9 +198,12 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
if (dvr == null) continue;
try {
final grabs = await dvr.fetchScheduledRecordings();
if (!_isCurrentLoad(loadGeneration)) return;
final rules = await dvr.fetchRecordingRules();
if (!_isCurrentLoad(loadGeneration)) return;
results.add(_ServerRecordings(serverId: serverInfo.serverId, client: client, grabs: grabs, rules: rules));
} catch (e) {
if (!_isCurrentLoad(loadGeneration)) return;
appLogger.e('Failed to load recordings for ${serverInfo.serverId}', error: e);
if (e is MediaServerHttpException && e.statusCode == 403) {
anyAdminError = true;
@@ -175,7 +213,7 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
}
}
if (!mounted) return;
if (!_isCurrentLoad(loadGeneration)) return;
setState(() {
_serverRecordings = results;
_isLoading = false;
@@ -184,7 +222,7 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
});
if (_pendingFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusContent();
if (_isCurrentLoad(loadGeneration)) focusContent();
});
}
}
+116 -72
View File
@@ -36,7 +36,8 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
MetadataEditAdapter? _adapter;
MetadataEditDraft? _draft;
bool _isLoading = true;
bool _isSaving = false;
bool _isCommitting = false;
int _loadGeneration = 0;
@override
void initState() {
@@ -44,20 +45,33 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
_loadMetadata();
}
@override
void didUpdateWidget(covariant MetadataEditScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (_sameMedia(oldWidget.metadata, widget.metadata)) return;
_adapter = null;
_draft = null;
_isLoading = true;
_loadMetadata();
}
Future<void> _loadMetadata() async {
final generation = ++_loadGeneration;
final metadata = widget.metadata;
try {
final client = context.getMediaClientWithFallback(serverIdOrNull(widget.metadata.serverId));
final client = context.getMediaClientWithFallback(serverIdOrNull(metadata.serverId));
final adapter = metadataEditAdapterFor(client);
if (adapter == null || !adapter.supportsKind(widget.metadata.kind)) {
if (!mounted) return;
if (adapter == null || !adapter.supportsKind(metadata.kind)) {
if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return;
setState(() {
_adapter = adapter;
_isLoading = false;
});
return;
}
final draft = await adapter.load(widget.metadata);
if (!mounted) return;
final draft = await adapter.load(metadata);
if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return;
setState(() {
_adapter = adapter;
_draft = draft;
@@ -65,7 +79,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
});
} catch (e, st) {
appLogger.e('Failed to load metadata editor', error: e, stackTrace: st);
if (!mounted) return;
if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return;
setState(() => _isLoading = false);
showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
}
@@ -80,9 +94,10 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _save() async {
final adapter = _adapter;
final draft = _draft;
if (adapter == null || draft == null || !_hasChanges || _isSaving) return;
final metadata = widget.metadata;
if (adapter == null || draft == null || !_hasChanges || _isCommitting) return;
setState(() => _isSaving = true);
setState(() => _isCommitting = true);
bool success = false;
try {
success = await adapter.save(draft);
@@ -91,7 +106,9 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
}
if (!mounted) return;
setState(() => _isSaving = false);
final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata);
setState(() => _isCommitting = false);
if (!ownsCompletion) return;
if (success) {
showSuccessSnackBar(context, t.metadataEdit.metadataUpdated);
@@ -103,7 +120,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _editTextField(MetadataEditField field, {bool multiline = false}) async {
final draft = _draft;
if (draft == null) return;
if (draft == null || _isCommitting) return;
final currentValue = draft.value<String>(field.id) ?? '';
final result = multiline
? await showTextInputDialog(
@@ -123,14 +140,14 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
allowEmpty: true,
);
if (result != null && mounted) {
if (result != null && mounted && !_isCommitting && identical(_draft, draft)) {
setState(() => draft.setValue(field.id, result));
}
}
Future<void> _editDate(MetadataEditField field) async {
final draft = _draft;
if (draft == null) return;
if (draft == null || _isCommitting) return;
DateTime initial = DateTime.now();
final current = draft.value<String>(field.id);
if (current != null && current.isNotEmpty) {
@@ -146,7 +163,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
useRootNavigator: false,
);
if (picked != null && mounted) {
if (picked != null && mounted && !_isCommitting && identical(_draft, draft)) {
setState(() {
draft.setValue(field.id, '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}');
});
@@ -155,12 +172,12 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _editStringList(MetadataEditField field) async {
final draft = _draft;
if (draft == null) return;
if (draft == null || _isCommitting) return;
final result = await showScopedDialog<List<String>>(
context: context,
builder: (context) => TagEditDialog(title: field.label, initialTags: metadataStringList(draft.values[field.id])),
);
if (result != null && mounted) {
if (result != null && mounted && !_isCommitting && identical(_draft, draft)) {
setState(() => draft.setValue(field.id, result));
}
}
@@ -168,7 +185,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _editChoice(MetadataEditField field) async {
final adapter = _adapter;
final draft = _draft;
if (adapter == null || draft == null) return;
if (adapter == null || draft == null || _isCommitting) return;
final current = draft.value<String>(field.id) ?? '';
final result = await showScopedDialog<String>(
context: context,
@@ -207,10 +224,14 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
},
);
if (result == null || !mounted) return;
if (result == null || !mounted || _isCommitting || !identical(_draft, draft)) return;
if (field.saveMode == MetadataEditSaveMode.immediate) {
final metadata = widget.metadata;
final previous = draft.values[field.id];
setState(() => draft.setValue(field.id, result));
setState(() {
draft.setValue(field.id, result);
_isCommitting = true;
});
bool success = false;
try {
success = await adapter.saveImmediateField(draft, field, result);
@@ -218,10 +239,15 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
appLogger.e('Failed to update metadata field', error: e, stackTrace: st);
}
if (!mounted) return;
if (!success) {
setState(() => draft.setValue(field.id, previous));
showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
}
final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata);
setState(() {
_isCommitting = false;
if (!success && ownsCompletion && metadataEditValueEquals(draft.values[field.id], result)) {
draft.setValue(field.id, previous);
}
});
if (!ownsCompletion) return;
if (!success) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
} else {
setState(() => draft.setValue(field.id, result));
}
@@ -230,13 +256,13 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _openArtworkPicker(MetadataEditField field) async {
final adapter = _adapter;
final draft = _draft;
if (adapter == null || draft == null) return;
if (adapter == null || draft == null || _isCommitting) return;
final result = await showScopedDialog<bool>(
context: context,
builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field),
);
if (result == true && mounted) {
if (result == true && mounted && !_isCommitting && identical(_draft, draft)) {
await _reloadArtwork();
}
}
@@ -272,32 +298,35 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
}
final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList();
return FocusedScrollScaffold(
title: Text(t.metadataEdit.screenTitle),
focusableAppBarActions: true,
actions: [
if (_isSaving)
const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24))
else
FocusableButton(
onPressed: _hasChanges ? _save : null,
child: IconButton(
return PopScope(
canPop: !_isCommitting,
child: FocusedScrollScaffold(
title: Text(t.metadataEdit.screenTitle),
focusableAppBarActions: true,
actions: [
if (_isCommitting)
const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24))
else
FocusableButton(
onPressed: _hasChanges ? _save : null,
icon: const AppIcon(Symbols.check_rounded, fill: 1),
tooltip: t.common.save,
child: IconButton(
onPressed: _hasChanges ? _save : null,
icon: const AppIcon(Symbols.check_rounded, fill: 1),
tooltip: t.common.save,
),
),
],
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList.separated(
itemCount: sections.length,
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) => _buildSectionCard(adapter, draft, sections[index]),
),
),
],
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList.separated(
itemCount: sections.length,
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) => _buildSectionCard(adapter, draft, sections[index]),
),
),
],
],
),
);
}
@@ -361,7 +390,8 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
: null,
),
trailing: const AppIcon(Symbols.chevron_right_rounded),
onTap: onTap,
enabled: !_isCommitting,
onTap: _isCommitting ? null : onTap,
dense: false,
visualDensity: VisualDensity.standard,
);
@@ -388,7 +418,8 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
),
title: Text(field.label),
trailing: const AppIcon(Symbols.chevron_right_rounded),
onTap: () => _openArtworkPicker(field),
enabled: !_isCommitting,
onTap: _isCommitting ? null : () => _openArtworkPicker(field),
dense: false,
visualDensity: VisualDensity.standard,
);
@@ -402,6 +433,10 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
}
}
bool _sameMedia(MediaItem left, MediaItem right) {
return left.id == right.id && left.backend == right.backend && left.serverId == right.serverId;
}
class ArtworkPickerDialog extends StatefulWidget {
final MetadataEditAdapter adapter;
final MetadataEditDraft draft;
@@ -491,27 +526,34 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(_config.selectTitle),
content: SizedBox(
width: 500,
height: 400,
child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(),
return PopScope(
canPop: !_isApplying,
child: AlertDialog(
title: Text(_config.selectTitle),
content: SizedBox(
width: 500,
height: 400,
child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(),
),
actions: [
if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)),
DialogActionButton(
onPressed: _isApplying ? null : _addFromUrl,
label: t.metadataEdit.fromUrl,
icon: const AppIcon(Symbols.link_rounded, size: 18),
),
DialogActionButton(
onPressed: _isApplying ? null : _uploadFile,
label: t.metadataEdit.uploadFile,
icon: const AppIcon(Symbols.upload_rounded, size: 18),
),
DialogActionButton(
autofocus: true,
onPressed: _isApplying ? null : () => Navigator.pop(context),
label: t.common.cancel,
),
],
),
actions: [
if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)),
DialogActionButton(
onPressed: _addFromUrl,
label: t.metadataEdit.fromUrl,
icon: const AppIcon(Symbols.link_rounded, size: 18),
),
DialogActionButton(
onPressed: _uploadFile,
label: t.metadataEdit.uploadFile,
icon: const AppIcon(Symbols.upload_rounded, size: 18),
),
DialogActionButton(autofocus: true, onPressed: () => Navigator.pop(context), label: t.common.cancel),
],
);
}
@@ -529,14 +571,16 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
itemCount: _artworkList!.length,
itemBuilder: (context, index) {
final artwork = _artworkList![index];
final VoidCallback? onSelect = _isApplying ? null : () => _selectArtwork(artwork);
return FocusableWrapper(
borderRadius: 8,
semanticLabel: artwork.selected
? t.metadataEdit.selectedArtworkOption(index: index + 1)
: t.metadataEdit.artworkOption(index: index + 1),
onSelect: () => _selectArtwork(artwork),
canRequestFocus: !_isApplying,
onSelect: onSelect,
child: GestureDetector(
onTap: () => _selectArtwork(artwork),
onTap: onSelect,
child: Stack(
fit: StackFit.expand,
children: [
+102 -95
View File
@@ -203,6 +203,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
int? _movingIndex;
int? _originalIndex;
List<MediaItem>? _originalOrder;
bool _isPlaylistMutationPending = false;
late final ContinuationPaginationCoordinator<MediaItem> _continuation = ContinuationPaginationCoordinator<MediaItem>(
loadPage: _fetchPlaylistContinuationPage,
@@ -215,6 +216,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
bool get _isPlaylistFullyLoaded => _continuation.totalCount != null && items.length >= _continuation.totalCount!;
bool get _canEditPlaylist => !_isReadOnly && _isPlaylistFullyLoaded;
bool get _canMutatePlaylist => _canEditPlaylist && !_isPlaylistMutationPending;
// Estimated item height for scroll-into-view (card + vertical margins)
static const double _estimatedItemHeight = 114.0;
@@ -293,7 +295,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
void _handleContinuationStateChanged() {
if (!mounted) return;
setState(() {
if (!_continuation.isLoading && _focusedColumn != 0 && !_canEditPlaylist) {
if (!_continuation.isLoading && _focusedColumn != 0 && !_canMutatePlaylist) {
_focusedColumn = 0;
}
});
@@ -402,136 +404,130 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
Future<void> _onReorder(int oldIndex, int newIndex) async {
if (!_canEditPlaylist) return;
// Can't reorder if indices are the same
if (!_canMutatePlaylist) return;
if (oldIndex < 0 || oldIndex >= items.length || newIndex < 0 || newIndex >= items.length) return;
if (oldIndex == newIndex) return;
final originalOrder = List<MediaItem>.of(items);
final movedItem = items[oldIndex];
appLogger.d('Reordering item from $oldIndex to $newIndex');
// Optimistically update UI
setState(() {
_isPlaylistMutationPending = true;
_focusedColumn = 0;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
final afterItem = _afterItemForIndex(newIndex);
bool success = false;
try {
success = await mediaClient.movePlaylistItem(
playlistId: widget.playlist.id,
item: movedItem,
newIndex: newIndex,
afterItem: _afterItemForIndex(newIndex),
);
} catch (e) {
appLogger.e('Failed to reorder playlist item', error: e);
}
if (!success) {
// Revert on failure
appLogger.e('Failed to reorder playlist item, reverting UI');
if (mounted) {
setState(() {
final item = items.removeAt(newIndex);
items.insert(oldIndex, item);
});
showErrorSnackBar(context, t.playlists.errorReordering);
}
}
await _persistMoveToServer(
originalIndex: oldIndex,
newIndex: newIndex,
movedItem: movedItem,
afterItem: afterItem,
originalOrder: originalOrder,
);
}
/// Persist a move that was already done in the UI (during move mode).
/// The item is already at newIndex in the items list.
Future<void> _persistMoveToServer(int originalIndex, int newIndex) async {
final movedItem = items[newIndex];
Future<void> _persistMoveToServer({
required int originalIndex,
required int newIndex,
required MediaItem movedItem,
required MediaItem? afterItem,
required List<MediaItem> originalOrder,
}) async {
appLogger.d('Persisting move from $originalIndex to $newIndex');
Object? failure;
var success = false;
bool success = false;
try {
success = await mediaClient.movePlaylistItem(
playlistId: widget.playlist.id,
item: movedItem,
newIndex: newIndex,
afterItem: _afterItemForIndex(newIndex),
);
} catch (e) {
appLogger.e('Failed to persist move', error: e);
}
try {
success = await mediaClient.movePlaylistItem(
playlistId: widget.playlist.id,
item: movedItem,
newIndex: newIndex,
afterItem: afterItem,
);
} catch (e, stackTrace) {
failure = e;
appLogger.e('Failed to persist move', error: e, stackTrace: stackTrace);
}
if (!success) {
// Revert on failure
appLogger.e('Failed to persist move, reverting UI');
if (!mounted || success) return;
appLogger.e('Failed to persist move, recovering UI');
if (failure == null) {
_restorePlaylistOrder(originalOrder, focusedIndex: originalIndex);
} else {
await loadItems();
}
if (mounted) showErrorSnackBar(context, t.playlists.errorReordering);
} finally {
if (mounted) {
_revertMove(newIndex, originalIndex);
showErrorSnackBar(context, t.playlists.errorReordering);
setState(() => _isPlaylistMutationPending = false);
}
}
}
/// Revert a move in the UI by moving item from [fromIndex] back to [toIndex].
void _revertMove(int fromIndex, int toIndex) {
setState(() {
final item = items.removeAt(fromIndex);
items.insert(toIndex, item);
_focusedIndex = toIndex;
});
}
Future<void> _removeItem(int index) async {
if (!_canEditPlaylist) return;
if (!_canMutatePlaylist) return;
if (items.isEmpty || index < 0 || index >= items.length) return;
final originalOrder = List<MediaItem>.of(items);
final item = items[index];
final previousItem = index > 0 ? items[index - 1] : null;
final nextItem = index + 1 < items.length ? items[index + 1] : null;
appLogger.d('Removing item ${item.title} from playlist');
// Optimistically update UI
setState(() {
_isPlaylistMutationPending = true;
_focusedColumn = 0;
items.removeAt(index);
if (_focusedIndex >= items.length) {
_focusedIndex = (items.length - 1).clamp(0, items.length);
}
if (items.isEmpty) {
_focusedColumn = 0;
}
});
bool success = false;
Object? failure;
var success = false;
try {
success = await mediaClient.removeFromPlaylist(playlistId: widget.playlist.id, item: item);
} catch (e) {
appLogger.e('Failed to remove playlist item', error: e);
}
try {
success = await mediaClient.removeFromPlaylist(playlistId: widget.playlist.id, item: item);
} catch (e, stackTrace) {
failure = e;
appLogger.e('Failed to remove playlist item', error: e, stackTrace: stackTrace);
}
if (mounted) {
if (!mounted) return;
if (success) {
showSuccessSnackBar(context, t.playlists.itemRemoved);
} else {
// Restore relative to surviving neighbors; concurrent mutations can
// make the original numeric index stale.
appLogger.e('Failed to remove playlist item, reverting UI');
setState(() {
final nextIndex = nextItem == null ? -1 : items.indexOf(nextItem);
final previousIndex = previousItem == null ? -1 : items.indexOf(previousItem);
final restoreIndex = nextIndex >= 0
? nextIndex
: previousIndex >= 0
? previousIndex + 1
: index.clamp(0, items.length);
items.insert(restoreIndex, item);
_focusedIndex = restoreIndex;
});
return;
}
showErrorSnackBar(context, t.playlists.errorRemoving);
appLogger.e('Failed to remove playlist item, recovering UI');
if (failure == null) {
_restorePlaylistOrder(originalOrder, focusedIndex: index);
} else {
await loadItems();
}
if (mounted) showErrorSnackBar(context, t.playlists.errorRemoving);
} finally {
if (mounted) {
setState(() => _isPlaylistMutationPending = false);
}
}
}
void _restorePlaylistOrder(List<MediaItem> order, {required int focusedIndex}) {
if (!mounted) return;
setState(() {
items = List<MediaItem>.of(order);
_focusedIndex = items.isEmpty ? 0 : focusedIndex.clamp(0, items.length - 1);
_focusedColumn = 0;
_movingIndex = null;
_originalIndex = null;
_originalOrder = null;
});
}
Future<void> _playFromItem(int index) async {
if (items.isEmpty || index < 0 || index >= items.length) return;
@@ -616,7 +612,11 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
// Confirm move - persist to server (UI is already updated during move)
final oldIndex = _originalIndex!;
final newIndex = _movingIndex!;
final originalOrder = List<MediaItem>.of(_originalOrder!);
final movedItem = items[newIndex];
final afterItem = _afterItemForIndex(newIndex);
setState(() {
_isPlaylistMutationPending = true;
_movingIndex = null;
_originalIndex = null;
_originalOrder = null;
@@ -624,8 +624,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
_focusedIndex = newIndex;
_focusedColumn = 0;
});
// Persist the change via API (list is already in correct order)
_persistMoveToServer(oldIndex, newIndex);
unawaited(
_persistMoveToServer(
originalIndex: oldIndex,
newIndex: newIndex,
movedItem: movedItem,
afterItem: afterItem,
originalOrder: originalOrder,
),
);
return KeyEventResult.handled;
}
} else {
@@ -657,7 +664,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
if (key.isLeftKey) {
// Navigate left within columns
if (_focusedColumn == 0 && _canEditPlaylist) {
if (_focusedColumn == 0 && _canMutatePlaylist) {
// Go to drag handle (column 1)
_focusedColumn = 1;
_notifyFocusChanged();
@@ -671,7 +678,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
if (key.isRightKey) {
// Navigate right within columns
if (_focusedColumn == 0 && _canEditPlaylist) {
if (_focusedColumn == 0 && _canMutatePlaylist) {
// Go to remove button (column 2)
_focusedColumn = 2;
_notifyFocusChanged();
@@ -687,14 +694,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
if (_focusedColumn == 0) {
// Play from this item
_playFromItem(_focusedIndex);
} else if (_focusedColumn == 1 && _canEditPlaylist) {
} else if (_focusedColumn == 1 && _canMutatePlaylist) {
// Enter move mode
setState(() {
_movingIndex = _focusedIndex;
_originalIndex = _focusedIndex;
_originalOrder = List.from(items);
});
} else if (_focusedColumn == 2 && _canEditPlaylist) {
} else if (_focusedColumn == 2 && _canMutatePlaylist) {
// Remove item
_removeItem(_focusedIndex);
}
@@ -852,10 +859,10 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
child: PlaylistItemCard(
item: item,
index: index,
onRemove: () => _removeItem(index),
onRemove: _canMutatePlaylist ? () => _removeItem(index) : null,
onTap: () => _playFromItem(index),
onRefresh: updateItem,
canReorder: _canEditPlaylist,
canReorder: _canMutatePlaylist,
isFocused: isFocused,
focusedColumn: isFocused ? focusState.$2 : null,
isMoving: focusState.$3,
+1 -1
View File
@@ -20,7 +20,7 @@ import '../../widgets/optimized_media_image.dart';
class PlaylistItemCard extends StatefulWidget {
final MediaItem item;
final int index;
final VoidCallback onRemove;
final VoidCallback? onRemove;
final VoidCallback? onTap;
final void Function(MediaItem source)? onRefresh;
final bool canReorder; // Whether drag handle should be shown
@@ -53,7 +53,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
int maxPreAuthMessageBytes = _productionMaxPreAuthMessageBytes,
Duration authTimeout = _productionAuthTimeout,
int maxFailedAuthAttempts = _productionMaxFailedAuthAttempts,
Duration authLockoutDuration = _productionAuthLockoutDuration,
this._authLockoutDuration = _productionAuthLockoutDuration,
Future<List<int>> Function(List<int> homeSecret, List<int> hostNonce, List<int> clientNonce)? deriveSessionEncKey,
({Future<void> Function() close, Future<void> ready, Stream<dynamic> stream}) Function(Uri uri)? raceProbeFactory,
this._afterHostUpgrade,
@@ -67,7 +67,6 @@ class CompanionRemotePeerService with KeepaliveMixin {
_maxPreAuthMessageBytes = maxPreAuthMessageBytes,
_authTimeout = authTimeout,
_maxFailedAuthAttempts = maxFailedAuthAttempts,
_authLockoutDuration = authLockoutDuration,
_deriveSessionEncKey =
deriveSessionEncKey ??
((homeSecret, hostNonce, clientNonce) {
@@ -98,6 +97,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
// Server-side (host) fields
HttpServer? _server;
// The socket is closed through its owning admission during disconnect/dispose.
// ignore: close_sinks
WebSocket? _clientSocket;
_HostAdmission? _currentHostAdmission;
final Set<_HostAdmission> _hostAdmissions = {};
@@ -287,6 +288,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
}
try {
// Ownership transfers to the admission immediately after upgrade.
// ignore: close_sinks
final socket = await WebSocketTransformer.upgrade(request, compression: CompressionOptions.compressionOff);
admission.socket = socket;
admission.completeUpgrade();
@@ -338,6 +341,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
}
bool _isHostAdmissionLive(_HostAdmission admission, {required _HostAdmissionPhase phase, int? commitGeneration}) {
// The admission owns and closes this socket.
// ignore: close_sinks
final socket = admission.socket;
return !admission.released &&
admission.phase == phase &&
@@ -354,6 +359,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
String hostPlatform,
List<RemoteAuthContext> authContexts,
) {
// The admission owns and closes this socket.
// ignore: close_sinks
final socket = admission.socket!;
final auth = RemoteAuthService.instance;
final hostNonce = auth.generateNonce();
@@ -387,6 +394,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
}
});
// The subscription is assigned to and cancelled through the admission.
// ignore: cancel_subscriptions
late final StreamSubscription<dynamic> socketSubscription;
socketSubscription = socket.listen(
(data) {
@@ -642,6 +651,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
admission.authTimer = null;
_recordFailedAuth(admission.sourceIp);
// The admission owns and closes this socket.
// ignore: close_sinks
final socket = admission.socket;
if (socket != null && socket.readyState == WebSocket.open) {
try {
@@ -36,11 +36,15 @@ class DiscoveredHost {
/// Hosts broadcast authenticated beacons; clients listen and filter
/// by matching Plex home membership.
class LanDiscoveryService {
static const int discoveryPort = 48633;
static const int defaultDiscoveryPort = 48633;
static const int _broadcastIntervalSeconds = 3;
static const int _staleTimeoutSeconds = 10;
static const int _beaconVersion = 1;
/// UDP port used for both beacon targets and listener binding.
final int discoveryPort;
LanDiscoveryService({this.discoveryPort = defaultDiscoveryPort});
// Broadcaster state (host)
UdpBroadcastSocketSet? _broadcastSockets;
Timer? _broadcastTimer;
@@ -81,27 +85,18 @@ class LanDiscoveryService {
for (final context in contexts) {
_sendBeacon(context, deviceName, platform, wsPort, ips);
}
_broadcastTimer = Timer.periodic(
const Duration(seconds: _broadcastIntervalSeconds),
(_) {
for (final context in contexts) {
_sendBeacon(context, deviceName, platform, wsPort, ips);
}
},
);
_broadcastTimer = Timer.periodic(const Duration(seconds: _broadcastIntervalSeconds), (_) {
for (final context in contexts) {
_sendBeacon(context, deviceName, platform, wsPort, ips);
}
});
} catch (e) {
appLogger.e('LanDiscovery: Failed to start broadcasting', error: e);
await stopBroadcasting();
}
}
void _sendBeacon(
RemoteAuthContext context,
String deviceName,
String platform,
int wsPort,
List<String> ips,
) {
void _sendBeacon(RemoteAuthContext context, String deviceName, String platform, int wsPort, List<String> ips) {
final broadcastSockets = _broadcastSockets;
if (broadcastSockets == null || broadcastSockets.isEmpty) return;
@@ -135,11 +130,7 @@ class LanDiscoveryService {
});
final data = utf8.encode(packet);
broadcastSockets.send(
data,
UdpBroadcastSockets.limitedBroadcastAddress,
discoveryPort,
);
broadcastSockets.send(data, UdpBroadcastSockets.limitedBroadcastAddress, discoveryPort);
} catch (e) {
appLogger.e('LanDiscovery: Failed to send beacon', error: e);
}
@@ -155,9 +146,7 @@ class LanDiscoveryService {
// ── Client: Listening ──
Stream<List<DiscoveredHost>> startListeningForContexts(
List<RemoteAuthContext> contexts,
) {
Stream<List<DiscoveredHost>> startListeningForContexts(List<RemoteAuthContext> contexts) {
_stopListeningInternal();
_discoveredHosts.clear();
final generation = _listenGeneration;
@@ -168,8 +157,7 @@ class LanDiscoveryService {
final now = DateTime.now();
final staleIds = <String>[];
for (final entry in _discoveredHosts.entries) {
if (now.difference(entry.value.lastSeen).inSeconds >
_staleTimeoutSeconds) {
if (now.difference(entry.value.lastSeen).inSeconds > _staleTimeoutSeconds) {
staleIds.add(entry.key);
}
}
@@ -184,10 +172,7 @@ class LanDiscoveryService {
return _hostsController.stream;
}
Future<void> _bindListener(
List<RemoteAuthContext> contexts,
int generation,
) async {
Future<void> _bindListener(List<RemoteAuthContext> contexts, int generation) async {
try {
final socket = await RawDatagramSocket.bind(
InternetAddress.anyIPv4,
@@ -292,9 +277,7 @@ class LanDiscoveryService {
ips: normalizedIps,
lastSeen: lastSeen,
);
appLogger.d(
'LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port',
);
appLogger.d('LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port');
_emitHosts();
}
} catch (e) {
+1
View File
@@ -43,6 +43,7 @@ import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../models/plex/plex_activity.dart';
import '../models/plex/plex_config.dart';
import '../models/plex/plex_metadata_preferences.dart';
import '../models/plex/play_queue_response.dart';
import '../media/media_file_info.dart';
import '../media/media_filter.dart';
@@ -167,6 +167,14 @@ mixin _PlexMetadataEditMethods on MediaServerCacheMixin {
return result;
}
Future<Map<String, String>> getMetadataPrefs(String ratingKey) async {
final response = await _getWithFailover(
'/library/metadata/$ratingKey',
queryParameters: const {'includePreferences': 1},
);
return PlexMetadataPreferences.fromMediaContainer(_getMediaContainer(response)).values;
}
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
+7 -2
View File
@@ -26,9 +26,14 @@ void scrollContextToCenter(BuildContext? context) {
/// then scrolls to `currentIndex * itemHeight`, clamped to max extent.
/// Call once after the first build; the callback is a no-op if the key or
/// controller aren't ready yet.
void scrollToCurrentItem(ScrollController controller, GlobalKey firstItemKey, int currentIndex) {
void scrollToCurrentItem(
ScrollController controller,
GlobalKey firstItemKey,
int currentIndex, {
bool Function()? isCurrent,
}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!controller.hasClients) return;
if (isCurrent?.call() == false || !controller.hasClients) return;
final itemHeight = (firstItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height;
if (itemHeight == null) return;
final maxExtent = controller.position.maxScrollExtent;
@@ -34,8 +34,7 @@ WatchTogetherPeerService _createWatchTogetherPeerService({WatchTogetherRelayEndp
/// - Participant list
/// - Media switching across the session
class WatchTogetherProvider with ChangeNotifier {
WatchTogetherProvider({WatchTogetherPeerServiceFactory peerServiceFactory = _createWatchTogetherPeerService})
: _peerServiceFactory = peerServiceFactory;
WatchTogetherProvider({this._peerServiceFactory = _createWatchTogetherPeerService});
final WatchTogetherPeerServiceFactory _peerServiceFactory;
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex/plex_metadata_preferences.dart';
import 'package:plezy/services/plex_mappers.dart';
void main() {
group('PlexMetadataPreferences', () {
test('canonicalizes booleans while preserving string, numeric, and empty preference values', () {
final preferences = PlexMetadataPreferences.fromMediaContainer({
'Metadata': [
{
'Setting': [
{'id': 'episodeSort', 'value': 1},
{'id': 'useOriginalTitle', 'value': true},
{'id': 'useLocalArtwork', 'value': false},
{'id': 'languageOverride', 'value': ''},
{'id': 'showOrdering', 'value': 'tvdbAbsolute'},
],
},
],
});
expect(preferences.values, {
'episodeSort': '1',
'useOriginalTitle': '1',
'useLocalArtwork': '0',
'languageOverride': '',
'showOrdering': 'tvdbAbsolute',
});
});
test('returns no preferences for absent, null, or malformed metadata envelopes', () {
final containers = <Map<String, dynamic>?>[
null,
const {},
const {'Metadata': null},
const {'Metadata': 'not-an-object'},
const {
'Metadata': [null, 'not-an-object'],
},
];
for (final container in containers) {
expect(PlexMetadataPreferences.fromMediaContainer(container).values, isEmpty, reason: '$container');
}
});
test('ignores malformed setting siblings without discarding valid rows', () {
final preferences = PlexMetadataPreferences.fromMediaContainer({
'Metadata': {
'Setting': [
null,
'not-an-object',
{'id': 7, 'value': 'wrong-id-type'},
{'id': 'missing-value'},
{'id': 'null-value', 'value': null},
{
'id': 'object-value',
'value': {'nested': true},
},
{'id': 'valid', 'value': -3},
],
},
});
expect(preferences.values, {'valid': '-3'});
});
test('accepts the single-setting object shape', () {
final preferences = PlexMetadataPreferences.fromMediaContainer({
'Metadata': {
'Setting': {'id': 'subtitleMode', 'value': 2},
},
});
expect(preferences.values, {'subtitleMode': '2'});
});
});
test('Plex metadata DTO preserves original title and flexible rating values', () {
final metadata = PlexMetadataDto.fromJson({
'ratingKey': 'movie-1',
'originalTitle': 'Le titre original',
'rating': '8.2',
'audienceRating': 8,
'userRating': '9.5',
});
expect(metadata.originalTitle, 'Le titre original');
expect(metadata.rating, 8.2);
expect(metadata.audienceRating, 8.0);
expect(metadata.userRating, 9.5);
});
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
@@ -7,6 +8,7 @@ import 'package:plezy/screens/libraries/tabs/base_library_tab.dart';
import 'package:plezy/utils/platform_detector.dart';
const _library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies');
const _libraryB = MediaLibrary(id: '2', backend: MediaBackend.plex, title: 'Shows');
class _ProbeTab extends BaseLibraryTab<String> {
const _ProbeTab({super.key, required this.loadedItems, required super.onBack})
@@ -42,6 +44,33 @@ class _ProbeTabState extends BaseLibraryTabState<String, _ProbeTab> {
}
}
class _ControlledTab extends BaseLibraryTab<String> {
const _ControlledTab({super.key, required super.library, required this.load, super.onDataLoaded})
: super(suppressAutoFocus: true);
final Future<List<String>> Function(MediaLibrary library) load;
@override
State<_ControlledTab> createState() => _ControlledTabState();
}
class _ControlledTabState extends BaseLibraryTabState<String, _ControlledTab> {
@override
Future<List<String>> loadData() => widget.load(widget.library);
@override
Widget buildContent(List<String> items) => ListView(children: items.map(Text.new).toList());
@override
IconData get emptyIcon => Icons.inbox_rounded;
@override
String get emptyMessage => 'Empty';
@override
String get errorContext => 'controlled';
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -88,4 +117,86 @@ void main() {
expect(fallbackCalls, 0);
expect(state.focusFirstItemCalls, 1);
});
testWidgets('retained state rejects a completion from the previous library', (tester) async {
final key = GlobalKey<_ControlledTabState>();
final a = Completer<List<String>>();
final b = Completer<List<String>>();
var loadedCalls = 0;
Future<List<String>> load(MediaLibrary library) => library.id == _library.id ? a.future : b.future;
await tester.pumpWidget(
MaterialApp(
home: _ControlledTab(key: key, library: _library, load: load, onDataLoaded: () => loadedCalls++),
),
);
await tester.pumpWidget(
MaterialApp(
home: _ControlledTab(key: key, library: _libraryB, load: load, onDataLoaded: () => loadedCalls++),
),
);
b.complete(const ['current B']);
await tester.pump();
await tester.pump();
expect(find.text('current B'), findsOneWidget);
expect(loadedCalls, 1);
a.complete(const ['stale A']);
await tester.pump();
await tester.pump();
expect(find.text('current B'), findsOneWidget);
expect(find.text('stale A'), findsNothing);
expect(loadedCalls, 1);
});
testWidgets('retained state rejects a stale failure after current success', (tester) async {
final key = GlobalKey<_ControlledTabState>();
final a = Completer<List<String>>();
final b = Completer<List<String>>();
Future<List<String>> load(MediaLibrary library) => library.id == _library.id ? a.future : b.future;
await tester.pumpWidget(
MaterialApp(
home: _ControlledTab(key: key, library: _library, load: load),
),
);
await tester.pumpWidget(
MaterialApp(
home: _ControlledTab(key: key, library: _libraryB, load: load),
),
);
b.complete(const ['current B']);
await tester.pump();
a.completeError(StateError('stale failure'));
await tester.pump();
expect(find.text('current B'), findsOneWidget);
expect(find.textContaining('stale failure'), findsNothing);
});
testWidgets('newest same-library refresh owns the committed result', (tester) async {
final key = GlobalKey<_ControlledTabState>();
final loads = [Completer<List<String>>(), Completer<List<String>>()];
var request = 0;
await tester.pumpWidget(
MaterialApp(
home: _ControlledTab(key: key, library: _library, load: (_) => loads[request++].future),
),
);
key.currentState!.refresh();
loads[1].complete(const ['newer']);
await tester.pump();
expect(find.text('newer'), findsOneWidget);
loads[0].complete(const ['older']);
await tester.pump();
expect(find.text('newer'), findsOneWidget);
expect(find.text('older'), findsNothing);
});
}
@@ -0,0 +1,286 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_filter.dart';
import 'package:plezy/screens/libraries/filters_bottom_sheet.dart';
import 'package:plezy/screens/libraries/state_messages.dart';
import 'package:plezy/widgets/bottom_sheet_header.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
final _filters = [
MediaFilter(filter: 'genre', filterType: 'string', key: 'genre', title: 'Genre', type: 'filter'),
MediaFilter(filter: 'studio', filterType: 'string', key: 'studio', title: 'Studio', type: 'filter'),
];
MediaFilterValue _value(String key, String title) => MediaFilterValue(key: key, title: title);
void main() {
testWidgets('filter switch rejects an obsolete success and its presentation effects', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
await _goBack(tester);
await _openFilter(tester, 'Studio');
requests.request('studio').complete([_value('studio-b', 'Current Studio')]);
await tester.pumpAndSettle();
expect(find.text('Current Studio'), findsOneWidget);
requests.request('genre').complete([_value('genre-a', 'Obsolete Genre')]);
await tester.pumpAndSettle();
expect(find.text('Current Studio'), findsOneWidget);
expect(find.text('Obsolete Genre'), findsNothing);
expect(tester.takeException(), isNull);
harness.dispose();
});
testWidgets('same-filter reopen rejects the first request completion', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
await _goBack(tester);
await _openFilter(tester, 'Genre');
requests.request('genre', 1).complete([_value('new', 'New Genre')]);
await tester.pumpAndSettle();
requests.request('genre').complete([_value('old', 'Old Genre')]);
await tester.pumpAndSettle();
expect(find.text('New Genre'), findsOneWidget);
expect(find.text('Old Genre'), findsNothing);
harness.dispose();
});
testWidgets('stale failure cannot replace a newer successful value list', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
await _goBack(tester);
await _openFilter(tester, 'Studio');
requests.request('studio').complete([_value('current', 'Current Studio')]);
await tester.pumpAndSettle();
requests.request('genre').completeError(StateError('obsolete failure'));
await tester.pumpAndSettle();
expect(find.byType(ErrorStateWidget), findsNothing);
expect(find.text('Current Studio'), findsOneWidget);
harness.dispose();
});
testWidgets('library replacement retires the old owner request', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
harness.config.value = harness.config.value.copyWith(libraryKey: 'library-b');
await tester.pump();
requests.request('genre').complete([_value('old-owner', 'Old Library Genre')]);
await tester.pumpAndSettle();
expect(find.text('Old Library Genre'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Filters'), findsOneWidget);
harness.dispose();
});
testWidgets('back then clear retires a loading request before closing', (tester) async {
final requests = _FilterRequests();
final applied = <Map<String, String>>[];
final harness = await _pumpSheet(
tester,
loader: requests.load,
selectedFilters: const {'studio': 'selected'},
onChanged: applied.add,
);
await _openFilter(tester, 'Genre');
await _goBack(tester);
await tester.tap(find.text('Clear All'));
await tester.pumpAndSettle();
expect(applied, hasLength(1));
expect(applied.single, isEmpty);
expect(find.byType(FiltersBottomSheet), findsNothing);
requests.request('genre').complete([_value('late', 'Late Genre')]);
await tester.pump();
expect(tester.takeException(), isNull);
harness.dispose();
});
testWidgets('missing selected value is preserved until explicit user action', (tester) async {
final requests = _FilterRequests();
final applied = <Map<String, String>>[];
final harness = await _pumpSheet(
tester,
loader: requests.load,
selectedFilters: const {'genre': 'missing'},
onChanged: applied.add,
);
await _openFilter(tester, 'Genre');
requests.request('genre').complete([_value('available', 'Available Genre')]);
await tester.pumpAndSettle();
await _goBack(tester);
expect(find.text('Clear All'), findsOneWidget);
expect(applied, isEmpty);
harness.dispose();
});
testWidgets('load failure has retry state while empty success remains selectable', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
requests.request('genre').completeError(StateError('temporary failure'));
await tester.pumpAndSettle();
expect(find.byType(ErrorStateWidget), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
expect(find.text('All'), findsNothing);
await tester.tap(find.text('Retry'));
await tester.pump();
requests.request('genre', 1).complete(const []);
await tester.pumpAndSettle();
expect(find.byType(ErrorStateWidget), findsNothing);
expect(find.text('All'), findsOneWidget);
harness.dispose();
});
testWidgets('cached values bypass the lazy loader', (tester) async {
var loadCount = 0;
final harness = await _pumpSheet(
tester,
loader: (_) async {
loadCount++;
return const [];
},
cachedValues: {
'genre': [_value('cached', 'Cached Genre')],
},
);
await tester.tap(find.text('Genre'));
await tester.pumpAndSettle();
expect(find.text('Cached Genre'), findsOneWidget);
expect(loadCount, 0);
harness.dispose();
});
}
Future<_SheetHarness> _pumpSheet(
WidgetTester tester, {
required Future<List<MediaFilterValue>> Function(MediaFilter filter) loader,
Map<String, String> selectedFilters = const {},
Map<String, List<MediaFilterValue>>? cachedValues,
ValueChanged<Map<String, String>>? onChanged,
}) async {
final config = ValueNotifier(
_SheetConfig(
serverId: 'server',
libraryKey: 'library-a',
selectedFilters: selectedFilters,
cachedValues: cachedValues,
loader: loader,
),
);
await tester.pumpWidget(
MaterialApp(
home: OverlaySheetHost(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (_) => ValueListenableBuilder(
valueListenable: config,
builder: (_, value, _) => FiltersBottomSheet(
key: const ValueKey('filters-sheet'),
filters: _filters,
selectedFilters: value.selectedFilters,
onFiltersChanged: onChanged ?? (_) {},
serverId: value.serverId,
libraryKey: value.libraryKey,
loadFilterValues: value.loader,
cachedValues: value.cachedValues,
),
),
);
},
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
return _SheetHarness(config);
}
Future<void> _openFilter(WidgetTester tester, String title) async {
await tester.tap(find.text(title));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
}
Future<void> _goBack(WidgetTester tester) async {
final headerRect = tester.getRect(find.byType(BottomSheetHeader));
await tester.tapAt(headerRect.centerLeft + const Offset(20, 0));
await tester.pump();
}
class _FilterRequests {
final Map<String, List<Completer<List<MediaFilterValue>>>> _requests = {};
Future<List<MediaFilterValue>> load(MediaFilter filter) {
final request = Completer<List<MediaFilterValue>>();
_requests.putIfAbsent(filter.filter, () => []).add(request);
return request.future;
}
Completer<List<MediaFilterValue>> request(String filter, [int index = 0]) => _requests[filter]![index];
}
class _SheetConfig {
const _SheetConfig({
required this.serverId,
required this.libraryKey,
required this.selectedFilters,
required this.loader,
this.cachedValues,
});
final String serverId;
final String libraryKey;
final Map<String, String> selectedFilters;
final Future<List<MediaFilterValue>> Function(MediaFilter filter) loader;
final Map<String, List<MediaFilterValue>>? cachedValues;
_SheetConfig copyWith({String? libraryKey}) => _SheetConfig(
serverId: serverId,
libraryKey: libraryKey ?? this.libraryKey,
selectedFilters: selectedFilters,
loader: loader,
cachedValues: cachedValues,
);
}
class _SheetHarness {
const _SheetHarness(this.config);
final ValueNotifier<_SheetConfig> config;
void dispose() => config.dispose();
}
@@ -0,0 +1,208 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/mixins/refreshable.dart';
import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/libraries_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../../test_helpers/prefs.dart';
const _libraryA = MediaLibrary(
id: 'movies',
backend: MediaBackend.plex,
title: 'Library A',
kind: MediaKind.movie,
serverId: 'server',
);
const _libraryB = MediaLibrary(
id: 'shows',
backend: MediaBackend.plex,
title: 'Library B',
kind: MediaKind.show,
serverId: 'server',
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
TvDetectionService.debugSetAppleTVOverride(false);
});
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('stale saved tab cannot replace the current library tab', (tester) async {
final preferences = _GatedPreferences({
'selected_library_key': _libraryB.globalKey,
'library_tab_${_libraryA.globalKey}': LibraryTabType.playlists.name,
'library_tab_${_libraryB.globalKey}': LibraryTabType.browse.name,
});
final harness = await _Harness.create(preferences);
addTearDown(harness.dispose);
final selected = <String>[];
await harness.pump(tester, onLibrarySelected: selected.add);
expect(harness.controller(tester).index, 1);
preferences.blockNextSelectedLibraryWrite(_libraryA.globalKey);
final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable;
loadable.loadLibraryByKey(_libraryA.globalKey);
await preferences.blocked;
loadable.loadLibraryByKey(_libraryB.globalKey);
await tester.pumpAndSettle();
expect(selected.last, _libraryB.globalKey);
expect(harness.controller(tester).index, 1);
preferences.release();
await tester.pumpAndSettle();
expect(selected.last, _libraryB.globalKey);
expect(harness.controller(tester).index, 1);
});
testWidgets('restoration applies a saved first tab', (tester) async {
final preferences = _GatedPreferences({
'selected_library_key': _libraryB.globalKey,
'library_tab_${_libraryA.globalKey}': LibraryTabType.recommended.name,
'library_tab_${_libraryB.globalKey}': LibraryTabType.browse.name,
});
final harness = await _Harness.create(preferences);
addTearDown(harness.dispose);
await harness.pump(tester);
expect(harness.controller(tester).index, 1);
final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable;
loadable.loadLibraryByKey(_libraryA.globalKey);
await tester.pumpAndSettle();
expect(harness.controller(tester).index, 0);
});
testWidgets('disposal rejects a pending saved-tab continuation', (tester) async {
final preferences = _GatedPreferences({
'selected_library_key': _libraryB.globalKey,
'library_tab_${_libraryA.globalKey}': LibraryTabType.playlists.name,
});
final harness = await _Harness.create(preferences);
addTearDown(harness.dispose);
await harness.pump(tester);
preferences.blockNextSelectedLibraryWrite(_libraryA.globalKey);
final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable;
loadable.loadLibraryByKey(_libraryA.globalKey);
await preferences.blocked;
await tester.pumpWidget(const SizedBox());
preferences.release();
await tester.pump();
expect(tester.takeException(), isNull);
});
}
final class _Harness {
_Harness({required this.libraries, required this.hiddenLibraries, required this.multiServer});
final LibrariesProvider libraries;
final HiddenLibrariesProvider hiddenLibraries;
final MultiServerProvider multiServer;
static Future<_Harness> create(_GatedPreferences preferences) async {
SharedPreferencesAsyncPlatform.instance = preferences;
await SettingsService.getInstance();
await StorageService.getInstance();
final libraries = LibrariesProvider();
await libraries.updateLibraryOrder(const [_libraryA, _libraryB]);
final hiddenLibraries = HiddenLibrariesProvider();
await hiddenLibraries.ensureInitialized();
final manager = MultiServerManager();
final multiServer = MultiServerProvider(manager, DataAggregationService(manager));
return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer);
}
Future<void> pump(WidgetTester tester, {ValueChanged<String>? onLibrarySelected}) async {
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<LibrariesProvider>.value(value: libraries),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibraries),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
],
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: LibrariesScreen(onLibrarySelected: onLibrarySelected),
),
),
),
);
await tester.pumpAndSettle();
}
TabController controller(WidgetTester tester) {
final dynamic state = tester.state(find.byType(LibrariesScreen));
return state.tabController as TabController;
}
void dispose() {
libraries.dispose();
hiddenLibraries.dispose();
multiServer.dispose();
}
}
final class _GatedPreferences extends InMemorySharedPreferencesAsync {
_GatedPreferences(super.data) : super.withData();
String? _blockedValue;
Completer<void>? _entered;
Completer<void>? _release;
Future<void> get blocked => _entered!.future;
void blockNextSelectedLibraryWrite(String value) {
_blockedValue = value;
_entered = Completer<void>();
_release = Completer<void>();
}
void release() {
final release = _release;
if (release != null && !release.isCompleted) release.complete();
}
@override
Future<bool> setString(String key, String value, SharedPreferencesOptions options) async {
final result = await super.setString(key, value, options);
if (key.endsWith('selected_library_key') && value == _blockedValue) {
_blockedValue = null;
_entered!.complete();
await _release!.future;
}
return result;
}
}
@@ -0,0 +1,300 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_filter_result.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_sort.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/navigation/main_screen_scope.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/state_messages.dart';
import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/focusable_filter_chip.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
await StorageService.getInstance();
});
testWidgets('switching libraries retires the old filter phase before it can start a page load', (tester) async {
final sortA = Completer<List<MediaSort>>();
final harness = _BrowseHarness(clientA: _BrowseClient('server-a', 'Library A', sortResponse: sortA.future));
addTearDown(harness.dispose);
await _pumpHarness(tester, harness);
expect(harness.clientA.sortRequestCount, 1);
expect(harness.clientA.pageRequestCount, 0);
harness.selectedLibrary.value = harness.libraryB;
await _pumpRequestFrames(tester);
expect(find.text('Library B'), findsOneWidget);
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
sortA.complete(const []);
await _pumpRequestFrames(tester);
expect(find.text('Library A'), findsNothing);
expect(find.text('Library B'), findsOneWidget);
expect(harness.clientA.pageRequestCount, 0);
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
});
testWidgets('accepted page post-frame effects are rejected after a library replacement', (tester) async {
final pageA = Completer<LibraryPage<MediaItem>>();
final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future);
final harness = _BrowseHarness(clientA: clientA);
addTearDown(harness.dispose);
await _pumpHarness(tester, harness, settle: false);
await _pumpUntil(tester, () => clientA.pageRequestCount == 1);
pageA.complete(_pageFor(clientA, 'Library A'));
await tester.idle();
expect(harness.loadedLibraries, isEmpty);
harness.selectedLibrary.value = harness.libraryB;
await _pumpRequestFrames(tester);
expect(find.text('Library A'), findsNothing);
expect(find.text('Library B'), findsOneWidget);
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
});
testWidgets('unmounting retires pending browse data and callbacks', (tester) async {
final pageA = Completer<LibraryPage<MediaItem>>();
final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future);
final harness = _BrowseHarness(clientA: clientA);
addTearDown(harness.dispose);
await _pumpHarness(tester, harness, settle: false);
await _pumpUntil(tester, () => clientA.pageRequestCount == 1);
await tester.pumpWidget(const SizedBox());
pageA.complete(_pageFor(clientA, 'Library A'));
await tester.pump();
expect(harness.loadedLibraries, isEmpty);
expect(tester.takeException(), isNull);
});
testWidgets('retry accepts an empty current page and returns keyboard focus to library chrome', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final emptyPage = Completer<LibraryPage<MediaItem>>();
final clientA = _BrowseClient('server-a', 'Library A')
..pageResponses.add(() => Future<LibraryPage<MediaItem>>.error(StateError('temporary browse failure')))
..pageResponses.add(() => emptyPage.future);
final harness = _BrowseHarness(clientA: clientA);
addTearDown(harness.dispose);
await _pumpHarness(tester, harness);
expect(find.byType(ErrorStateWidget), findsOneWidget);
final groupingChip = tester.widget<FocusableFilterChip>(find.byType(FocusableFilterChip).first);
groupingChip.focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await _pumpUntil(tester, () => clientA.pageRequestCount == 2);
emptyPage.complete(const LibraryPage<MediaItem>(items: [], totalCount: 0));
await _pumpRequestFrames(tester);
expect(find.byType(ErrorStateWidget), findsNothing);
expect(find.byType(EmptyStateWidget), findsOneWidget);
expect(harness.loadedLibraries, [harness.libraryA.globalKey]);
expect(groupingChip.focusNode!.hasFocus, isTrue);
});
}
Future<void> _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>.value(
value: harness.provider,
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, _) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)),
),
],
body: ValueListenableBuilder<MediaLibrary>(
valueListenable: harness.selectedLibrary,
builder: (context, library, _) => LibraryBrowseTab(
library: library,
canGroupByFolders: true,
isActive: true,
onDataLoaded: () => harness.loadedLibraries.add(library.globalKey),
onBack: () => harness.chromeFocusRequests++,
),
),
),
),
),
),
),
),
);
if (settle) await _pumpRequestFrames(tester);
}
Future<void> _pumpRequestFrames(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 500));
}
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
for (var i = 0; i < 20 && !condition(); i++) {
await tester.pump(const Duration(milliseconds: 10));
}
expect(condition(), isTrue);
}
LibraryPage<MediaItem> _pageFor(_BrowseClient client, String title) {
return LibraryPage<MediaItem>(
items: [
testMediaItem(
id: '${client.serverId.value}-item',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: title,
serverId: client.serverId.value,
serverName: client.serverName,
),
],
totalCount: 1,
);
}
class _BrowseHarness {
final _BrowseClient clientA;
late final _BrowseClient clientB;
late final MediaLibrary libraryA;
late final MediaLibrary libraryB;
late final ValueNotifier<MediaLibrary> selectedLibrary;
late final MultiServerManager manager;
late final MultiServerProvider provider;
final List<String> loadedLibraries = [];
var chromeFocusRequests = 0;
_BrowseHarness({required this.clientA}) {
clientB = _BrowseClient('server-b', 'Library B');
libraryA = _libraryFor(clientA);
libraryB = _libraryFor(clientB);
selectedLibrary = ValueNotifier<MediaLibrary>(libraryA);
manager = MultiServerManager()
..debugRegisterClientForTesting(clientA)
..debugRegisterClientForTesting(clientB);
provider = MultiServerProvider(manager, DataAggregationService(manager));
}
MediaLibrary _libraryFor(_BrowseClient client) {
return MediaLibrary(
id: '${client.serverId.value}-library',
backend: MediaBackend.jellyfin,
title: client.itemTitle,
kind: MediaKind.artist,
serverId: client.serverId,
);
}
void dispose() {
selectedLibrary.dispose();
provider.dispose();
manager.dispose();
}
}
class _BrowseClient implements MediaServerClient {
@override
final ServerId serverId;
final String itemTitle;
final Future<List<MediaSort>>? sortResponse;
final Queue<Future<LibraryPage<MediaItem>> Function()> pageResponses = Queue();
var sortRequestCount = 0;
var pageRequestCount = 0;
_BrowseClient(String serverId, this.itemTitle, {this.sortResponse}) : serverId = ServerId(serverId);
@override
String get serverName => itemTitle;
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType}) {
sortRequestCount++;
return sortResponse ?? Future.value(const []);
}
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async {
return LibraryFilterResult.empty;
}
@override
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
String libraryId, {
required LibraryQuery query,
MediaKind? libraryKind,
AbortController? abort,
}) {
pageRequestCount++;
if (pageResponses.isNotEmpty) return pageResponses.removeFirst()();
return Future.value(_pageFor(this, itemTitle));
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+247 -1
View File
@@ -1,11 +1,30 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/dpad_select_long_press_controller.dart';
import 'package:plezy/models/livetv_channel.dart';
import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/screens/livetv/tabs/guide_tab.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:provider/provider.dart';
const _selectDown = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.enter,
@@ -28,7 +47,16 @@ LiveTvProgram _program({String ratingKey = 'program/42', int beginsAt = 1_800_00
);
void main() {
tearDown(SelectKeyUpSuppressor.clearSuppression);
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => initializeDateFormatting('en'));
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(false);
});
tearDown(() {
SelectKeyUpSuppressor.clearSuppression();
TvDetectionService.debugSetAppleTVOverride(null);
});
test('SELECT hold survives equivalent fresh guide objects and opens details once', () {
fakeAsync((async) {
@@ -93,4 +121,222 @@ void main() {
controller.dispose();
});
});
testWidgets('superseded guide load keeps one interval and cannot replace current programs', (tester) async {
final harness = _GuideHarness.twoServers();
addTearDown(harness.dispose);
await harness.pump(tester);
await harness.completeInitial(tester);
final rightButton = _rightTimeButton();
expect(rightButton, findsOneWidget);
await tester.tap(rightButton);
await tester.tap(rightButton);
expect(harness.serverA.schedule.requests, hasLength(3));
final older = harness.serverA.schedule.requests[1];
final newer = harness.serverA.schedule.requests[2];
expect(older.to.difference(older.from), const Duration(hours: 6));
expect(newer.to.difference(newer.from), const Duration(hours: 6));
expect(newer.from.difference(older.from), const Duration(hours: 2));
harness.serverA.schedule.complete(2, 'Current A');
await tester.pump();
expect(harness.serverB!.schedule.requests, hasLength(2));
final currentB = harness.serverB!.schedule.requests[1];
expect(currentB.from, newer.from);
expect(currentB.to, newer.to);
harness.serverB!.schedule.complete(1, 'Current B');
await tester.pumpAndSettle();
expect(find.text('Current A'), findsOneWidget);
expect(find.text('Current B'), findsOneWidget);
harness.serverA.schedule.complete(1, 'Obsolete A');
await tester.pump();
await tester.pump();
expect(harness.serverB!.schedule.requests, hasLength(2));
expect(find.text('Obsolete A'), findsNothing);
expect(find.text('Current A'), findsOneWidget);
expect(find.text('Current B'), findsOneWidget);
});
testWidgets('obsolete completion cannot clear loading owned by a newer guide request', (tester) async {
final harness = _GuideHarness.oneServer();
addTearDown(harness.dispose);
await harness.pump(tester);
await harness.completeInitial(tester);
final rightButton = _rightTimeButton();
await tester.tap(rightButton);
await tester.tap(rightButton);
expect(harness.serverA.schedule.requests, hasLength(3));
harness.serverA.schedule.complete(1, 'Obsolete');
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Obsolete'), findsNothing);
harness.serverA.schedule.complete(2, 'Current');
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Current'), findsOneWidget);
expect(find.text('Obsolete'), findsNothing);
});
}
Finder _rightTimeButton() {
final icon = find.byWidgetPredicate((widget) => widget is AppIcon && widget.icon == Symbols.chevron_right_rounded);
return find.ancestor(of: icon, matching: find.byType(IconButton));
}
final class _GuideHarness {
_GuideHarness._({required this.serverA, required this.serverB, required this.provider, required this.channels});
factory _GuideHarness.oneServer() => _GuideHarness._create(includeServerB: false);
factory _GuideHarness.twoServers() => _GuideHarness._create(includeServerB: true);
factory _GuideHarness._create({required bool includeServerB}) {
final serverA = _FakeMediaServerClient(serverId: 'server-a', stationId: 'station-a');
final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null;
final manager = MultiServerManager()..debugRegisterClientForTesting(serverA);
if (serverB != null) manager.debugRegisterClientForTesting(serverB);
final provider = MultiServerProvider(manager, DataAggregationService(manager))
..debugSetLiveTvServersForTesting([
LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'),
if (serverB != null) LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'),
]);
return _GuideHarness._(
serverA: serverA,
serverB: serverB,
provider: provider,
channels: [
_guideChannel(serverId: 'server-a', stationId: 'station-a', callSign: 'A'),
if (serverB != null) _guideChannel(serverId: 'server-b', stationId: 'station-b', callSign: 'B'),
],
);
}
final _FakeMediaServerClient serverA;
final _FakeMediaServerClient? serverB;
final MultiServerProvider provider;
final List<LiveTvChannel> channels;
Future<void> pump(WidgetTester tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(body: GuideTab(channels: channels)),
),
),
),
),
);
expect(serverA.schedule.requests, hasLength(1));
}
Future<void> completeInitial(WidgetTester tester) async {
serverA.schedule.complete(0, 'Initial A');
await tester.pump();
final serverB = this.serverB;
if (serverB != null) {
expect(serverB.schedule.requests, hasLength(1));
serverB.schedule.complete(0, 'Initial B');
}
await tester.pumpAndSettle();
expect(find.text('Initial A'), findsOneWidget);
if (serverB != null) expect(find.text('Initial B'), findsOneWidget);
}
void dispose() => provider.dispose();
}
LiveTvChannel _guideChannel({required String serverId, required String stationId, required String callSign}) =>
LiveTvChannel(
key: 'channel-$stationId',
identifier: stationId,
callSign: callSign,
serverId: serverId,
liveDvrKey: 'dvr-$serverId',
);
final class _FakeMediaServerClient implements MediaServerClient {
_FakeMediaServerClient({required String serverId, required String stationId})
: serverId = ServerId(serverId),
schedule = _ControllableLiveTvSupport(serverId: serverId, stationId: stationId);
@override
final ServerId serverId;
final _ControllableLiveTvSupport schedule;
@override
LiveTvSupport get liveTv => schedule;
@override
String get serverName => serverId.value;
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _ControllableLiveTvSupport implements LiveTvSupport {
_ControllableLiveTvSupport({required this.serverId, required this.stationId});
final String serverId;
final String stationId;
final List<_ScheduleRequest> requests = [];
@override
LiveTvDvrSupport? get dvr => null;
@override
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to}) {
final request = _ScheduleRequest(from: from!, to: to!);
requests.add(request);
return request.completer.future;
}
void complete(int index, String title) {
final request = requests[index];
final beginsAt = request.from.millisecondsSinceEpoch ~/ 1000 + 600;
request.completer.complete([
LiveTvProgram(
ratingKey: '$serverId-$index',
title: title,
beginsAt: beginsAt,
endsAt: beginsAt + 3600,
channelIdentifier: stationId,
serverId: serverId,
),
]);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _ScheduleRequest {
_ScheduleRequest({required this.from, required this.to});
final DateTime from;
final DateTime to;
final Completer<List<LiveTvProgram>> completer = Completer<List<LiveTvProgram>>();
}
@@ -0,0 +1,155 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/models/media_grab_operation.dart';
import 'package:plezy/models/media_subscription.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/livetv/tabs/recordings_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:provider/provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('coalesces concurrent refreshes and commits completed loads in order', (tester) async {
final dvr = _ControllableDvr();
final client = _FakeMediaServerClient(dvr);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager))
..debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr')]);
addTearDown(provider.dispose);
final tabKey = GlobalKey<RecordingsTabState>();
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(body: RecordingsTab(key: tabKey)),
),
),
),
),
);
expect(dvr.grabRequests, hasLength(1));
final queuedRefresh = tabKey.currentState!.reload();
final coalescedRefresh = tabKey.currentState!.reload();
expect(identical(queuedRefresh, coalescedRefresh), isTrue);
expect(dvr.grabRequests, hasLength(1));
dvr.completeGrabs(0, const []);
await tester.pump();
dvr.completeRules(0, const [_rule]);
await tester.pump();
expect(find.text(_rule.title!), findsOneWidget);
expect(dvr.grabRequests, hasLength(2));
await tester.pump(const Duration(seconds: 30));
final stillCoalesced = tabKey.currentState!.reload();
expect(identical(queuedRefresh, stillCoalesced), isTrue);
expect(dvr.grabRequests, hasLength(2));
dvr.completeGrabs(1, const []);
await tester.pump();
dvr.completeRules(1, const [_rule]);
await tester.pump();
expect(find.text(_rule.title!), findsOneWidget);
expect(dvr.grabRequests, hasLength(3));
dvr.completeGrabs(2, const []);
await tester.pump();
dvr.completeRules(2, const []);
await queuedRefresh;
await tester.pumpAndSettle();
expect(dvr.grabRequests, hasLength(3));
expect(dvr.ruleRequests, hasLength(3));
expect(find.text(_rule.title!), findsNothing);
expect(find.text(t.liveTv.noScheduledRecordings), findsOneWidget);
});
}
const _rule = MediaSubscription(key: 'rule-1', type: 2, title: 'Obsolete series rule');
final class _FakeMediaServerClient implements MediaServerClient {
_FakeMediaServerClient(this.dvr);
final _ControllableDvr dvr;
@override
ServerId get serverId => ServerId('server-a');
@override
String get serverName => 'DVR server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true, liveTvDvr: true);
@override
LiveTvSupport get liveTv => _FakeLiveTvSupport(dvr);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _FakeLiveTvSupport implements LiveTvSupport {
_FakeLiveTvSupport(this.dvr);
@override
final LiveTvDvrSupport dvr;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _ControllableDvr implements LiveTvDvrSupport {
final List<Completer<List<MediaGrabOperation>>> grabRequests = [];
final List<Completer<List<MediaSubscription>>> ruleRequests = [];
final List<String> deleteCalls = [];
@override
Future<List<MediaGrabOperation>> fetchScheduledRecordings() {
final request = Completer<List<MediaGrabOperation>>();
grabRequests.add(request);
return request.future;
}
@override
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) {
final request = Completer<List<MediaSubscription>>();
ruleRequests.add(request);
return request.future;
}
@override
Future<void> deleteRecordingRule(String subscriptionId) async {
deleteCalls.add(subscriptionId);
}
void completeGrabs(int index, List<MediaGrabOperation> grabs) => grabRequests[index].complete(grabs);
void completeRules(int index, List<MediaSubscription> rules) => ruleRequests[index].complete(rules);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+651
View File
@@ -0,0 +1,651 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
import 'package:drift/native.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/metadata_edit/metadata_edit_models.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/metadata_edit_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
import 'package:plezy/widgets/loading_indicator_box.dart';
import 'package:provider/provider.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
void main() {
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
testWidgets('immediate choices serialize commits and block edits and route dismissal', (tester) async {
final requests = _PlexMetadataRequests();
final harness = await _pumpEditor(tester, requests);
final first = requests.holdNextPreferenceUpdate();
await _chooseEpisodeSort(tester, 'Oldest first');
await tester.pump();
expect(requests.preferenceUpdateCalls, 1);
expect(find.byType(LoadingIndicatorBox), findsOneWidget);
expect(find.byTooltip('Save'), findsNothing);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(_fieldTile('Episode Sorting'), warnIfMissed: false);
await tester.tap(_fieldTile('Keep'), warnIfMissed: false);
await tester.pump();
expect(find.byType(AlertDialog), findsNothing);
expect(requests.preferenceUpdateCalls, 1);
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byType(MetadataEditScreen), findsOneWidget);
first.complete(_ok());
await tester.pumpAndSettle();
expect(_tileText('Episode Sorting', 'Oldest first'), findsOneWidget);
final second = requests.holdNextPreferenceUpdate();
await _chooseEpisodeSort(tester, 'Newest first');
await tester.pump();
expect(requests.preferenceUpdateCalls, 2);
expect(requests.maxConcurrentPreferenceUpdates, 1);
second.complete(_ok());
await tester.pumpAndSettle();
expect(_tileText('Episode Sorting', 'Newest first'), findsOneWidget);
expect(requests.serverPreferences['episodeSort'], '1');
await harness.dispose();
});
testWidgets('boolean preference selection and updates use canonical choice values', (tester) async {
final requests = _PlexMetadataRequests();
final harness = await _pumpEditor(tester, requests);
addTearDown(harness.dispose);
await _scrollToImmediateChoice(tester, 'Use Original Title');
expect(_tileText('Use Original Title', 'Yes'), findsOneWidget);
await _chooseImmediateChoice(tester, 'Use Original Title', 'No');
await tester.pumpAndSettle();
expect(_tileText('Use Original Title', 'No'), findsOneWidget);
expect(requests.preferenceUpdatePayloads.single['useOriginalTitle'], '0');
expect(requests.serverPreferences['useOriginalTitle'], '0');
await _chooseImmediateChoice(tester, 'Use Original Title', 'Yes');
await tester.pumpAndSettle();
expect(_tileText('Use Original Title', 'Yes'), findsOneWidget);
expect(requests.preferenceUpdatePayloads.last['useOriginalTitle'], '1');
expect(requests.preferenceUpdatePayloads.map((payload) => payload['useOriginalTitle']), orderedEquals(['0', '1']));
});
testWidgets('immediate failure rolls back its value and re-enables controls', (tester) async {
final requests = _PlexMetadataRequests()..queuePreferenceResponse(_response(503));
final harness = await _pumpEditor(tester, requests);
await _chooseEpisodeSort(tester, 'Oldest first');
await tester.pumpAndSettle();
expect(_tileText('Episode Sorting', 'Library default'), findsOneWidget);
expect(find.text('Failed to update metadata'), findsOneWidget);
expect(find.byType(LoadingIndicatorBox), findsNothing);
await _chooseEpisodeSort(tester, 'Newest first');
await tester.pumpAndSettle();
expect(_tileText('Episode Sorting', 'Newest first'), findsOneWidget);
expect(requests.preferenceUpdateCalls, 2);
await harness.dispose();
});
testWidgets('final save rejects duplicate submit and back then recovers after failure', (tester) async {
final requests = _PlexMetadataRequests();
final harness = await _pumpEditor(tester, requests);
await _editTitle(tester, 'Updated title');
final save = requests.holdNextMetadataUpdate();
final saveAction = find.byTooltip('Save');
await tester.tap(saveAction);
await tester.tap(saveAction);
await tester.pump();
expect(requests.metadataUpdateCalls, 1);
expect(find.byTooltip('Save'), findsNothing);
await tester.tap(_fieldTile('Title'), warnIfMissed: false);
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byType(AlertDialog), findsNothing);
expect(find.byType(MetadataEditScreen), findsOneWidget);
save.complete(_response(500));
await tester.pumpAndSettle();
expect(find.byType(MetadataEditScreen), findsOneWidget);
expect(find.text('Failed to update metadata'), findsOneWidget);
expect(_tileText('Title', 'Updated title'), findsOneWidget);
expect(find.byTooltip('Save'), findsOneWidget);
await harness.dispose();
});
testWidgets('late final save completion cannot pop a replacement media editor', (tester) async {
final requests = _PlexMetadataRequests();
final harness = await _pumpEditor(tester, requests);
await _editTitle(tester, 'Updated first title');
final save = requests.holdNextMetadataUpdate();
await tester.tap(find.byTooltip('Save'));
await tester.pump();
expect(requests.metadataUpdateCalls, 1);
harness.metadata.value = _show(id: 'show-2', title: 'Second show');
await tester.pump();
await tester.pump();
expect(_tileText('Title', 'Second show'), findsOneWidget);
save.complete(_ok());
await tester.pumpAndSettle();
expect(find.byType(MetadataEditScreen), findsOneWidget);
expect(_tileText('Title', 'Second show'), findsOneWidget);
expect(find.text('Metadata updated'), findsNothing);
await harness.dispose();
});
testWidgets('artwork mutation blocks cancel, back, actions, and duplicate options until success', (tester) async {
final apply = Completer<bool>();
final adapter = _ArtworkAdapter()..applyResult = apply.future;
final result = _DialogResult();
await _pumpArtworkDialog(tester, adapter, result);
await tester.tap(_artworkOption());
await tester.pump();
expect(adapter.applyCalls, 1);
expect(_dialogButton(tester, 'From URL').onPressed, isNull);
expect(_dialogButton(tester, 'Upload File').onPressed, isNull);
expect(_dialogButton(tester, 'Cancel').onPressed, isNull);
await tester.tap(find.text('Cancel'), warnIfMissed: false);
await tester.tap(_artworkOption(), warnIfMissed: false);
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
expect(adapter.applyCalls, 1);
expect(result.completions, 0);
apply.complete(true);
await tester.pumpAndSettle();
expect(find.byType(ArtworkPickerDialog), findsNothing);
expect(result.completions, 1);
expect(result.value, isTrue);
expect(find.text('Artwork updated'), findsOneWidget);
});
testWidgets('upload failure keeps artwork dialog mounted and restores cancellation', (tester) async {
final picker = _FakeFilePicker()
..queueResult(
FilePickerResult([
PlatformFile(name: 'poster.png', size: 3, bytes: Uint8List.fromList([1, 2, 3])),
]),
);
FilePicker.platform = picker;
addTearDown(() => FilePicker.platform = _FakeFilePicker());
final adapter = _ArtworkAdapter()..uploadResult = Future<bool>.value(false);
final result = _DialogResult();
await _pumpArtworkDialog(tester, adapter, result);
await tester.tap(find.text('Upload File'));
await tester.pumpAndSettle();
expect(adapter.uploadCalls, 1);
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
expect(find.text('Failed to update artwork'), findsOneWidget);
expect(_dialogButton(tester, 'Cancel').onPressed, isNotNull);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(result.completions, 1);
expect(result.value, isNull);
});
testWidgets('picker cancellation and stale picker completion never start an upload', (tester) async {
final picker = _FakeFilePicker()..queueResult(null);
FilePicker.platform = picker;
addTearDown(() => FilePicker.platform = _FakeFilePicker());
final adapter = _ArtworkAdapter();
final firstResult = _DialogResult();
await _pumpArtworkDialog(tester, adapter, firstResult);
await tester.tap(find.text('Upload File'));
await tester.pumpAndSettle();
expect(adapter.uploadCalls, 0);
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
final pendingPicker = Completer<FilePickerResult?>();
picker.queueFuture(pendingPicker.future);
final staleResult = _DialogResult();
await _pumpArtworkDialog(tester, adapter, staleResult);
await tester.tap(find.text('Upload File'));
await tester.pump();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
pendingPicker.complete(
FilePickerResult([
PlatformFile(name: 'stale.png', size: 1, bytes: Uint8List.fromList([1])),
]),
);
await tester.pumpAndSettle();
expect(adapter.uploadCalls, 0);
expect(staleResult.completions, 1);
expect(staleResult.value, isNull);
expect(find.text('Artwork updated'), findsNothing);
});
}
Finder _fieldTile(String label) => find.widgetWithText(FocusableListTile, label);
Finder _tileText(String label, String value) {
return find.descendant(of: _fieldTile(label), matching: find.text(value));
}
Finder _artworkOption() {
return find.descendant(of: find.byType(GridView), matching: find.byType(FocusableWrapper)).first;
}
DialogActionButton _dialogButton(WidgetTester tester, String label) {
return tester.widget<DialogActionButton>(find.widgetWithText(DialogActionButton, label));
}
Future<void> _chooseEpisodeSort(WidgetTester tester, String option) {
return _chooseImmediateChoice(tester, 'Episode Sorting', option);
}
Future<void> _chooseImmediateChoice(WidgetTester tester, String labelText, String option) async {
await _scrollToImmediateChoice(tester, labelText);
await tester.tap(_fieldTile(labelText));
await tester.pumpAndSettle();
await tester.tap(find.text(option));
await tester.pump();
}
Future<void> _scrollToImmediateChoice(WidgetTester tester, String labelText) async {
final label = find.text(labelText);
await tester.scrollUntilVisible(
label,
300,
scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first,
);
await Scrollable.ensureVisible(tester.element(label), alignment: 0.5);
await tester.pumpAndSettle();
}
Future<void> _editTitle(WidgetTester tester, String title) async {
final tile = _fieldTile('Title');
await tester.ensureVisible(tile);
await tester.tap(tile);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), title);
await tester.tap(find.widgetWithText(DialogActionButton, 'Save'));
await tester.pumpAndSettle();
expect(_tileText('Title', title), findsOneWidget);
}
Future<_EditorHarness> _pumpEditor(WidgetTester tester, _PlexMetadataRequests requests) async {
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
final database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
final client = testPlexClient(serverId: ServerId('server-1'), handler: requests.handle);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
final metadata = ValueNotifier<MediaItem>(_show());
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ValueListenableBuilder<MediaItem>(
valueListenable: metadata,
builder: (context, item, _) => MetadataEditScreen(metadata: item),
),
),
);
},
child: const Text('Open editor'),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open editor'));
await tester.pumpAndSettle();
expect(find.byType(MetadataEditScreen), findsOneWidget);
expect(_tileText('Title', 'First show'), findsOneWidget);
return _EditorHarness(tester: tester, database: database, manager: manager, provider: provider, metadata: metadata);
}
MediaItem _show({String id = 'show-1', String title = 'First show'}) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.show,
title: title,
originalTitle: 'Original $title',
summary: 'Summary',
libraryId: '1',
serverId: 'server-1',
thumbPath: '',
);
class _EditorHarness {
final WidgetTester tester;
final AppDatabase database;
final MultiServerManager manager;
final MultiServerProvider provider;
final ValueNotifier<MediaItem> metadata;
const _EditorHarness({
required this.tester,
required this.database,
required this.manager,
required this.provider,
required this.metadata,
});
Future<void> dispose() async {
await tester.pumpWidget(const SizedBox.shrink());
provider.dispose();
manager.dispose();
metadata.dispose();
await database.close();
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
}
}
class _PlexMetadataRequests {
final serverPreferences = <String, Object>{
'episodeSort': '-1',
'autoDeletionItemPolicyUnwatchedLibrary': '0',
'autoDeletionItemPolicyWatchedLibrary': '0',
'flattenSeasons': '-1',
'showOrdering': '',
'languageOverride': '',
'useOriginalTitle': true,
'audioLanguage': '',
'subtitleLanguage': '',
'subtitleMode': '-1',
};
final Queue<Future<http.Response>> _preferenceResponses = Queue();
final List<Map<String, String>> preferenceUpdatePayloads = [];
final Queue<Future<http.Response>> _metadataResponses = Queue();
int preferenceUpdateCalls = 0;
int metadataUpdateCalls = 0;
int _activePreferenceUpdates = 0;
int maxConcurrentPreferenceUpdates = 0;
Completer<http.Response> holdNextPreferenceUpdate() {
final completer = Completer<http.Response>();
_preferenceResponses.add(completer.future);
return completer;
}
Completer<http.Response> holdNextMetadataUpdate() {
final completer = Completer<http.Response>();
_metadataResponses.add(completer.future);
return completer;
}
void queuePreferenceResponse(http.Response response) {
_preferenceResponses.add(Future.value(response));
}
Future<http.Response> handle(http.Request request) async {
final path = request.url.path;
if (request.method == 'GET' &&
path.startsWith('/library/metadata/') &&
request.url.queryParameters['includePreferences'] == '1') {
final id = path.split('/').last;
return _jsonResponse({
'MediaContainer': {
'Metadata': [
{
'ratingKey': id,
'Setting': [
for (final entry in serverPreferences.entries) {'id': entry.key, 'value': entry.value},
],
},
],
},
});
}
if (request.method == 'PUT' && path.endsWith('/prefs')) {
preferenceUpdateCalls++;
preferenceUpdatePayloads.add(Map<String, String>.of(request.url.queryParameters));
_activePreferenceUpdates++;
if (_activePreferenceUpdates > maxConcurrentPreferenceUpdates) {
maxConcurrentPreferenceUpdates = _activePreferenceUpdates;
}
final response = await (_preferenceResponses.isEmpty ? Future.value(_ok()) : _preferenceResponses.removeFirst());
_activePreferenceUpdates--;
if (response.statusCode >= 200 && response.statusCode < 300) {
for (final key in serverPreferences.keys) {
final value = request.url.queryParameters[key];
if (value != null) serverPreferences[key] = value;
}
}
return response;
}
if (request.method == 'PUT' && path == '/library/sections/1/all') {
metadataUpdateCalls++;
return _metadataResponses.isEmpty ? _ok() : await _metadataResponses.removeFirst();
}
return _response(404);
}
}
Future<void> _pumpArtworkDialog(WidgetTester tester, _ArtworkAdapter adapter, _DialogResult result) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () async {
final value = await showDialog<bool>(
context: context,
builder: (_) => ArtworkPickerDialog(adapter: adapter, draft: adapter.draft, field: adapter.field),
);
result.value = value;
result.completions++;
},
child: const Text('Open artwork'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open artwork'));
await tester.pumpAndSettle();
expect(find.byType(ArtworkPickerDialog), findsOneWidget);
}
class _DialogResult {
int completions = 0;
bool? value;
}
class _ArtworkAdapter extends MetadataEditAdapter {
final MediaServerClient _client = _NoopMediaClient();
late final MetadataEditDraft draft = MetadataEditDraft(
sourceItem: _show(),
currentItem: _show(),
values: {'artwork:posters': ''},
);
late final MetadataEditField field = const MetadataEditField(
id: 'artwork:posters',
label: 'Poster',
type: MetadataEditFieldType.artwork,
saveMode: MetadataEditSaveMode.immediate,
artwork: MetadataArtworkConfig(
key: 'posters',
selectTitle: 'Select Poster',
previewWidth: 40,
previewHeight: 60,
gridColumns: 2,
gridAspectRatio: 2 / 3,
),
);
Future<bool> applyResult = Future.value(true);
Future<bool> uploadResult = Future.value(true);
int applyCalls = 0;
int uploadCalls = 0;
@override
MediaBackend get backend => MediaBackend.plex;
@override
MediaServerClient get mediaClient => _client;
@override
bool supportsKind(MediaKind kind) => true;
@override
Future<MetadataEditDraft> load(MediaItem item) async => draft;
@override
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) => const [];
@override
Future<bool> save(MetadataEditDraft draft) async => true;
@override
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field) async {
return const [MetadataArtworkOption(id: 'option-1', thumbnailPath: '', sourceUrl: 'option-1')];
}
@override
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
applyCalls++;
return applyResult;
}
@override
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async => true;
@override
Future<bool> uploadArtwork(MetadataEditDraft draft, MetadataEditField field, List<int> bytes, {String? fileName}) {
uploadCalls++;
return uploadResult;
}
}
class _NoopMediaClient implements MediaServerClient {
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
ServerId get serverId => ServerId('server-1');
@override
String? get serverName => 'Server';
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeFilePicker extends FilePicker {
final Queue<Future<FilePickerResult?>> _results = Queue();
void queueResult(FilePickerResult? result) {
_results.add(Future.value(result));
}
void queueFuture(Future<FilePickerResult?> result) {
_results.add(result);
}
@override
Future<FilePickerResult?> pickFiles({
String? dialogTitle,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
Function(FilePickerStatus)? onFileLoading,
bool allowCompression = false,
int compressionQuality = 0,
bool allowMultiple = false,
bool withData = false,
bool withReadStream = false,
bool lockParentWindow = false,
bool readSequential = false,
}) {
return _results.removeFirst();
}
}
http.Response _jsonResponse(Object body) {
return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'});
}
http.Response _ok() => _response(200);
http.Response _response(int statusCode) {
return http.Response('{}', statusCode, headers: const {'content-type': 'application/json'});
}
@@ -456,6 +456,114 @@ void main() {
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('pending D-pad move disables playlist mutations and restores the exact order on rejection', (
tester,
) async {
final harness = await _createHarness(_mediaItems(3));
await _pushPlaylistRoute(tester, harness);
await _startFirstItemMoveDown(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
expect(harness.client.moveRequests, hasLength(1));
expect(harness.client.activeMutationCount, 1);
expect(
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)),
everyElement(
isA<PlaylistItemCard>()
.having((card) => card.canReorder, 'canReorder', isFalse)
.having((card) => card.onRemove, 'onRemove', isNull),
),
);
expect(find.byType(ReorderableDragStartListener), findsNothing);
harness.client.completeMove(0, false);
await tester.pumpAndSettle();
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']);
expect(harness.client.activeMutationCount, 0);
expect(
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)),
everyElement(
isA<PlaylistItemCard>()
.having((card) => card.canReorder, 'canReorder', isTrue)
.having((card) => card.onRemove, 'onRemove', isNotNull),
),
);
await _startFirstItemMoveDown(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
harness.client.completeMove(1, true, applyToServer: true);
await tester.pumpAndSettle();
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']);
expect(harness.client.peakMutationCount, 1);
});
testWidgets('ambiguous playlist move failure refetches authoritative order before reopening edits', (tester) async {
final harness = await _createHarness(_mediaItems(3));
await _pushPlaylistRoute(tester, harness);
await _startFirstItemMoveDown(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(harness.client.moveRequests, hasLength(1));
harness.client.failMove(0, applyToServer: true);
await tester.pumpAndSettle();
expect(harness.client.requestedStarts, [0, 0]);
expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']);
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
expect(harness.client.activeMutationCount, 0);
expect(tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => card.canReorder), isTrue);
});
testWidgets('playlist remove rejection restores its snapshot and ambiguous failure refetches', (tester) async {
final harness = await _createHarness(_mediaItems(3));
await _pushPlaylistRoute(tester, harness);
await tester.tap(find.byTooltip(t.playlists.removeItem).first);
await tester.pump();
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']);
expect(harness.client.removeRequests, hasLength(1));
expect(
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => card.onRemove == null),
isTrue,
);
harness.client.completeRemove(0, false);
await tester.pumpAndSettle();
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']);
await tester.tap(find.byTooltip(t.playlists.removeItem).first);
await tester.pump();
harness.client.failRemove(1, applyToServer: true);
await tester.pumpAndSettle();
expect(harness.client.requestedStarts, [0, 0]);
expect(harness.client.authoritativeItemIds, ['item_1', 'item_2']);
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']);
expect(harness.client.activeMutationCount, 0);
expect(harness.client.peakMutationCount, 1);
});
}
Future<void> _startFirstItemMoveDown(WidgetTester tester) async {
final listFocus = find.byWidgetPredicate(
(widget) => widget is Focus && widget.focusNode?.debugLabel == 'playlist_list',
);
tester.widget<Focus>(listFocus).focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
}
Future<void> _pushPlaylistRoute(WidgetTester tester, _PlaylistHarness harness) async {
@@ -621,6 +729,10 @@ class _PagedPlaylistClient implements MediaServerClient {
AbortController? activeAbort;
final List<int?> requestedStarts = [];
final List<int?> requestedSizes = [];
final List<_MoveRequest> moveRequests = [];
final List<_RemoveRequest> removeRequests = [];
int activeMutationCount = 0;
int peakMutationCount = 0;
int deleteCalls = 0;
bool _hasFailed = false;
@@ -633,6 +745,8 @@ class _PagedPlaylistClient implements MediaServerClient {
_PagedPlaylistClient._(this.items, this.failOnceAt, this.deleteResult, this._backend);
List<String> get authoritativeItemIds => items.map((item) => item.id).toList();
@override
ServerId get serverId => ServerId('server_1');
@@ -668,6 +782,72 @@ class _PagedPlaylistClient implements MediaServerClient {
return fakeLibraryPage(items, start: start, size: size);
}
@override
Future<bool> movePlaylistItem({
required String playlistId,
required MediaItem item,
required int newIndex,
required MediaItem? afterItem,
}) async {
final request = _MoveRequest(item: item, newIndex: newIndex);
moveRequests.add(request);
activeMutationCount++;
peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount;
try {
return await request.result.future;
} finally {
activeMutationCount--;
}
}
void completeMove(int index, bool result, {bool applyToServer = false}) {
final request = moveRequests[index];
if (applyToServer) _applyMove(request);
request.result.complete(result);
}
void failMove(int index, {bool applyToServer = false}) {
final request = moveRequests[index];
if (applyToServer) _applyMove(request);
request.result.completeError(StateError('connection closed after playlist move'), StackTrace.current);
}
void _applyMove(_MoveRequest request) {
final oldIndex = items.indexWhere((item) => item.id == request.item.id);
if (oldIndex < 0) return;
final item = items.removeAt(oldIndex);
items.insert(request.newIndex.clamp(0, items.length), item);
}
@override
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) async {
final request = _RemoveRequest(item);
removeRequests.add(request);
activeMutationCount++;
peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount;
try {
return await request.result.future;
} finally {
activeMutationCount--;
}
}
void completeRemove(int index, bool result, {bool applyToServer = false}) {
final request = removeRequests[index];
if (applyToServer) _applyRemove(request);
request.result.complete(result);
}
void failRemove(int index, {bool applyToServer = false}) {
final request = removeRequests[index];
if (applyToServer) _applyRemove(request);
request.result.completeError(StateError('connection closed after playlist removal'), StackTrace.current);
}
void _applyRemove(_RemoveRequest request) {
items.removeWhere((item) => item.id == request.item.id);
}
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
deleteCalls++;
@@ -680,3 +860,18 @@ class _PagedPlaylistClient implements MediaServerClient {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _MoveRequest {
final MediaItem item;
final int newIndex;
final Completer<bool> result = Completer<bool>();
_MoveRequest({required this.item, required this.newIndex});
}
class _RemoveRequest {
final MediaItem item;
final Completer<bool> result = Completer<bool>();
_RemoveRequest(this.item);
}
@@ -9,149 +9,81 @@ import 'package:plezy/services/companion_remote/remote_auth_service.dart';
void main() {
group('LanDiscoveryService', () {
test(
'publishes a changed normalized IP set for an existing host',
() async {
final context = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index),
);
final listener = await _DiscoveryListener.start([context]);
test('publishes a changed normalized IP set for an existing host', () async {
final context = _authContext(id: 'context-a', discoveryKey: List<int>.generate(32, (index) => index));
final listener = await _DiscoveryListener.start([context]);
try {
listener.sendBeacon(context: context, ips: const ['192.0.2.10']);
await _waitFor(() => listener.emissions.length == 1);
try {
await listener.sendBeacon(context: context, ips: const ['192.0.2.10']);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: context,
ips: const ['192.0.2.30', '10.0.0.30'],
);
await _waitFor(() => listener.emissions.length == 2);
await listener.sendBeacon(context: context, ips: const ['192.0.2.30', '10.0.0.30']);
await _waitFor(() => listener.emissions.length == 2);
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.authContextId, 'context-a');
expect(host.ips, ['10.0.0.30', '192.0.2.30']);
expect(
host.addresses,
unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100']),
);
expect(host.addresses, isNot(contains('192.0.2.10:52100')));
} finally {
await listener.close();
}
},
);
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.authContextId, 'context-a');
expect(host.ips, ['10.0.0.30', '192.0.2.30']);
expect(host.addresses, unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100']));
expect(host.addresses, isNot(contains('192.0.2.10:52100')));
} finally {
await listener.close();
}
});
test(
'suppresses reordered IPs and publishes a platform-only change',
() async {
final context = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index + 32),
);
final listener = await _DiscoveryListener.start([context]);
test('suppresses reordered IPs and publishes a platform-only change', () async {
final context = _authContext(id: 'context-a', discoveryKey: List<int>.generate(32, (index) => index + 32));
final listener = await _DiscoveryListener.start([context]);
try {
listener.sendBeacon(
context: context,
platform: 'macOS',
ips: const ['192.0.2.40', '10.0.0.40'],
);
await _waitFor(() => listener.emissions.length == 1);
try {
await listener.sendBeacon(context: context, platform: 'macOS', ips: const ['192.0.2.40', '10.0.0.40']);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: context,
platform: 'macOS',
ips: const ['10.0.0.40', '192.0.2.40'],
);
listener.sendBeacon(
context: context,
platform: 'Android',
ips: const ['192.0.2.40', '10.0.0.40'],
);
await _waitFor(
() => listener.emissions.any(
(hosts) => hosts.single.platform == 'Android',
),
);
await listener.sendBeacon(context: context, platform: 'macOS', ips: const ['10.0.0.40', '192.0.2.40']);
await listener.sendBeacon(context: context, platform: 'Android', ips: const ['192.0.2.40', '10.0.0.40']);
await _waitFor(() => listener.emissions.any((hosts) => hosts.single.platform == 'Android'));
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.platform, 'Android');
expect(
host.addresses,
unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100']),
);
} finally {
await listener.close();
}
},
);
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.platform, 'Android');
expect(host.addresses, unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100']));
} finally {
await listener.close();
}
});
test(
'suppresses context-only churn and retains the usable context',
() async {
final firstContext = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index + 64),
);
final secondContext = _authContext(
id: 'context-b',
discoveryKey: List<int>.generate(32, (index) => index + 96),
);
final listener = await _DiscoveryListener.start([
firstContext,
secondContext,
]);
test('suppresses context-only churn and retains the usable context', () async {
final firstContext = _authContext(id: 'context-a', discoveryKey: List<int>.generate(32, (index) => index + 64));
final secondContext = _authContext(id: 'context-b', discoveryKey: List<int>.generate(32, (index) => index + 96));
final listener = await _DiscoveryListener.start([firstContext, secondContext]);
try {
listener.sendBeacon(
context: firstContext,
name: 'Living Room',
ips: const ['192.0.2.50'],
);
await _waitFor(() => listener.emissions.length == 1);
try {
await listener.sendBeacon(context: firstContext, name: 'Living Room', ips: const ['192.0.2.50']);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: secondContext,
name: 'Living Room',
ips: const ['192.0.2.50'],
);
listener.sendBeacon(
context: secondContext,
name: 'Living Room TV',
ips: const ['192.0.2.50'],
);
await _waitFor(
() => listener.emissions.any(
(hosts) => hosts.single.name == 'Living Room TV',
),
);
await listener.sendBeacon(context: secondContext, name: 'Living Room', ips: const ['192.0.2.50']);
await listener.sendBeacon(context: secondContext, name: 'Living Room TV', ips: const ['192.0.2.50']);
await _waitFor(() => listener.emissions.any((hosts) => hosts.single.name == 'Living Room TV'));
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
expect(hosts.single.clientId, 'shared-client');
expect(hosts.single.name, 'Living Room TV');
expect(hosts.single.authContextId, 'context-a');
} finally {
await listener.close();
}
},
);
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
expect(hosts.single.clientId, 'shared-client');
expect(hosts.single.name, 'Living Room TV');
expect(hosts.single.authContextId, 'context-a');
} finally {
await listener.close();
}
});
});
}
RemoteAuthContext _authContext({
required String id,
required List<int> discoveryKey,
}) {
RemoteAuthContext _authContext({required String id, required List<int> discoveryKey}) {
return RemoteAuthContext(
id: id,
backend: 'plex',
@@ -177,27 +109,18 @@ class _DiscoveryListener {
final StreamSubscription<List<DiscoveredHost>> subscription;
final List<List<DiscoveredHost>> emissions;
static Future<_DiscoveryListener> start(
List<RemoteAuthContext> contexts,
) async {
final service = LanDiscoveryService();
static Future<_DiscoveryListener> start(List<RemoteAuthContext> contexts) async {
final reservation = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
final discoveryPort = reservation.port;
reservation.close();
final service = LanDiscoveryService(discoveryPort: discoveryPort);
final emissions = <List<DiscoveredHost>>[];
final subscription = service
.startListeningForContexts(contexts)
.listen(emissions.add);
final sender = await RawDatagramSocket.bind(
InternetAddress.loopbackIPv4,
0,
);
final subscription = service.startListeningForContexts(contexts).listen(emissions.add);
final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
try {
await _waitFor(() => service.isListening);
return _DiscoveryListener._(
service: service,
sender: sender,
subscription: subscription,
emissions: emissions,
);
return _DiscoveryListener._(service: service, sender: sender, subscription: subscription, emissions: emissions);
} catch (_) {
sender.close();
await subscription.cancel();
@@ -206,13 +129,13 @@ class _DiscoveryListener {
}
}
void sendBeacon({
Future<void> sendBeacon({
required RemoteAuthContext context,
required List<String> ips,
String name = 'Living Room',
String platform = 'macOS',
int port = 52100,
}) {
}) async {
const version = 1;
final auth = RemoteAuthService.instance;
final homeHash = auth.computeDiscoveryTag(context.discoveryKey);
@@ -240,11 +163,11 @@ class _DiscoveryListener {
}),
);
sender.send(
packet,
InternetAddress.loopbackIPv4,
LanDiscoveryService.discoveryPort,
);
for (var attempt = 0; attempt < 100; attempt++) {
if (sender.send(packet, InternetAddress.loopbackIPv4, service.discoveryPort) == packet.length) return;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
fail('Timed out sending LAN discovery beacon');
}
Future<void> close() async {
@@ -670,6 +670,8 @@ class _RawWebSocketClient {
}
static Future<_RawWebSocketClient> connect(int port) async {
// Ownership transfers to the returned helper.
// ignore: close_sinks
final socket = await WebSocket.connect('ws://127.0.0.1:$port/ws').timeout(_ioTimeout);
final client = _RawWebSocketClient._(socket);
try {
@@ -0,0 +1,117 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/metadata_edit/plex_metadata_edit_adapter.dart';
import 'package:plezy/services/plex_api_cache.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
});
tearDown(() => database.close());
test('metadata preference request uses includePreferences and canonicalizes server values', () async {
Uri? requestedUri;
final client = testPlexClient(
handler: (request) async {
requestedUri = request.url;
return _metadataResponse([
{'id': 'episodeSort', 'value': 0},
{'id': 'autoDeletionItemPolicyUnwatchedLibrary', 'value': 3},
{'id': 'autoDeletionItemPolicyWatchedLibrary', 'value': 7},
{'id': 'flattenSeasons', 'value': 1},
{'id': 'showOrdering', 'value': 'tvdbAbsolute'},
{'id': 'languageOverride', 'value': 'fr-FR'},
{'id': 'useOriginalTitle', 'value': true},
{'id': 'audioLanguage', 'value': 'ja'},
{'id': 'subtitleLanguage', 'value': 'en'},
{'id': 'subtitleMode', 'value': 2},
]);
},
);
addTearDown(client.close);
final draft = await PlexMetadataEditAdapter(client).load(_show());
expect(requestedUri?.path, '/library/metadata/show-1');
expect(requestedUri?.queryParameters['includePreferences'], '1');
expect(draft.value<String>('originalTitle'), 'Original show title');
expect(draft.value<String>('pref:episodeSort'), '0');
expect(draft.value<String>('pref:autoDeletionItemPolicyUnwatchedLibrary'), '3');
expect(draft.value<String>('pref:autoDeletionItemPolicyWatchedLibrary'), '7');
expect(draft.value<String>('pref:flattenSeasons'), '1');
expect(draft.value<String>('pref:showOrdering'), 'tvdbAbsolute');
expect(draft.value<String>('pref:languageOverride'), 'fr-FR');
expect(draft.value<String>('pref:useOriginalTitle'), '1');
expect(draft.value<String>('pref:audioLanguage'), 'ja');
expect(draft.value<String>('pref:subtitleLanguage'), 'en');
expect(draft.value<String>('pref:subtitleMode'), '2');
});
test('missing preference rows remain unset instead of becoming fabricated defaults', () async {
final client = testPlexClient(
handler: (_) async => _metadataResponse([
{'id': 'episodeSort', 'value': 0},
]),
);
addTearDown(client.close);
final draft = await PlexMetadataEditAdapter(client).load(_show());
expect(draft.value<String>('pref:episodeSort'), '0');
expect(draft.value<String>('pref:flattenSeasons'), isNull);
expect(draft.value<String>('pref:showOrdering'), isNull);
expect(draft.value<String>('pref:languageOverride'), isNull);
expect(draft.value<String>('pref:useOriginalTitle'), isNull);
expect(draft.value<String>('pref:audioLanguage'), isNull);
expect(draft.value<String>('pref:subtitleMode'), isNull);
});
test('metadata preference request failures preserve the editable basic fields', () async {
final client = testPlexClient(handler: (_) async => http.Response('{}', 503));
addTearDown(client.close);
final draft = await PlexMetadataEditAdapter(client).load(_show());
expect(draft.value<String>('title'), 'Show');
expect(draft.value<String>('originalTitle'), 'Original show title');
expect(draft.value<String>('pref:episodeSort'), isNull);
});
}
http.Response _metadataResponse(List<Object?> settings) {
return http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{'ratingKey': 'show-1', 'Setting': settings},
],
},
}),
200,
headers: const {'content-type': 'application/json'},
);
}
MediaItem _show() => testMediaItem(
id: 'show-1',
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'Show',
originalTitle: 'Original show title',
summary: 'Summary',
libraryId: '1',
);