Files
plezy/lib/utils/coalesced_load_coordinator.dart
T
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00

103 lines
3.2 KiB
Dart

import 'dart:async';
/// Coalesces full and targeted delta loads behind one in-flight drain.
///
/// A full load takes priority and supersedes every queued delta. Requests made
/// while a pass is running share the drain future and are replayed as trailing
/// work. The callbacks own fetch, commit, and failure policy.
final class CoalescedLoadCoordinator<T> {
factory CoalescedLoadCoordinator({
required Future<void> Function() onFull,
required Future<void> Function(Set<T>) onDelta,
}) => CoalescedLoadCoordinator._(onFull, onDelta);
CoalescedLoadCoordinator._(this._onFull, this._onDelta);
final Future<void> Function() _onFull;
final Future<void> Function(Set<T>) _onDelta;
final Set<T> _pendingDelta = {};
Future<void>? _inFlight;
bool _pendingFull = false;
bool _runningFull = false;
bool _disposed = false;
/// Whether a pass is running (or queued behind the running one). Lets a
/// caller tell "this surface is already loading" from "nothing has started",
/// without changing the drain's trailing-replay contract.
bool get isBusy => _inFlight != null;
/// Whether the busy work includes a full pass (running or queued). A busy
/// coordinator draining only deltas does not cover a caller that needs the
/// whole surface refetched.
bool get isFullActive => _pendingFull || _runningFull;
Future<void> requestFull() {
if (_disposed) return Future<void>.value();
_pendingFull = true;
return _ensureDrain();
}
Future<void> requestDelta(Iterable<T> values) {
if (_disposed) return Future<void>.value();
_pendingDelta.addAll(values);
if (_pendingDelta.isEmpty) return _inFlight ?? Future<void>.value();
return _ensureDrain();
}
/// Discards trailing work without interrupting the active callback.
void clearPending() {
if (_disposed) return;
_pendingFull = false;
_pendingDelta.clear();
}
/// Prevents new work and discards work queued behind the active callback.
void dispose() {
_disposed = true;
_pendingFull = false;
_pendingDelta.clear();
}
Future<void> _ensureDrain() {
final active = _inFlight;
if (active != null) return active;
// Install the shared future before invoking a callback so a synchronous,
// reentrant request is queued behind this drain rather than starting one.
final completer = Completer<void>();
final future = completer.future;
_inFlight = future;
_drain().then(
(_) {
if (!_disposed && identical(_inFlight, future)) _inFlight = null;
completer.complete();
},
onError: (Object error, StackTrace stackTrace) {
if (!_disposed && identical(_inFlight, future)) _inFlight = null;
completer.completeError(error, stackTrace);
},
);
return future;
}
Future<void> _drain() async {
while ((_pendingFull || _pendingDelta.isNotEmpty) && !_disposed) {
if (_pendingFull) {
_pendingFull = false;
_pendingDelta.clear();
_runningFull = true;
try {
await _onFull();
} finally {
_runningFull = false;
}
} else {
final values = Set<T>.of(_pendingDelta);
_pendingDelta.clear();
await _onDelta(values);
}
}
}
}