refactor(discover): DiscoverProvider owns hubs and continue-watching

This commit is contained in:
edde746
2026-06-12 15:53:26 +02:00
parent 665603b314
commit f671f367ea
5 changed files with 810 additions and 449 deletions
+12
View File
@@ -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<MainApp> with WidgetsBindingObserver {
return provider;
},
),
ChangeNotifierProvider(
create: (context) {
final activeProfile = context.read<ActiveProfileProvider>();
return DiscoverProvider(
context.read<MultiServerProvider>(),
context.read<HiddenLibrariesProvider>(),
context.read<LibrariesProvider>(),
isProfileBinding: () => activeProfile.isBinding,
);
},
),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()),
+389
View File
@@ -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<WatchStateEvent>(
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<WatchStateEvent>? _watchStateSubscription;
List<MediaItem> _onDeck = [];
List<MediaHub> _hubs = [];
bool _hasMoreContinueWatching = false;
DiscoverLoadState _onDeckState = DiscoverLoadState.initial;
DiscoverLoadState _hubsState = DiscoverLoadState.initial;
String? _errorMessage;
int _loadGeneration = 0;
Set<String> _lastSeenHiddenKeys = {};
List<String> _lastSeenLibraryOrderKeys = const [];
Future<void>? _inFlightLoad;
bool _hasPendingLoad = false;
Future<void>? _systemShelfSyncFuture;
List<MediaItem>? _pendingSystemShelfItems;
List<MediaItem> get onDeck => _onDeck;
List<MediaHub> 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<void> load() {
_hasPendingLoad = true;
return _inFlightLoad ??= _runLoadLoop().whenComplete(() => _inFlightLoad = null);
}
Future<void> _runLoadLoop() async {
while (_hasPendingLoad && !isDisposed) {
_hasPendingLoad = false;
await _loadOnce();
}
}
Future<void> _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<void> 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<List<MediaItem>> 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<void> 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<MediaItem>.from(hub.items);
newItems[itemIndex] = updatedItem;
_hubs = List.of(_hubs)..[i] = hub.copyWith(items: newItems);
}
}
}
void _applyOnDeck(List<MediaItem> 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<String>? get _watchedIds {
final keys = <String>{};
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<String>? get _watchedGlobalKeys {
final keys = <String>{};
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<MediaHub>.from(_hubs);
if (!sortMediaHubsByLibraryOrder(sortedHubs, _libraries.libraries)) return;
_hubs = sortedHubs;
safeNotifyListeners();
}
List<String> _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<void> _syncSystemShelf(List<MediaItem> onDeck) async {
_pendingSystemShelfItems = List<MediaItem>.unmodifiable(onDeck);
if (_systemShelfSyncFuture != null) {
await _systemShelfSyncFuture;
return;
}
final syncFuture = _drainSystemShelfSyncQueue();
_systemShelfSyncFuture = syncFuture;
await syncFuture;
}
Future<void> _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();
}
}
+101 -449
View File
@@ -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<DiscoverScreen>
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<void> 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<MediaItem> get _onDeck => _discover.onDeck;
List<MediaHub> 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<MediaItem> _onDeck = [];
List<MediaHub> _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<DiscoverScreen>
// full-screen backdrop for every intermediate item.
final Debouncer _spotlightDebouncer = Debouncer(const Duration(milliseconds: 150));
bool _isTabVisible = true;
HiddenLibrariesProvider? _hiddenLibrariesProvider;
LibrariesProvider? _librariesProvider;
Set<String> _lastSeenHiddenKeys = {};
List<String> _lastSeenLibraryOrderKeys = const [];
Future<void>? _systemShelfSyncFuture;
List<MediaItem>? _pendingSystemShelfItems;
// WatchStateAware: watch on-deck items and their parent shows/seasons
@override
Set<String>? get watchedIds {
final keys = <String>{};
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<String>? get watchedGlobalKeys {
final keys = <String>{};
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<DiscoverScreen>
// Hub navigation keys
GlobalKey<HubSectionState>? _continueWatchingHubKey;
final List<GlobalKey<HubSectionState>> _hubKeys = [];
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
// Hero and app bar focus
@@ -237,15 +142,27 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<HubSectionState>());
}
if (_hubKeys.length > _hubs.length) {
_hubKeys.removeRange(_hubs.length, _hubKeys.length);
final occurrences = <String, int>{};
final liveIdentities = <String>{};
final ordered = <GlobalKey<HubSectionState>>[];
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<HubSectionState>.new));
}
_hubKeysByIdentity.removeWhere((identity, _) => !liveIdentities.contains(identity));
_orderedHubKeys = ordered;
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
}
@@ -255,7 +172,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
keys.add(_continueWatchingHubKey!);
}
keys.addAll(_hubKeys);
keys.addAll(_orderedHubKeys);
return keys;
}
@@ -450,10 +367,58 @@ class _DiscoverScreenState extends State<DiscoverScreen>
WidgetsBinding.instance.addObserver(this);
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
_heroFocusNode.addListener(_onHeroFocusChanged);
_loadContent();
_discover = context.read<DiscoverProvider>();
_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<DiscoverScreen>
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final provider = context.read<HiddenLibrariesProvider>();
if (provider != _hiddenLibrariesProvider) {
_hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged);
_hiddenLibrariesProvider = provider;
_hiddenLibrariesProvider!.addListener(_onHiddenLibrariesChanged);
}
final librariesProvider = context.read<LibrariesProvider>();
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<MediaHub>.from(_hubs);
if (!sortMediaHubsByLibraryOrder(sortedHubs, provider.libraries)) return;
setState(() {
_hubs = sortedHubs;
_updateHubKeys();
});
}
List<String> _libraryOrderKeys(LibrariesProvider provider) {
return [for (final library in provider.libraries) library.globalKey];
}
bool _sameStringList(List<String> a, List<String> 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<DiscoverScreen>
@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<DiscoverScreen>
// 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<DiscoverScreen>
return 8.0; // Normal size
}
Future<void> _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<MultiServerProvider>(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<ActiveProfileProvider>(context, listen: false);
if (activeProfile.isBinding) return;
throw Exception('No servers available');
}
// Get hidden libraries for filtering
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(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<HubSectionState>();
}
});
_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<LibrariesProvider>().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<void> _refreshContinueWatching() async {
appLogger.d('Refreshing Continue Watching in background from all servers');
try {
final multiServerProvider = context.read<MultiServerProvider>();
if (!multiServerProvider.hasConnectedServers) {
appLogger.w('No servers available for background refresh');
return;
}
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
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<List<MediaItem>> _loadAllContinueWatchingItems() async {
final multiServerProvider = context.read<MultiServerProvider>();
if (!multiServerProvider.hasConnectedServers) return const [];
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
await hiddenLibrariesProvider.ensureInitialized();
if (!mounted) return const [];
return multiServerProvider.aggregationService.getOnDeckFromAllServers(
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
);
}
/// Sync Continue Watching items to the platform launcher shelf.
Future<void> _syncSystemShelf(List<MediaItem> onDeck) async {
_pendingSystemShelfItems = List<MediaItem>.unmodifiable(onDeck);
if (_systemShelfSyncFuture != null) {
await _systemShelfSyncFuture;
return;
}
final syncFuture = _drainSystemShelfSyncQueue();
_systemShelfSyncFuture = syncFuture;
await syncFuture;
}
Future<void> _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<DiscoverScreen>
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<MediaItem>.from(hub.items);
newItems[itemIndex] = updatedItem;
_hubs[i] = hub.copyWith(items: newItems);
}
}
}
Future<void> _handleLogout() async {
final confirm = await showConfirmDialog(
context,
@@ -1282,7 +934,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
FocusableAction(
icon: Symbols.refresh_rounded,
iconColor: foregroundColor,
onPressed: _loadContent,
onPressed: _discover.load,
),
// Watch Together
FocusableAction(
@@ -1440,7 +1092,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
},
),
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<DiscoverScreen>
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<DiscoverScreen>
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<DiscoverScreen>
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<DiscoverScreen>
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,
+299
View File
@@ -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<MediaItem>? 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<MediaItem> Function() onDeckResult = () => const [];
List<MediaHub> Function() hubsResult = () => const [];
@override
Future<List<MediaItem>> getOnDeckFromAllServers({int? limit, Set<String>? hiddenLibraryKeys}) async {
onDeckCalls++;
final items = onDeckResult();
return limit != null && items.length > limit ? items.sublist(0, limit) : items;
}
@override
Future<List<MediaHub>> getHubsFromAllServers({
int? limit,
Set<String>? 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<MediaItem?> 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);
});
}
+9
View File
@@ -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<State<DiscoverScreen>>();
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<WatchTogetherProvider>.value(value: watchTogetherProvider),
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<DiscoverProvider>.value(value: discoverProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),