// Per-item hydration coordinator for the home/playlist/liked surfaces. // // Tile widgets are reactive to their per-entity drift row. On first // build, if drift has no row for the requested id, the tile asks this // queue to hydrate it. The queue dispatches one /api//:id // request, writes the result to drift, and the drift watch() on the // tile re-emits with the populated row. // // Concurrency cap: HydrationQueue limits the number of in-flight // requests so a 50-tile home screen doesn't fire 50 parallel /api/ // calls and stall the auth-token-bound dio pool. In-flight dedup // (keyed by `:`) ensures a single hydration when multiple // tiles share an entity (rare on home, common when several screens // reference the same album). // // Failures are swallowed — a single failed hydration leaves the tile // in skeleton state. A future retry-on-visit pass can layer on top // without changing the queue's contract. import 'dart:async'; import 'dart:collection'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../cache/adapters.dart'; import '../cache/audio_cache_manager.dart' show appDbProvider; import '../library/library_providers.dart' show libraryApiProvider; /// Bounded-concurrency request queue for per-entity hydration. One /// instance per Riverpod scope (provider singleton below). class HydrationQueue { HydrationQueue(this._ref); final Ref _ref; /// Concurrent slot count. 4 keeps the dio token pool from saturating /// while still pulling tiles down faster than a serial loop. Bump /// if profiling shows the queue idle while the network is healthy. static const _maxConcurrent = 4; final Queue<_HydrationRequest> _pending = Queue(); final Set _inFlightKeys = {}; int _inFlight = 0; /// Request hydration for (entityType, entityId). Idempotent — second /// call for the same key while the first is queued or in flight is /// dropped, so multiple tile rebuilds during a fast scroll don't /// inflate the queue. void enqueue({required String entityType, required String entityId}) { if (entityId.isEmpty) return; final key = '$entityType:$entityId'; if (_inFlightKeys.contains(key)) return; if (_pending.any((r) => r.key == key)) return; _pending.add(_HydrationRequest( entityType: entityType, entityId: entityId, key: key)); _pump(); } void _pump() { while (_inFlight < _maxConcurrent && _pending.isNotEmpty) { final req = _pending.removeFirst(); _inFlightKeys.add(req.key); _inFlight++; // Unawaited on purpose — _pump returns immediately so further // enqueues can fill remaining slots while this one runs. unawaited(_execute(req).whenComplete(() { _inFlight--; _inFlightKeys.remove(req.key); _pump(); })); } } Future _execute(_HydrationRequest req) async { try { switch (req.entityType) { case 'album': await _hydrateAlbum(req.entityId); case 'artist': await _hydrateArtist(req.entityId); case 'track': // TODO(per-item slice B): GET /api/tracks/:id doesn't exist // yet; tracks remain bulk-loaded until that endpoint lands. // Tile providers for tracks read drift but never enqueue. break; } } catch (_) { // Swallow — tile stays in skeleton. A retry-on-visit pass can // layer on top later without changing this contract. } } Future _hydrateAlbum(String id) async { final api = await _ref.read(libraryApiProvider.future); final result = await api.getAlbum(id); final db = _ref.read(appDbProvider); // Album + tracks come bundled in the same response; persist both // so opening the album detail later is also a drift hit. await db.transaction(() async { await db .into(db.cachedAlbums) .insertOnConflictUpdate(result.album.toDrift()); if (result.tracks.isNotEmpty) { await db.batch((b) { b.insertAllOnConflictUpdate( db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList()); }); } }); } Future _hydrateArtist(String id) async { final api = await _ref.read(libraryApiProvider.future); final fresh = await api.getArtist(id); final db = _ref.read(appDbProvider); await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift()); } } class _HydrationRequest { _HydrationRequest({ required this.entityType, required this.entityId, required this.key, }); final String entityType; final String entityId; final String key; } /// Singleton queue scoped to the Riverpod container. Kept as a plain /// Provider (not StateProvider) since the queue's internal state is /// pump-driven, not reactively observed. final hydrationQueueProvider = Provider((ref) { return HydrationQueue(ref); });