fix: serialize async state transitions
This commit is contained in:
+9
-1
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user