From 97f750806730269588085fb3fa926240709589d6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:01:43 +0200 Subject: [PATCH] refactor(core): consolidate shared app foundations --- lib/focus/focusable_action_bar.dart | 52 ++-- lib/focus/focusable_text_field.dart | 10 +- lib/focus/focusable_wrapper.dart | 30 +-- lib/focus/hub_vertical_navigation.dart | 33 +++ lib/focus/owned_focus_node_binding.dart | 4 +- lib/media/playback_timeline.dart | 54 ++++ lib/mixins/paginated_item_loader.dart | 37 +++ lib/providers/download_metadata_store.dart | 230 ++++++++++++++++++ lib/providers/download_provider.dart | 226 +++-------------- lib/screens/actor_media_screen.dart | 43 ++-- lib/screens/collection_detail_screen.dart | 45 ++-- lib/screens/discover_screen.dart | 38 +-- lib/screens/explore_screen.dart | 22 +- .../tabs/library_collections_tab.dart | 56 ++--- .../libraries/tabs/library_playlists_tab.dart | 56 ++--- .../tabs/library_recommended_tab.dart | 31 +-- lib/screens/livetv/tabs/whats_on_tab.dart | 30 +-- lib/screens/media_detail_screen.dart | 38 ++- lib/services/discord_rpc_service.dart | 35 ++- lib/services/playback_progress_tracker.dart | 52 ++-- .../trackers/tracker_coordinator.dart | 34 +-- .../trakt/trakt_scrobble_service.dart | 35 ++- test/focus/focus_node_ownership_test.dart | 104 ++++++++ test/focus/hub_vertical_navigation_test.dart | 73 ++++++ test/media/playback_timeline_test.dart | 41 ++++ test/mixins/paginated_item_loader_test.dart | 52 ++++ test/screens/hub_detail_screen_test.dart | 8 +- test/screens/media_detail_screen_test.dart | 8 +- test/screens/playlist_detail_screen_test.dart | 5 +- .../data_aggregation_bridge_test.dart | 8 +- .../download_artwork_service_test.dart | 46 +--- .../download_manager_service_test.dart | 49 +--- .../download_storage_service_test.dart | 36 +-- test/services/jellyfin_auth_service_test.dart | 9 +- .../jellyfin_client_failures_test.dart | 10 +- test/services/jellyfin_client_urls_test.dart | 14 +- .../jellyfin_favorites_isolation_test.dart | 16 +- test/services/jellyfin_music_mapper_test.dart | 9 +- .../jellyfin_playback_bundle_test.dart | 17 +- .../jellyfin_sequential_launcher_test.dart | 14 +- .../jellyfin_trickplay_service_test.dart | 9 +- .../live_tv_capability_contract_test.dart | 25 +- test/services/multi_server_manager_test.dart | 14 +- .../offline_watch_sync_service_test.dart | 9 +- ...ack_initialization_offline_cache_test.dart | 32 +-- .../plex_client_http_contract_test.dart | 19 +- test/services/plex_library_details_test.dart | 19 +- test/services/plex_music_transcode_test.dart | 19 +- .../plex_playback_data_request_test.dart | 19 +- test/services/plex_search_test.dart | 20 +- test/services/plex_timeline_session_test.dart | 19 +- .../plex_transcoder_capability_test.dart | 17 +- test/services/sync_rule_executor_test.dart | 9 +- .../test_helpers/backend_client_fixtures.dart | 113 +++++++++ test/test_helpers/io_fakes.dart | 44 ++++ test/test_helpers/paged_fakes.dart | 20 ++ test/test_helpers/paged_fakes_test.dart | 28 +++ test/utils/episode_collection_test.dart | 13 +- test/widgets/media_context_menu_test.dart | 5 +- 59 files changed, 1244 insertions(+), 919 deletions(-) create mode 100644 lib/focus/hub_vertical_navigation.dart create mode 100644 lib/media/playback_timeline.dart create mode 100644 lib/providers/download_metadata_store.dart create mode 100644 test/focus/focus_node_ownership_test.dart create mode 100644 test/focus/hub_vertical_navigation_test.dart create mode 100644 test/media/playback_timeline_test.dart create mode 100644 test/test_helpers/backend_client_fixtures.dart create mode 100644 test/test_helpers/io_fakes.dart create mode 100644 test/test_helpers/paged_fakes.dart create mode 100644 test/test_helpers/paged_fakes_test.dart diff --git a/lib/focus/focusable_action_bar.dart b/lib/focus/focusable_action_bar.dart index e0b290a2..8c8afdae 100644 --- a/lib/focus/focusable_action_bar.dart +++ b/lib/focus/focusable_action_bar.dart @@ -5,6 +5,7 @@ import '../widgets/clickable_cursor.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; import 'key_event_utils.dart'; +import 'owned_focus_node_binding.dart'; typedef FocusableActionBuilder = Widget Function(BuildContext context, FocusableActionBuildState state); @@ -94,9 +95,8 @@ class FocusableActionBar extends StatefulWidget { } class FocusableActionBarState extends State { + late List _focusBindings; late List _focusNodes; - late List _ownsFocusNodes; - late List _focusListeners; late List _focusStates; bool _hasAnyFocus = false; @@ -131,27 +131,28 @@ class FocusableActionBarState extends State { } void _initNodes() { - _focusNodes = List.generate( - widget.actions.length, - (i) => widget.actions[i].focusNode ?? FocusNode(debugLabel: widget.actions[i].debugLabel ?? 'ActionBar[$i]'), - ); - _ownsFocusNodes = List.generate(widget.actions.length, (i) => widget.actions[i].focusNode == null); - _focusListeners = []; - _focusStates = List.generate(widget.actions.length, (i) => _focusNodes[i].hasFocus); - _hasAnyFocus = _focusNodes.any((node) => node.hasFocus); - for (var i = 0; i < _focusNodes.length; i++) { - final idx = i; - void listener() { - final hasFocus = _focusNodes[idx].hasFocus; - if (_focusStates[idx] != hasFocus) { - setState(() => _focusStates[idx] = hasFocus); - } - _notifyRowFocusIfChanged(); - } - - _focusListeners.add(listener); - _focusNodes[i].addListener(listener); + _focusBindings = []; + _focusNodes = []; + _focusStates = List.filled(widget.actions.length, false); + for (var i = 0; i < widget.actions.length; i++) { + final index = i; + final binding = OwnedFocusNodeBinding(); + binding.bind( + externalNode: widget.actions[i].focusNode, + debugLabel: widget.actions[i].debugLabel ?? 'ActionBar[$i]', + listener: () { + final hasFocus = _focusNodes[index].hasFocus; + if (_focusStates[index] != hasFocus) { + setState(() => _focusStates[index] = hasFocus); + } + _notifyRowFocusIfChanged(); + }, + ); + _focusBindings.add(binding); + _focusNodes.add(binding.node); + _focusStates[i] = binding.node.hasFocus; } + _hasAnyFocus = _focusNodes.any((node) => node.hasFocus); } void _notifyRowFocusIfChanged() { @@ -162,11 +163,8 @@ class FocusableActionBarState extends State { } void _disposeNodes() { - for (var i = 0; i < _focusNodes.length; i++) { - _focusNodes[i].removeListener(_focusListeners[i]); - if (_ownsFocusNodes[i]) { - _focusNodes[i].dispose(); - } + for (final binding in _focusBindings) { + binding.dispose(); } } diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index c34a96cb..8144b724 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -8,6 +8,7 @@ import '../utils/platform_detector.dart'; import '../utils/text_input_diagnostics.dart'; import '../widgets/tv_virtual_keyboard.dart'; import 'dpad_navigator.dart'; +import 'owned_focus_node_binding.dart'; bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isTV(); @@ -667,7 +668,7 @@ class _FocusableTextInputHost extends StatefulWidget { } class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { - FocusNode? _ownedFocusNode; + final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding(); FocusNode? _installedFocusNode; FocusOnKeyEventCallback? _previousOnKeyEvent; late final FocusOnKeyEventCallback _keyHandler = _handleKey; @@ -681,12 +682,12 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { bool _hasSeenTvKeyboardFocus = false; bool _suppressTvKeyboardForCurrentFocus = false; - FocusNode get _effectiveFocusNode => - widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput')); + FocusNode get _effectiveFocusNode => _focusNodeBinding.node; @override void initState() { super.initState(); + _focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput'); widget.input.tvKeyboardController?._attach(this); } @@ -701,6 +702,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { // An open keyboard dialog intentionally survives rebuilds and focusNode // swaps; it is closed only when this host unmounts — see dispose. _restoreInstalledHandler(); + _focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput'); _suppressTvKeyboardAutoOpen = false; _tvKeyboardOpenScheduled = false; _hasSeenTvKeyboardFocus = false; @@ -720,7 +722,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { if (keyboard != null) { WidgetsBinding.instance.addPostFrameCallback((_) => keyboard.close()); } - _ownedFocusNode?.dispose(); + _focusNodeBinding.dispose(); super.dispose(); } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index c5acc3d8..e1db7313 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -9,6 +9,7 @@ import 'dpad_select_long_press_controller.dart'; import 'focus_glow_overlay.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; +import 'owned_focus_node_binding.dart'; import 'key_event_utils.dart'; String _describeFocusableKey(KeyEvent event) { @@ -164,8 +165,8 @@ class FocusableWrapper extends StatefulWidget { } class _FocusableWrapperState extends State with SingleTickerProviderStateMixin { - late FocusNode _focusNode; - bool _ownsNode = false; + final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding(); + FocusNode get _focusNode => _focusNodeBinding.node; bool _isFocused = false; // Created lazily on first focus/keyboard-mode build: touch scrolling builds @@ -178,20 +179,12 @@ class _FocusableWrapperState extends State with SingleTickerPr @override void initState() { super.initState(); - _initFocusNode(); + _bindFocusNode(); } - void _initFocusNode() { - if (widget.focusNode != null) { - _focusNode = widget.focusNode!; - _ownsNode = false; - } else { - _focusNode = FocusNode( - debugLabel: widget.semanticLabel ?? 'FocusableWrapper', - canRequestFocus: widget.canRequestFocus, - ); - _ownsNode = true; - } + void _bindFocusNode() { + _focusNodeBinding.bind(externalNode: widget.focusNode, debugLabel: widget.semanticLabel ?? 'FocusableWrapper'); + _focusNode.canRequestFocus = widget.canRequestFocus; } AnimationController _ensureAnimationController() { @@ -216,10 +209,7 @@ class _FocusableWrapperState extends State with SingleTickerPr // Handle focusNode changes if (widget.focusNode != oldWidget.focusNode) { - if (_ownsNode) { - _focusNode.dispose(); - } - _initFocusNode(); + _bindFocusNode(); } // Update canRequestFocus @@ -239,9 +229,7 @@ class _FocusableWrapperState extends State with SingleTickerPr void dispose() { _selectLongPress.dispose(); _animationController?.dispose(); - if (_ownsNode) { - _focusNode.dispose(); - } + _focusNodeBinding.dispose(); super.dispose(); } diff --git a/lib/focus/hub_vertical_navigation.dart b/lib/focus/hub_vertical_navigation.dart new file mode 100644 index 00000000..54b1d600 --- /dev/null +++ b/lib/focus/hub_vertical_navigation.dart @@ -0,0 +1,33 @@ +/// Routes vertical D-pad movement between ordered hub rows. +/// +/// A valid target and the bottom boundary are consumed. The top boundary can +/// either invoke an explicit focus handoff or propagate to the row's +/// `onNavigateUp` callback. +bool navigateVerticalHubRows({ + required int hubCount, + required int hubIndex, + required bool isUp, + required void Function(int targetIndex) requestFocus, + void Function()? onTopBoundary, + void Function()? onBottomBoundary, + bool propagateTopBoundary = false, +}) { + if (hubCount <= 0) return false; + + final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; + if (targetIndex < 0) { + if (onTopBoundary != null) { + onTopBoundary(); + return true; + } + return !propagateTopBoundary; + } + + if (targetIndex >= hubCount) { + onBottomBoundary?.call(); + return true; + } + + requestFocus(targetIndex); + return true; +} diff --git a/lib/focus/owned_focus_node_binding.dart b/lib/focus/owned_focus_node_binding.dart index 2f60e3cb..befb6040 100644 --- a/lib/focus/owned_focus_node_binding.dart +++ b/lib/focus/owned_focus_node_binding.dart @@ -18,12 +18,12 @@ class OwnedFocusNodeBinding { FocusNode get node => _node!; - void bind({required FocusNode? externalNode, required VoidCallback listener, String? debugLabel}) { + void bind({required FocusNode? externalNode, VoidCallback? listener, String? debugLabel}) { dispose(); _node = externalNode ?? _createNode(debugLabel); _ownsNode = externalNode == null; _listener = listener; - _node!.addListener(listener); + if (listener != null) _node!.addListener(listener); } void dispose() { diff --git a/lib/media/playback_timeline.dart b/lib/media/playback_timeline.dart new file mode 100644 index 00000000..d45a579e --- /dev/null +++ b/lib/media/playback_timeline.dart @@ -0,0 +1,54 @@ +import 'watch_progress.dart'; + +/// Position jump used by playback integrations to distinguish normal ticks +/// from seeks. +const playbackSeekDetectionThreshold = Duration(seconds: 5); + +/// Mutable timing state shared by playback integrations. +/// +/// This keeps seek classification, progress clamping, and watched-threshold +/// semantics identical without coupling backend-specific lifecycle calls. +class PlaybackTimeline { + PlaybackTimeline({this.position = Duration.zero, this.duration, this.watchedThreshold = 0.9}); + + Duration position; + Duration? duration; + double watchedThreshold; + + void reset({Duration position = Duration.zero, Duration? duration, double? watchedThreshold}) { + this.position = position; + this.duration = duration; + if (watchedThreshold != null) this.watchedThreshold = watchedThreshold; + } + + /// Stores [next] and reports whether it jumped farther than [seekThreshold]. + bool updatePosition(Duration next, {Duration seekThreshold = playbackSeekDetectionThreshold}) { + final isSeek = (next - position).abs() > seekThreshold; + position = next; + return isSeek; + } + + /// Stores a known duration. Zero/negative values remain unknown by default. + bool updateDuration(Duration next, {bool ignoreNonPositive = true}) { + if (ignoreNonPositive && next.inMilliseconds <= 0) return false; + if (duration == next) return false; + duration = next; + return true; + } + + bool get watchedThresholdReached { + final total = duration; + return total != null && + isWatchedProgress( + positionMs: position.inMilliseconds, + durationMs: total.inMilliseconds, + threshold: watchedThreshold, + ); + } + + double get progressPercent { + final totalMs = duration?.inMilliseconds ?? 0; + if (totalMs <= 0) return 0; + return ((position.inMilliseconds / totalMs) * 100).clamp(0.0, 100.0); + } +} diff --git a/lib/mixins/paginated_item_loader.dart b/lib/mixins/paginated_item_loader.dart index ead8a9ff..610f6257 100644 --- a/lib/mixins/paginated_item_loader.dart +++ b/lib/mixins/paginated_item_loader.dart @@ -96,6 +96,43 @@ mixin PaginatedItemLoader on State { return (page: result, applied: true); } + /// Shared initial-load transaction for paginated consumers. + /// + /// Owns reset, stale-result rejection, mounted checks, and error-state + /// application. Callers supply only their view fields, logging, and + /// post-success behavior. + Future loadInitialPaginatedItems({ + required int pageSize, + required VoidCallback resetViewState, + required void Function(List items) applyLoadedItems, + required void Function(Object error, StackTrace stackTrace) applyError, + void Function(int loadedCount, int totalCount)? onLoaded, + void Function(Object error, StackTrace stackTrace)? onError, + }) async { + setState(() { + resetViewState(); + resetPaginationState(); + }); + + try { + final initialPage = await loadInitialPageWithStatus(pageSize); + if (!initialPage.applied || !mounted) return false; + + setState(() { + applyLoadedItems(loadedItems.values.toList()); + }); + onLoaded?.call(loadedItems.length, totalSize); + return true; + } catch (error, stackTrace) { + onError?.call(error, stackTrace); + if (!mounted) return false; + setState(() { + applyError(error, stackTrace); + }); + return false; + } + } + /// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount) /// with [buffer] extra indices on each side. Serialized — only one /// range-fetch runs at a time — and re-checks after each success so a diff --git a/lib/providers/download_metadata_store.dart b/lib/providers/download_metadata_store.dart new file mode 100644 index 00000000..24de2d19 --- /dev/null +++ b/lib/providers/download_metadata_store.dart @@ -0,0 +1,230 @@ +part of 'download_provider.dart'; + +/// Owns downloaded metadata, artwork references, and profile-scoped watch +/// overlays. [DownloadProvider] remains responsible for queue and ownership +/// orchestration; this store keeps cache hydration and watch synchronization in +/// one lifecycle-bound component. +class _DownloadMetadataStore extends ChangeNotifier { + _DownloadMetadataStore({ + required DownloadManagerService downloadManager, + required AppDatabase database, + String? activeProfileId, + }) : _downloadManager = downloadManager, + _database = database, + _activeProfileId = activeProfileId { + _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged); + _watchStateStore.addListener(notifyListeners); + _watchStateStore.setActiveProfileId(activeProfileId); + } + + final DownloadManagerService _downloadManager; + final AppDatabase _database; + final WatchStateStore _watchStateStore = WatchStateStore(); + late final StreamSubscription _watchStateSubscription; + + final Map items = {}; + final Map artworkPaths = {}; + final Map _watchScopesByServer = {}; + String? _activeProfileId; + + void setActiveProfileId(String? profileId) { + if (_activeProfileId == profileId) return; + _activeProfileId = profileId; + _watchScopesByServer.clear(); + _watchStateStore + ..setActiveProfileId(profileId) + ..setActiveClientScopesByServer(const {}); + } + + MediaItem applyWatchState(MediaItem item) => _watchStateStore.apply(item); + + MediaItem? resolved(String globalKey) { + final item = items[globalKey]; + return item == null ? null : applyWatchState(item); + } + + Map get resolvedItems => + Map.unmodifiable({for (final entry in items.entries) entry.key: applyWatchState(entry.value)}); + + /// Loads show/season or artist/album metadata from an already-fetched cache + /// snapshot without issuing per-parent database queries. + void loadParentMetadataFromMap(MediaItem leaf, Map allMetadata, {String? clientScopeId}) { + final serverId = leaf.serverId; + if (serverId == null) return; + + MediaItem? lookupParent(String ratingKey) { + if (clientScopeId != null && clientScopeId.isNotEmpty) { + final scoped = allMetadata[buildGlobalKey(ServerId(clientScopeId), ratingKey)]; + if (scoped != null) return scoped; + } + return allMetadata[buildGlobalKey(ServerId(serverId), ratingKey)]; + } + + void loadParent(String? ratingKey) { + if (ratingKey == null) return; + final parentGlobalKey = buildGlobalKey(ServerId(serverId), ratingKey); + if (items.containsKey(parentGlobalKey)) return; + final parentMetadata = lookupParent(ratingKey); + if (parentMetadata == null) return; + items[parentGlobalKey] = parentMetadata; + if (parentMetadata.thumbPath != null) { + artworkPaths[parentGlobalKey] = DownloadedArtwork(thumbPath: parentMetadata.thumbPath); + } + } + + loadParent(leaf.grandparentId); + loadParent(leaf.parentId); + } + + /// Rehydrates queued offline watch actions into the canonical hierarchy-aware + /// watch-state layer for the active profile. + Future hydrateOfflineWatchOverlay({ + required Map downloads, + required bool Function(String globalKey) ownsDownloadKey, + bool Function()? isStale, + }) async { + bool stale() => isStale?.call() ?? false; + + try { + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) { + _watchStateStore.setHydratedPatches(const []); + return; + } + + final keys = {}; + for (final item in items.values) { + keys.add(item.globalKey); + final serverId = serverIdOrNull(item.serverId); + if (serverId == null) continue; + for (final parentId in item.parentChain) { + keys.add(buildGlobalKey(serverId, parentId)); + } + } + if (keys.isEmpty) { + _watchStateStore.setHydratedPatches(const []); + return; + } + + final scopes = {}; + final scopesByServer = {}; + for (final key in keys) { + final parsed = parseGlobalKey(key); + if (parsed == null) continue; + var scope = scopesByServer[parsed.serverId]; + if (!scopesByServer.containsKey(parsed.serverId)) { + scope = await _offlineWatchScopeForServer( + parsed.serverId, + downloads: downloads, + ownsDownloadKey: ownsDownloadKey, + ); + scopesByServer[parsed.serverId] = scope; + } + scopes[key] = scope; + if (stale()) return; + } + + _watchScopesByServer + ..clear() + ..addAll(scopesByServer); + _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer); + + final actions = await _database.getWatchActionsForKeys( + keys, + profileId: profileId, + filterProfile: true, + clientScopeIdsByGlobalKey: scopes, + ); + if (stale()) return; + + final hydrated = []; + for (final entry in actions.entries) { + final snapshot = WatchStateResolver.fromActions(entry.value); + if (snapshot.isEmpty) continue; + final latest = entry.value.firstWhere( + (action) => + action.actionType == 'watched' || action.actionType == 'unwatched' || action.actionType == 'progress', + ); + final scopedKey = latest.clientScopeId != null && latest.clientScopeId!.isNotEmpty + ? buildGlobalKey(ServerId(latest.clientScopeId!), latest.ratingKey) + : latest.globalKey; + hydrated.add( + HydratedWatchStatePatch( + globalKey: scopedKey, + patch: WatchStatePatch.fromSnapshot(snapshot), + updatedAt: latest.updatedAt, + order: latest.id, + ), + ); + } + _watchStateStore.setHydratedPatches(hydrated); + } catch (error) { + appLogger.w('Failed to apply offline watch overlay', error: error); + } + } + + Future _offlineWatchScopeForServer( + String serverId, { + required Map downloads, + required bool Function(String globalKey) ownsDownloadKey, + }) async { + final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(serverId)); + if (activeScope != null && activeScope.isNotEmpty) return activeScope; + for (final globalKey in downloads.keys) { + if (!ownsDownloadKey(globalKey)) continue; + final parsed = parseGlobalKey(globalKey); + if (parsed?.serverId != serverId) continue; + final downloadedScope = (await _database.getDownloadedMedia(globalKey))?.clientScopeId; + if (downloadedScope != null && downloadedScope.isNotEmpty) return downloadedScope; + } + return null; + } + + void _onWatchStateChanged(WatchStateEvent event) { + final snapshot = WatchStateResolver.fromEvent(event); + if (snapshot.isEmpty) return; + + final globalKey = buildGlobalKey(ServerId(event.serverId), event.itemId); + final base = items[globalKey]; + final eventScope = event.cacheServerId; + final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(event.serverId)); + if (activeScope != null && activeScope.isNotEmpty) { + _watchScopesByServer[event.serverId] = activeScope; + _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer); + } + if (base == null) return; + if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) { + return; + } + + final isWatched = snapshot.isWatched; + final shouldPersistToCache = + isWatched != null && (event.changeType != WatchStateChangeType.progressUpdate || event.isNowWatched == true); + if (!shouldPersistToCache) return; + + unawaited( + () async { + if (base.backend == MediaBackend.plex && + await _database.hasDownloadOwner(globalKey, excludingProfileId: _activeProfileId)) { + return; + } + await ApiCache.forBackend(base.backend).applyWatchState( + serverId: ServerId(event.cacheServerId ?? event.serverId), + itemId: event.itemId, + isWatched: isWatched, + ); + }().catchError((Object error) { + appLogger.w('Failed to apply watch state to cache for $globalKey', error: error); + }), + ); + } + + @override + void dispose() { + _watchStateSubscription.cancel(); + _watchStateStore + ..removeListener(notifyListeners) + ..dispose(); + super.dispose(); + } +} diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index e7d414ef..7c8e0cfa 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -31,6 +31,8 @@ import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; +part 'download_metadata_store.dart'; + /// Filter mode for batch downloads (shows/seasons). /// Use [all] to download everything, or [unwatched] with an optional maxCount. enum DownloadFilter { all, unwatched } @@ -62,8 +64,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final SyncRuleExecutor _syncRuleExecutor; StreamSubscription? _progressSubscription; StreamSubscription? _deletionProgressSubscription; - StreamSubscription? _watchStateSubscription; - final WatchStateStore _watchStateStore = WatchStateStore(); late final Future _initFuture; // Track download progress by public globalKey (serverId:ratingKey). @@ -71,12 +71,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // watch actions, cache namespaces, and sync-rule ownership. final Map _downloads = {}; - // Store metadata for display - final Map _metadata = {}; - - // Store Plex thumb paths for offline display (actual file path computed from hash) - final Map _artworkPaths = {}; - final Map _watchScopesByServer = {}; + // Metadata and artwork cache lifecycle is isolated from queue ownership. + late final _DownloadMetadataStore _metadataStore; + Map get _metadata => _metadataStore.items; + Map get _artworkPaths => _metadataStore.artworkPaths; // Track items currently being queued (building download queue) final Set _queueing = {}; @@ -100,17 +98,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin DownloadProvider({required this._downloadManager, required this._database}) : _syncRuleExecutor = SyncRuleExecutor(database: _database) { + _metadataStore = _DownloadMetadataStore(downloadManager: _downloadManager, database: _database) + ..addListener(_onMetadataStoreChanged); // Listen to progress updates from the download manager _progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate); // Listen to deletion progress updates _deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate); - // Keep cached metadata fresh when items get marked watched/unwatched anywhere - // in the app, so re-entering a screen reflects the latest state. - _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged); - _watchStateStore.addListener(_onWatchStateOverlayChanged); - // Load persisted downloads from database _initFuture = _loadPersistedDownloads(); } @@ -126,11 +121,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin required this._database, this._activeProfileId = 'test-profile', }) : _syncRuleExecutor = SyncRuleExecutor(database: _database) { + _metadataStore = _DownloadMetadataStore( + downloadManager: _downloadManager, + database: _database, + activeProfileId: _activeProfileId, + )..addListener(_onMetadataStoreChanged); _progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate); _deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate); - _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged); - _watchStateStore.addListener(_onWatchStateOverlayChanged); - _watchStateStore.setActiveProfileId(_activeProfileId); _initFuture = _loadProfileScopedState(); } @@ -150,9 +147,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin void setActiveProfileId(String? profileId) { if (_activeProfileId == profileId) return; _activeProfileId = profileId; - _watchStateStore.setActiveProfileId(profileId); - _watchScopesByServer.clear(); - _watchStateStore.setActiveClientScopesByServer(const {}); + _metadataStore.setActiveProfileId(profileId); _profileGeneration++; final reload = _reloadProfileScopedStateForActiveProfile(); _profileScopedReloadFuture = reload; @@ -219,6 +214,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // reload may still finish its DB adoption, but cannot repopulate the // active-profile view after this point. _activeProfileId = null; + _metadataStore.setActiveProfileId(null); _profileGeneration++; await _initFuture; await _profileScopedReloadFuture; @@ -378,120 +374,21 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Hydrate queued OfflineWatchProgress actions into the canonical /// hierarchy-aware watch-state layer. - Future _applyOfflineWatchOverlay({int? expectedProfileGeneration}) async { - bool isStale() => - expectedProfileGeneration != null && (isDisposed || expectedProfileGeneration != _profileGeneration); - try { - final profileId = _activeProfileId; - if (profileId == null || profileId.isEmpty) { - _watchStateStore.setHydratedPatches(const []); - return; - } - final keys = {}; - for (final item in _metadata.values) { - keys.add(item.globalKey); - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) continue; - for (final parentId in item.parentChain) { - keys.add(buildGlobalKey(serverId, parentId)); - } - } - if (keys.isEmpty) { - _watchStateStore.setHydratedPatches(const []); - return; - } - final scopes = {}; - final scopesByServer = {}; - for (final key in keys) { - final parsed = parseGlobalKey(key); - if (parsed == null) continue; - var scope = scopesByServer[parsed.serverId]; - if (!scopesByServer.containsKey(parsed.serverId)) { - scope = await _offlineWatchScopeForServer(parsed.serverId); - scopesByServer[parsed.serverId] = scope; - } - scopes[key] = scope; - if (isStale()) return; - } - _watchScopesByServer - ..clear() - ..addAll(scopesByServer); - _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer); - final actions = await _database.getWatchActionsForKeys( - keys, - profileId: profileId, - filterProfile: true, - clientScopeIdsByGlobalKey: scopes, - ); - if (isStale()) return; - final hydrated = []; - for (final entry in actions.entries) { - final snapshot = WatchStateResolver.fromActions(entry.value); - if (snapshot.isEmpty) continue; - final latest = entry.value.firstWhere( - (action) => - action.actionType == 'watched' || action.actionType == 'unwatched' || action.actionType == 'progress', - ); - final scopedKey = latest.clientScopeId != null && latest.clientScopeId!.isNotEmpty - ? buildGlobalKey(ServerId(latest.clientScopeId!), latest.ratingKey) - : latest.globalKey; - hydrated.add( - HydratedWatchStatePatch( - globalKey: scopedKey, - patch: WatchStatePatch.fromSnapshot(snapshot), - updatedAt: latest.updatedAt, - order: latest.id, - ), - ); - } - _watchStateStore.setHydratedPatches(hydrated); - } catch (e) { - appLogger.w('Failed to apply offline watch overlay', error: e); - } - } - - Future _offlineWatchScopeForServer(String serverId) async { - final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(serverId)); - if (activeScope != null && activeScope.isNotEmpty) return activeScope; - for (final globalKey in _downloads.keys) { - if (!_ownsDownloadKey(globalKey)) continue; - final parsed = parseGlobalKey(globalKey); - if (parsed?.serverId != serverId) continue; - final downloadedScope = (await _database.getDownloadedMedia(globalKey))?.clientScopeId; - if (downloadedScope != null && downloadedScope.isNotEmpty) return downloadedScope; - } - return null; + Future _applyOfflineWatchOverlay({int? expectedProfileGeneration}) { + return _metadataStore.hydrateOfflineWatchOverlay( + downloads: _downloads, + ownsDownloadKey: _ownsDownloadKey, + isStale: expectedProfileGeneration == null + ? null + : () => isDisposed || expectedProfileGeneration != _profileGeneration, + ); } /// Load parent metadata (show + season for episodes, artist + album for /// tracks) from a pre-loaded map (no DB I/O). Used during bulk /// initialization to avoid per-item DB queries. void _loadParentMetadataFromMap(MediaItem leaf, Map allMetadata, {String? clientScopeId}) { - final serverId = leaf.serverId; - if (serverId == null) return; - - MediaItem? lookupParent(String ratingKey) { - if (clientScopeId != null && clientScopeId.isNotEmpty) { - final scoped = allMetadata[buildGlobalKey(ServerId(clientScopeId), ratingKey)]; - if (scoped != null) return scoped; - } - return allMetadata[buildGlobalKey(ServerId(serverId), ratingKey)]; - } - - void loadParent(String? ratingKey) { - if (ratingKey == null) return; - final parentGlobalKey = buildGlobalKey(ServerId(serverId), ratingKey); - if (_metadata.containsKey(parentGlobalKey)) return; - final parentMetadata = lookupParent(ratingKey); - if (parentMetadata == null) return; - _metadata[parentGlobalKey] = parentMetadata; - if (parentMetadata.thumbPath != null) { - _artworkPaths[parentGlobalKey] = DownloadedArtwork(thumbPath: parentMetadata.thumbPath); - } - } - - loadParent(leaf.grandparentId); // show / artist - loadParent(leaf.parentId); // season / album + _metadataStore.loadParentMetadataFromMap(leaf, allMetadata, clientScopeId: clientScopeId); } void _onProgressUpdate(DownloadProgress progress) { @@ -512,62 +409,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin void dispose() { _progressSubscription?.cancel(); _deletionProgressSubscription?.cancel(); - _watchStateSubscription?.cancel(); - _watchStateStore.removeListener(_onWatchStateOverlayChanged); - _watchStateStore.dispose(); + _metadataStore + ..removeListener(_onMetadataStoreChanged) + ..dispose(); super.dispose(); } - void _onWatchStateOverlayChanged() => safeNotifyListeners(); - - void _onWatchStateChanged(WatchStateEvent event) { - final snapshot = WatchStateResolver.fromEvent(event); - if (snapshot.isEmpty) return; - - final globalKey = buildGlobalKey(ServerId(event.serverId), event.itemId); - final base = _metadata[globalKey]; - final eventScope = event.cacheServerId; - final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(event.serverId)); - if (activeScope != null && activeScope.isNotEmpty) { - _watchScopesByServer[event.serverId] = activeScope; - _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer); - } - if (base == null) return; - if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) { - return; - } - - final isWatched = snapshot.isWatched; - // Sub-threshold progress ticks are frequent; offline reloads re-apply them - // from queued watch actions, so only durable watch flips hit the cache here. - final shouldPersistToCache = - isWatched != null && (event.changeType != WatchStateChangeType.progressUpdate || event.isNowWatched == true); - - // Persist into the per-backend pinned cache so the patch survives reloads - // (`_loadPersistedDownloads` rehydrates `_metadata` from the cache). - if (shouldPersistToCache) { - unawaited( - () async { - // Jellyfin cache rows are per-user (cacheServerId embeds the user); - // Plex rows are keyed by server only, so persisting one user's flip - // into a download SHARED with another profile would surface as that - // profile's watch state too. Skip the shared case — each profile's - // own queued watch actions still re-apply its state on reload. - if (base.backend == MediaBackend.plex && - await _database.hasDownloadOwner(globalKey, excludingProfileId: _activeProfileId)) { - return; - } - await ApiCache.forBackend(base.backend).applyWatchState( - serverId: ServerId(event.cacheServerId ?? event.serverId), - itemId: event.itemId, - isWatched: isWatched, - ); - }().catchError((Object e) { - appLogger.w('Failed to apply watch state to cache for $globalKey', error: e); - }), - ); - } - } + void _onMetadataStoreChanged() => safeNotifyListeners(); /// Ensure metadata has a serverId, falling back to a parent's serverId. MediaItem _ensureServerId(MediaItem metadata, String? fallbackServerId) => @@ -578,8 +426,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Map.unmodifiable(Map.fromEntries(_downloads.entries.where(_ownsProgressEntry))); /// All metadata for downloads - Map get metadata => - Map.unmodifiable({for (final entry in _metadata.entries) entry.key: _watchStateStore.apply(entry.value)}); + Map get metadata => _metadataStore.resolvedItems; /// Get unique TV shows that have downloaded episodes /// Returns stored show metadata, or synthesizes from episode metadata as fallback @@ -589,7 +436,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin for (final entry in _metadata.entries) { final globalKey = entry.key; if (!_ownsDownloadKey(globalKey)) continue; - final meta = _watchStateStore.apply(entry.value); + final meta = _metadataStore.applyWatchState(entry.value); final progress = _downloads[globalKey]; if (progress?.status == DownloadStatus.completed && meta.isEpisode) { @@ -637,7 +484,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final progress = _downloads[entry.key]; return progress?.status == DownloadStatus.completed && entry.value.isMovie; }) - .map((entry) => _watchStateStore.apply(entry.value)) + .map((entry) => _metadataStore.applyWatchState(entry.value)) .toList(); } @@ -650,7 +497,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin for (final entry in _metadata.entries) { final globalKey = entry.key; if (!_ownsDownloadKey(globalKey)) continue; - final meta = _watchStateStore.apply(entry.value); + final meta = _metadataStore.applyWatchState(entry.value); if (meta.kind != MediaKind.track) continue; if (_downloads[globalKey]?.status != DownloadStatus.completed) continue; @@ -695,7 +542,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin meta.parentId == albumRatingKey && _downloads[entry.key]?.status == DownloadStatus.completed; }) - .map((entry) => _watchStateStore.apply(entry.value)) + .map((entry) => _metadataStore.applyWatchState(entry.value)) .toList(); tracks.sort((a, b) { final byDisc = (a.discNumber ?? 1).compareTo(b.discNumber ?? 1); @@ -706,10 +553,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Get metadata for a specific download - MediaItem? _resolvedMetadata(String globalKey) { - final item = _metadata[globalKey]; - return item == null ? null : _watchStateStore.apply(item); - } + MediaItem? _resolvedMetadata(String globalKey) => _metadataStore.resolved(globalKey); MediaItem? getMetadata(String globalKey) => _resolvedMetadata(globalKey); @@ -732,7 +576,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final meta = entry.value; return progress?.status == DownloadStatus.completed && meta.isEpisode && meta.grandparentId == showRatingKey; }) - .map((entry) => _watchStateStore.apply(entry.value)) + .map((entry) => _metadataStore.applyWatchState(entry.value)) .toList(); } diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 87a9210e..286d37ed 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -98,30 +98,29 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen @override Future loadItems() async { - setState(() { - isLoading = true; - errorMessage = null; - items = []; - resetPaginationState(); - }); - - try { - final initialPage = await loadInitialPageWithStatus(_pageSize); - if (!initialPage.applied || !mounted) return; - setState(() { - items = loadedItems.values.toList(); + await loadInitialPaginatedItems( + pageSize: _pageSize, + resetViewState: () { + isLoading = true; + errorMessage = null; + items = []; + }, + applyLoadedItems: (loaded) { + items = loaded; isLoading = false; - }); - appLogger.d('Loaded ${loadedItems.length} of $totalSize items for actor: ${widget.actorName}'); - autoFocusFirstItemAfterLoad(); - } catch (e, st) { - appLogger.e('Failed to load actor media', error: e, stackTrace: st); - if (!mounted) return; - setState(() { - errorMessage = t.messages.errorLoading(error: e.toString()); + }, + applyError: (error, _) { + errorMessage = t.messages.errorLoading(error: error.toString()); isLoading = false; - }); - } + }, + onLoaded: (loadedCount, totalCount) { + appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}'); + autoFocusFirstItemAfterLoad(); + }, + onError: (error, stackTrace) { + appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace); + }, + ); } @override diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index b8f4df0f..cecba182 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -86,32 +86,29 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen loadItems() async { - setState(() { - isLoading = true; - errorMessage = null; - items = []; - resetPaginationState(); - }); - try { - final initialPage = await loadInitialPageWithStatus(_pageSize); - if (!initialPage.applied || !mounted) return; - // Mirror loadedItems into base-class [items] once so state-sliver checks - // (items.isEmpty vs items.isEmpty && isLoading) pick the right branch. - // Further pages only update loadedItems; items.isEmpty stays false. - setState(() { - items = loadedItems.values.toList(); + await loadInitialPaginatedItems( + pageSize: _pageSize, + resetViewState: () { + isLoading = true; + errorMessage = null; + items = []; + }, + applyLoadedItems: (loaded) { + items = loaded; isLoading = false; - }); - appLogger.d('Loaded ${loadedItems.length} of $totalSize items for collection: ${widget.collection.title}'); - autoFocusFirstItemAfterLoad(); - } catch (e) { - appLogger.e('Failed to load collection items', error: e); - if (!mounted) return; - setState(() { - errorMessage = t.collections.failedToLoadItems(error: e.toString()); + }, + applyError: (error, _) { + errorMessage = t.collections.failedToLoadItems(error: error.toString()); isLoading = false; - }); - } + }, + onLoaded: (loadedCount, totalCount) { + appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}'); + autoFocusFirstItemAfterLoad(); + }, + onError: (error, stackTrace) { + appLogger.e('Failed to load collection items', error: error, stackTrace: stackTrace); + }, + ); } @override diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index db192e15..cfecd2a6 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -9,6 +9,7 @@ import '../widgets/server_activities_button.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; +import '../focus/hub_vertical_navigation.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import 'package:cached_network_image_ce/cached_network_image.dart'; @@ -330,34 +331,15 @@ class _DiscoverScreenState extends State /// Returns true if the navigation was handled bool _handleVerticalNavigation(int hubIndex, bool isUp) { final keys = _allHubKeys; - if (keys.isEmpty) return false; - - // UP from first hub: navigate to hero when visible, otherwise app bar - if (isUp && hubIndex == 0) { - if (PlatformDetector.isTV()) { - _focusTopActions(); - return true; - } - _focusTopBoundary(); - return true; - } - - final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; - - // Check if target is valid - if (targetIndex < 0 || targetIndex >= keys.length) { - // At boundary, block navigation (return true to consume the event) - return true; - } - - // Navigate to target hub, clamping to available items - final targetState = keys[targetIndex].currentState; - if (targetState != null) { - targetState.requestFocusFromMemory(); - return true; - } - - return false; + return navigateVerticalHubRows( + hubCount: keys.length, + hubIndex: hubIndex, + isUp: isUp, + onTopBoundary: _focusTopBoundary, + requestFocus: (targetIndex) { + keys[targetIndex].currentState?.requestFocusFromMemory(); + }, + ); } /// Navigate focus to the sidebar diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index eba37639..241ed0d7 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; +import '../focus/hub_vertical_navigation.dart'; import '../i18n/strings.g.dart'; import '../media/ids.dart'; import '../media/media_hub.dart'; @@ -118,18 +119,15 @@ class ExploreScreenState extends State bool _handleVerticalNavigation(int hubIndex, bool isUp) { final keys = _orderedHubKeys; - if (keys.isEmpty) return false; - - if (isUp && hubIndex == 0) { - _actionBarKey.currentState?.requestFocusOnFirst(); - return true; - } - - final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; - // At a boundary, consume the event so focus can't escape the rows. - if (targetIndex < 0 || targetIndex >= keys.length) return true; - keys[targetIndex].currentState?.requestFocusFromMemory(); - return true; + return navigateVerticalHubRows( + hubCount: keys.length, + hubIndex: hubIndex, + isUp: isUp, + onTopBoundary: _actionBarKey.currentState?.requestFocusOnFirst, + requestFocus: (targetIndex) { + keys[targetIndex].currentState?.requestFocusFromMemory(); + }, + ); } void _navigateToSidebar() { diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index e9253702..c1888067 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -81,38 +81,34 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState loadItems() async { - setState(() { - isLoading = true; - errorMessage = null; - items = []; - resetPaginationState(); - }); - - try { - final initialPage = await loadInitialPageWithStatus(_pageSize); - if (!initialPage.applied || !mounted) return; - - setState(() { - items = loadedItems.values.toList(); + await loadInitialPaginatedItems( + pageSize: _pageSize, + resetViewState: () { + isLoading = true; + errorMessage = null; + items = []; + }, + applyLoadedItems: (loaded) { + items = loaded; isLoading = false; - }); - - hasLoadedData = true; - tryFocus(); - - if (widget.onDataLoaded != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onDataLoaded!(); - }); - } - } catch (e, st) { - appLogger.e('Error loading $errorContext', error: e, stackTrace: st); - if (!mounted) return; - setState(() { - errorMessage = 'Failed to load $errorContext: ${e.toString()}'; + }, + applyError: (error, _) { + errorMessage = 'Failed to load $errorContext: ${error.toString()}'; isLoading = false; - }); - } + }, + onLoaded: (_, _) { + hasLoadedData = true; + tryFocus(); + if (widget.onDataLoaded != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onDataLoaded!(); + }); + } + }, + onError: (error, stackTrace) { + appLogger.e('Error loading $errorContext', error: error, stackTrace: stackTrace); + }, + ); } @override diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index db57f0cd..e6b2be75 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -86,38 +86,34 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState loadItems() async { - setState(() { - isLoading = true; - errorMessage = null; - items = []; - resetPaginationState(); - }); - - try { - final initialPage = await loadInitialPageWithStatus(_pageSize); - if (!initialPage.applied || !mounted) return; - - setState(() { - items = loadedItems.values.toList(); + await loadInitialPaginatedItems( + pageSize: _pageSize, + resetViewState: () { + isLoading = true; + errorMessage = null; + items = []; + }, + applyLoadedItems: (loaded) { + items = loaded; isLoading = false; - }); - - hasLoadedData = true; - tryFocus(); - - if (widget.onDataLoaded != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onDataLoaded!(); - }); - } - } catch (e, st) { - appLogger.e('Error loading $errorContext', error: e, stackTrace: st); - if (!mounted) return; - setState(() { - errorMessage = 'Failed to load $errorContext: ${e.toString()}'; + }, + applyError: (error, _) { + errorMessage = 'Failed to load $errorContext: ${error.toString()}'; isLoading = false; - }); - } + }, + onLoaded: (_, _) { + hasLoadedData = true; + tryFocus(); + if (widget.onDataLoaded != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onDataLoaded!(); + }); + } + }, + onError: (error, stackTrace) { + appLogger.e('Error loading $errorContext', error: error, stackTrace: stackTrace); + }, + ); } @override diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index e3fbb926..2bd1b04f 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -4,6 +4,7 @@ import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../focus/hub_vertical_navigation.dart'; import '../../../i18n/strings.g.dart'; import '../../../media/media_hub.dart'; import '../../../media/media_item.dart'; @@ -267,27 +268,15 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState= _hubKeys.length) { - // At bottom boundary, block navigation - return true; - } - - // Navigate to target hub with column memory - final targetState = _hubKeys[targetIndex].currentState; - if (targetState != null) { - targetState.requestFocusFromMemory(); - return true; - } - - return false; + return navigateVerticalHubRows( + hubCount: items.length, + hubIndex: hubIndex, + isUp: isUp, + propagateTopBoundary: true, + requestFocus: (targetIndex) { + _hubKeys[targetIndex].currentState?.requestFocusFromMemory(); + }, + ); } /// Focus the first item in the first hub (for tab activation) diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index bb8d0be1..0e46f45d 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -9,6 +9,7 @@ import '../../../focus/dpad_navigator.dart'; import '../../../focus/dpad_select_long_press_controller.dart'; import '../../../focus/key_event_utils.dart'; import '../../../focus/locked_hub_controller.dart'; +import '../../../focus/hub_vertical_navigation.dart'; import '../../../i18n/strings.g.dart'; import '../../../media/media_item_types.dart'; import '../../../mixins/mounted_set_state_mixin.dart'; @@ -118,26 +119,15 @@ class WhatsOnTabState extends State with LiveTvActionsMixin= _hubKeys.length) { - return true; // At boundary, consume the event - } - - final targetState = _hubKeys[targetIndex].currentState; - if (targetState != null) { - targetState.requestFocusFromMemory(); - return true; - } - - return false; + return navigateVerticalHubRows( + hubCount: _hubKeys.length, + hubIndex: hubIndex, + isUp: isUp, + onTopBoundary: widget.onNavigateUp, + requestFocus: (targetIndex) { + _hubKeys[targetIndex].currentState?.requestFocusFromMemory(); + }, + ); } void _onItemTap(LiveTvHubEntry entry) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9e6c1da0..c4d0e8f6 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -20,6 +20,7 @@ import '../focus/dpad_navigator.dart'; import '../focus/dpad_select_long_press_controller.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/focusable_wrapper.dart'; +import '../focus/hub_vertical_navigation.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../widgets/cast_member_strip.dart'; @@ -2568,26 +2569,23 @@ class _MediaDetailScreenState extends State /// Handle vertical navigation between related hub sections bool _handleRelatedHubNavigation(int hubIndex, bool isUp) { - if (_relatedHubKeys.isEmpty) return false; - - if (isUp && hubIndex == 0) { - if (_extras != null && _extras!.isNotEmpty) { - _extrasFocusNode.requestFocus(); - _scrollSectionIntoView(_extrasSectionKey); - } else { - _focusSectionAboveExtras(); - } - return true; - } - - final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; - if (targetIndex < 0 || targetIndex >= _relatedHubKeys.length) { - if (!isUp && _hasInfoRows) _focusInfoRows(); - return true; // at boundary, consume - } - - _relatedHubKeys[targetIndex].currentState?.requestFocusFromMemory(); - return true; + return navigateVerticalHubRows( + hubCount: _relatedHubKeys.length, + hubIndex: hubIndex, + isUp: isUp, + onTopBoundary: () { + if (_extras != null && _extras!.isNotEmpty) { + _extrasFocusNode.requestFocus(); + _scrollSectionIntoView(_extrasSectionKey); + } else { + _focusSectionAboveExtras(); + } + }, + onBottomBoundary: _hasInfoRows ? _focusInfoRows : null, + requestFocus: (targetIndex) { + _relatedHubKeys[targetIndex].currentState?.requestFocusFromMemory(); + }, + ); } /// Handle key events for the trailing info rows (studio / contentRating). diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index 324fb3db..3d59abea 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -5,6 +5,7 @@ import 'package:dart_discord_presence/dart_discord_presence.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; +import '../media/playback_timeline.dart'; import '../utils/app_logger.dart'; import '../utils/media_image_helper.dart'; import '../utils/platform_detector.dart'; @@ -50,8 +51,7 @@ class DiscordRPCService { MediaServerClient? _currentClient; String? _cachedThumbnailUrl; DateTime? _playbackStartTime; - Duration? _mediaDuration; - Duration? _currentPosition; + final PlaybackTimeline _timeline = PlaybackTimeline(); double _playbackSpeed = 1.0; int _playbackRevision = 0; Timer? _reconnectTimer; @@ -111,8 +111,7 @@ class DiscordRPCService { _currentMetadata = metadata; _currentClient = client; _playbackStartTime = DateTime.now(); - _mediaDuration = metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null; - _currentPosition = Duration.zero; + _timeline.reset(duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null); _cachedThumbnailUrl = null; _playbackSpeed = 1.0; @@ -124,20 +123,15 @@ class DiscordRPCService { /// Update current playback position (for progress bar) void updatePosition(Duration position) { - final previousPosition = _currentPosition; - _currentPosition = position; + final isSeek = _timeline.updatePosition(position); // Update presence if position jumped significantly (seek detected) - if (_isEnabled && _isConnected && _playbackStartTime != null && previousPosition != null) { - final drift = (position - previousPosition).abs(); - // If position changed by more than 5 seconds, likely a seek - if (drift > const Duration(seconds: 5)) { - // Throttle updates to max once per second - final now = DateTime.now(); - if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) { - _lastPresenceUpdate = now; - _updatePresence(); - } + if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) { + // Throttle updates to max once per second + final now = DateTime.now(); + if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) { + _lastPresenceUpdate = now; + _updatePresence(); } } } @@ -397,17 +391,16 @@ class DiscordRPCService { // When paused, don't show timestamps (progress bar would be inaccurate) if (_playbackStartTime == null) return null; - // If we have duration, show progress bar - if (_mediaDuration != null) { + final duration = _timeline.duration; + if (duration != null) { final now = DateTime.now(); - final position = _currentPosition ?? Duration.zero; // Calculate remaining time accounting for playback speed - final remainingDuration = _mediaDuration! - position; + final remainingDuration = duration - _timeline.position; final adjustedRemaining = Duration(microseconds: (remainingDuration.inMicroseconds / _playbackSpeed).round()); // Calculate total adjusted duration for progress bar - final adjustedTotal = Duration(microseconds: (_mediaDuration!.inMicroseconds / _playbackSpeed).round()); + final adjustedTotal = Duration(microseconds: (duration.inMicroseconds / _playbackSpeed).round()); final effectiveEnd = now.add(adjustedRemaining); final effectiveStart = effectiveEnd.subtract(adjustedTotal); diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index db6b175e..34708aa0 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -7,6 +7,7 @@ import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; +import '../media/watch_progress.dart'; import 'offline_watch_sync_service.dart'; import 'playback_report_session.dart'; import 'settings_service.dart'; @@ -334,34 +335,37 @@ class PlaybackProgressTracker { // Explicitly scrobble once progress crosses the watched threshold. // Some servers (Plex with no active play session, Jellyfin always) // don't auto-mark from progress updates alone. - if (!_scrobbled && duration.inMilliseconds > 0) { + if (!_scrobbled && + isWatchedProgress( + positionMs: position.inMilliseconds, + durationMs: duration.inMilliseconds, + threshold: c.watchedThreshold, + )) { final percent = position.inMilliseconds / duration.inMilliseconds; final threshold = c.watchedThreshold; - if (percent >= threshold) { - _scrobbled = true; + _scrobbled = true; + try { + // Backends that mark the item played from the playback-stopped report + // (Jellyfin) only emit the local watch event here — an explicit + // markWatched would double-scrobble via the Trakt plugin (#1287). + // Plex still issues the server call. Either path emits the watched + // event through WatchStateNotifier, so no extra notify is needed. + await c.markWatchedFromPlaybackStop(metadata); + appLogger.d( + 'Scrobbled ${metadata.id} (${(percent * 100).toStringAsFixed(0)}% >= ${(threshold * 100).toStringAsFixed(0)}%)', + ); + } catch (e) { + appLogger.w('Failed to scrobble ${metadata.id}', error: e); + _scrobbled = false; // Retry on next tick + } + // After (and only after) the primary mark succeeded. A failure here + // must not reset _scrobbled — that would re-scrobble the primary + // item and inflate its view count. + if (_scrobbled && onScrobbled != null) { try { - // Backends that mark the item played from the playback-stopped report - // (Jellyfin) only emit the local watch event here — an explicit - // markWatched would double-scrobble via the Trakt plugin (#1287). - // Plex still issues the server call. Either path emits the watched - // event through WatchStateNotifier, so no extra notify is needed. - await c.markWatchedFromPlaybackStop(metadata); - appLogger.d( - 'Scrobbled ${metadata.id} (${(percent * 100).toStringAsFixed(0)}% >= ${(threshold * 100).toStringAsFixed(0)}%)', - ); + await onScrobbled!(); } catch (e) { - appLogger.w('Failed to scrobble ${metadata.id}', error: e); - _scrobbled = false; // Retry on next tick - } - // After (and only after) the primary mark succeeded. A failure here - // must not reset _scrobbled — that would re-scrobble the primary - // item and inflate its view count. - if (_scrobbled && onScrobbled != null) { - try { - await onScrobbled!(); - } catch (e) { - appLogger.w('Post-scrobble hook failed for ${metadata.id}', error: e); - } + appLogger.w('Post-scrobble hook failed for ${metadata.id}', error: e); } } } diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index 1a629000..6f026b27 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -3,6 +3,7 @@ import 'dart:async'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; +import '../../media/playback_timeline.dart'; import '../../models/trackers/tracker_context.dart'; import '../../utils/app_logger.dart'; import '../../media/episode_collection.dart'; @@ -38,20 +39,16 @@ class TrackerCoordinator { AnimeEpisodeProgressLookup? _debugAnimeProgress; TrackerContext? _ctx; - Duration _duration = Duration.zero; - Duration _lastPosition = Duration.zero; - bool _thresholdCrossed = false; /// Seed used before [startPlayback] captures the server's threshold; never /// actually consulted (a crossing is only evaluated once `_ctx` is set, /// after the client value is assigned). static const double _fallbackWatchedThreshold = TrackerConstants.watchedThresholdPercent / 100.0; - /// Captured from the active server client in [startPlayback]; trackers mark - /// watched once progress crosses it (Plex's `LibraryVideoPlayedThreshold`, - /// Jellyfin's fixed 0.9). Mirrors [PlaybackProgressTracker]'s local-marking - /// path so trackers and the server stay in lock-step. - double _watchedThreshold = _fallbackWatchedThreshold; + /// Captures position, duration, and the active client's watched threshold so + /// tracker crossing semantics stay aligned with playback progress reporting. + final PlaybackTimeline _timeline = PlaybackTimeline(watchedThreshold: _fallbackWatchedThreshold); + bool _thresholdCrossed = false; Future initialize() async { await Future.wait(_trackers.map((t) => t.initialize())); @@ -82,7 +79,7 @@ class TrackerCoordinator { } _reset(); _ctx = ctx; - _watchedThreshold = client.watchedThreshold; + _timeline.watchedThreshold = client.watchedThreshold; } bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_activeLibraryGlobalKey); @@ -304,24 +301,23 @@ class TrackerCoordinator { return; } // Safety net: fire if we passed the threshold but missed the tick. - if (!_thresholdCrossed && _crossed(_duration, _lastPosition)) { + if (!_thresholdCrossed && _timeline.watchedThresholdReached) { await _dispatchMarkWatched(ctx); } _reset(); } void updatePosition(Duration position) { - _lastPosition = position; + _timeline.updatePosition(position); final ctx = _ctx; if (ctx == null || _thresholdCrossed) return; - if (!_crossed(_duration, position)) return; + if (!_timeline.watchedThresholdReached) return; _thresholdCrossed = true; unawaited(_dispatchMarkWatched(ctx)); } void updateDuration(Duration duration) { - if (duration == _duration) return; - _duration = duration; + _timeline.updateDuration(duration); } /// Called on Plex profile switch — drops in-flight state across all @@ -341,16 +337,8 @@ class TrackerCoordinator { void _reset() { _ctx = null; _activeLibraryGlobalKey = null; - _duration = Duration.zero; - _lastPosition = Duration.zero; + _timeline.reset(watchedThreshold: _fallbackWatchedThreshold); _thresholdCrossed = false; - _watchedThreshold = _fallbackWatchedThreshold; - } - - bool _crossed(Duration duration, Duration position) { - final dMs = duration.inMilliseconds; - if (dMs == 0) return false; - return position.inMilliseconds / dMs >= _watchedThreshold; } Future _dispatchMarkWatched(TrackerContext ctx) async { diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index f899b746..d3c22903 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; +import '../../media/playback_timeline.dart'; import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_scrobble_request.dart'; import '../../utils/app_logger.dart'; @@ -33,9 +34,6 @@ class TraktScrobbleService implements TrackerRatingSource { /// spamming 409s during rapid pause/play cycles. static const Duration _startResendThrottle = Duration(seconds: 30); - /// Position-jump magnitude that counts as a seek (matches DiscordRPCService). - static const Duration _seekDetectionThreshold = Duration(seconds: 5); - /// Max one seek-checkpoint per this window — slider drag fires many position /// updates per second; we only want to ship one to Trakt. static const Duration _seekCheckpointThrottle = Duration(seconds: 5); @@ -51,8 +49,7 @@ class TraktScrobbleService implements TrackerRatingSource { TraktClient? _client; TrackerIdResolver? _resolver; TraktScrobbleRequest? _currentBody; - Duration _currentPosition = Duration.zero; - Duration _currentDuration = Duration.zero; + final PlaybackTimeline _timeline = PlaybackTimeline(); TraktScrobbleState? _lastSentState; DateTime? _lastSentAt; DateTime? _lastSeekCheckpointAt; @@ -132,8 +129,7 @@ class TraktScrobbleService implements TrackerRatingSource { _lastSentAt = null; _resolver?.clearCache(); _resolver = null; - _currentPosition = Duration.zero; - _currentDuration = Duration.zero; + _timeline.reset(); } bool get _canScrobble => _isEnabled && _client != null; @@ -227,8 +223,10 @@ class TraktScrobbleService implements TrackerRatingSource { // Seed with the resume offset so the first real position update doesn't // look like a seek when resuming mid-item. - _currentPosition = metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero; - _currentDuration = metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : Duration.zero; + _timeline.reset( + position: metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero, + duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null, + ); _lastSeekCheckpointAt = null; _resolver = TrackerIdResolver(client, needsFribb: () => false); @@ -243,8 +241,7 @@ class TraktScrobbleService implements TrackerRatingSource { } void updatePosition(Duration position) { - final previous = _currentPosition; - _currentPosition = position; + final isSeek = _timeline.updatePosition(position); // Trakt has no seek event — instead, official apps send pause+start with // the new progress to checkpoint. Without this, the "resume on another @@ -252,18 +249,18 @@ class TraktScrobbleService implements TrackerRatingSource { // pause/stop. if (_currentBody == null) return; if (_lastSentState != TraktScrobbleState.start) return; - if ((position - previous).abs() <= _seekDetectionThreshold) return; + if (!isSeek) return; final now = DateTime.now(); - if (_lastSeekCheckpointAt != null && now.difference(_lastSeekCheckpointAt!) < _seekCheckpointThrottle) return; + if (_lastSeekCheckpointAt != null && now.difference(_lastSeekCheckpointAt!) < _seekCheckpointThrottle) { + return; + } _lastSeekCheckpointAt = now; unawaited(_sendSeekCheckpoint()); } void updateDuration(Duration duration) { - if (duration.inMilliseconds == 0) return; - if (duration == _currentDuration) return; - _currentDuration = duration; + _timeline.updateDuration(duration); } Future pausePlayback() async { @@ -306,11 +303,7 @@ class TraktScrobbleService implements TrackerRatingSource { ); } - double _progressPercent() { - if (_currentDuration.inMilliseconds == 0) return 0; - final pct = (_currentPosition.inMilliseconds / _currentDuration.inMilliseconds) * 100; - return pct.clamp(0.0, 100.0); - } + double _progressPercent() => _timeline.progressPercent; /// Send pause→start to Trakt so the playback-progress endpoint reflects the /// new position. Bypasses [_send]'s state throttle (this is a checkpoint, diff --git a/test/focus/focus_node_ownership_test.dart b/test/focus/focus_node_ownership_test.dart new file mode 100644 index 00000000..b679bc08 --- /dev/null +++ b/test/focus/focus_node_ownership_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; +import 'package:plezy/focus/focusable_text_field.dart'; +import 'package:plezy/focus/focusable_wrapper.dart'; + +void main() { + testWidgets('FocusableWrapper never disposes caller-owned nodes across swaps', (tester) async { + final first = _TrackingFocusNode(); + final second = _TrackingFocusNode(); + late StateSetter rebuild; + var node = first; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return FocusableWrapper(focusNode: node, child: const SizedBox(width: 10, height: 10)); + }, + ), + ), + ); + + rebuild(() => node = second); + await tester.pump(); + await tester.pumpWidget(const SizedBox.shrink()); + + expect(first.disposeCalls, 0); + expect(second.disposeCalls, 0); + first.dispose(); + second.dispose(); + }); + + testWidgets('FocusableActionBar never disposes caller-owned nodes across swaps', (tester) async { + final first = _TrackingFocusNode(); + final second = _TrackingFocusNode(); + late StateSetter rebuild; + var node = first; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return FocusableActionBar( + actions: [FocusableAction(focusNode: node, onPressed: () {})], + ); + }, + ), + ), + ); + + rebuild(() => node = second); + await tester.pump(); + await tester.pumpWidget(const SizedBox.shrink()); + + expect(first.disposeCalls, 0); + expect(second.disposeCalls, 0); + first.dispose(); + second.dispose(); + }); + + testWidgets('FocusableTextField never disposes caller-owned nodes across swaps', (tester) async { + final first = _TrackingFocusNode(); + final second = _TrackingFocusNode(); + final controller = TextEditingController(); + late StateSetter rebuild; + var node = first; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return FocusableTextField(controller: controller, focusNode: node, enableTvKeyboard: false); + }, + ), + ), + ), + ); + + rebuild(() => node = second); + await tester.pump(); + await tester.pumpWidget(const SizedBox.shrink()); + + expect(first.disposeCalls, 0); + expect(second.disposeCalls, 0); + first.dispose(); + second.dispose(); + controller.dispose(); + }); +} + +class _TrackingFocusNode extends FocusNode { + int disposeCalls = 0; + + @override + void dispose() { + disposeCalls++; + super.dispose(); + } +} diff --git a/test/focus/hub_vertical_navigation_test.dart b/test/focus/hub_vertical_navigation_test.dart new file mode 100644 index 00000000..787864f5 --- /dev/null +++ b/test/focus/hub_vertical_navigation_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/hub_vertical_navigation.dart'; + +void main() { + test('empty hub lists do not consume navigation', () { + expect( + navigateVerticalHubRows( + hubCount: 0, + hubIndex: 0, + isUp: true, + requestFocus: (_) => fail('must not request focus'), + ), + isFalse, + ); + }); + + test('valid movement requests the adjacent row and consumes', () { + int? requested; + + final handled = navigateVerticalHubRows( + hubCount: 3, + hubIndex: 1, + isUp: false, + requestFocus: (index) => requested = index, + ); + + expect(handled, isTrue); + expect(requested, 2); + }); + + test('top boundary can propagate to the row callback', () { + expect( + navigateVerticalHubRows( + hubCount: 2, + hubIndex: 0, + isUp: true, + propagateTopBoundary: true, + requestFocus: (_) => fail('must not request focus'), + ), + isFalse, + ); + }); + + test('explicit top handoff consumes navigation', () { + var handoffs = 0; + + final handled = navigateVerticalHubRows( + hubCount: 2, + hubIndex: 0, + isUp: true, + onTopBoundary: () => handoffs++, + requestFocus: (_) => fail('must not request focus'), + ); + + expect(handled, isTrue); + expect(handoffs, 1); + }); + + test('bottom boundary invokes its handoff and always consumes', () { + var handoffs = 0; + + final handled = navigateVerticalHubRows( + hubCount: 2, + hubIndex: 1, + isUp: false, + onBottomBoundary: () => handoffs++, + requestFocus: (_) => fail('must not request focus'), + ); + + expect(handled, isTrue); + expect(handoffs, 1); + }); +} diff --git a/test/media/playback_timeline_test.dart b/test/media/playback_timeline_test.dart new file mode 100644 index 00000000..8dfcf030 --- /dev/null +++ b/test/media/playback_timeline_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/playback_timeline.dart'; + +void main() { + test('seek detection uses one shared strict threshold', () { + final timeline = PlaybackTimeline(); + + expect(timeline.updatePosition(const Duration(seconds: 5)), isFalse); + expect(timeline.updatePosition(const Duration(seconds: 11)), isTrue); + expect(timeline.position, const Duration(seconds: 11)); + }); + + test('watched threshold accepts the exact boundary', () { + final timeline = PlaybackTimeline(duration: const Duration(seconds: 100), watchedThreshold: 0.9); + + timeline.updatePosition(const Duration(seconds: 90)); + + expect(timeline.watchedThresholdReached, isTrue); + }); + + test('unknown duration is not watched and reports zero progress', () { + final timeline = PlaybackTimeline(position: const Duration(seconds: 30)); + + expect(timeline.updateDuration(Duration.zero), isFalse); + expect(timeline.watchedThresholdReached, isFalse); + expect(timeline.progressPercent, 0); + }); + + test('progress is clamped and reset clears prior playback timing', () { + final timeline = PlaybackTimeline(position: const Duration(seconds: 120), duration: const Duration(seconds: 100)); + + expect(timeline.progressPercent, 100); + + timeline.reset(watchedThreshold: 0.8); + + expect(timeline.position, Duration.zero); + expect(timeline.duration, isNull); + expect(timeline.watchedThreshold, 0.8); + expect(timeline.watchedThresholdReached, isFalse); + }); +} diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index a2aa3053..b82758c4 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -108,6 +108,58 @@ void main() { expect(hooked, [(0, 5)]); }); + testWidgets('loadInitialPaginatedItems applies reset, data, and success callback', (tester) async { + late _PaginatedProbeState state; + var reset = false; + List? applied; + (int, int)? counts; + await tester.pumpWidget( + _PaginatedProbe( + onState: (s) => state = s, + fetcher: (start, size, abort) async => _result(start: start, size: size, totalSize: 7), + ), + ); + + final succeeded = await state.loadInitialPaginatedItems( + pageSize: 3, + resetViewState: () => reset = true, + applyLoadedItems: (items) => applied = items, + applyError: (error, stackTrace) => fail('unexpected error: $error'), + onLoaded: (loaded, total) => counts = (loaded, total), + ); + await tester.pump(); + + expect(succeeded, isTrue); + expect(reset, isTrue); + expect(applied?.map((item) => item.id), ['k0', 'k1', 'k2']); + expect(counts, (3, 7)); + }); + + testWidgets('loadInitialPaginatedItems applies one error transaction', (tester) async { + late _PaginatedProbeState state; + Object? appliedError; + Object? loggedError; + await tester.pumpWidget( + _PaginatedProbe( + onState: (s) => state = s, + fetcher: (start, size, abort) async => throw StateError('failed page'), + ), + ); + + final succeeded = await state.loadInitialPaginatedItems( + pageSize: 3, + resetViewState: () {}, + applyLoadedItems: (_) => fail('items must not be applied'), + applyError: (error, stackTrace) => appliedError = error, + onError: (error, stackTrace) => loggedError = error, + ); + await tester.pump(); + + expect(succeeded, isFalse); + expect(appliedError, isA()); + expect(loggedError, same(appliedError)); + }); + testWidgets('totalSize == 0 means no more pages — ensureRangeLoaded is a no-op', (tester) async { late _PaginatedProbeState state; await tester.pumpWidget( diff --git a/test/screens/hub_detail_screen_test.dart b/test/screens/hub_detail_screen_test.dart index 71e2092a..ec63780c 100644 --- a/test/screens/hub_detail_screen_test.dart +++ b/test/screens/hub_detail_screen_test.dart @@ -18,6 +18,7 @@ import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -159,12 +160,7 @@ class _PagedHubClient implements MediaServerClient { AbortController? abort, }) async { requestedStarts.add(start); - final offset = start ?? 0; - return LibraryPage( - items: items.skip(offset).take(size ?? items.length).toList(growable: false), - totalCount: items.length, - offset: offset, - ); + return fakeLibraryPage(items, start: start, size: size); } @override diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index d58896b2..66f1031b 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -23,6 +23,8 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/watch_state_store.dart'; import 'package:plezy/screens/media_detail_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; + +import '../test_helpers/paged_fakes.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -1024,11 +1026,7 @@ class _FakeMediaServerClient implements MediaServerClient { if (error != null) throw error; final all = await (childrenPageFutures[parentId] ?? Future.value(childrenByParent[parentId] ?? const [])); - final offset = start ?? 0; - final limit = size ?? all.length; - final end = (offset + limit).clamp(0, all.length).toInt(); - final items = offset >= all.length ? const [] : all.sublist(offset, end); - return LibraryPage(items: items, totalCount: all.length, offset: offset); + return fakeLibraryPage(all, start: start, size: size); } @override diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 636f42b9..9bc2f2f2 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -23,6 +23,8 @@ import 'package:plezy/services/playlist_items_loader.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; + +import '../test_helpers/paged_fakes.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:provider/provider.dart'; @@ -271,8 +273,7 @@ class _PagedPlaylistClient implements MediaServerClient { _hasFailed = true; throw StateError('temporary continuation failure'); } - final limit = size ?? items.length; - return LibraryPage(items: items.skip(offset).take(limit).toList(), totalCount: items.length, offset: offset); + return fakeLibraryPage(items, start: start, size: size); } @override diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index e31aa404..ad40b6c6 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -20,14 +20,10 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/settings_service.dart'; +import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; -JellyfinConnection _conn() => JellyfinConnection( - id: 'srv-1/user-1', - baseUrl: 'https://jf.example.com', - serverName: 'Home', - serverMachineId: 'srv-1', - userId: 'user-1', +JellyfinConnection _conn() => testJellyfinConnection( userName: 'edde', accessToken: 'tok-abc', deviceId: 'dev-xyz', diff --git a/test/services/download_artwork_service_test.dart b/test/services/download_artwork_service_test.dart index 746c43f8..24eec491 100644 --- a/test/services/download_artwork_service_test.dart +++ b/test/services/download_artwork_service_test.dart @@ -17,46 +17,10 @@ import 'package:plezy/services/download_artwork_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; -class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { - _FakePathProvider(this.root); - - final Directory root; - - @override - Future getApplicationDocumentsPath() async => _ensure('documents'); - - @override - Future getApplicationSupportPath() async => _ensure('support'); - - @override - Future getApplicationCachePath() async => _ensure('cache'); - - @override - Future getTemporaryPath() async => _ensure('temp'); - - String _ensure(String name) { - final path = p.join(root.path, name); - Directory(path).createSync(recursive: true); - return path; - } -} - -class _FakeHttpClient extends http.BaseClient { - _FakeHttpClient(this.statusCode, this.body); - - final int statusCode; - final List body; - - @override - Future send(http.BaseRequest request) async { - return http.StreamedResponse(Stream>.value(body), statusCode, request: request); - } -} - class _DelayedCountingHttpClient extends http.BaseClient { _DelayedCountingHttpClient(this.body); @@ -80,7 +44,7 @@ void main() { SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); tmpRoot = await Directory.systemTemp.createTemp('download_artwork_service_test_'); - PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + PathProviderPlatform.instance = FakePathProvider(tmpRoot); }); tearDown(() async { @@ -114,7 +78,7 @@ void main() { await storage.initialize(settings); final service = DownloadArtworkService( storageService: storage, - http: MediaServerHttpClient(client: _FakeHttpClient(200, utf8.encode('image'))), + http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('image'))), ); const tokenized = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; @@ -125,7 +89,7 @@ void main() { test('downloadFile rejects non-success responses without leaving final files', () async { final file = File(p.join(tmpRoot.path, 'art.jpg')); - final httpClient = MediaServerHttpClient(client: _FakeHttpClient(404, utf8.encode('not found'))); + final httpClient = MediaServerHttpClient(client: FakeHttpClient(404, utf8.encode('not found'))); await expectLater( httpClient.downloadFile('https://example.test/art.jpg', file.path), @@ -143,7 +107,7 @@ void main() { final body = utf8.encode('valid image bytes'); final service = DownloadArtworkService( storageService: storage, - http: MediaServerHttpClient(client: _FakeHttpClient(200, body)), + http: MediaServerHttpClient(client: FakeHttpClient(200, body)), ); const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index a02f4cd6..88ea3e70 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -6,7 +6,6 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/database/app_database.dart'; @@ -26,9 +25,9 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/saf_storage_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:saf_util/saf_util_platform_interface.dart'; +import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -190,7 +189,7 @@ void main() { SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_'); - PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + PathProviderPlatform.instance = FakePathProvider(tmpRoot); addTearDown(() async { DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); @@ -263,7 +262,7 @@ void main() { database: db, storageService: storage, clientResolver: (serverId, {clientScopeId}) => client, - http: MediaServerHttpClient(client: _FakeHttpClient(200, utf8.encode('image bytes'))), + http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('image bytes'))), ); await manager.repairMissingArtworkForDownloads(); @@ -284,7 +283,7 @@ void main() { SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_delete_test_'); - PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + PathProviderPlatform.instance = FakePathProvider(tmpRoot); addTearDown(() async { DownloadStorageService.resetForTesting(); SettingsService.resetForTesting(); @@ -551,7 +550,7 @@ Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDe SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_backend_delete_test_'); - PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + PathProviderPlatform.instance = FakePathProvider(tmpRoot); final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance; if (!saf) { @@ -664,7 +663,7 @@ Future<_ContainerDeletionResult> _runContainerDeletion({required MediaKind kind, SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); final tmpRoot = await Directory.systemTemp.createTemp('download_manager_container_delete_test_'); - PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + PathProviderPlatform.instance = FakePathProvider(tmpRoot); final storage = saf ? DownloadStorageService.forTestingSaf('content://downloads') : DownloadStorageService.instance; if (!saf) await storage.initialize(await SettingsService.getInstance()); @@ -939,42 +938,6 @@ class _ScopedJellyfinClient implements MediaServerClient, ScopedMediaServerClien dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { - _FakePathProvider(this.root); - - final Directory root; - - @override - Future getApplicationDocumentsPath() async => _ensure('documents'); - - @override - Future getApplicationSupportPath() async => _ensure('support'); - - @override - Future getApplicationCachePath() async => _ensure('cache'); - - @override - Future getTemporaryPath() async => _ensure('temp'); - - String _ensure(String name) { - final path = p.join(root.path, name); - Directory(path).createSync(recursive: true); - return path; - } -} - -class _FakeHttpClient extends http.BaseClient { - _FakeHttpClient(this.statusCode, this.body); - - final int statusCode; - final List body; - - @override - Future send(http.BaseRequest request) async { - return http.StreamedResponse(Stream>.value(body), statusCode, request: request); - } -} - class _ArtworkRepairClient implements MediaServerClient { _ArtworkRepairClient({required this.serverId, required this.items}); diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart index 312572cf..a5b83085 100644 --- a/test/services/download_storage_service_test.dart +++ b/test/services/download_storage_service_test.dart @@ -9,42 +9,10 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; -/// In-test fake PathProviderPlatform that points all directories at a real -/// on-disk temp folder. Required because the production service calls -/// [getApplicationDocumentsDirectory] / [getApplicationSupportDirectory] — -/// both of which fail outside an app context unless the platform interface -/// is mocked. -class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { - _FakePathProvider(this.root); - - final Directory root; - String get _docs => p.join(root.path, 'documents'); - String get _support => p.join(root.path, 'support'); - String get _cache => p.join(root.path, 'cache'); - String get _temp => p.join(root.path, 'temp'); - - @override - Future getApplicationDocumentsPath() async => _ensure(_docs); - - @override - Future getApplicationSupportPath() async => _ensure(_support); - - @override - Future getApplicationCachePath() async => _ensure(_cache); - - @override - Future getTemporaryPath() async => _ensure(_temp); - - String _ensure(String dir) { - Directory(dir).createSync(recursive: true); - return dir; - } -} - void main() { late Directory tmpRoot; @@ -53,7 +21,7 @@ void main() { SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); tmpRoot = await Directory.systemTemp.createTemp('dss_test_'); - PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + PathProviderPlatform.instance = FakePathProvider(tmpRoot); }); tearDown(() async { diff --git a/test/services/jellyfin_auth_service_test.dart b/test/services/jellyfin_auth_service_test.dart index d4069ab4..90015adb 100644 --- a/test/services/jellyfin_auth_service_test.dart +++ b/test/services/jellyfin_auth_service_test.dart @@ -12,6 +12,8 @@ import 'package:plezy/services/jellyfin_endpoint_discovery.dart'; import 'package:plezy/utils/log_redaction_manager.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + /// Helpers for stubbing http responses keyed by request path. typedef _Handler = FutureOr Function(http.BaseRequest req); @@ -20,12 +22,7 @@ http.Response _bareOk(String body) => http.Response(body, 200, headers: {'conten http.Response _status(int code, [Object? json]) => http.Response(json == null ? '' : jsonEncode(json), code, headers: {'content-type': 'application/json'}); -JellyfinConnection _existingConn({String accessToken = 'tok-old'}) => JellyfinConnection( - id: 'srv-1/user-1', - baseUrl: 'https://jf.example.com', - serverName: 'Home', - serverMachineId: 'srv-1', - userId: 'user-1', +JellyfinConnection _existingConn({String accessToken = 'tok-old'}) => testJellyfinConnection( userName: 'edde', accessToken: accessToken, deviceId: 'dev-xyz', diff --git a/test/services/jellyfin_client_failures_test.dart b/test/services/jellyfin_client_failures_test.dart index 5720861d..b33bb565 100644 --- a/test/services/jellyfin_client_failures_test.dart +++ b/test/services/jellyfin_client_failures_test.dart @@ -11,20 +11,18 @@ import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; -JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List? baseUrls}) => JellyfinConnection( - id: 'srv-1/user-1', +import '../test_helpers/backend_client_fixtures.dart'; + +JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List? baseUrls}) => testJellyfinConnection( baseUrl: baseUrl, baseUrls: baseUrls, - serverName: 'Home', - serverMachineId: 'srv-1', - userId: 'user-1', userName: 'edde', accessToken: 'tok-abc', deviceId: 'dev-xyz', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); -JellyfinClient _withMock(MockClient mock) => JellyfinClient.forTesting(connection: _conn(), httpClient: mock); +JellyfinClient _withMock(MockClient mock) => testJellyfinClient(connection: _conn(), httpClient: mock); /// Failure-path coverage for the Jellyfin HTTP layer. /// diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 9d4059c2..10792417 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -14,13 +14,12 @@ import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/utils/device_identity.dart'; +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/paged_fakes.dart'; + JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) => - JellyfinConnection( - id: 'srv-1/user-1', + testJellyfinConnection( baseUrl: baseUrl, - serverName: 'Home', - serverMachineId: 'srv-1', - userId: 'user-1', userName: 'edde', accessToken: accessToken, deviceId: 'dev-xyz', @@ -3282,7 +3281,10 @@ void main() { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final limit = int.parse(req.url.queryParameters['Limit'] ?? '2'); return http.Response( - jsonEncode({'Items': allItems.skip(start).take(limit).toList(), 'TotalRecordCount': allItems.length}), + jsonEncode({ + 'Items': sliceFakePage(allItems, start: start, size: limit), + 'TotalRecordCount': allItems.length, + }), 200, headers: {'content-type': 'application/json'}, ); diff --git a/test/services/jellyfin_favorites_isolation_test.dart b/test/services/jellyfin_favorites_isolation_test.dart index febcae4a..7edc8b6b 100644 --- a/test/services/jellyfin_favorites_isolation_test.dart +++ b/test/services/jellyfin_favorites_isolation_test.dart @@ -7,25 +7,23 @@ import 'package:plezy/models/livetv_channel.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; -JellyfinConnection _conn({required String userId}) => JellyfinConnection( - id: 'srv-shared/$userId', - baseUrl: 'https://jf.example.com', - serverName: 'Shared JF', - serverMachineId: 'srv-shared', +JellyfinConnection _conn({required String userId}) => testJellyfinConnection( + machineId: 'srv-shared', userId: userId, + serverName: 'Shared JF', userName: 'user-$userId', accessToken: 'tok-$userId', deviceId: 'dev-$userId', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); -JellyfinClient _client(JellyfinConnection conn) => JellyfinClient.forTesting( +JellyfinClient _client(JellyfinConnection conn) => testJellyfinClient( connection: conn, - // Favorites read path is local-only; an http stub that always 500s is - // fine since fetchFavoriteChannels never hits it. - httpClient: MockClient((_) async => throw StateError('no HTTP expected')), + // Favorites read path is local-only; any HTTP call is a test failure. + handler: (_) async => throw StateError('no HTTP expected'), ); String _favKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.id}'; diff --git a/test/services/jellyfin_music_mapper_test.dart b/test/services/jellyfin_music_mapper_test.dart index 5e85a14c..7b7f7dea 100644 --- a/test/services/jellyfin_music_mapper_test.dart +++ b/test/services/jellyfin_music_mapper_test.dart @@ -9,6 +9,8 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/jellyfin_mappers.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + const _serverId = 'jf-machine-1'; /// Captured (trimmed) from a live Jellyfin 10.11 server — an `Audio` row @@ -65,12 +67,7 @@ Map _albumJson() => { 'MediaType': 'Unknown', }; -JellyfinConnection _conn() => JellyfinConnection( - id: 'srv-1/user-1', - baseUrl: 'https://jf.example.com', - serverName: 'Home', - serverMachineId: 'srv-1', - userId: 'user-1', +JellyfinConnection _conn() => testJellyfinConnection( userName: 'edde', accessToken: 'tok-abc', deviceId: 'dev-xyz', diff --git a/test/services/jellyfin_playback_bundle_test.dart b/test/services/jellyfin_playback_bundle_test.dart index 5e5412f2..53022839 100644 --- a/test/services/jellyfin_playback_bundle_test.dart +++ b/test/services/jellyfin_playback_bundle_test.dart @@ -10,12 +10,9 @@ import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; -JellyfinConnection _conn() => JellyfinConnection( - id: 'srv-1/user-1', - baseUrl: 'https://jf.example.com', - serverName: 'Home', - serverMachineId: 'srv-1', - userId: 'user-1', +import '../test_helpers/backend_client_fixtures.dart'; + +JellyfinConnection _conn() => testJellyfinConnection( userName: 'edde', accessToken: 'tok-abc', deviceId: 'dev-xyz', @@ -44,10 +41,10 @@ void main() { }); JellyfinClient buildClient(String body) { - final mock = MockClient((req) async { - return http.Response(body, 200, headers: {'content-type': 'application/json'}); - }); - return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + return testJellyfinClient( + connection: _conn(), + handler: (_) async => http.Response(body, 200, headers: {'content-type': 'application/json'}), + ); } group('JellyfinClient.fetchPlaybackBundle', () { diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart index 5ec8ed37..17f7883f 100644 --- a/test/services/jellyfin_sequential_launcher_test.dart +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -13,6 +13,8 @@ import 'package:plezy/services/media_list_playback_launcher.dart'; import 'package:plezy/services/playlist_items_loader.dart'; import 'package:plezy/utils/media_server_http_client.dart'; +import '../test_helpers/paged_fakes.dart'; + /// Recording fake that satisfies [JellyfinClient] via `implements` + /// `noSuchMethod`. The launcher only needs the /// [MediaServerClient.fetchPlayableDescendants] / @@ -62,17 +64,9 @@ class _RecordingJellyfinClient implements JellyfinClient { @override Future> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async { final offset = start ?? 0; - final limit = size ?? 100; + final limit = size ?? fakeMediaPageSize; fetchPlaylistItemsCalls.add((id: id, offset: offset, limit: limit)); - if (offset >= playlistItemsResponse.length) { - return LibraryPage(items: const [], totalCount: playlistItemsResponse.length, offset: offset); - } - final end = (offset + limit).clamp(0, playlistItemsResponse.length); - return LibraryPage( - items: playlistItemsResponse.sublist(offset, end), - totalCount: playlistItemsResponse.length, - offset: offset, - ); + return fakeLibraryPage(playlistItemsResponse, start: start, size: size); } @override diff --git a/test/services/jellyfin_trickplay_service_test.dart b/test/services/jellyfin_trickplay_service_test.dart index 13575158..b0ee4c70 100644 --- a/test/services/jellyfin_trickplay_service_test.dart +++ b/test/services/jellyfin_trickplay_service_test.dart @@ -10,12 +10,9 @@ import 'package:plezy/services/jellyfin_trickplay_service.dart'; import 'package:plezy/services/scrub_preview_source.dart'; import 'package:plezy/utils/device_identity.dart'; -JellyfinConnection _conn() => JellyfinConnection( - id: 'srv-1/user-1', - baseUrl: 'https://jf.example.com', - serverName: 'Home', - serverMachineId: 'srv-1', - userId: 'user-1', +import '../test_helpers/backend_client_fixtures.dart'; + +JellyfinConnection _conn() => testJellyfinConnection( userName: 'edde', accessToken: 'tok-abc', deviceId: 'dev-xyz', diff --git a/test/services/live_tv_capability_contract_test.dart b/test/services/live_tv_capability_contract_test.dart index 885a3891..1c51665e 100644 --- a/test/services/live_tv_capability_contract_test.dart +++ b/test/services/live_tv_capability_contract_test.dart @@ -8,7 +8,6 @@ import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; @@ -16,6 +15,8 @@ import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + void main() { late AppDatabase db; @@ -26,29 +27,19 @@ void main() { tearDown(() => db.close()); - PlexClient plexClient(http.Client httpClient) => PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'plex-token', - clientIdentifier: 'plex-device', - product: 'Plezy', - version: '1', - machineIdentifier: 'plex-machine', - ), + PlexClient plexClient(http.Client httpClient) => testPlexClient( + config: testPlexConfig(token: 'plex-token', clientIdentifier: 'plex-device', machineIdentifier: 'plex-machine'), serverId: ServerId('plex-machine'), httpClient: httpClient, ); - JellyfinClient jellyfinClient(http.Client httpClient) => JellyfinClient.forTesting( - connection: JellyfinConnection( - id: 'jellyfin-machine/user-1', + JellyfinClient jellyfinClient(http.Client httpClient) => testJellyfinClient( + connection: testJellyfinConnection( + machineId: 'jellyfin-machine', + userId: 'user-1', baseUrl: 'https://jellyfin.example.com', serverName: 'Jellyfin', - serverMachineId: 'jellyfin-machine', - userId: 'user-1', - userName: 'User', accessToken: 'jellyfin-token', - deviceId: 'device-1', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ), httpClient: httpClient, diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index ffd642ca..6f317ac0 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -14,24 +14,20 @@ import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; -JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( - id: 'jf-machine/$userId', - baseUrl: 'https://jf.example.com', - serverName: 'Shared JF', - serverMachineId: 'jf-machine', +JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( + machineId: 'jf-machine', userId: userId, + serverName: 'Shared JF', userName: userId, accessToken: 'token-$userId', deviceId: 'device', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); -JellyfinClient _jellyfinClient(String userId) => JellyfinClient.forTesting( - connection: _jellyfinConnection(userId), - httpClient: MockClient((_) async => http.Response('{}', 200)), -); +JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: _jellyfinConnection(userId)); // NOTE on coverage scope: // [MultiServerManager.addServer] / `connectToAllServers` / `_createClientForServer` diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index e92eb23d..4da145b5 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -19,6 +19,7 @@ import 'package:plezy/services/offline_mode_source.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; // NOTE on coverage scope: @@ -154,12 +155,10 @@ class _ScopedRecordingMediaClient extends _RecordingMediaClient implements Scope return (svc: svc, db: db, mgr: mgr); } -JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( - id: 'jf-machine/$userId', - baseUrl: 'https://jf.example.com', - serverName: 'Shared JF', - serverMachineId: 'jf-machine', +JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( + machineId: 'jf-machine', userId: userId, + serverName: 'Shared JF', userName: userId, accessToken: 'token-$userId', deviceId: 'device', diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 6695bf3d..0985efa3 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -5,7 +5,6 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/media_backend.dart'; @@ -21,37 +20,10 @@ import 'package:plezy/services/playback_initialization_service.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_mappers.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; -class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { - _FakePathProvider(this.root); - - final Directory root; - String get _docs => p.join(root.path, 'documents'); - String get _support => p.join(root.path, 'support'); - String get _cache => p.join(root.path, 'cache'); - String get _temp => p.join(root.path, 'temp'); - - @override - Future getApplicationDocumentsPath() async => _ensure(_docs); - - @override - Future getApplicationSupportPath() async => _ensure(_support); - - @override - Future getApplicationCachePath() async => _ensure(_cache); - - @override - Future getTemporaryPath() async => _ensure(_temp); - - String _ensure(String dir) { - Directory(dir).createSync(recursive: true); - return dir; - } -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -63,7 +35,7 @@ void main() { SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_'); - PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + PathProviderPlatform.instance = FakePathProvider(tmpRoot); db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); JellyfinApiCache.initialize(db); diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index 6b02516a..39b3c6c3 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -3,14 +3,14 @@ import 'dart:convert'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/ids.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + void main() { late AppDatabase db; @@ -21,19 +21,8 @@ void main() { tearDown(() => db.close()); - PlexClient makeClient(Future Function(http.Request request) handler) { - return PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'token', - clientIdentifier: 'client-id', - product: 'Plezy', - version: '1', - ), - serverId: ServerId('server-id'), - httpClient: MockClient(handler), - ); - } + PlexClient makeClient(Future Function(http.Request request) handler) => + testPlexClient(serverId: ServerId('server-id'), handler: handler); test('void mutations surface non-success responses', () async { final client = makeClient((_) async => http.Response('rejected', 500)); diff --git a/test/services/plex_library_details_test.dart b/test/services/plex_library_details_test.dart index caa7a2dd..8cafc4bf 100644 --- a/test/services/plex_library_details_test.dart +++ b/test/services/plex_library_details_test.dart @@ -4,13 +4,13 @@ import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/library_query.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + void main() { late AppDatabase db; @@ -23,19 +23,8 @@ void main() { await db.close(); }); - PlexClient makeClient(Future Function(http.Request request) handler) { - return PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'token', - clientIdentifier: 'client-id', - product: 'Plezy', - version: '1', - ), - serverId: ServerId('server-id'), - httpClient: MockClient(handler), - ); - } + PlexClient makeClient(Future Function(http.Request request) handler) => + testPlexClient(serverId: ServerId('server-id'), handler: handler); test('filters and sorts use dedicated Plex endpoints', () async { final requests = []; diff --git a/test/services/plex_music_transcode_test.dart b/test/services/plex_music_transcode_test.dart index 841d1b37..61ecdc4c 100644 --- a/test/services/plex_music_transcode_test.dart +++ b/test/services/plex_music_transcode_test.dart @@ -1,14 +1,14 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/models/audio_quality_preset.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + void main() { late AppDatabase db; @@ -21,19 +21,8 @@ void main() { await db.close(); }); - PlexClient makeClient(Future Function(http.Request request) handler) { - return PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'token', - clientIdentifier: 'client-id', - product: 'Plezy', - version: '1', - ), - serverId: ServerId('server-id'), - httpClient: MockClient(handler), - ); - } + PlexClient makeClient(Future Function(http.Request request) handler) => + testPlexClient(serverId: ServerId('server-id'), handler: handler); test('music transcode params cap bitrate and carry the musicProfile target', () { final client = makeClient((_) async => http.Response('not used', 500)); diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index cce4681d..08bac882 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -4,19 +4,19 @@ import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + void main() { late AppDatabase db; @@ -29,19 +29,8 @@ void main() { await db.close(); }); - PlexClient makeClient(Future Function(http.Request request) handler) { - return PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'token', - clientIdentifier: 'client-id', - product: 'Plezy', - version: '1', - ), - serverId: ServerId('server-id'), - httpClient: MockClient(handler), - ); - } + PlexClient makeClient(Future Function(http.Request request) handler) => + testPlexClient(serverId: ServerId('server-id'), handler: handler); MediaSourceInfo mediaInfoWithSubtitles(List subtitleTracks) { return MediaSourceInfo( diff --git a/test/services/plex_search_test.dart b/test/services/plex_search_test.dart index 303e00ad..c9e9d3c7 100644 --- a/test/services/plex_search_test.dart +++ b/test/services/plex_search_test.dart @@ -4,12 +4,12 @@ import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); void main() { @@ -24,20 +24,8 @@ void main() { await db.close(); }); - PlexClient makeClient(Future Function(http.Request request) handler) { - return PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'token', - clientIdentifier: 'client-id', - product: 'Plezy', - version: 'test', - ), - serverId: ServerId('plex-1'), - serverName: 'Plex', - httpClient: MockClient(handler), - ); - } + PlexClient makeClient(Future Function(http.Request request) handler) => + testPlexClient(serverId: ServerId('plex-1'), serverName: 'Plex', handler: handler); test('search defaults to 100 movie, TV, and music candidates', () async { final captured = []; diff --git a/test/services/plex_timeline_session_test.dart b/test/services/plex_timeline_session_test.dart index c476fa79..35f6f1ed 100644 --- a/test/services/plex_timeline_session_test.dart +++ b/test/services/plex_timeline_session_test.dart @@ -3,18 +3,18 @@ import 'dart:convert'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + /// Regression coverage for the Plex transcode reporting bug: while /// transcoding, the `/:/timeline` reports must carry the playback's /// `X-Plex-Session-Identifier` so the server correlates the timeline with the @@ -32,19 +32,8 @@ void main() { await db.close(); }); - PlexClient makeClient(Future Function(http.Request request) handler) { - return PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'token', - clientIdentifier: 'client-id', - product: 'Plezy', - version: '1', - ), - serverId: ServerId('server-id'), - httpClient: MockClient(handler), - ); - } + PlexClient makeClient(Future Function(http.Request request) handler) => + testPlexClient(serverId: ServerId('server-id'), handler: handler); /// Captures every request and answers `/:/timeline` with 200 so /// [PlexClient.updateProgress]'s `throwIfHttpError` is satisfied. diff --git a/test/services/plex_transcoder_capability_test.dart b/test/services/plex_transcoder_capability_test.dart index bf894fd4..94660481 100644 --- a/test/services/plex_transcoder_capability_test.dart +++ b/test/services/plex_transcoder_capability_test.dart @@ -5,12 +5,12 @@ import 'dart:io'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + void main() { late AppDatabase db; @@ -77,23 +77,16 @@ void main() { } PlexClient _makeClient(Map rootContainer) { - return PlexClient.forTesting( - config: PlexConfig( - baseUrl: 'https://plex.example.com', - token: 'token', - clientIdentifier: 'client-id', - product: 'Plezy', - version: 'test', - ), + return testPlexClient( serverId: ServerId('server-id'), - httpClient: MockClient((request) async { + handler: (request) async { expect(request.url.path, '/'); return http.Response( jsonEncode({'MediaContainer': rootContainer}), 200, headers: {'content-type': 'application/json'}, ); - }), + }, ); } diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 6af59f06..34e1157e 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -17,14 +17,13 @@ import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/sync_rule_executor.dart'; +import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; -JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( - id: 'jf-machine/$userId', - baseUrl: 'https://jf.example.com', - serverName: 'Shared JF', - serverMachineId: 'jf-machine', +JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( + machineId: 'jf-machine', userId: userId, + serverName: 'Shared JF', userName: userId, accessToken: 'token-$userId', deviceId: 'device', diff --git a/test/test_helpers/backend_client_fixtures.dart b/test/test_helpers/backend_client_fixtures.dart new file mode 100644 index 00000000..318f84a1 --- /dev/null +++ b/test/test_helpers/backend_client_fixtures.dart @@ -0,0 +1,113 @@ +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/plex_client.dart'; + +JellyfinConnection testJellyfinConnection({ + String machineId = 'srv-1', + String userId = 'user-1', + String? id, + String baseUrl = 'https://jf.example.com', + List? baseUrls, + String serverName = 'Home', + String userName = 'User', + String accessToken = 'token', + String deviceId = 'device-1', + bool isAdministrator = false, + ConnectionStatus status = ConnectionStatus.unknown, + DateTime? createdAt, + DateTime? lastAuthenticatedAt, +}) { + return JellyfinConnection( + id: id ?? '$machineId/$userId', + baseUrl: baseUrl, + baseUrls: baseUrls, + serverName: serverName, + serverMachineId: machineId, + userId: userId, + userName: userName, + accessToken: accessToken, + deviceId: deviceId, + isAdministrator: isAdministrator, + status: status, + createdAt: createdAt ?? DateTime.utc(2024), + lastAuthenticatedAt: lastAuthenticatedAt, + ); +} + +PlexConfig testPlexConfig({ + String baseUrl = 'https://plex.example.com', + String? token = 'token', + String clientIdentifier = 'test-client', + String product = 'Plezy Test', + String version = '1.0.0', + String platform = 'Flutter Test', + String? device, + String? deviceName, + bool acceptJson = true, + String? machineIdentifier, + String? languageCode, +}) { + return PlexConfig( + baseUrl: baseUrl, + token: token, + clientIdentifier: clientIdentifier, + product: product, + version: version, + platform: platform, + device: device, + deviceName: deviceName, + acceptJson: acceptJson, + machineIdentifier: machineIdentifier, + languageCode: languageCode, + ); +} + +JellyfinClient testJellyfinClient({ + JellyfinConnection? connection, + http.Client? httpClient, + Future Function(http.Request request)? handler, + void Function()? onAllEndpointsExhausted, +}) { + assert(httpClient == null || handler == null, 'Provide either httpClient or handler, not both'); + return JellyfinClient.forTesting( + connection: connection ?? testJellyfinConnection(), + httpClient: httpClient ?? MockClient(handler ?? _defaultResponse), + onAllEndpointsExhausted: onAllEndpointsExhausted, + ); +} + +PlexClient testPlexClient({ + PlexConfig? config, + String baseUrl = 'https://plex.example.com', + String? token = 'token', + ServerId? serverId, + String? serverName = 'Server', + http.Client? httpClient, + Future Function(http.Request request)? handler, + List? prioritizedEndpoints, + List<({String identifier, String gridEndpoint})> epgProviders = const [], + String? homeHubKey, + String? promotedHubKey, + String? continueWatchingHubKey, +}) { + assert(httpClient == null || handler == null, 'Provide either httpClient or handler, not both'); + return PlexClient.forTesting( + config: config ?? testPlexConfig(baseUrl: baseUrl, token: token), + serverId: serverId ?? ServerId('server-1'), + serverName: serverName, + httpClient: httpClient ?? MockClient(handler ?? _defaultResponse), + prioritizedEndpoints: prioritizedEndpoints, + epgProviders: epgProviders, + homeHubKey: homeHubKey, + promotedHubKey: promotedHubKey, + continueWatchingHubKey: continueWatchingHubKey, + ); +} + +Future _defaultResponse(http.Request request) async { + return http.Response('{}', 200, headers: const {'content-type': 'application/json'}); +} diff --git a/test/test_helpers/io_fakes.dart b/test/test_helpers/io_fakes.dart new file mode 100644 index 00000000..d2dcbde7 --- /dev/null +++ b/test/test_helpers/io_fakes.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +/// Routes path-provider lookups to isolated directories below [root]. +class FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { + FakePathProvider(this.root); + + final Directory root; + + @override + Future getApplicationDocumentsPath() async => _ensure('documents'); + + @override + Future getApplicationSupportPath() async => _ensure('support'); + + @override + Future getApplicationCachePath() async => _ensure('cache'); + + @override + Future getTemporaryPath() async => _ensure('temp'); + + String _ensure(String name) { + final path = p.join(root.path, name); + Directory(path).createSync(recursive: true); + return path; + } +} + +/// Returns one deterministic streamed response for every request. +class FakeHttpClient extends http.BaseClient { + FakeHttpClient(this.statusCode, this.body); + + final int statusCode; + final List body; + + @override + Future send(http.BaseRequest request) async { + return http.StreamedResponse(Stream>.value(body), statusCode, request: request); + } +} diff --git a/test/test_helpers/paged_fakes.dart b/test/test_helpers/paged_fakes.dart new file mode 100644 index 00000000..33f98c12 --- /dev/null +++ b/test/test_helpers/paged_fakes.dart @@ -0,0 +1,20 @@ +import 'package:plezy/media/library_query.dart'; + +/// Default page size used by production media paging paths. +const fakeMediaPageSize = 200; + +List sliceFakePage(List allItems, {int? start, int? size, int defaultPageSize = fakeMediaPageSize}) { + final offset = (start ?? 0).clamp(0, allItems.length); + final requestedSize = (size ?? defaultPageSize).clamp(0, allItems.length - offset); + if (requestedSize == 0) return List.empty(growable: false); + return allItems.sublist(offset, offset + requestedSize); +} + +LibraryPage fakeLibraryPage(List allItems, {int? start, int? size, int defaultPageSize = fakeMediaPageSize}) { + final offset = start ?? 0; + return LibraryPage( + items: sliceFakePage(allItems, start: offset, size: size, defaultPageSize: defaultPageSize), + totalCount: allItems.length, + offset: offset, + ); +} diff --git a/test/test_helpers/paged_fakes_test.dart b/test/test_helpers/paged_fakes_test.dart new file mode 100644 index 00000000..5a6d7351 --- /dev/null +++ b/test/test_helpers/paged_fakes_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'paged_fakes.dart'; + +void main() { + test('fakeLibraryPage uses the shared 200-item default', () { + final items = List.generate(250, (index) => index); + + final first = fakeLibraryPage(items); + final second = fakeLibraryPage(items, start: fakeMediaPageSize); + + expect(first.items, orderedEquals(List.generate(200, (index) => index))); + expect(first.totalCount, 250); + expect(first.offset, 0); + expect(second.items, orderedEquals(List.generate(50, (index) => index + 200))); + expect(second.offset, 200); + }); + + test('fakeLibraryPage honors explicit bounds and empty trailing pages', () { + final items = List.generate(10, (index) => index); + + expect(fakeLibraryPage(items, start: 3, size: 4).items, [3, 4, 5, 6]); + final trailing = fakeLibraryPage(items, start: 20, size: 4); + expect(trailing.items, isEmpty); + expect(trailing.totalCount, 10); + expect(trailing.offset, 20); + }); +} diff --git a/test/utils/episode_collection_test.dart b/test/utils/episode_collection_test.dart index 347831c4..3e815afa 100644 --- a/test/utils/episode_collection_test.dart +++ b/test/utils/episode_collection_test.dart @@ -1,3 +1,4 @@ +import '../test_helpers/paged_fakes.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; @@ -67,11 +68,7 @@ class _RecordingClient implements MediaServerClient { Future> fetchChildrenPage(String parentId, {int? start, int? size, abort}) async { childrenPageCalls.add((parentId: parentId, start: start, size: size)); final all = childrenPageByParent[parentId] ?? const []; - final offset = start ?? 0; - final limit = size ?? all.length; - final end = (offset + limit).clamp(0, all.length).toInt(); - final items = offset >= all.length ? const [] : all.sublist(offset, end); - return LibraryPage(items: items, totalCount: all.length, offset: offset); + return fakeLibraryPage(all, start: start, size: size); } @override @@ -97,11 +94,7 @@ class _SeasonPagingRecordingClient extends _RecordingClient implements SeasonEpi }) async { seasonEpisodePageCalls.add((seriesId: seriesId, seasonId: seasonId, start: start, size: size)); final all = seasonPageBySeason[(seriesId: seriesId, seasonId: seasonId)] ?? const []; - final offset = start ?? 0; - final limit = size ?? all.length; - final end = (offset + limit).clamp(0, all.length).toInt(); - final items = offset >= all.length ? const [] : all.sublist(offset, end); - return LibraryPage(items: items, totalCount: all.length, offset: offset); + return fakeLibraryPage(all, start: start, size: size); } } diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 719004b9..24e07e18 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -1,3 +1,4 @@ +import '../test_helpers/paged_fakes.dart'; import 'dart:convert'; import 'package:drift/native.dart'; @@ -432,9 +433,7 @@ class _AudioPlaylistClient implements MediaServerClient { @override Future> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async { - final offset = start ?? 0; - final limit = size ?? tracks.length; - return LibraryPage(items: tracks.skip(offset).take(limit).toList(), totalCount: tracks.length, offset: offset); + return fakeLibraryPage(tracks, start: start, size: size); } @override