fix(media): serialize browsing and metadata mutations
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
Reference in New Issue
Block a user