Files
plezy/lib/watch_together/services/current_playback_dispatcher.dart
T
edde746 86abf3e9da fix(watch-together): retry guest media switches until they commit
Guest switch dispatch pre-marked its dedup key and fired-and-forgot, so
any failure (fetch error, reload busy with an auto-advance, navigation
race with the host exiting) silently stranded the guest on the old media.
A CurrentPlaybackDispatcher now marks a key handled only after the sink
reports success against the committed identity, with a serialized
in-flight slot, timeout, and generation reset; the reconciler re-offers
unattached media on every host heartbeat, making the heartbeat the retry
channel. Fetches that outlive their dispatch are re-validated against the
current snapshot so a stale switch can't override the live one.
hostExitedPlayer now rides the controller's ordered message queue with
host authentication instead of racing state handling in the provider.
2026-07-02 12:44:15 +02:00

51 lines
2.1 KiB
Dart

import '../../utils/app_logger.dart';
/// Serializes guest media-switch dispatches and provides heartbeat-driven
/// retry: a key is only marked handled after its callback reports success,
/// so a failed switch is re-dispatched by the host's next state heartbeat.
class CurrentPlaybackDispatcher {
static const dispatchTimeout = Duration(seconds: 30);
String? _lastHandledKey;
String? _inFlightKey;
int _generation = 0;
String? get inFlightKey => _inFlightKey;
/// Whether [key] should be dispatched now. A single in-flight slot
/// serializes dispatches (concurrent navigations would stack player
/// routes); once it frees, the next heartbeat carries the latest key.
bool shouldDispatch(String? key) => key != null && key != _lastHandledKey && _inFlightKey == null;
/// Suppress future dispatches of [key] (e.g. a user-initiated join already
/// navigating to it).
void markHandled(String key) => _lastHandledKey = key;
/// Session left / host exited player: clears state and invalidates any
/// in-flight completion so a stale success can't suppress a later re-join
/// of the same media.
void reset() {
_generation++;
_inFlightKey = null;
_lastHandledKey = null;
}
Future<void> dispatch(String key, Future<bool> Function() invoke, {Duration timeout = dispatchTimeout}) async {
// Synchronous — claims the slot before the first await so a
// same-microtask second state can't double-dispatch.
_inFlightKey = key;
final generation = _generation;
var handled = false;
try {
// then<bool> re-types the future: a throwing async callback is
// reified as Future<Never>, whose timeout() rejects a bool onTimeout.
handled = await invoke().then<bool>((value) => value).timeout(timeout, onTimeout: () => false);
} catch (e, stackTrace) {
appLogger.w('WatchTogether: media switch dispatch failed for $key', error: e, stackTrace: stackTrace);
}
if (generation != _generation) return; // reset() happened mid-flight
_inFlightKey = null;
if (handled) _lastHandledKey = key; // else: next heartbeat retries
}
}