Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet.
224 lines
9.0 KiB
Dart
224 lines
9.0 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
import 'multi_server_provider.dart';
|
|
import '../services/multi_server_manager.dart';
|
|
import '../services/offline_mode_source.dart';
|
|
|
|
enum OfflineModeReason {
|
|
online,
|
|
noNetworkConnection,
|
|
waitingForServerStatus,
|
|
noKnownVisibleServers,
|
|
onlyAuthErrorServers,
|
|
noServerConnection,
|
|
}
|
|
|
|
/// Tracks offline mode status based on network connectivity and server reachability.
|
|
class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMixin implements OfflineModeSource {
|
|
final MultiServerManager _serverManager;
|
|
MultiServerProvider? _multiServerProvider;
|
|
|
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
|
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
|
|
|
|
bool _hasNetworkConnection = true;
|
|
late bool _hasServerConnection;
|
|
bool _lastOfflineState = false;
|
|
bool _isInitialized = false;
|
|
|
|
/// Latest raw connectivity results. This provider owns the app's single
|
|
/// `Connectivity()` subscription; consumers needing the connection *type*
|
|
/// (e.g. the WiFi-reconnect sync trigger in main.dart) read it from here
|
|
/// instead of subscribing themselves.
|
|
List<ConnectivityResult> _lastConnectivityResults = const [];
|
|
bool _lastWifiOrEthernetState = false;
|
|
|
|
/// Whether the current connection is WiFi or Ethernet (unmetered-ish).
|
|
bool get hasWifiOrEthernet =>
|
|
_lastConnectivityResults.contains(ConnectivityResult.wifi) ||
|
|
_lastConnectivityResults.contains(ConnectivityResult.ethernet);
|
|
|
|
/// True once [MultiServerManager] has emitted its first server-status
|
|
/// snapshot. Until then we don't actually know whether any server is
|
|
/// online — the binder hasn't finished its first connect yet — so we
|
|
/// treat the app as online to avoid flashing the "offline" UI for the
|
|
/// few hundred ms it takes to come up. After the first emission we
|
|
/// trust the real flag.
|
|
bool _hasReceivedServerStatus = false;
|
|
|
|
OfflineModeProvider(this._serverManager, {MultiServerProvider? multiServerProvider})
|
|
: _multiServerProvider = multiServerProvider,
|
|
_hasServerConnection = (multiServerProvider?.hasConnectedServers ?? _serverManager.onlineServerIds.isNotEmpty) {
|
|
// Pre-seed the "received status" flag if there are already online
|
|
// servers (e.g. provider rebuilt mid-session) or the active profile's
|
|
// visibility filter has already settled.
|
|
_markServerStatusKnownIfSettled();
|
|
_lastOfflineState = isOffline;
|
|
_multiServerProvider?.addListener(_handleMultiServerProviderChanged);
|
|
}
|
|
|
|
/// Whether the app is currently in offline mode
|
|
/// Offline = no network OR (we know servers are unreachable)
|
|
@override
|
|
bool get isOffline =>
|
|
offlineReason == OfflineModeReason.noNetworkConnection || offlineReason == OfflineModeReason.noServerConnection;
|
|
|
|
OfflineModeReason get offlineReason {
|
|
if (!_hasNetworkConnection) return OfflineModeReason.noNetworkConnection;
|
|
if (!_hasReceivedServerStatus) return OfflineModeReason.waitingForServerStatus;
|
|
if (!_hasKnownVisibleServers) return OfflineModeReason.noKnownVisibleServers;
|
|
if (_hasOnlyAuthErrorServers) return OfflineModeReason.onlyAuthErrorServers;
|
|
if (!_hasServerConnection) return OfflineModeReason.noServerConnection;
|
|
return OfflineModeReason.online;
|
|
}
|
|
|
|
/// Whether there is network connectivity at all (WiFi, Ethernet, cellular…).
|
|
///
|
|
/// Public alongside [hasWifiOrEthernet] because this provider owns the app's
|
|
/// single connectivity subscription: consumers that care about reaching the
|
|
/// internet rather than a media server — the tracker write-queue retry, for
|
|
/// one — read it here instead of subscribing themselves. Changes to it notify,
|
|
/// even when the composite [isOffline] does not move.
|
|
bool get hasNetworkConnection => _hasNetworkConnection;
|
|
|
|
/// Whether at least one media server (Plex or Jellyfin) is reachable
|
|
@visibleForTesting
|
|
bool get hasServerConnection => _hasServerConnection;
|
|
|
|
bool get _hasKnownVisibleServers =>
|
|
(_multiServerProvider?.expectedServerIds.length ?? _serverManager.serverIds.length) > 0;
|
|
|
|
bool get _hasOnlyAuthErrorServers {
|
|
final provider = _multiServerProvider;
|
|
if (provider == null) return false;
|
|
final serverCount = provider.expectedServerIds.length;
|
|
return serverCount > 0 && provider.authErrorServerIds.length == serverCount;
|
|
}
|
|
|
|
/// Attach the profile-visible server provider. Offline state is evaluated
|
|
/// against visible servers, not global manager state, so another profile's
|
|
/// online server does not keep the active profile out of offline mode.
|
|
void updateMultiServerProvider(MultiServerProvider provider) {
|
|
if (identical(_multiServerProvider, provider)) return;
|
|
_multiServerProvider?.removeListener(_handleMultiServerProviderChanged);
|
|
_multiServerProvider = provider;
|
|
_multiServerProvider?.addListener(_handleMultiServerProviderChanged);
|
|
_hasServerConnection = provider.hasConnectedServers;
|
|
_markServerStatusKnownIfSettled();
|
|
_notifyIfOfflineChanged();
|
|
}
|
|
|
|
/// Updates network and server connection flags
|
|
Future<void> _updateConnectionFlags() async {
|
|
try {
|
|
final connectivityResult = await Connectivity().checkConnectivity().timeout(
|
|
const Duration(seconds: 3),
|
|
onTimeout: () => [ConnectivityResult.other],
|
|
);
|
|
_lastConnectivityResults = connectivityResult;
|
|
_lastWifiOrEthernetState = hasWifiOrEthernet;
|
|
_hasNetworkConnection = !connectivityResult.contains(ConnectivityResult.none);
|
|
} catch (e) {
|
|
// connectivity_plus can throw PlatformException on Windows (NetworkManager::StartListen)
|
|
_hasNetworkConnection = true;
|
|
}
|
|
_hasServerConnection = _multiServerProvider?.hasConnectedServers ?? _serverManager.onlineServerIds.isNotEmpty;
|
|
}
|
|
|
|
void _handleMultiServerProviderChanged() {
|
|
_hasServerConnection = _multiServerProvider?.hasConnectedServers ?? _serverManager.onlineServerIds.isNotEmpty;
|
|
_markServerStatusKnownIfSettled();
|
|
_notifyIfOfflineChanged();
|
|
}
|
|
|
|
void _markServerStatusKnownIfSettled() {
|
|
if (_hasServerConnection || (_multiServerProvider?.hasExplicitVisibleServerFilter ?? false)) {
|
|
_hasReceivedServerStatus = true;
|
|
}
|
|
}
|
|
|
|
void _notifyIfOfflineChanged() {
|
|
final offline = isOffline;
|
|
if (_lastOfflineState == offline) return;
|
|
_lastOfflineState = offline;
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Apply a connectivity snapshot and notify when anything observable moved.
|
|
///
|
|
/// All three observable answers count, not just [isOffline]: regaining
|
|
/// cellular while every media server stays unreachable leaves [isOffline] true
|
|
/// through `noServerConnection` and [hasWifiOrEthernet] false, yet
|
|
/// [hasNetworkConnection] has flipped — and consumers that only need the
|
|
/// internet (tracker history writes) can act on exactly that.
|
|
@visibleForTesting
|
|
void applyConnectivityResults(List<ConnectivityResult> results) {
|
|
final hadNetwork = _hasNetworkConnection;
|
|
_lastConnectivityResults = results;
|
|
_hasNetworkConnection = !results.contains(ConnectivityResult.none);
|
|
|
|
final wifiNow = hasWifiOrEthernet;
|
|
final offline = isOffline;
|
|
final changed =
|
|
_hasNetworkConnection != hadNetwork || wifiNow != _lastWifiOrEthernetState || offline != _lastOfflineState;
|
|
if (!changed) return;
|
|
|
|
_lastWifiOrEthernetState = wifiNow;
|
|
_lastOfflineState = offline;
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Initialize the provider and start monitoring
|
|
Future<void> initialize() async {
|
|
if (_isInitialized) return;
|
|
_isInitialized = true;
|
|
|
|
// Check initial connectivity
|
|
await _updateConnectionFlags();
|
|
|
|
// Monitor connectivity changes — runZonedGuarded catches async errors from
|
|
// connectivity_plus (e.g. DBusServiceUnknownException on Linux without NetworkManager)
|
|
runZonedGuarded(
|
|
() {
|
|
_connectivitySubscription = Connectivity().onConnectivityChanged.listen(
|
|
applyConnectivityResults,
|
|
onError: (e) {
|
|
_hasNetworkConnection = true;
|
|
},
|
|
);
|
|
},
|
|
(error, stack) {
|
|
// connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager
|
|
_hasNetworkConnection = true;
|
|
},
|
|
);
|
|
|
|
// Monitor server status from MultiServerManager
|
|
_serverStatusSubscription = _serverManager.statusStream.listen((statusMap) {
|
|
_hasServerConnection = _multiServerProvider?.hasConnectedServers ?? statusMap.values.any((isOnline) => isOnline);
|
|
_hasReceivedServerStatus = true;
|
|
_notifyIfOfflineChanged();
|
|
});
|
|
|
|
_lastOfflineState = isOffline;
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Force a refresh of connectivity status
|
|
Future<void> refresh() async {
|
|
await _updateConnectionFlags();
|
|
_lastOfflineState = isOffline;
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_multiServerProvider?.removeListener(_handleMultiServerProviderChanged);
|
|
_connectivitySubscription?.cancel();
|
|
_serverStatusSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|