diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 555627fe..5716fb90 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -44,7 +46,7 @@ class DiscoverScreen extends StatefulWidget { } class _DiscoverScreenState extends State - with Refreshable, FullRefreshable, ItemUpdatable, WatchStateAware, SingleTickerProviderStateMixin { + with Refreshable, FullRefreshable, ItemUpdatable, WatchStateAware, SingleTickerProviderStateMixin, WidgetsBindingObserver { static const Duration _heroAutoScrollDuration = Duration(seconds: 8); @override @@ -179,6 +181,7 @@ class _DiscoverScreenState extends State @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _indicatorAnimationController = AnimationController(vsync: this, duration: _heroAutoScrollDuration); _heroFocusNode = FocusNode(debugLabel: 'hero_section'); _refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button'); @@ -376,6 +379,7 @@ class _DiscoverScreenState extends State @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _autoScrollTimer?.cancel(); _heroController.dispose(); _scrollController.dispose(); @@ -390,6 +394,16 @@ class _DiscoverScreenState extends State super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Refresh continue watching when app resumes on mobile platforms + // Skip on desktop to avoid excessive refreshes from window focus changes + if (state == AppLifecycleState.resumed && (Platform.isIOS || Platform.isAndroid)) { + appLogger.d('App resumed on mobile - refreshing continue watching'); + _refreshContinueWatching(); + } + } + void _startAutoScroll() { if (_isAutoScrollPaused) return; diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 4271e110..9a0752ae 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/foundation.dart'; import '../database/app_database.dart'; @@ -31,8 +33,15 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Watch threshold - mark as watched when progress exceeds this percentage static const double watchedThreshold = 0.90; - /// Minimum interval between syncs (10 minutes) - static const Duration minSyncInterval = Duration(minutes: 10); + /// Minimum interval between syncs. + /// Mobile: no throttle (always sync on resume for cross-device updates) + /// Desktop: 2 minutes (reduced from 10 min to handle tab-switching better) + static Duration get minSyncInterval { + if (Platform.isIOS || Platform.isAndroid) { + return Duration.zero; + } + return const Duration(minutes: 2); + } /// Maximum sync attempts before giving up on an item static const int maxSyncAttempts = 5; @@ -107,11 +116,14 @@ class OfflineWatchSyncService extends ChangeNotifier { } } - /// Called when app becomes active - syncs if interval has passed. + /// Called when app becomes active - syncs for cross-device updates. + /// On mobile, always syncs immediately (device-switching scenario). + /// On desktop, respects the throttle interval. void onAppResumed() { if (_offlineModeProvider?.isOffline != true) { - appLogger.d('App resumed - checking if sync needed'); - _performBidirectionalSync(); + final isMobile = Platform.isIOS || Platform.isAndroid; + appLogger.d('App resumed - ${isMobile ? "forcing" : "checking"} sync'); + _performBidirectionalSync(force: isMobile); } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 5a2f89db..4310d9c8 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -357,7 +357,13 @@ class PlexClient { /// Uses cache when offline or as fallback on network error /// Note: OnDeck data is not relevant for offline mode /// Always fetches with chapters/markers but caches at base endpoint - Future> getMetadataWithImagesAndOnDeck(String ratingKey) async { + /// + /// When [forceRefresh] is true, bypasses cache to get fresh OnDeck data. + /// Use this when cross-device sync is needed (e.g., after app resume). + Future> getMetadataWithImagesAndOnDeck( + String ratingKey, { + bool forceRefresh = false, + }) async { // Cache key is always the base endpoint (no query params) final cacheKey = '/library/metadata/$ratingKey'; @@ -398,6 +404,7 @@ class PlexClient { return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode}; }, + forceRefresh: forceRefresh, ) ?? {'metadata': null, 'onDeckEpisode': null}; } @@ -470,12 +477,17 @@ class PlexClient { /// 3. If network succeeds and cacheResponse is true, cache the response /// 4. If network fails, fall back to cached data /// 5. If no cached data available, rethrow the network error + /// Fetch data with cache fallback for offline mode and network errors. + /// + /// When [forceRefresh] is true, skips reading from cache (still writes to cache). + /// Use this to get fresh data when cross-device sync is needed. Future _fetchWithCacheFallback({ required String cacheKey, required Future Function() networkCall, required T? Function(dynamic cachedData) parseCache, required T? Function(Response response) parseResponse, bool cacheResponse = true, + bool forceRefresh = false, }) async { if (_offlineMode) { final cached = await _cache.get(serverId, cacheKey); @@ -489,6 +501,7 @@ class PlexClient { } return parseResponse(response); } catch (e) { + // On forceRefresh, still try cache as last resort on network error appLogger.w('Network request failed for $cacheKey, trying cache', error: e); final cached = await _cache.get(serverId, cacheKey); if (cached != null) return parseCache(cached);