refactor(discover): DiscoverProvider owns hubs and continue-watching
This commit is contained in:
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user