diff --git a/lib/main.dart b/lib/main.dart index 98206e90..26c44e70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -48,6 +48,7 @@ import 'providers/multi_server_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/hidden_libraries_provider.dart'; import 'providers/libraries_provider.dart'; +import 'providers/discover_provider.dart'; import 'providers/playback_state_provider.dart'; import 'providers/download_provider.dart'; import 'providers/offline_mode_provider.dart'; @@ -919,6 +920,17 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, ), + ChangeNotifierProvider( + create: (context) { + final activeProfile = context.read(); + return DiscoverProvider( + context.read(), + context.read(), + context.read(), + isProfileBinding: () => activeProfile.isBinding, + ); + }, + ), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()), diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart new file mode 100644 index 00000000..aee9988f --- /dev/null +++ b/lib/providers/discover_provider.dart @@ -0,0 +1,389 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../media/ids.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import '../mixins/disposable_change_notifier_mixin.dart'; +import '../mixins/event_aware.dart'; +import '../services/settings_service.dart'; +import '../services/system_shelf_service.dart'; +import '../utils/app_logger.dart'; +import '../utils/global_key_utils.dart'; +import '../utils/media_hub_ordering.dart'; +import '../utils/watch_state_notifier.dart'; +import 'hidden_libraries_provider.dart'; +import 'libraries_provider.dart'; +import 'multi_server_provider.dart'; + +enum DiscoverLoadState { initial, loading, loaded, error } + +/// Owns the Discover tab's data: the Continue Watching row and the home hub +/// list, including the refresh policy that used to live in the screen — +/// watch events refresh only Continue Watching (one on-deck call, zero hub +/// refetches), hidden-library changes trigger a full reload, library-order +/// changes re-sort hubs in place without refetching, and the platform +/// launcher shelf syncs from every on-deck update. +/// +/// Lives inside the profile-keyed provider subtree, so a profile switch +/// resets it by construction. The screen is a consumer: it renders this +/// state and keeps only UI concerns (hero carousel, focus, spotlight). +class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin { + /// Preview row caps at 20; one extra item is fetched as a probe so + /// [hasMoreContinueWatching] can show the "more" affordance without a + /// second request. + static const int continueWatchingPreviewLimit = 20; + static const int _continueWatchingProbeLimit = continueWatchingPreviewLimit + 1; + + DiscoverProvider(this._multiServer, this._hiddenLibraries, this._libraries, {required this.isProfileBinding}) { + _hiddenLibraries.addListener(_onHiddenLibrariesChanged); + _lastSeenLibraryOrderKeys = _libraryOrderKeys(); + _libraries.addListener(_onLibrariesChanged); + _watchStateSubscription = subscribeToHierarchicalEvents( + notifier: WatchStateNotifier(), + mounted: () => !isDisposed, + serverId: () => null, + globalKeys: () => _watchedGlobalKeys, + itemIds: () => _watchedIds, + onEvent: _onWatchStateChanged, + ); + } + + final MultiServerProvider _multiServer; + final HiddenLibrariesProvider _hiddenLibraries; + final LibrariesProvider _libraries; + + /// Whether the profile binder is still wiring servers — a no-servers load + /// during binding stays in the loading state instead of flashing an error + /// (main_screen primes another load once binding settles). + final bool Function() isProfileBinding; + + StreamSubscription? _watchStateSubscription; + + List _onDeck = []; + List _hubs = []; + bool _hasMoreContinueWatching = false; + DiscoverLoadState _onDeckState = DiscoverLoadState.initial; + DiscoverLoadState _hubsState = DiscoverLoadState.initial; + String? _errorMessage; + int _loadGeneration = 0; + + Set _lastSeenHiddenKeys = {}; + List _lastSeenLibraryOrderKeys = const []; + + Future? _inFlightLoad; + bool _hasPendingLoad = false; + + Future? _systemShelfSyncFuture; + List? _pendingSystemShelfItems; + + List get onDeck => _onDeck; + List get hubs => _hubs; + bool get hasMoreContinueWatching => _hasMoreContinueWatching; + + /// Raw load failure (unlocalized); the screen wraps it for display. + String? get errorMessage => _errorMessage; + + /// True until the first on-deck result (or error) of a [load] pass lands. + bool get isLoading => _onDeckState == DiscoverLoadState.initial || _onDeckState == DiscoverLoadState.loading; + + bool get areHubsLoading => _hubsState == DiscoverLoadState.initial || _hubsState == DiscoverLoadState.loading; + + /// Bumped each time a [load] pass replaces the on-deck list. The screen + /// uses this to distinguish "full reload — reset the hero carousel" from + /// a background Continue Watching refresh (clamp only). + int get loadGeneration => _loadGeneration; + + /// Full load of Continue Watching + hubs. Concurrent calls coalesce into + /// the in-flight pass plus at most one trailing pass (so a request that + /// arrives mid-load still observes its own fresh fetch). + Future load() { + _hasPendingLoad = true; + return _inFlightLoad ??= _runLoadLoop().whenComplete(() => _inFlightLoad = null); + } + + Future _runLoadLoop() async { + while (_hasPendingLoad && !isDisposed) { + _hasPendingLoad = false; + await _loadOnce(); + } + } + + Future _loadOnce() async { + // Yield to the microtask queue before the first notify so a load() + // kicked off during build (the screen's initState) doesn't mark + // listening widgets dirty mid-build. + await null; + appLogger.d('DiscoverProvider: loading content from all servers'); + _onDeckState = DiscoverLoadState.loading; + _hubsState = DiscoverLoadState.loading; + _errorMessage = null; + safeNotifyListeners(); + + try { + if (!_multiServer.hasConnectedServers) { + if (isProfileBinding()) return; + throw Exception('No servers available'); + } + + await _hiddenLibraries.ensureInitialized(); + if (isDisposed) return; + _lastSeenHiddenKeys = Set.of(_hiddenLibraries.hiddenLibraryKeys); + + final settings = await SettingsService.getInstance(); + final useGlobalHubs = settings.read(SettingsService.useGlobalHubs); + final aggregation = _multiServer.aggregationService; + + // On-deck and hubs fetch in parallel; on-deck is published as soon as + // it lands so the hero renders while hubs are still loading. + final onDeckFuture = aggregation.getOnDeckFromAllServers( + limit: _continueWatchingProbeLimit, + hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys, + ); + final hubsFuture = aggregation.getHubsFromAllServers( + hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys, + useGlobalHubs: useGlobalHubs, + includePlaybackHubs: false, + ); + + final fetchedOnDeck = await onDeckFuture; + if (isDisposed) return; + _applyOnDeck(fetchedOnDeck); + _onDeckState = DiscoverLoadState.loaded; + _loadGeneration++; + safeNotifyListeners(); + unawaited(_syncSystemShelf(_onDeck)); + + final allHubs = await hubsFuture; + if (isDisposed) return; + + // Playback-progress hubs duplicate the top Continue Watching row. + final filteredHubs = allHubs.where((hub) { + final hubId = hub.identifier?.toLowerCase() ?? ''; + final title = hub.title.toLowerCase(); + return !hubId.contains('ondeck') && + !hubId.contains('continue') && + !hubId.contains('nextup') && + !title.contains('continue watching') && + !title.contains('on deck') && + !title.contains('next up'); + }).toList(); + sortMediaHubsByLibraryOrder(filteredHubs, _libraries.libraries); + + appLogger.d('DiscoverProvider: ${_onDeck.length} on-deck items, ${filteredHubs.length} hubs'); + _hubs = filteredHubs; + _hubsState = DiscoverLoadState.loaded; + safeNotifyListeners(); + } catch (e) { + appLogger.e('Failed to load discover content', error: e); + if (isDisposed) return; + _errorMessage = e.toString(); + _onDeckState = DiscoverLoadState.error; + _hubsState = DiscoverLoadState.error; + safeNotifyListeners(); + } + } + + /// Background refresh of Continue Watching only — never flips load states + /// or surfaces errors (a stale row beats an error flash), never refetches + /// hubs. + Future refreshContinueWatching() async { + try { + if (!_multiServer.hasConnectedServers) return; + final fetched = await _multiServer.aggregationService.getOnDeckFromAllServers( + limit: _continueWatchingProbeLimit, + hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys, + ); + if (isDisposed) return; + _applyOnDeck(fetched); + safeNotifyListeners(); + unawaited(_syncSystemShelf(_onDeck)); + } catch (e) { + appLogger.w('Failed to refresh Continue Watching', error: e); + } + } + + /// The full unlimited Continue Watching list for the hub's load-more path. + Future> loadAllContinueWatching() async { + if (!_multiServer.hasConnectedServers) return const []; + await _hiddenLibraries.ensureInitialized(); + if (isDisposed) return const []; + return _multiServer.aggregationService.getOnDeckFromAllServers( + hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys, + ); + } + + /// Refetch a single item (post-edit refresh from a hub row) and swap it + /// into whichever lists contain it. Items can come from any registered + /// server, so the owning server is resolved by scanning the visible lists. + Future updateItem(String itemId) async { + try { + final serverId = _serverIdForItem(itemId); + if (serverId == null) return; + final updated = await _multiServer.getClientForServer(ServerId(serverId))?.fetchItem(itemId); + if (updated == null || isDisposed) return; + _updateItemInLists(itemId, updated); + safeNotifyListeners(); + } catch (_) { + // Silently fail — the item will refresh on the next full reload. + } + } + + String? _serverIdForItem(String itemId) { + for (final item in _onDeck) { + if (item.id == itemId) return item.serverId; + } + for (final hub in _hubs) { + for (final item in hub.items) { + if (item.id == itemId) return item.serverId; + } + } + return null; + } + + void _updateItemInLists(String itemId, MediaItem updatedItem) { + final onDeckIndex = _onDeck.indexWhere((item) => item.id == itemId); + if (onDeckIndex != -1) { + _onDeck = List.of(_onDeck)..[onDeckIndex] = updatedItem; + } + + for (var i = 0; i < _hubs.length; i++) { + final hub = _hubs[i]; + final itemIndex = hub.items.indexWhere((item) => item.id == itemId); + if (itemIndex != -1) { + final newItems = List.from(hub.items); + newItems[itemIndex] = updatedItem; + _hubs = List.of(_hubs)..[i] = hub.copyWith(items: newItems); + } + } + } + + void _applyOnDeck(List fetched) { + final hasMore = fetched.length > continueWatchingPreviewLimit; + _onDeck = hasMore ? fetched.take(continueWatchingPreviewLimit).toList() : fetched; + _hasMoreContinueWatching = hasMore; + } + + // --- Event reactions ----------------------------------------------------- + + /// Watch on-deck items and their parent shows/seasons (an episode's watch + /// flip changes what Continue Watching should show for its series). + Set? get _watchedIds { + final keys = {}; + for (final item in _onDeck) { + keys.add(item.id); + if (item.parentId != null) keys.add(item.parentId!); + if (item.grandparentId != null) keys.add(item.grandparentId!); + } + return keys; + } + + Set? get _watchedGlobalKeys { + final keys = {}; + for (final item in _onDeck) { + final serverId = item.serverId; + if (serverId == null) return null; + + keys.add(buildGlobalKey(ServerId(serverId), item.id)); + if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); + if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); + } + return keys; + } + + void _onWatchStateChanged(WatchStateEvent event) { + if (event.changeType == WatchStateChangeType.removedFromContinueWatching) { + final remaining = _onDeck.where((item) => item.id != event.itemId).toList(); + if (remaining.length != _onDeck.length) { + _onDeck = remaining; + safeNotifyListeners(); + } + } + unawaited(refreshContinueWatching()); + } + + void _onHiddenLibrariesChanged() { + final currentKeys = _hiddenLibraries.hiddenLibraryKeys; + if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) { + return; + } + _lastSeenHiddenKeys = Set.of(currentKeys); + unawaited(load()); + } + + void _onLibrariesChanged() { + final currentKeys = _libraryOrderKeys(); + if (listEquals(currentKeys, _lastSeenLibraryOrderKeys)) return; + _lastSeenLibraryOrderKeys = currentKeys; + if (_hubs.isEmpty) return; + + final sortedHubs = List.from(_hubs); + if (!sortMediaHubsByLibraryOrder(sortedHubs, _libraries.libraries)) return; + _hubs = sortedHubs; + safeNotifyListeners(); + } + + List _libraryOrderKeys() => [for (final library in _libraries.libraries) library.globalKey]; + + // --- Platform launcher shelf ---------------------------------------------- + + /// Sync Continue Watching to the platform launcher shelf. Rapid updates + /// coalesce: a sync that arrives while one is in flight queues exactly one + /// follow-up pass with the latest items. + Future _syncSystemShelf(List onDeck) async { + _pendingSystemShelfItems = List.unmodifiable(onDeck); + if (_systemShelfSyncFuture != null) { + await _systemShelfSyncFuture; + return; + } + + final syncFuture = _drainSystemShelfSyncQueue(); + _systemShelfSyncFuture = syncFuture; + await syncFuture; + } + + Future _drainSystemShelfSyncQueue() async { + try { + while (_pendingSystemShelfItems != null) { + final onDeck = _pendingSystemShelfItems!; + _pendingSystemShelfItems = null; + if (isDisposed) return; + + try { + final settings = await SettingsService.getInstance(); + await SystemShelfService().syncFromContinueWatching( + onDeck, + _clientWithFallback, + hideSpoilers: settings.read(SettingsService.hideSpoilers), + ); + } catch (e) { + appLogger.w('Failed to sync system shelf', error: e); + } + } + } finally { + _systemShelfSyncFuture = null; + } + } + + MediaServerClient _clientWithFallback(ServerId serverId) { + final direct = _multiServer.getClientForServer(serverId); + if (direct != null) return direct; + for (final id in _multiServer.onlineServerIds) { + final fallback = _multiServer.getClientForServer(ServerId(id)); + if (fallback != null) return fallback; + } + throw Exception('No client available for $serverId'); + } + + @override + void dispose() { + _hiddenLibraries.removeListener(_onHiddenLibrariesChanged); + _libraries.removeListener(_onLibrariesChanged); + _watchStateSubscription?.cancel(); + _watchStateSubscription = null; + _pendingSystemShelfItems = null; + super.dispose(); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index ff0d9e74..6510c846 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -11,7 +11,6 @@ import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; -import '../utils/global_key_utils.dart'; import 'package:cached_network_image_ce/cached_network_image.dart'; import '../services/apple_tv_remote_touch_service.dart'; @@ -23,9 +22,9 @@ import '../media/media_hub.dart'; import '../utils/media_image_helper.dart'; import '../utils/content_utils.dart'; import '../widgets/optimized_media_image.dart' show blurArtwork; +import '../providers/discover_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/hidden_libraries_provider.dart'; -import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../providers/watch_state_store.dart'; import '../widgets/hub_section.dart'; @@ -52,21 +51,16 @@ import '../widgets/tv_spotlight_background.dart'; import '../mixins/refreshable.dart'; import '../mixins/tab_visibility_aware.dart'; import '../i18n/strings.g.dart'; -import '../mixins/item_updatable.dart'; -import '../mixins/watch_state_aware.dart'; -import '../utils/watch_state_notifier.dart'; import '../utils/app_logger.dart'; import '../utils/debouncer.dart'; import '../utils/dialogs.dart'; import '../utils/formatters.dart'; -import '../utils/media_hub_ordering.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/layout_constants.dart'; import '../utils/platform_detector.dart'; import '../theme/mono_tokens.dart'; -import '../services/system_shelf_service.dart'; import 'auth_screen.dart'; import 'libraries/content_state_builder.dart'; import 'main_screen.dart'; @@ -84,59 +78,27 @@ class DiscoverScreen extends StatefulWidget { } class _DiscoverScreenState extends State - with - Refreshable, - FullRefreshable, - ItemUpdatable, - WatchStateAware, - TabVisibilityAware, - FocusableTab, - WidgetsBindingObserver { + with Refreshable, FullRefreshable, TabVisibilityAware, FocusableTab, WidgetsBindingObserver { static const Duration _heroAutoScrollDuration = Duration(seconds: 8); static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200); - static const int _continueWatchingPreviewLimit = 20; - static const int _continueWatchingProbeLimit = _continueWatchingPreviewLimit + 1; - /// Items in [_onDeck] and [_hubs] can come from any registered server - /// (Plex or Jellyfin), so resolve the server per-item rather than via the - /// mixin's single-server [itemServerId] hook. - @override - Future updateItem(String itemId) async { - if (!mounted) return; + /// Data + refresh policy live in [DiscoverProvider]; this state keeps only + /// UI concerns (hero carousel, focus, spotlight). The proxy getters keep + /// the build code reading naturally. + late final DiscoverProvider _discover; + int _seenLoadGeneration = 0; - try { - final serverId = _serverIdForItem(itemId); - if (serverId == null) return; - final updated = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(itemId); - if (updated == null || !mounted) return; - setState(() { - updateItemInLists(itemId, updated); - }); - } catch (_) { - // Silently fail — the item will refresh on the next full reload. - } + List get _onDeck => _discover.onDeck; + List get _hubs => _discover.hubs; + bool get _hasMoreContinueWatching => _discover.hasMoreContinueWatching; + bool get _isLoading => _discover.isLoading; + bool get _areHubsLoading => _discover.areHubsLoading; + String? get _errorMessage { + final raw = _discover.errorMessage; + return raw == null ? null : t.errors.failedToLoad(context: t.discover.title, error: raw); } - /// Locate the server that owns [itemId] by scanning the visible lists. - String? _serverIdForItem(String itemId) { - for (final item in _onDeck) { - if (item.id == itemId) return item.serverId; - } - for (final hub in _hubs) { - for (final item in hub.items) { - if (item.id == itemId) return item.serverId; - } - } - return null; - } - - List _onDeck = []; - List _hubs = []; - bool _hasMoreContinueWatching = false; - bool _isLoading = true; - bool _areHubsLoading = true; bool _switchingProfile = false; - String? _errorMessage; final PageController _heroController = PageController(); final ScrollController _scrollController = ScrollController(); int _currentHeroIndex = 0; @@ -152,64 +114,6 @@ class _DiscoverScreenState extends State // full-screen backdrop for every intermediate item. final Debouncer _spotlightDebouncer = Debouncer(const Duration(milliseconds: 150)); bool _isTabVisible = true; - HiddenLibrariesProvider? _hiddenLibrariesProvider; - LibrariesProvider? _librariesProvider; - Set _lastSeenHiddenKeys = {}; - List _lastSeenLibraryOrderKeys = const []; - Future? _systemShelfSyncFuture; - List? _pendingSystemShelfItems; - - // WatchStateAware: watch on-deck items and their parent shows/seasons - @override - Set? get watchedIds { - final keys = {}; - for (final item in _onDeck) { - keys.add(item.id); - if (item.parentId != null) { - keys.add(item.parentId!); - } - if (item.grandparentId != null) { - keys.add(item.grandparentId!); - } - } - return keys; - } - - @override - Set? get watchedGlobalKeys { - final keys = {}; - for (final item in _onDeck) { - final serverId = item.serverId; - if (serverId == null) return null; - - keys.add(buildGlobalKey(ServerId(serverId), item.id)); - if (item.parentId != null) { - keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); - } - if (item.grandparentId != null) { - keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); - } - } - return keys; - } - - @override - void onWatchStateChanged(WatchStateEvent event) { - if (event.changeType == WatchStateChangeType.removedFromContinueWatching) { - _removeContinueWatchingItem(event.itemId); - unawaited(_refreshContinueWatching()); - return; - } - - // Refresh continue watching when any relevant item changes - unawaited(_refreshContinueWatching()); - } - - void _removeContinueWatchingItem(String itemId) { - setState(() { - _onDeck.removeWhere((item) => item.id == itemId); - }); - } // Track initial load so we can focus hero when content first appears bool _initialLoadComplete = false; @@ -217,7 +121,8 @@ class _DiscoverScreenState extends State // Hub navigation keys GlobalKey? _continueWatchingHubKey; - final List> _hubKeys = []; + final Map> _hubKeysByIdentity = {}; + List> _orderedHubKeys = const []; final _tvBrowseRailKey = GlobalKey(); // Hero and app bar focus @@ -237,15 +142,27 @@ class _DiscoverScreenState extends State return context.tryGetMediaClientForServer(ServerId(serverId)); } - /// Update hub keys when hubs list changes — reuse existing keys to avoid - /// mass deep unmounts (ARM32 stack overflow during finalizeTree). + String _hubIdentity(MediaHub hub) => '${hub.serverId ?? ''}:${hub.identifier ?? hub.id}'; + + /// Rebuild the per-hub focus keys, keyed by hub *identity* rather than + /// list position so a row's focus memory follows it when the provider + /// re-sorts hubs (library-order change). Existing keys are reused to avoid + /// mass deep unmounts (ARM32 stack overflow during finalizeTree); + /// duplicate identities get positional suffixes so two rows can never + /// share a GlobalKey. void _updateHubKeys() { - while (_hubKeys.length < _hubs.length) { - _hubKeys.add(GlobalKey()); - } - if (_hubKeys.length > _hubs.length) { - _hubKeys.removeRange(_hubs.length, _hubKeys.length); + final occurrences = {}; + final liveIdentities = {}; + final ordered = >[]; + for (final hub in _hubs) { + var identity = _hubIdentity(hub); + final occurrence = occurrences.update(identity, (n) => n + 1, ifAbsent: () => 0); + if (occurrence > 0) identity = '$identity#$occurrence'; + liveIdentities.add(identity); + ordered.add(_hubKeysByIdentity.putIfAbsent(identity, GlobalKey.new)); } + _hubKeysByIdentity.removeWhere((identity, _) => !liveIdentities.contains(identity)); + _orderedHubKeys = ordered; _continueWatchingHubKey ??= GlobalKey(); } @@ -255,7 +172,7 @@ class _DiscoverScreenState extends State if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) { keys.add(_continueWatchingHubKey!); } - keys.addAll(_hubKeys); + keys.addAll(_orderedHubKeys); return keys; } @@ -450,10 +367,58 @@ class _DiscoverScreenState extends State WidgetsBinding.instance.addObserver(this); _heroFocusNode = FocusNode(debugLabel: 'hero_section'); _heroFocusNode.addListener(_onHeroFocusChanged); - _loadContent(); + _discover = context.read(); + _seenLoadGeneration = _discover.loadGeneration; + _discover.addListener(_onDiscoverChanged); + _updateHubKeys(); + unawaited(_discover.load()); _startAutoScroll(); } + /// Mirror provider changes into this state's UI concerns: rebuild, apply + /// pending TV-rail focus, and keep the hero carousel index in sync — a + /// fresh [DiscoverProvider.load] resets it, a background Continue Watching + /// refresh only clamps it. + void _onDiscoverChanged() { + if (!mounted) return; + final generation = _discover.loadGeneration; + final isNewLoad = generation != _seenLoadGeneration; + _seenLoadGeneration = generation; + final heroOutOfBounds = _currentHeroIndex >= _onDeck.length; + + setState(() { + if (isNewLoad || heroOutOfBounds) { + _currentHeroIndex = 0; + } + _updateHubKeys(); + }); + _applyPendingTvBrowseRailFocus(); + + if ((isNewLoad || heroOutOfBounds) && _heroController.hasClients && _onDeck.isNotEmpty) { + _heroController.jumpToPage(0); + } + // Focus hero when fresh content lands, but only if no modal route is on top + if (isNewLoad && !PlatformDetector.isTV() && _onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) { + _heroFocusNode.requestFocus(); + } + + // On initial load, focus content so the user doesn't start on the toolbar + if (!_initialLoadComplete) { + if (PlatformDetector.isTV() && (_onDeck.isNotEmpty || _hubs.isNotEmpty)) { + _initialLoadComplete = true; + _focusTvBrowseRailWhenReady(); + } else if (!PlatformDetector.isTV() && _onDeck.isNotEmpty) { + _initialLoadComplete = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; + if (_heroFocusNode.canRequestFocus) { + _heroFocusNode.requestFocus(); + } + }); + } + } + } + void _onHeroFocusChanged() { if (!PlatformDetector.isTV()) return; @@ -470,61 +435,6 @@ class _DiscoverScreenState extends State } } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final provider = context.read(); - if (provider != _hiddenLibrariesProvider) { - _hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged); - _hiddenLibrariesProvider = provider; - _hiddenLibrariesProvider!.addListener(_onHiddenLibrariesChanged); - } - final librariesProvider = context.read(); - if (librariesProvider != _librariesProvider) { - _librariesProvider?.removeListener(_onLibrariesChanged); - _librariesProvider = librariesProvider; - _lastSeenLibraryOrderKeys = _libraryOrderKeys(librariesProvider); - _librariesProvider!.addListener(_onLibrariesChanged); - } - } - - void _onHiddenLibrariesChanged() { - final currentKeys = _hiddenLibrariesProvider?.hiddenLibraryKeys ?? {}; - if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) { - return; // No actual change - } - _lastSeenHiddenKeys = Set.of(currentKeys); - _loadContent(); - } - - void _onLibrariesChanged() { - final provider = _librariesProvider; - if (provider == null) return; - final currentKeys = _libraryOrderKeys(provider); - if (_sameStringList(currentKeys, _lastSeenLibraryOrderKeys)) return; - _lastSeenLibraryOrderKeys = currentKeys; - if (_hubs.isEmpty || !mounted) return; - - final sortedHubs = List.from(_hubs); - if (!sortMediaHubsByLibraryOrder(sortedHubs, provider.libraries)) return; - setState(() { - _hubs = sortedHubs; - _updateHubKeys(); - }); - } - - List _libraryOrderKeys(LibrariesProvider provider) { - return [for (final library in provider.libraries) library.globalKey]; - } - - bool _sameStringList(List a, List b) { - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } - /// Handle key events for the hero section. KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) { final backResult = handleBackKeyAction(event, _navigateToSidebar); @@ -558,14 +468,12 @@ class _DiscoverScreenState extends State @override void dispose() { - _hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged); - _librariesProvider?.removeListener(_onLibrariesChanged); + _discover.removeListener(_onDiscoverChanged); WidgetsBinding.instance.removeObserver(this); _autoScrollTimer?.cancel(); _indicatorTimer?.cancel(); _spotlightDebouncer.dispose(); _spotlightItem.dispose(); - _pendingSystemShelfItems = null; _indicatorProgress.dispose(); _heroController.dispose(); _scrollController.dispose(); @@ -582,7 +490,7 @@ class _DiscoverScreenState extends State // Refresh continue watching on mobile only // (on desktop, "resumed" fires on every window focus gain) if (Platform.isIOS || Platform.isAndroid) { - _refreshContinueWatching(); + unawaited(_discover.refreshContinueWatching()); } } else if (state == AppLifecycleState.inactive || state == AppLifecycleState.hidden) { // Stop animations to prevent scroll state corruption while backgrounded @@ -721,252 +629,17 @@ class _DiscoverScreenState extends State return 8.0; // Normal size } - Future _loadContent() async { - appLogger.d('Loading discover content from all servers'); - setState(() { - _isLoading = true; - _areHubsLoading = true; - _errorMessage = null; - }); - - try { - appLogger.d('Fetching onDeck and global hubs from all Plex servers'); - final multiServerProvider = Provider.of(context, listen: false); - - if (!multiServerProvider.hasConnectedServers) { - // Stay in the loading state set above (no error, no spinner replacement) - // when the binder hasn't finished wiring servers yet — main_screen - // calls fullRefresh() once binding settles. Surfacing the throw here - // would briefly flash an error during cold start. - final activeProfile = Provider.of(context, listen: false); - if (activeProfile.isBinding) return; - throw Exception('No servers available'); - } - - // Get hidden libraries for filtering - final hiddenLibrariesProvider = Provider.of(context, listen: false); - await hiddenLibrariesProvider.ensureInitialized(); - if (!mounted) return; - _lastSeenHiddenKeys = Set.of(hiddenLibrariesProvider.hiddenLibraryKeys); - - // Let aggregation service fetch libraries internally; the LibrariesProvider - // stores neutral MediaLibrary objects. - - // Start OnDeck and hubs fetch in parallel - final useGlobalHubs = context.settingsRead(SettingsService.useGlobalHubs); - final onDeckFuture = multiServerProvider.aggregationService.getOnDeckFromAllServers( - limit: _continueWatchingProbeLimit, - hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - ); - final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers( - hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - useGlobalHubs: useGlobalHubs, - includePlaybackHubs: false, - ); - - // Wait for OnDeck to complete and show it immediately - final fetchedOnDeck = await onDeckFuture; - final hasMoreContinueWatching = fetchedOnDeck.length > _continueWatchingPreviewLimit; - final onDeck = hasMoreContinueWatching - ? fetchedOnDeck.take(_continueWatchingPreviewLimit).toList() - : fetchedOnDeck; - - if (!mounted) return; - setState(() { - _onDeck = onDeck; - _hasMoreContinueWatching = hasMoreContinueWatching; - _isLoading = false; // Show content, but hubs still loading - - // Reset hero index to avoid sync issues - _currentHeroIndex = 0; - - // Create continue watching hub key if needed - if (_onDeck.isNotEmpty) { - _continueWatchingHubKey ??= GlobalKey(); - } - }); - _applyPendingTvBrowseRailFocus(); - - // Focus hero section now that it's visible, but only if no modal route is on top - if (!PlatformDetector.isTV() && onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) { - _heroFocusNode.requestFocus(); - } - - unawaited(_syncSystemShelf(onDeck)); - - // Sync PageController to first page after OnDeck loads - if (_heroController.hasClients && onDeck.isNotEmpty) { - _heroController.jumpToPage(0); - } - - // On initial load, focus the hero so the user starts on content (not the toolbar) - if (!_initialLoadComplete && onDeck.isNotEmpty) { - _initialLoadComplete = true; - if (PlatformDetector.isTV()) { - _focusTvBrowseRailWhenReady(); - } else { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; - if (_heroFocusNode.canRequestFocus) { - _heroFocusNode.requestFocus(); - } - }); - } - } - - // Wait for global hubs - final allHubs = await hubsFuture; - - if (!mounted) return; - - // Filter out playback-progress hubs handled by the top Continue Watching row. - final filteredHubs = allHubs.where((hub) { - final hubId = hub.identifier?.toLowerCase() ?? ''; - final title = hub.title.toLowerCase(); - return !hubId.contains('ondeck') && - !hubId.contains('continue') && - !hubId.contains('nextup') && - !title.contains('continue watching') && - !title.contains('on deck') && - !title.contains('next up'); - }).toList(); - - final libraryOrder = context.read().libraries; - sortMediaHubsByLibraryOrder(filteredHubs, libraryOrder); - - appLogger.d('Received ${onDeck.length} on deck items and ${filteredHubs.length} global hubs from all servers'); - if (!mounted) return; - setState(() { - _hubs = filteredHubs; - _areHubsLoading = false; - _updateHubKeys(); - }); - _applyPendingTvBrowseRailFocus(); - - if (PlatformDetector.isTV() && !_initialLoadComplete && filteredHubs.isNotEmpty) { - _initialLoadComplete = true; - _focusTvBrowseRailWhenReady(); - } - - appLogger.d('Discover content loaded successfully'); - } catch (e) { - appLogger.e('Failed to load discover content', error: e); - if (!mounted) return; - setState(() { - _errorMessage = t.errors.failedToLoad(context: t.discover.title, error: e.toString()); - _isLoading = false; - _areHubsLoading = false; - }); - } - } - - /// Refresh only the Continue Watching section in the background - /// This is called when returning to the home screen to avoid blocking UI - Future _refreshContinueWatching() async { - appLogger.d('Refreshing Continue Watching in background from all servers'); - - try { - final multiServerProvider = context.read(); - if (!multiServerProvider.hasConnectedServers) { - appLogger.w('No servers available for background refresh'); - return; - } - - final hiddenLibrariesProvider = context.read(); - final fetchedOnDeck = await multiServerProvider.aggregationService.getOnDeckFromAllServers( - limit: _continueWatchingProbeLimit, - hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - ); - final hasMoreContinueWatching = fetchedOnDeck.length > _continueWatchingPreviewLimit; - final onDeck = hasMoreContinueWatching - ? fetchedOnDeck.take(_continueWatchingPreviewLimit).toList() - : fetchedOnDeck; - - if (mounted) { - setState(() { - _onDeck = onDeck; - _hasMoreContinueWatching = hasMoreContinueWatching; - // Reset hero index if needed - if (_currentHeroIndex >= onDeck.length) { - _currentHeroIndex = 0; - if (_heroController.hasClients && onDeck.isNotEmpty) { - _heroController.jumpToPage(0); - } - } - }); - - unawaited(_syncSystemShelf(onDeck)); - - appLogger.d('Continue Watching refreshed successfully'); - } - } catch (e) { - appLogger.w('Failed to refresh Continue Watching', error: e); - // Silently fail - don't show error to user for background refresh - } - } - - Future> _loadAllContinueWatchingItems() async { - final multiServerProvider = context.read(); - if (!multiServerProvider.hasConnectedServers) return const []; - - final hiddenLibrariesProvider = context.read(); - await hiddenLibrariesProvider.ensureInitialized(); - if (!mounted) return const []; - - return multiServerProvider.aggregationService.getOnDeckFromAllServers( - hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - ); - } - - /// Sync Continue Watching items to the platform launcher shelf. - Future _syncSystemShelf(List onDeck) async { - _pendingSystemShelfItems = List.unmodifiable(onDeck); - if (_systemShelfSyncFuture != null) { - await _systemShelfSyncFuture; - return; - } - - final syncFuture = _drainSystemShelfSyncQueue(); - _systemShelfSyncFuture = syncFuture; - await syncFuture; - } - - Future _drainSystemShelfSyncQueue() async { - try { - while (_pendingSystemShelfItems != null) { - final onDeck = _pendingSystemShelfItems!; - _pendingSystemShelfItems = null; - if (!mounted) return; - - try { - await SystemShelfService().syncFromContinueWatching( - onDeck, - (serverId) => context.getMediaClientWithFallback(serverId), - hideSpoilers: context.settingsRead(SettingsService.hideSpoilers), - ); - } catch (e) { - appLogger.w('Failed to sync system shelf', error: e); - } - } - } finally { - _systemShelfSyncFuture = null; - } - } - // Public method to refresh content (for normal navigation) @override void refresh() { - appLogger.d('DiscoverScreen.refresh() called'); // Only refresh Continue Watching in background, not full screen reload - _refreshContinueWatching(); + unawaited(_discover.refreshContinueWatching()); } // Public method to fully reload all content (for profile switches) @override void fullRefresh() { - appLogger.d('DiscoverScreen.fullRefresh() called - reloading all content'); - // Reload all content including On Deck and content hubs - _loadContent(); + unawaited(_discover.load()); } /// Get icon for hub based on its title @@ -1073,27 +746,6 @@ class _DiscoverScreenState extends State return titleCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } - @override - void updateItemInLists(String itemId, MediaItem updatedItem) { - // Check and update in _onDeck list - final onDeckIndex = _onDeck.indexWhere((item) => item.id == itemId); - if (onDeckIndex != -1) { - _onDeck[onDeckIndex] = updatedItem; - } - - // Check and update in hub items. [MediaHub.items] is immutable list view; - // rebuild the hub when one of its items needs to change. - for (var i = 0; i < _hubs.length; i++) { - final hub = _hubs[i]; - final itemIndex = hub.items.indexWhere((item) => item.id == itemId); - if (itemIndex != -1) { - final newItems = List.from(hub.items); - newItems[itemIndex] = updatedItem; - _hubs[i] = hub.copyWith(items: newItems); - } - } - } - Future _handleLogout() async { final confirm = await showConfirmDialog( context, @@ -1282,7 +934,7 @@ class _DiscoverScreenState extends State FocusableAction( icon: Symbols.refresh_rounded, iconColor: foregroundColor, - onPressed: _loadContent, + onPressed: _discover.load, ), // Watch Together FocusableAction( @@ -1440,7 +1092,7 @@ class _DiscoverScreenState extends State }, ), if (_isLoading) LoadingIndicatorBox.sliver, - if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _loadContent), + if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load), if (!_isLoading && _errorMessage == null) ...[ // On Deck / Continue Watching if (_onDeck.isNotEmpty) @@ -1457,10 +1109,10 @@ class _DiscoverScreenState extends State items: _onDeck, ), icon: Symbols.play_circle_rounded, - onRefresh: updateItem, - onRemoveFromContinueWatching: _refreshContinueWatching, + onRefresh: _discover.updateItem, + onRemoveFromContinueWatching: _discover.refreshContinueWatching, isInContinueWatching: true, - loadMoreItems: _loadAllContinueWatchingItems, + loadMoreItems: _discover.loadAllContinueWatching, onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp), onNavigateUp: _focusTopBoundary, onNavigateToSidebar: _navigateToSidebar, @@ -1471,11 +1123,11 @@ class _DiscoverScreenState extends State for (int i = 0; i < _hubs.length; i++) SliverToBoxAdapter( child: HubSection( - key: i < _hubKeys.length ? _hubKeys[i] : null, + key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null, hub: _hubs[i], icon: _getHubIcon(_hubs[i].title), showServerName: showServerNameOnHubs || duplicateHubTitles.contains(_hubs[i].title), - onRefresh: updateItem, + onRefresh: _discover.updateItem, // Hub index is i + 1 if continue watching exists, otherwise i onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp), onNavigateUp: (i == 0 && _onDeck.isEmpty) ? _focusTopBoundary : null, @@ -1631,7 +1283,7 @@ class _DiscoverScreenState extends State const SizedBox(height: 16), Text(_errorMessage!), const SizedBox(height: 16), - FilledButton(onPressed: _loadContent, child: Text(t.common.retry)), + FilledButton(onPressed: _discover.load, child: Text(t.common.retry)), ], ), ), @@ -1659,11 +1311,11 @@ class _DiscoverScreenState extends State iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), onFocusedItemChanged: _setSpotlightItem, - onRefresh: updateItem, - onRemoveFromContinueWatching: _refreshContinueWatching, + onRefresh: _discover.updateItem, + onRemoveFromContinueWatching: _discover.refreshContinueWatching, isContinueWatchingHub: (hub) => hub.id == 'continue_watching', loadMoreItems: (hub) => - hub.id == 'continue_watching' ? _loadAllContinueWatchingItems() : Future.value(hub.items), + hub.id == 'continue_watching' ? _discover.loadAllContinueWatching() : Future.value(hub.items), onNavigateUp: _focusTopActions, onNavigateToSidebar: _navigateToSidebar, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart new file mode 100644 index 00000000..edc1c3d0 --- /dev/null +++ b/test/providers/discover_provider_test.dart @@ -0,0 +1,299 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_hub.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/server_capabilities.dart'; +import 'package:plezy/providers/discover_provider.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/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; + +import '../test_helpers/prefs.dart'; + +MediaItem _item(String id, {String? parentId}) => MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: id, + serverId: 'server_1', + serverName: 'Server', + parentId: parentId, +); + +MediaHub _hub(String id, {String? identifier, String? libraryId, List? items}) => MediaHub( + id: id, + title: id, + type: 'movie', + identifier: identifier, + items: items ?? [_item('$id-item')], + size: 1, + libraryId: libraryId, + serverId: 'server_1', +); + +/// Counting fake — the provider's fetch-cost policy is the contract under +/// test: a watch event must cost exactly one on-deck call and zero hub +/// refetches, an order change zero calls, a hidden-set change one full pass. +class _FakeAggregationService extends DataAggregationService { + _FakeAggregationService(super.serverManager); + + int onDeckCalls = 0; + int hubCalls = 0; + List Function() onDeckResult = () => const []; + List Function() hubsResult = () => const []; + + @override + Future> getOnDeckFromAllServers({int? limit, Set? hiddenLibraryKeys}) async { + onDeckCalls++; + final items = onDeckResult(); + return limit != null && items.length > limit ? items.sublist(0, limit) : items; + } + + @override + Future> getHubsFromAllServers({ + int? limit, + Set? hiddenLibraryKeys, + bool useGlobalHubs = true, + bool includePlaybackHubs = true, + }) async { + hubCalls++; + return hubsResult(); + } +} + +class _FakeClient implements MediaServerClient { + MediaItem? itemResult; + + @override + ServerId get serverId => ServerId('server_1'); + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + Future fetchItem(String id, {bool useCache = true}) async => itemResult; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeClient client; + late _FakeAggregationService aggregation; + late MultiServerProvider multiServer; + late HiddenLibrariesProvider hiddenLibraries; + late LibrariesProvider libraries; + late DiscoverProvider provider; + bool isBinding = false; + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + isBinding = false; + + client = _FakeClient(); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + aggregation = _FakeAggregationService(manager); + multiServer = MultiServerProvider(manager, aggregation); + hiddenLibraries = HiddenLibrariesProvider(); + libraries = LibrariesProvider(); + provider = DiscoverProvider(multiServer, hiddenLibraries, libraries, isProfileBinding: () => isBinding); + }); + + tearDown(() { + provider.dispose(); + libraries.dispose(); + hiddenLibraries.dispose(); + multiServer.dispose(); + }); + + test('load publishes on-deck and hubs; concurrent calls coalesce', () async { + aggregation.onDeckResult = () => [_item('a')]; + aggregation.hubsResult = () => [_hub('hub-1')]; + + // Three synchronous calls: one in-flight pass plus at most one trailing + // pass (a request arriving mid-load must observe its own fresh fetch). + await Future.wait([provider.load(), provider.load(), provider.load()]); + + expect(provider.onDeck.map((i) => i.id), ['a']); + expect(provider.hubs.map((h) => h.id), ['hub-1']); + expect(provider.isLoading, isFalse); + expect(provider.areHubsLoading, isFalse); + expect(provider.errorMessage, isNull); + expect(aggregation.onDeckCalls, 2); + expect(aggregation.hubCalls, 2); + }); + + test('limits the preview row and probes for more', () async { + aggregation.onDeckResult = () => [for (var i = 0; i < 30; i++) _item('item-$i')]; + + await provider.load(); + + expect(provider.onDeck, hasLength(DiscoverProvider.continueWatchingPreviewLimit)); + expect(provider.hasMoreContinueWatching, isTrue); + }); + + test('filters playback-progress hubs that duplicate the continue watching row', () async { + aggregation.hubsResult = () => [ + _hub('keep'), + _hub('cw', identifier: 'home.continue'), + _hub('od', identifier: 'home.ondeck'), + _hub('nu', identifier: 'home.nextup'), + ]; + + await provider.load(); + + expect(provider.hubs.map((h) => h.id), ['keep']); + }); + + test('watch event refreshes continue watching with one call and zero hub refetches', () async { + aggregation.onDeckResult = () => [_item('ep-1', parentId: 'season-1')]; + aggregation.hubsResult = () => [_hub('hub-1')]; + await provider.load(); + final onDeckCallsBefore = aggregation.onDeckCalls; + final hubCallsBefore = aggregation.hubCalls; + + WatchStateNotifier().notifyWatched(item: _item('ep-1', parentId: 'season-1')); + await pumpEventQueue(); + + expect(aggregation.onDeckCalls, onDeckCallsBefore + 1); + expect(aggregation.hubCalls, hubCallsBefore); + }); + + test('removal event drops the row immediately, then refreshes in background', () async { + aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')]; + await provider.load(); + + var sawImmediateRemoval = false; + provider.addListener(() { + if (provider.onDeck.length == 1 && provider.onDeck.single.id == 'ep-2') { + sawImmediateRemoval = true; + } + }); + aggregation.onDeckResult = () => [_item('ep-2')]; + + WatchStateNotifier().notifyRemovedFromContinueWatching(item: _item('ep-1')); + await pumpEventQueue(); + + expect(sawImmediateRemoval, isTrue); + expect(provider.onDeck.map((i) => i.id), ['ep-2']); + }); + + test('library order change re-sorts hubs without any refetch', () async { + aggregation.hubsResult = () => [ + _hub('hub-lib2', libraryId: 'lib-2'), + _hub('hub-lib1', libraryId: 'lib-1'), + ]; + await provider.load(); + expect(provider.hubs.map((h) => h.id), ['hub-lib2', 'hub-lib1']); + final hubCallsBefore = aggregation.hubCalls; + + MediaLibrary lib(String id) => + MediaLibrary(id: id, backend: MediaBackend.plex, title: id, serverId: 'server_1'); + await libraries.updateLibraryOrder([lib('lib-1'), lib('lib-2')]); + await pumpEventQueue(); + + expect(provider.hubs.map((h) => h.id), ['hub-lib1', 'hub-lib2']); + expect(aggregation.hubCalls, hubCallsBefore); + }); + + test('hidden-library change triggers exactly one full reload', () async { + await provider.load(); + final onDeckCallsBefore = aggregation.onDeckCalls; + final hubCallsBefore = aggregation.hubCalls; + + await hiddenLibraries.hideLibrary('server_1:lib-1'); + await pumpEventQueue(); + + expect(aggregation.onDeckCalls, onDeckCallsBefore + 1); + expect(aggregation.hubCalls, hubCallsBefore + 1); + }); + + test('refreshContinueWatching never flips states or surfaces errors', () async { + aggregation.onDeckResult = () => [_item('a')]; + await provider.load(); + + aggregation.onDeckResult = () => throw Exception('server down'); + await provider.refreshContinueWatching(); + + expect(provider.onDeck.map((i) => i.id), ['a']); + expect(provider.errorMessage, isNull); + expect(provider.isLoading, isFalse); + }); + + test('load failure surfaces the error and ends both loading states', () async { + aggregation.onDeckResult = () => throw Exception('boom'); + + await provider.load(); + + expect(provider.errorMessage, contains('boom')); + expect(provider.isLoading, isFalse); + expect(provider.areHubsLoading, isFalse); + }); + + test('no servers while the profile binder runs stays loading instead of erroring', () async { + final emptyManager = MultiServerManager(); + final emptyAggregation = _FakeAggregationService(emptyManager); + final emptyMultiServer = MultiServerProvider(emptyManager, emptyAggregation); + addTearDown(emptyMultiServer.dispose); + final binderProvider = DiscoverProvider( + emptyMultiServer, + hiddenLibraries, + libraries, + isProfileBinding: () => isBinding, + ); + addTearDown(binderProvider.dispose); + + isBinding = true; + await binderProvider.load(); + expect(binderProvider.isLoading, isTrue); + expect(binderProvider.errorMessage, isNull); + + isBinding = false; + await binderProvider.load(); + expect(binderProvider.isLoading, isFalse); + expect(binderProvider.errorMessage, isNotNull); + }); + + test('updateItem refetches one item and swaps it in place', () async { + aggregation.onDeckResult = () => [_item('ep-1')]; + aggregation.hubsResult = () => [ + _hub('hub-1', items: [_item('movie-1')]), + ]; + await provider.load(); + + client.itemResult = _item('movie-1').copyWith(title: 'Updated Title'); + await provider.updateItem('movie-1'); + + expect(provider.hubs.single.items.single.title, 'Updated Title'); + expect(provider.onDeck.single.id, 'ep-1'); + }); + + test('loadGeneration bumps on full loads only', () async { + aggregation.onDeckResult = () => [_item('a')]; + final initial = provider.loadGeneration; + + await provider.load(); + expect(provider.loadGeneration, initial + 1); + + await provider.refreshContinueWatching(); + expect(provider.loadGeneration, initial + 1); + }); +} diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 5e74e194..c02b5c30 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -23,6 +23,7 @@ import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/companion_remote_provider.dart'; +import 'package:plezy/providers/discover_provider.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; @@ -101,12 +102,19 @@ void main() { connections: connectionRegistry, storage: storage, ); + final discoverProvider = DiscoverProvider( + multiServerProvider, + hiddenLibrariesProvider, + librariesProvider, + isProfileBinding: () => activeProfileProvider.isBinding, + ); final discoverKey = GlobalKey>(); const targetSidebarOffset = SideNavigationRailState.expandedWidth; const currentForegroundLeft = 120.0; const foregroundWidth = 1280 - SideNavigationRailState.tvCollapsedWidth; addTearDown(() async { + discoverProvider.dispose(); activeProfileProvider.dispose(); companionRemoteProvider.dispose(); watchTogetherProvider.dispose(); @@ -127,6 +135,7 @@ void main() { ChangeNotifierProvider.value(value: watchTogetherProvider), ChangeNotifierProvider.value(value: companionRemoteProvider), ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: discoverProvider), ], child: MaterialApp( theme: monoTheme(dark: true),