fix: serialize async state transitions

This commit is contained in:
edde746
2026-07-12 18:59:14 +02:00
parent d1c4ae83eb
commit cd88f73f05
16 changed files with 294 additions and 82 deletions
+9 -1
View File
@@ -664,7 +664,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
List<String>? targetKeys, List<String>? targetKeys,
bool force = false, bool force = false,
}) async { }) async {
if (_isAutoDeleteRunning) return; if (_isAutoDeleteRunning) {
if (targetKeys != null) _pendingSyncKeys.addAll(targetKeys);
return;
}
_isAutoDeleteRunning = true; _isAutoDeleteRunning = true;
try { try {
await downloadProvider.refreshMetadataFromCache(); await downloadProvider.refreshMetadataFromCache();
@@ -697,6 +700,11 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
} }
} finally { } finally {
_isAutoDeleteRunning = false; _isAutoDeleteRunning = false;
if (_pendingSyncKeys.isNotEmpty) {
final queuedKeys = _pendingSyncKeys.toList();
_pendingSyncKeys.clear();
unawaited(_autoDeleteAndSync(downloadProvider, targetKeys: queuedKeys));
}
} }
} }
+7
View File
@@ -210,6 +210,13 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
/// Mirrors the "one item deleted on the server" invariant: decrements /// Mirrors the "one item deleted on the server" invariant: decrements
/// [totalSize] even if [index] wasn't in the sparse map (evicted). /// [totalSize] even if [index] wasn't in the sparse map (evicted).
void removeLoadedItemAndShift(int index) { void removeLoadedItemAndShift(int index) {
_requestId++;
_cancelToken?.abort();
_cancelToken = AbortController();
_retryTimer?.cancel();
_retryTimer = null;
_loadingRanges.clear();
_scheduledRetry = null;
loadedItems.remove(index); loadedItems.remove(index);
final shifted = <int, T>{}; final shifted = <int, T>{};
for (final entry in loadedItems.entries) { for (final entry in loadedItems.entries) {
+10 -3
View File
@@ -174,6 +174,7 @@ class PlayerNative extends PlayerBase {
// Two concurrent invokes on Android caused MpvPlayerPlugin.handleInitialize // Two concurrent invokes on Android caused MpvPlayerPlugin.handleInitialize
// to dispose-and-recreate the in-flight core, hanging playback (#930). // to dispose-and-recreate the in-flight core, hanging playback (#930).
Future<void>? _initFuture; Future<void>? _initFuture;
Future<void> _rateChangeTail = Future<void>.value();
Future<void> _ensureInitialized() async { Future<void> _ensureInitialized() async {
if (initialized) return; if (initialized) return;
@@ -554,10 +555,16 @@ class PlayerNative extends PlayerBase {
} }
@override @override
Future<void> setRate(double rate) async { Future<void> setRate(double rate) {
// mpv cannot scaletempo compressed (spdif) audio and silently keeps
// playing at 1x, so suspend passthrough while the rate is not 1.0.
_currentRate = rate; _currentRate = rate;
final operation = _rateChangeTail.then((_) => _applyRateChange(rate));
_rateChangeTail = operation.catchError((Object _, StackTrace _) {});
return operation;
}
Future<void> _applyRateChange(double rate) async {
// mpv cannot scaletempo compressed (spdif) audio and silently keeps
// playing at 1x, so serialize passthrough and speed transitions.
if (_passthroughActive && rate != 1.0) { if (_passthroughActive && rate != 1.0) {
await _applyPassthrough(false); await _applyPassthrough(false);
} }
+13 -1
View File
@@ -43,6 +43,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
Timer? _reconnectTimer; Timer? _reconnectTimer;
int _reconnectAttempts = 0; int _reconnectAttempts = 0;
Future<void>? _activeReconnect;
bool _intentionalDisconnect = false; bool _intentionalDisconnect = false;
// Reconnection context (only hostAddresses and hostClientId are connection-specific) // Reconnection context (only hostAddresses and hostClientId are connection-specific)
@@ -759,7 +760,18 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
_reconnectTimer = Timer(delay, _attemptReconnect); _reconnectTimer = Timer(delay, _attemptReconnect);
} }
Future<void> _attemptReconnect() async { Future<void> _attemptReconnect() {
final active = _activeReconnect;
if (active != null) return active;
late final Future<void> attempt;
attempt = _runReconnectAttempt().whenComplete(() {
if (identical(_activeReconnect, attempt)) _activeReconnect = null;
});
_activeReconnect = attempt;
return attempt;
}
Future<void> _runReconnectAttempt() async {
if (_lastHostAddresses == null || !isCryptoReady) { if (_lastHostAddresses == null || !isCryptoReady) {
appLogger.w('CompanionRemote: No stored context for reconnect'); appLogger.w('CompanionRemote: No stored context for reconnect');
_session = _session?.copyWith( _session = _session?.copyWith(
+38 -5
View File
@@ -90,6 +90,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
DiscoverLoadState _hubsState = DiscoverLoadState.initial; DiscoverLoadState _hubsState = DiscoverLoadState.initial;
String? _errorMessage; String? _errorMessage;
int _loadGeneration = 0; int _loadGeneration = 0;
int _contentRevision = 0;
Future<void>? _continueWatchingRefreshFuture;
bool _continueWatchingRefreshQueued = false;
Set<String> _lastSeenHiddenKeys = {}; Set<String> _lastSeenHiddenKeys = {};
List<String> _lastSeenLibraryOrderKeys = const []; List<String> _lastSeenLibraryOrderKeys = const [];
@@ -161,6 +164,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// listening widgets dirty mid-build. // listening widgets dirty mid-build.
await null; await null;
if (isDisposed) return; if (isDisposed) return;
++_contentRevision;
appLogger.d('DiscoverProvider: loading content from all servers'); appLogger.d('DiscoverProvider: loading content from all servers');
_onDeckState = DiscoverLoadState.loading; _onDeckState = DiscoverLoadState.loading;
_hubsState = DiscoverLoadState.loading; _hubsState = DiscoverLoadState.loading;
@@ -262,6 +266,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Failures keep the loaded state and leave the ids un-loaded, so the next /// Failures keep the loaded state and leave the ids un-loaded, so the next
/// status emission retries them. /// status emission retries them.
Future<void> _loadDeltaOnce(Set<String> serverIds) async { Future<void> _loadDeltaOnce(Set<String> serverIds) async {
++_contentRevision;
// A full pass may have covered these ids while they sat in the queue. // A full pass may have covered these ids while they sat in the queue.
final ids = serverIds.difference(_fullyLoadedServerIds); final ids = serverIds.difference(_fullyLoadedServerIds);
final onDeckIds = ids.difference(_loadedOnDeckServerIds); final onDeckIds = ids.difference(_loadedOnDeckServerIds);
@@ -350,17 +355,45 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}).toList(); }).toList();
} }
/// Background refresh of Continue Watching only — never flips load states /// Background refresh of Continue Watching only. Concurrent events coalesce
/// or surfaces errors (a stale row beats an error flash), never refetches /// into the active request plus at most one trailing fresh request.
/// hubs. Future<void> refreshContinueWatching() {
Future<void> refreshContinueWatching() async { final active = _continueWatchingRefreshFuture;
if (active != null) {
_continueWatchingRefreshQueued = true;
return active;
}
late final Future<void> refresh;
refresh = _runContinueWatchingRefreshes().whenComplete(() {
if (identical(_continueWatchingRefreshFuture, refresh)) {
_continueWatchingRefreshFuture = null;
}
});
_continueWatchingRefreshFuture = refresh;
return refresh;
}
Future<void> _runContinueWatchingRefreshes() async {
do {
_continueWatchingRefreshQueued = false;
await _refreshContinueWatchingOnce();
} while (_continueWatchingRefreshQueued && !isDisposed);
}
Future<void> _refreshContinueWatchingOnce() async {
try { try {
if (!_multiServer.hasConnectedServers) return; if (!_multiServer.hasConnectedServers) return;
final revision = _contentRevision;
final hiddenKeys = Set<String>.of(_hiddenLibraries.hiddenLibraryKeys);
final fetched = await _multiServer.aggregationService.getOnDeckFromAllServers( final fetched = await _multiServer.aggregationService.getOnDeckFromAllServers(
limit: _continueWatchingProbeLimit, limit: _continueWatchingProbeLimit,
hiddenLibraryKeys: _hiddenLibraries.hiddenLibraryKeys, hiddenLibraryKeys: hiddenKeys,
); );
if (isDisposed) return; if (isDisposed) return;
if (revision != _contentRevision) {
_continueWatchingRefreshQueued = true;
return;
}
_applyOnDeck(fetched.items); _applyOnDeck(fetched.items);
_loadedOnDeckServerIds = fetched.succeededServerIds; _loadedOnDeckServerIds = fetched.succeededServerIds;
safeNotifyListeners(); safeNotifyListeners();
+4 -2
View File
@@ -40,6 +40,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
/// Previously-seen set of online server IDs, used to detect new servers /// Previously-seen set of online server IDs, used to detect new servers
Set<String> _previousOnlineServerIds = {}; Set<String> _previousOnlineServerIds = {};
int _liveTvCheckGeneration = 0;
/// Invoked with the current visibility-filtered online server ids whenever /// Invoked with the current visibility-filtered online server ids whenever
/// the manager's status stream fires (a server connects, reconnects, drops, /// the manager's status stream fires (a server connects, reconnects, drops,
@@ -286,8 +287,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
/// uniformly). /// uniformly).
Future<void> checkLiveTvAvailability() async { Future<void> checkLiveTvAvailability() async {
if (isDisposed) return; if (isDisposed) return;
final generation = ++_liveTvCheckGeneration;
final newLiveTvServers = <LiveTvServerInfo>[]; final newLiveTvServers = <LiveTvServerInfo>[];
for (final serverId in onlineServerIds) { for (final serverId in onlineServerIds) {
final genericClient = _serverManager.getClient(ServerId(serverId)); final genericClient = _serverManager.getClient(ServerId(serverId));
if (genericClient == null) continue; if (genericClient == null) continue;
@@ -319,7 +320,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
final hadLiveTv = _hasLiveTv; final hadLiveTv = _hasLiveTv;
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
final newServerIds = visibleLiveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); final newServerIds = visibleLiveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
if (isDisposed) return; if (isDisposed || generation != _liveTvCheckGeneration) return;
_liveTvServers _liveTvServers
..clear() ..clear()
..addAll(visibleLiveTvServers); ..addAll(visibleLiveTvServers);
@@ -333,6 +334,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
@override @override
void dispose() { void dispose() {
++_liveTvCheckGeneration;
_statusSubscription?.cancel(); _statusSubscription?.cancel();
super.dispose(); super.dispose();
} }
@@ -48,6 +48,7 @@ class LanDiscoveryService {
RawDatagramSocket? _listenSocket; RawDatagramSocket? _listenSocket;
StreamSubscription<RawSocketEvent>? _listenSubscription; StreamSubscription<RawSocketEvent>? _listenSubscription;
Timer? _staleCleanupTimer; Timer? _staleCleanupTimer;
int _listenGeneration = 0;
final Map<String, DiscoveredHost> _discoveredHosts = {}; final Map<String, DiscoveredHost> _discoveredHosts = {};
final _hostsController = StreamController<List<DiscoveredHost>>.broadcast(); final _hostsController = StreamController<List<DiscoveredHost>>.broadcast();
@@ -143,9 +144,9 @@ class LanDiscoveryService {
Stream<List<DiscoveredHost>> startListeningForContexts(List<RemoteAuthContext> contexts) { Stream<List<DiscoveredHost>> startListeningForContexts(List<RemoteAuthContext> contexts) {
_stopListeningInternal(); _stopListeningInternal();
_discoveredHosts.clear(); _discoveredHosts.clear();
final generation = _listenGeneration;
_bindListener(contexts); unawaited(_bindListener(contexts, generation));
// Periodically remove stale hosts // Periodically remove stale hosts
_staleCleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) { _staleCleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) {
final now = DateTime.now(); final now = DateTime.now();
@@ -166,23 +167,29 @@ class LanDiscoveryService {
return _hostsController.stream; return _hostsController.stream;
} }
Future<void> _bindListener(List<RemoteAuthContext> contexts) async { Future<void> _bindListener(List<RemoteAuthContext> contexts, int generation) async {
try { try {
_listenSocket = await RawDatagramSocket.bind( final socket = await RawDatagramSocket.bind(
InternetAddress.anyIPv4, InternetAddress.anyIPv4,
discoveryPort, discoveryPort,
reuseAddress: true, reuseAddress: true,
reusePort: true, reusePort: true,
); );
if (generation != _listenGeneration) {
socket.close();
return;
}
_listenSocket = socket;
appLogger.d('LanDiscovery: Listening on port $discoveryPort'); appLogger.d('LanDiscovery: Listening on port $discoveryPort');
_listenSubscription = _listenSocket!.listenDatagrams( _listenSubscription = socket.listenDatagrams(
(datagram) => _handleDatagram(datagram, contexts), (datagram) => _handleDatagram(datagram, contexts),
debugLabel: 'LanDiscovery listener', debugLabel: 'LanDiscovery listener',
); );
} catch (e) { } catch (e) {
appLogger.e('LanDiscovery: Failed to bind listener', error: e); if (generation == _listenGeneration) {
appLogger.e('LanDiscovery: Failed to bind listener', error: e);
}
} }
} }
@@ -274,6 +281,7 @@ class LanDiscoveryService {
} }
void _stopListeningInternal() { void _stopListeningInternal() {
++_listenGeneration;
_staleCleanupTimer?.cancel(); _staleCleanupTimer?.cancel();
_staleCleanupTimer = null; _staleCleanupTimer = null;
_listenSubscription?.cancel(); _listenSubscription?.cancel();
+42 -8
View File
@@ -90,6 +90,7 @@ class MultiServerManager {
/// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps /// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps
Future<void>? _activeReconnect; Future<void>? _activeReconnect;
int _profileRefreshGeneration = 0;
/// Debounce timer for connectivity events — collapses rapid network flapping /// Debounce timer for connectivity events — collapses rapid network flapping
Timer? _connectivityDebounce; Timer? _connectivityDebounce;
@@ -326,16 +327,26 @@ class MultiServerManager {
return client; return client;
} }
/// Persists a new endpoint, rebuilds the failover list, and switches the client. /// Persists a new endpoint, rebuilds the failover list, and switches the
Future<void> _promoteEndpoint({ /// client only while it is still the registered client for this server.
Future<bool> _promoteEndpoint({
required PlexClient client, required PlexClient client,
required PlexServer server, required PlexServer server,
required StorageService storage, required StorageService storage,
required String newUrl, required String newUrl,
}) async { }) async {
await storage.saveServerEndpoint(ServerId(server.clientIdentifier), newUrl); final serverId = ServerId(server.clientIdentifier);
bool isCurrent() {
final registered = _clients[serverId];
return identical(_plexServers[serverId], server) && (registered == null || identical(registered, client));
}
if (!isCurrent()) return false;
await storage.saveServerEndpoint(serverId, newUrl);
if (!isCurrent()) return false;
final newEndpoints = server.prioritizedEndpointUrls(preferredFirst: newUrl); final newEndpoints = server.prioritizedEndpointUrls(preferredFirst: newUrl);
await client.updateEndpointPreferences(newEndpoints, switchToFirst: true); await client.updateEndpointPreferences(newEndpoints, switchToFirst: true);
return isCurrent();
} }
/// Continues draining the connection optimization stream in the background, /// Continues draining the connection optimization stream in the background,
@@ -349,6 +360,12 @@ class MultiServerManager {
() async { () async {
try { try {
while (await streamIterator.moveNext()) { while (await streamIterator.moveNext()) {
final serverId = ServerId(server.clientIdentifier);
final registered = _clients[serverId];
if (!identical(_plexServers[serverId], server) || (registered != null && !identical(registered, client))) {
appLogger.d('Stopping stale endpoint optimization for ${server.name}');
break;
}
final connection = streamIterator.current; final connection = streamIterator.current;
final newUrl = connection.uri; final newUrl = connection.uri;
@@ -483,6 +500,7 @@ class MultiServerManager {
PlexAccountConnection connection, { PlexAccountConnection connection, {
Duration timeout = MediaServerTimeouts.perServerConnect, Duration timeout = MediaServerTimeouts.perServerConnect,
}) async { }) async {
final generation = ++_profileRefreshGeneration;
if (connection.servers.isEmpty) return const {}; if (connection.servers.isEmpty) return const {};
final bound = <String>{}; final bound = <String>{};
final futures = connection.servers.map((server) async { final futures = connection.servers.map((server) async {
@@ -491,11 +509,12 @@ class MultiServerManager {
_plexServers[serverId] = server; _plexServers[serverId] = server;
final existing = _clients[serverId]; final existing = _clients[serverId];
if (existing is PlexClient && ((_serverStatus[serverId] ?? false) || _authErrorServers.contains(serverId))) { if (existing is PlexClient && ((_serverStatus[serverId] ?? false) || _authErrorServers.contains(serverId))) {
// Rotate the X-Plex-Token in-place so the server treats requests
// as the new user. `applyTokenUpdate` updates both config and
// _http.defaultHeaders — leaving headers stale would silently
// keep authenticating as the previous user.
await existing.applyTokenUpdate(server.accessToken); await existing.applyTokenUpdate(server.accessToken);
if (generation != _profileRefreshGeneration ||
!identical(_plexServers[serverId], server) ||
!identical(_clients[serverId], existing)) {
return;
}
_authErrorServers.remove(serverId); _authErrorServers.remove(serverId);
_serverStatus[serverId] = true; _serverStatus[serverId] = true;
bound.add(serverId); bound.add(serverId);
@@ -507,6 +526,10 @@ class MultiServerManager {
server: server, server: server,
clientIdentifier: connection.clientIdentifier, clientIdentifier: connection.clientIdentifier,
).namedTimeout(timeout, operation: 'connect to ${server.name}'); ).namedTimeout(timeout, operation: 'connect to ${server.name}');
if (generation != _profileRefreshGeneration || !identical(_plexServers[serverId], server)) {
_closeClient(client);
return;
}
final oldClient = _clients[serverId]; final oldClient = _clients[serverId];
if (oldClient != null) _closeClient(oldClient); if (oldClient != null) _closeClient(oldClient);
_clients[serverId] = client; _clients[serverId] = client;
@@ -515,12 +538,14 @@ class MultiServerManager {
bound.add(serverId); bound.add(serverId);
_connectProgressController.add((serverId: serverId, online: true)); _connectProgressController.add((serverId: serverId, online: true));
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (generation != _profileRefreshGeneration || !identical(_plexServers[serverId], server)) return;
appLogger.e('refreshTokensForProfile: failed to connect ${server.name}', error: e, stackTrace: stackTrace); appLogger.e('refreshTokensForProfile: failed to connect ${server.name}', error: e, stackTrace: stackTrace);
_serverStatus[serverId] = false; _serverStatus[serverId] = false;
_connectProgressController.add((serverId: serverId, online: false)); _connectProgressController.add((serverId: serverId, online: false));
} }
}); });
await Future.wait(futures); await Future.wait(futures);
if (generation != _profileRefreshGeneration) return const {};
_statusController.add(Map.from(_serverStatus)); _statusController.add(Map.from(_serverStatus));
if (bound.isNotEmpty && _connectivitySubscription == null) { if (bound.isNotEmpty && _connectivitySubscription == null) {
_startNetworkMonitoring(); _startNetworkMonitoring();
@@ -937,10 +962,13 @@ class MultiServerManager {
} }
if (client != null) { if (client != null) {
await _promoteEndpoint(client: client, server: server, storage: storage, newUrl: newUrl); final promoted = await _promoteEndpoint(client: client, server: server, storage: storage, newUrl: newUrl);
if (!promoted) return;
appLogger.i('Switched ${server.name} to better endpoint: $newUrl', error: {'type': connection.displayType}); appLogger.i('Switched ${server.name} to better endpoint: $newUrl', error: {'type': connection.displayType});
} else { } else {
if (_plexServers[serverId] != server) return;
await storage.saveServerEndpoint(serverId, newUrl); await storage.saveServerEndpoint(serverId, newUrl);
if (_plexServers[serverId] != server) return;
appLogger.i('Updated optimal endpoint for ${server.name}: $newUrl', error: {'type': connection.displayType}); appLogger.i('Updated optimal endpoint for ${server.name}: $newUrl', error: {'type': connection.displayType});
} }
} }
@@ -960,6 +988,11 @@ class MultiServerManager {
try { try {
appLogger.d('Attempting reconnection for ${server.name}'); appLogger.d('Attempting reconnection for ${server.name}');
final client = await _createClientForServer(server: server, clientIdentifier: clientId); final client = await _createClientForServer(server: server, clientIdentifier: clientId);
if (!identical(_plexServers[serverId], server) || _resolveClientIdentifier(serverId) != clientId) {
_closeClient(client);
appLogger.d('Ignoring stale reconnection result for ${server.name}');
return;
}
final oldClient = _clients[serverId]; final oldClient = _clients[serverId];
if (oldClient != null) _closeClient(oldClient); if (oldClient != null) _closeClient(oldClient);
@@ -1138,6 +1171,7 @@ class MultiServerManager {
} }
Set<MediaServerClient> _detachAllClients() { Set<MediaServerClient> _detachAllClients() {
++_profileRefreshGeneration;
_stopNetworkMonitoring(); _stopNetworkMonitoring();
for (final timer in _reconnectDebounce.values) { for (final timer in _reconnectDebounce.values) {
timer.cancel(); timer.cancel();
+16 -9
View File
@@ -49,15 +49,23 @@ class TrackerCoordinator {
/// tracker crossing semantics stay aligned with playback progress reporting. /// tracker crossing semantics stay aligned with playback progress reporting.
final PlaybackTimeline _timeline = PlaybackTimeline(watchedThreshold: _fallbackWatchedThreshold); final PlaybackTimeline _timeline = PlaybackTimeline(watchedThreshold: _fallbackWatchedThreshold);
bool _thresholdCrossed = false; bool _thresholdCrossed = false;
int _playbackRevision = 0;
Future<void> initialize() async { Future<void> initialize() async {
await Future.wait(_trackers.map((t) => t.initialize())); await Future.wait(_trackers.map((t) => t.initialize()));
} }
Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async { Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async {
if (isLive) return; final revision = ++_playbackRevision;
if (isLive) {
_reset();
return;
}
final mediaType = metadata.kind; final mediaType = metadata.kind;
if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return; if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) {
_reset();
return;
}
final libraryGlobalKey = metadata.libraryGlobalKey; final libraryGlobalKey = metadata.libraryGlobalKey;
if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) { if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) {
_reset(); _reset();
@@ -72,6 +80,7 @@ class TrackerCoordinator {
_resolverClientKey = clientKey; _resolverClientKey = clientKey;
} }
final ctx = await _buildContext(metadata, _resolver!); final ctx = await _buildContext(metadata, _resolver!);
if (revision != _playbackRevision) return;
if (ctx == null) { if (ctx == null) {
appLogger.d('Trackers: no external IDs for ${metadata.id}'); appLogger.d('Trackers: no external IDs for ${metadata.id}');
_reset(); _reset();
@@ -287,16 +296,13 @@ class TrackerCoordinator {
} }
Future<void> stopPlayback() async { Future<void> stopPlayback() async {
++_playbackRevision;
final ctx = _ctx; final ctx = _ctx;
if (ctx == null) { final shouldMarkWatched = ctx != null && !_thresholdCrossed && _timeline.watchedThresholdReached;
_reset(); _reset();
return; if (ctx != null && shouldMarkWatched) {
}
// Safety net: fire if we passed the threshold but missed the tick.
if (!_thresholdCrossed && _timeline.watchedThresholdReached) {
await _dispatchMarkWatched(ctx); await _dispatchMarkWatched(ctx);
} }
_reset();
} }
void updatePosition(Duration position) { void updatePosition(Duration position) {
@@ -315,6 +321,7 @@ class TrackerCoordinator {
/// Called on Plex profile switch — drops in-flight state across all /// Called on Plex profile switch — drops in-flight state across all
/// trackers and invalidates the resolver so a fresh Plex client is used. /// trackers and invalidates the resolver so a fresh Plex client is used.
void cancelInFlight() { void cancelInFlight() {
++_playbackRevision;
_reset(); _reset();
_resolver?.clearCache(); _resolver?.clearCache();
_resolver = null; _resolver = null;
+18 -6
View File
@@ -53,6 +53,7 @@ class TraktScrobbleService implements TrackerRatingSource {
TraktScrobbleState? _lastSentState; TraktScrobbleState? _lastSentState;
DateTime? _lastSentAt; DateTime? _lastSentAt;
DateTime? _lastSeekCheckpointAt; DateTime? _lastSeekCheckpointAt;
int _playbackRevision = 0;
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
@@ -124,9 +125,15 @@ class TraktScrobbleService implements TrackerRatingSource {
/// Drop the current scrobble state without sending a stop. Called on profile /// Drop the current scrobble state without sending a stop. Called on profile
/// switch and when the service is disabled mid-playback. /// switch and when the service is disabled mid-playback.
void cancelInFlight() { void cancelInFlight() {
++_playbackRevision;
_clearPlaybackState();
}
void _clearPlaybackState() {
_currentBody = null; _currentBody = null;
_lastSentState = null; _lastSentState = null;
_lastSentAt = null; _lastSentAt = null;
_lastSeekCheckpointAt = null;
_resolver?.clearCache(); _resolver?.clearCache();
_resolver = null; _resolver = null;
_timeline.reset(); _timeline.reset();
@@ -209,8 +216,9 @@ class TraktScrobbleService implements TrackerRatingSource {
} }
Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async { Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async {
if (!_canScrobble) return; final revision = ++_playbackRevision;
if (isLive) return; _clearPlaybackState();
if (!_canScrobble || isLive) return;
final type = metadata.kind; final type = metadata.kind;
if (type != MediaKind.movie && type != MediaKind.episode) return; if (type != MediaKind.movie && type != MediaKind.episode) return;
@@ -227,13 +235,13 @@ class TraktScrobbleService implements TrackerRatingSource {
position: metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero, position: metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero,
duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null, duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null,
); );
_lastSeekCheckpointAt = null;
_resolver = TrackerIdResolver(client, needsFribb: () => false); _resolver = TrackerIdResolver(client, needsFribb: () => false);
final body = await _buildBody(metadata); final body = await _buildBody(metadata);
if (revision != _playbackRevision) return;
if (body == null) { if (body == null) {
appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.id}'); appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.id}');
cancelInFlight(); _clearPlaybackState();
return; return;
} }
_currentBody = body; _currentBody = body;
@@ -274,9 +282,13 @@ class TraktScrobbleService implements TrackerRatingSource {
} }
Future<void> stopPlayback() async { Future<void> stopPlayback() async {
if (_currentBody == null) return; final revision = ++_playbackRevision;
if (_currentBody == null) {
_clearPlaybackState();
return;
}
await _send(TraktScrobbleState.stop, progress: _progressPercent()); await _send(TraktScrobbleState.stop, progress: _progressPercent());
cancelInFlight(); if (revision == _playbackRevision) _clearPlaybackState();
} }
Future<TraktScrobbleRequest?> _buildBody(MediaItem metadata) async { Future<TraktScrobbleRequest?> _buildBody(MediaItem metadata) async {
@@ -389,8 +389,7 @@ class WatchTogetherProvider with ChangeNotifier {
appLogger.d('WatchTogether: Joined session successfully'); appLogger.d('WatchTogether: Joined session successfully');
} catch (e) { } catch (e) {
appLogger.e('WatchTogether: Failed to join session', error: e); appLogger.e('WatchTogether: Failed to join session', error: e);
_session = _session?.copyWith(state: SessionState.error, errorMessage: e.toString()); await leaveSession();
notifyListeners();
rethrow; rethrow;
} }
} }
@@ -103,6 +103,7 @@ class GuestPlaybackReconciler {
bool _nudgeDisabled = false; bool _nudgeDisabled = false;
bool _nudgeConfirmed = false; bool _nudgeConfirmed = false;
Timer? _nudgeConfirmTimer; Timer? _nudgeConfirmTimer;
double? _nudgeTargetRate;
int _lastHardSeekMs = -hardSeekCooldownMs; int _lastHardSeekMs = -hardSeekCooldownMs;
final List<int> _driftSamples = []; final List<int> _driftSamples = [];
@@ -212,6 +213,9 @@ class GuestPlaybackReconciler {
_settleTimer = null; _settleTimer = null;
_settling = false; _settling = false;
_nudging = false; _nudging = false;
_nudgeDisabled = false;
_nudgeConfirmed = false;
_nudgeTargetRate = null;
_nudgeConfirmTimer?.cancel(); _nudgeConfirmTimer?.cancel();
_nudgeConfirmTimer = null; _nudgeConfirmTimer = null;
_scheduledStartTimer?.cancel(); _scheduledStartTimer?.cancel();
@@ -499,6 +503,7 @@ class GuestPlaybackReconciler {
if (_nudging && (player.rate - targetRate).abs() < 0.001) return; if (_nudging && (player.rate - targetRate).abs() < 0.001) return;
_nudging = true; _nudging = true;
_nudgeTargetRate = targetRate;
unawaited(player.setRate(targetRate)); unawaited(player.setRate(targetRate));
// Arm the capability check once per (un-confirmed) nudge episode — a // Arm the capability check once per (un-confirmed) nudge episode — a
@@ -507,11 +512,13 @@ class GuestPlaybackReconciler {
_nudgeConfirmTimer = Timer(const Duration(milliseconds: nudgeConfirmMs), () { _nudgeConfirmTimer = Timer(const Duration(milliseconds: nudgeConfirmMs), () {
_nudgeConfirmTimer = null; _nudgeConfirmTimer = null;
final currentPlayer = _player; final currentPlayer = _player;
if (currentPlayer == null || !_nudging) return; final expectedRate = _nudgeTargetRate;
if ((currentPlayer.rate - targetRate).abs() > 0.005) { if (currentPlayer == null || !_nudging || expectedRate == null) return;
appLogger.w('WatchTogether: Rate nudges not taking effect — disabling for this session'); if ((currentPlayer.rate - expectedRate).abs() > 0.005) {
appLogger.w('WatchTogether: Rate nudges not taking effect — disabling for this attachment');
_nudgeDisabled = true; _nudgeDisabled = true;
_nudging = false; _nudging = false;
_nudgeTargetRate = null;
unawaited(currentPlayer.setRate(_latestState?.rate ?? 1.0)); unawaited(currentPlayer.setRate(_latestState?.rate ?? 1.0));
} else { } else {
_nudgeConfirmed = true; _nudgeConfirmed = true;
@@ -523,6 +530,7 @@ class GuestPlaybackReconciler {
void _exitNudgeIfNeeded(PlaybackState state) { void _exitNudgeIfNeeded(PlaybackState state) {
if (!_nudging) return; if (!_nudging) return;
_nudging = false; _nudging = false;
_nudgeTargetRate = null;
final player = _player; final player = _player;
if (player != null) { if (player != null) {
unawaited(player.setRate(state.rate)); unawaited(player.setRate(state.rate));
@@ -62,6 +62,8 @@ class WatchTogetherPeerService with KeepaliveMixin {
int _reconnectAttempts = 0; int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 3; static const int _maxReconnectAttempts = 3;
Timer? _reconnectTimer; Timer? _reconnectTimer;
int _connectionEpoch = 0;
bool _disposed = false;
/// Called after a successful reconnection so the provider can re-announce join. /// Called after a successful reconnection so the provider can re-announce join.
void Function()? onReconnected; void Function()? onReconnected;
@@ -126,8 +128,12 @@ class WatchTogetherPeerService with KeepaliveMixin {
} }
/// Connect, listen, and send a room setup announcement. /// Connect, listen, and send a room setup announcement.
Future<Completer<void>> _connectAndAnnounce(String type) async { Future<Completer<void>> _connectAndAnnounce(String type, int epoch) async {
final channel = await _connectToRelay(); final channel = await _connectToRelay();
if (_disposed || epoch != _connectionEpoch || _sessionId == null) {
unawaited(channel.sink.close());
throw StateError('Watch Together connection attempt became stale');
}
_channel = channel; _channel = channel;
_listenToChannel(channel); _listenToChannel(channel);
@@ -288,26 +294,28 @@ class WatchTogetherPeerService with KeepaliveMixin {
/// Handle the WebSocket being closed unexpectedly — attempt reconnection. /// Handle the WebSocket being closed unexpectedly — attempt reconnection.
void _handleWebSocketClosed() { void _handleWebSocketClosed() {
final channel = _channel;
++_connectionEpoch;
stopKeepalive(); stopKeepalive();
_channelSubscription?.cancel(); unawaited(_channelSubscription?.cancel());
_channelSubscription = null; _channelSubscription = null;
_channel = null; _channel = null;
if (channel != null) unawaited(channel.sink.close());
// Notify peers lost
for (final peerId in _connectedPeers.toList()) { for (final peerId in _connectedPeers.toList()) {
_safeAdd(_peerDisconnectedController, peerId); _safeAdd(_peerDisconnectedController, peerId);
} }
_connectedPeers.clear(); _connectedPeers.clear();
_safeAdd(_connectionStateController, false); _safeAdd(_connectionStateController, false);
// Attempt to reconnect if we had a session if (!_disposed && _sessionId != null) {
if (_sessionId != null) { _attemptReconnect(_connectionEpoch);
_attemptReconnect();
} }
} }
/// Attempt to reconnect to the relay and re-join/re-create the room. /// Attempt to reconnect to the relay and re-join/re-create the room.
void _attemptReconnect() { void _attemptReconnect(int epoch) {
if (_disposed || epoch != _connectionEpoch || _sessionId == null) return;
if (_reconnectAttempts >= _maxReconnectAttempts) { if (_reconnectAttempts >= _maxReconnectAttempts) {
appLogger.e('WatchTogether: Max reconnect attempts reached'); appLogger.e('WatchTogether: Max reconnect attempts reached');
_safeAdd( _safeAdd(
@@ -326,15 +334,15 @@ class WatchTogetherPeerService with KeepaliveMixin {
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () async { _reconnectTimer = Timer(delay, () async {
if (_disposed || epoch != _connectionEpoch || _sessionId == null) return;
try { try {
// Always try join first — the room may still have peers (e.g. host final completer = await _connectAndAnnounce(RelayProtocol.join, epoch);
// reconnecting while guests remain). Fall back to create only if if (_disposed || epoch != _connectionEpoch) return;
// the room no longer exists and we were the host.
final completer = await _connectAndAnnounce(RelayProtocol.join);
try { try {
await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect'); await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect');
} on PeerError catch (e) { } on PeerError catch (e) {
if (_disposed || epoch != _connectionEpoch) return;
if (_isHost && e.serverCode == RelayProtocol.roomNotFoundCode) { if (_isHost && e.serverCode == RelayProtocol.roomNotFoundCode) {
appLogger.d('WatchTogether: Room gone, re-creating as host'); appLogger.d('WatchTogether: Room gone, re-creating as host');
final createCompleter = _announce(RelayProtocol.create); final createCompleter = _announce(RelayProtocol.create);
@@ -347,6 +355,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
} }
} }
if (_disposed || epoch != _connectionEpoch) return;
_reconnectAttempts = 0; _reconnectAttempts = 0;
appLogger.d('WatchTogether: Reconnected successfully'); appLogger.d('WatchTogether: Reconnected successfully');
try { try {
@@ -355,6 +364,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
appLogger.e('WatchTogether: Reconnect callback failed', error: e); appLogger.e('WatchTogether: Reconnect callback failed', error: e);
} }
} catch (e) { } catch (e) {
if (_disposed || epoch != _connectionEpoch) return;
appLogger.e('WatchTogether: Reconnect failed', error: e); appLogger.e('WatchTogether: Reconnect failed', error: e);
_handleWebSocketClosed(); _handleWebSocketClosed();
} }
@@ -382,9 +392,10 @@ class WatchTogetherPeerService with KeepaliveMixin {
_sessionId = resolvedSessionId; _sessionId = resolvedSessionId;
_myPeerId = watchTogetherHostPeerId(resolvedSessionId); _myPeerId = watchTogetherHostPeerId(resolvedSessionId);
_reconnectAttempts = 0; _reconnectAttempts = 0;
final epoch = ++_connectionEpoch;
try { try {
final completer = await _connectAndAnnounce(RelayProtocol.create); final completer = await _connectAndAnnounce(RelayProtocol.create, epoch);
await completer.future.timeout( await completer.future.timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
@@ -420,9 +431,10 @@ class WatchTogetherPeerService with KeepaliveMixin {
_sessionId = resolvedSessionId; _sessionId = resolvedSessionId;
_myPeerId = const Uuid().v4(); _myPeerId = const Uuid().v4();
_reconnectAttempts = 0; _reconnectAttempts = 0;
final epoch = ++_connectionEpoch;
try { try {
final completer = await _connectAndAnnounce(RelayProtocol.join); final completer = await _connectAndAnnounce(RelayProtocol.join, epoch);
await completer.future.timeout( await completer.future.timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
@@ -457,34 +469,40 @@ class WatchTogetherPeerService with KeepaliveMixin {
/// Disconnect from all peers and close the session /// Disconnect from all peers and close the session
Future<void> disconnect() async { Future<void> disconnect() async {
appLogger.d('WatchTogether: Disconnecting...'); appLogger.d('WatchTogether: Disconnecting...');
++_connectionEpoch;
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
_reconnectTimer = null; _reconnectTimer = null;
stopKeepalive(); stopKeepalive();
unawaited(_channelSubscription?.cancel()); final subscription = _channelSubscription;
final channel = _channel;
_channelSubscription = null; _channelSubscription = null;
try {
await _channel?.sink.close();
} catch (e) {
appLogger.d('WatchTogether: channel close ignored', error: e);
}
_channel = null; _channel = null;
final setupCompleter = _setupCompleter;
_setupCompleter = null; _setupCompleter = null;
if (setupCompleter != null && !setupCompleter.isCompleted) {
setupCompleter.completeError(StateError('Watch Together connection cancelled'));
}
_connectedPeers.clear(); _connectedPeers.clear();
_sessionId = null; _sessionId = null;
_myPeerId = null; _myPeerId = null;
_isHost = false; _isHost = false;
_reconnectAttempts = 0; _reconnectAttempts = 0;
unawaited(subscription?.cancel());
try {
await channel?.sink.close();
} catch (e) {
appLogger.d('WatchTogether: channel close ignored', error: e);
}
_safeAdd(_connectionStateController, false); _safeAdd(_connectionStateController, false);
} }
/// Dispose all resources /// Dispose all resources.
void dispose() { void dispose() {
disconnect(); if (_disposed) return;
_disposed = true;
unawaited(disconnect());
_peerConnectedController.close(); _peerConnectedController.close();
_peerDisconnectedController.close(); _peerDisconnectedController.close();
@@ -60,6 +60,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
bool _isSearching = false; bool _isSearching = false;
String? _error; String? _error;
String? _downloadingKey; String? _downloadingKey;
int _searchGeneration = 0;
bool _showLanguagePicker = false; bool _showLanguagePicker = false;
@@ -83,6 +84,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
@override @override
void dispose() { void dispose() {
++_searchGeneration;
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_languageFocusNode.dispose(); _languageFocusNode.dispose();
_titleFocusNode.dispose(); _titleFocusNode.dispose();
@@ -92,37 +94,34 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
Future<void> _search() async { Future<void> _search() async {
if (!mounted) return; if (!mounted) return;
final generation = ++_searchGeneration;
setState(() { setState(() {
_isSearching = true; _isSearching = true;
_error = null; _error = null;
}); });
try { try {
// Defense-in-depth: searchSubtitles is Plex-only. The UI gates this
// sheet on `subtitleSearchSupported` upstream, but if a future caller
// reaches us with a Jellyfin server, fail soft instead of throwing.
final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId)); final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId));
final client = neutral is PlexClient ? neutral : null; final client = neutral is PlexClient ? neutral : null;
if (client == null) { if (client == null) {
if (!mounted) return; if (!mounted || generation != _searchGeneration) return;
setState(() { setState(() => _isSearching = false);
_isSearching = false;
});
return; return;
} }
final title = _titleController.text.trim(); final title = _titleController.text.trim();
final language = _languageCode;
final results = await client.searchSubtitles( final results = await client.searchSubtitles(
widget.ratingKey, widget.ratingKey,
language: _languageCode, language: language,
title: title.isEmpty ? null : title, title: title.isEmpty ? null : title,
); );
if (!mounted) return; if (!mounted || generation != _searchGeneration) return;
setState(() { setState(() {
_results = results; _results = results;
_isSearching = false; _isSearching = false;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted || generation != _searchGeneration) return;
setState(() { setState(() {
_error = e.toString(); _error = e.toString();
_isSearching = false; _isSearching = false;
@@ -149,7 +148,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
Future<void> _submitSearchAndFocusFirstResult() async { Future<void> _submitSearchAndFocusFirstResult() async {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
await _search(); await _search();
if (!mounted || !InputModeTracker.isKeyboardMode(context)) return; if (!mounted || !InputModeTracker.isKeyboardMode(context, listen: false)) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
@@ -158,6 +157,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
} }
void _onLanguageSelected(String code, String name) { void _onLanguageSelected(String code, String name) {
_debounceTimer?.cancel();
setState(() { setState(() {
_languageCode = code; _languageCode = code;
_languageName = name; _languageName = name;
+39
View File
@@ -248,4 +248,43 @@ void main() {
}, },
); );
}); });
test('overlapping playback-rate changes are serialized in call order', () async {
final releaseFirstSpeed = Completer<void>();
final firstSpeedStarted = Completer<void>();
final speedValues = <String>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
if (arguments['name'] == 'speed') {
speedValues.add(arguments['value'] as String);
if (!firstSpeedStarted.isCompleted) firstSpeedStarted.complete();
if (speedValues.length == 1) await releaseFirstSpeed.future;
}
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
final first = player.setRate(1.25);
final second = player.setRate(1.5);
await firstSpeedStarted.future;
expect(speedValues, ['1.25']);
releaseFirstSpeed.complete();
await Future.wait([first, second]);
expect(speedValues, ['1.25', '1.5']);
} finally {
if (!releaseFirstSpeed.isCompleted) releaseFirstSpeed.complete();
await player.dispose();
}
},
);
});
} }
@@ -215,4 +215,22 @@ void main() {
expect(peerEvents, ['guest-1']); expect(peerEvents, ['guest-1']);
expect(relay.messages.single.where((message) => message['type'] == 'create'), hasLength(1)); expect(relay.messages.single.where((message) => message['type'] == 'create'), hasLength(1));
}); });
test('disconnect cancels an in-flight room announcement without timeout delay', () async {
final announcementSeen = Completer<void>();
final relay = await relayWith((_, _, message) {
if (message['type'] == 'create' && !announcementSeen.isCompleted) {
announcementSeen.complete();
}
});
final service = serviceFor(relay);
final pending = service.createSession(sessionId: 'cancel1');
await announcementSeen.future.timeout(const Duration(seconds: 1));
await service.disconnect();
await expectLater(pending, throwsStateError);
expect(service.sessionId, isNull);
expect(service.connectedPeers, isEmpty);
});
} }