fix: cross-device watch progress sync

close #240
This commit is contained in:
edde746
2026-01-15 11:45:13 +01:00
parent 4edaf3ebea
commit 60c95c58fe
3 changed files with 46 additions and 7 deletions
+15 -1
View File
@@ -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<DiscoverScreen>
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<DiscoverScreen>
@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<DiscoverScreen>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_autoScrollTimer?.cancel();
_heroController.dispose();
_scrollController.dispose();
@@ -390,6 +394,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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;
+17 -5
View File
@@ -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);
}
}
+14 -1
View File
@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<T?> _fetchWithCacheFallback<T>({
required String cacheKey,
required Future<Response> 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);