0504cae27c
Slice A landed with three transitive imports that the analyzer correctly flagged as unused. AppDb / CachedAlbums table refs propagate through audio_cache_manager.dart's `show appDbProvider` re-export chain so the explicit db.dart import in the consumers is redundant.
136 lines
4.8 KiB
Dart
136 lines
4.8 KiB
Dart
// 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/<type>/: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 `<type>:<id>`) 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<String> _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<void> _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<void> _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<void> _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<HydrationQueue>((ref) {
|
|
return HydrationQueue(ref);
|
|
});
|