Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29fee5aa37 | |||
| 02336967b4 | |||
| 452e29bc59 | |||
| f6ee837be6 | |||
| d27dd69bfc | |||
| b991fde3fe | |||
| 335940cf23 | |||
| 60085b1368 | |||
| fb95a462fb | |||
| 67bacac84b | |||
| e59ccba961 | |||
| e38189470b | |||
| 5511f87b4b | |||
| e5ab471ce1 | |||
| 28b0107925 | |||
| bfad4dddb6 | |||
| d1e276204e | |||
| 2df35e6227 | |||
| 573aa4226d | |||
| 30fc7603d4 | |||
| 86d67f6fc6 | |||
| 8cb9a8b797 | |||
| 7339815ea9 | |||
| 2a18e91c39 | |||
| 507c532f6d | |||
| bf0ef5e0c3 | |||
| 6efb3159d5 | |||
| 8f1bc60757 | |||
| c29d25d1cb | |||
| 2ebe6229b7 | |||
| 6a08d94255 | |||
| 3e7b2582a2 |
@@ -54,6 +54,24 @@ class LibraryApi {
|
|||||||
return ArtistRef.fromJson(r.data ?? const {});
|
return ArtistRef.fromJson(r.data ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/artists/{id} — full response with the ArtistRef AND the
|
||||||
|
/// embedded album list, both parsed. Single round-trip variant used
|
||||||
|
/// by CacheFiller and other callers that want to populate both
|
||||||
|
/// cached_artists and cached_albums in one shot. The existing
|
||||||
|
/// getArtist / getArtistAlbums keep working for callers that only
|
||||||
|
/// need one half — they hit the same URL but the per-id Riverpod
|
||||||
|
/// caching layer dedupes.
|
||||||
|
Future<({ArtistRef artist, List<AlbumRef> albums})> getArtistDetail(
|
||||||
|
String id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
|
||||||
|
final body = r.data ?? const <String, dynamic>{};
|
||||||
|
final artist = ArtistRef.fromJson(body);
|
||||||
|
final albums = ((body['albums'] as List?) ?? const [])
|
||||||
|
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
return (artist: artist, albums: albums);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
|
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
|
||||||
/// that need both the artist and its albums should issue two provider
|
/// that need both the artist and its albums should issue two provider
|
||||||
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
|
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'cache/cache_filler.dart';
|
||||||
import 'cache/metadata_prefetcher.dart';
|
import 'cache/metadata_prefetcher.dart';
|
||||||
|
import 'cache/mutation_queue.dart';
|
||||||
import 'cache/prefetcher.dart';
|
import 'cache/prefetcher.dart';
|
||||||
import 'cache/sync_controller.dart';
|
import 'cache/sync_controller.dart';
|
||||||
import 'shared/live_events_dispatcher.dart';
|
import 'shared/live_events_dispatcher.dart';
|
||||||
@@ -39,6 +41,20 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
|||||||
// arrive. Also installs an AppLifecycleState observer for
|
// arrive. Also installs an AppLifecycleState observer for
|
||||||
// resume-time defensive invalidation.
|
// resume-time defensive invalidation.
|
||||||
ref.read(liveEventsDispatcherProvider);
|
ref.read(liveEventsDispatcherProvider);
|
||||||
|
// Cache filler: background sweeper that walks cached_artists
|
||||||
|
// / cached_albums for missing relations and fetches the
|
||||||
|
// per-entity detail so tapping an artist surfaces albums
|
||||||
|
// immediately (drift hit, no /api/artists/:id round-trip at
|
||||||
|
// tap time). First sweep 10s after launch; every 5 minutes
|
||||||
|
// thereafter. Throttled 200ms between requests so it never
|
||||||
|
// competes with user activity.
|
||||||
|
ref.read(cacheFillerProvider);
|
||||||
|
// Mutation replayer: drains the cached_mutations queue when
|
||||||
|
// connectivity comes back. Controllers (LikesController,
|
||||||
|
// MyQuarantineController, the add-to-playlist + request flows)
|
||||||
|
// enqueue on REST failure so user intent persists across
|
||||||
|
// network loss instead of getting rolled back.
|
||||||
|
ref.read(mutationReplayerProvider);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -17,10 +17,21 @@ import '../models/track.dart';
|
|||||||
import 'db.dart';
|
import 'db.dart';
|
||||||
|
|
||||||
extension CachedArtistAdapter on CachedArtist {
|
extension CachedArtistAdapter on CachedArtist {
|
||||||
ArtistRef toRef() => ArtistRef(
|
/// `coverAlbumId` lets the caller pass a representative album id so
|
||||||
|
/// the ArtistRef carries a reconstructed `/api/albums/<id>/cover`
|
||||||
|
/// URL. cached_artists doesn't store this directly — the server
|
||||||
|
/// derives it from the most-recent album at query time — so drift
|
||||||
|
/// readers that want the cover URL join cached_albums and pass the
|
||||||
|
/// first album's id through. Empty string yields empty coverUrl,
|
||||||
|
/// matching the server's behavior for endpoints that don't carry
|
||||||
|
/// the lookup (artist detail, search, raw cached_artists row reads).
|
||||||
|
ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
sortName: sortName,
|
sortName: sortName,
|
||||||
|
coverUrl: coverAlbumId.isNotEmpty
|
||||||
|
? '/api/albums/$coverAlbumId/cover'
|
||||||
|
: '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
// Background metadata sweeper that walks the drift cache for missing
|
||||||
|
// relations and fills them via /api/artists/:id + /api/albums/:id.
|
||||||
|
// Cover bytes for newly-filled albums are pre-warmed too so the
|
||||||
|
// per-tile display path doesn't have to wait on network.
|
||||||
|
//
|
||||||
|
// Why this layer on top of SyncController and HydrationQueue:
|
||||||
|
// - SyncController.sync only ingests entities the server emitted
|
||||||
|
// into the library_changes delta for this user. For a fresh
|
||||||
|
// install, that's everything; for an existing user, only recent
|
||||||
|
// changes. The per-artist album list and per-album track list
|
||||||
|
// are NOT in those deltas — they're derived at query time.
|
||||||
|
// - HydrationQueue fills these lazily on tile render. CacheFiller
|
||||||
|
// fills them proactively on a slow schedule so tapping an artist
|
||||||
|
// surfaces albums immediately instead of triggering a fresh
|
||||||
|
// /api/artists/:id at tap time.
|
||||||
|
//
|
||||||
|
// Pacing is conservative — 200ms between requests, max 200 entities
|
||||||
|
// per sweep, 5-minute interval. Designed to never compete with user
|
||||||
|
// activity. Wall time on a 1000-artist library: ~3-4 minutes spread
|
||||||
|
// over multiple sweeps.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' show Variable;
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart'
|
||||||
|
show serverUrlProvider, sessionTokenProvider;
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/connectivity_provider.dart';
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
|
||||||
|
class CacheFiller {
|
||||||
|
CacheFiller(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Timer? _initialTimer;
|
||||||
|
Timer? _intervalTimer;
|
||||||
|
bool _running = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Delay between launch and the first sweep. Long enough for
|
||||||
|
/// SyncController to land its initial /api/library/sync so the
|
||||||
|
/// CacheFiller's "unfilled relations" query has meaningful work.
|
||||||
|
static const _initialDelay = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// Cadence between sweeps. Once a sweep finds nothing to do (steady
|
||||||
|
/// state) the interval becomes a cheap drift query + early exit.
|
||||||
|
static const _interval = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// Throttle between per-entity REST requests so the filler doesn't
|
||||||
|
/// saturate the server or compete with user-initiated playback.
|
||||||
|
static const _requestThrottle = Duration(milliseconds: 200);
|
||||||
|
|
||||||
|
/// Per-sweep cap. Without this, a fresh install with thousands of
|
||||||
|
/// artists would tie up the network for many minutes in one go.
|
||||||
|
/// The next sweep continues where this one left off (the WHERE
|
||||||
|
/// NOT EXISTS query naturally skips already-filled rows).
|
||||||
|
static const _maxIdsPerSweep = 200;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
_initialTimer = Timer(_initialDelay, _sweep);
|
||||||
|
_intervalTimer = Timer.periodic(_interval, (_) => _sweep());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sweep() async {
|
||||||
|
if (_disposed || _running) return;
|
||||||
|
final online = await _ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
if (!online) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
await _fillArtists();
|
||||||
|
if (_disposed) return;
|
||||||
|
await _fillAlbums();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('cache_filler: sweep failed: $e\n$st');
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find artists with no albums in cache and fetch /api/artists/:id
|
||||||
|
/// for each (single round-trip yields artist + albums via the new
|
||||||
|
/// getArtistDetail API method). Newly-discovered album covers
|
||||||
|
/// land in flutter_cache_manager's disk cache too so the next
|
||||||
|
/// visit to the artist's albums grid paints from disk.
|
||||||
|
Future<void> _fillArtists() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT a.id FROM cached_artists a
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id
|
||||||
|
)
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
variables: [Variable.withInt(_maxIdsPerSweep)],
|
||||||
|
).get();
|
||||||
|
final ids = rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final newAlbumIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in ids) {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
final detail = await api.getArtistDetail(id);
|
||||||
|
await db.transaction(() async {
|
||||||
|
await db
|
||||||
|
.into(db.cachedArtists)
|
||||||
|
.insertOnConflictUpdate(detail.artist.toDrift());
|
||||||
|
if (detail.albums.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedAlbums,
|
||||||
|
detail.albums.map((a) => a.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
newAlbumIds.addAll(detail.albums.map((a) => a.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('cache_filler: fillArtist($id) failed: $e');
|
||||||
|
}
|
||||||
|
await Future.delayed(_requestThrottle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAlbumIds.isNotEmpty) {
|
||||||
|
await _prewarmCovers(newAlbumIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find albums with no tracks in cache and fetch /api/albums/:id
|
||||||
|
/// for each. The bulk response carries the album's track list which
|
||||||
|
/// we persist into cached_tracks for instant album detail render
|
||||||
|
/// on the next visit.
|
||||||
|
Future<void> _fillAlbums() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT a.id FROM cached_albums a
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id
|
||||||
|
)
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
variables: [Variable.withInt(_maxIdsPerSweep)],
|
||||||
|
).get();
|
||||||
|
final ids = rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
for (final id in ids) {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
final result = await api.getAlbum(id);
|
||||||
|
if (result.tracks.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedTracks,
|
||||||
|
result.tracks.map((t) => t.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('cache_filler: fillAlbum($id) failed: $e');
|
||||||
|
}
|
||||||
|
await Future.delayed(_requestThrottle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-warm album cover bytes via flutter_cache_manager so the
|
||||||
|
/// per-tile ServerImage / CachedNetworkImage paints from disk on
|
||||||
|
/// the user's first visit. Mirrors SyncController's prewarm
|
||||||
|
/// helper — duplicated here intentionally to keep this file self-
|
||||||
|
/// contained; a shared helper can come out of #357 follow-up if
|
||||||
|
/// the duplication grows.
|
||||||
|
Future<void> _prewarmCovers(List<String> albumIds) async {
|
||||||
|
final baseUrl = await _ref.read(serverUrlProvider.future);
|
||||||
|
if (baseUrl == null || baseUrl.isEmpty) return;
|
||||||
|
final token = await _ref.read(sessionTokenProvider.future);
|
||||||
|
final headers = (token != null && token.isNotEmpty)
|
||||||
|
? {'Authorization': 'Bearer $token'}
|
||||||
|
: <String, String>{};
|
||||||
|
final base = baseUrl.endsWith('/')
|
||||||
|
? baseUrl.substring(0, baseUrl.length - 1)
|
||||||
|
: baseUrl;
|
||||||
|
final mgr = DefaultCacheManager();
|
||||||
|
|
||||||
|
var idx = 0;
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (idx < albumIds.length) {
|
||||||
|
if (_disposed) return;
|
||||||
|
final i = idx++;
|
||||||
|
try {
|
||||||
|
await mgr.downloadFile(
|
||||||
|
'$base/api/albums/${albumIds[i]}/cover',
|
||||||
|
authHeaders: headers,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort prewarm — 404 (missing collage) and 401
|
||||||
|
// (token refresh races) shouldn't abort the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrency 3 — same as SyncController's prewarm. Covers are
|
||||||
|
// small enough that more parallelism doesn't help much and
|
||||||
|
// burns radio.
|
||||||
|
await Future.wait(List.generate(3, (_) => worker()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_initialTimer?.cancel();
|
||||||
|
_intervalTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read once at app start (from app.dart's postFrameCallback) to
|
||||||
|
/// activate the filler. Disposed via ref.onDispose when the provider
|
||||||
|
/// scope tears down; in practice the scope lives for the app's
|
||||||
|
/// lifetime so dispose only fires on uninstall / process death.
|
||||||
|
final cacheFillerProvider = Provider<CacheFiller>((ref) {
|
||||||
|
final filler = CacheFiller(ref);
|
||||||
|
ref.onDispose(filler.dispose);
|
||||||
|
filler.start();
|
||||||
|
return filler;
|
||||||
|
});
|
||||||
+29
-2
@@ -48,9 +48,22 @@ Stream<T> cacheFirst<D, T>({
|
|||||||
// drift re-emission (otherwise the populate cycles forever).
|
// drift re-emission (otherwise the populate cycles forever).
|
||||||
var revalidated = false;
|
var revalidated = false;
|
||||||
|
|
||||||
|
// Tracks whether we've already tried a cold-cache fetch on this
|
||||||
|
// subscription. Without this, providers can spin forever in the
|
||||||
|
// rows-empty branch when fetchAndPopulate writes rows that don't
|
||||||
|
// match THIS filter — e.g. three liked-tab streams sharing one
|
||||||
|
// populate: the track populate writes track-typed rows, drift
|
||||||
|
// watch fires for the cached_likes table, the album stream
|
||||||
|
// re-emits with rows-empty (no album likes), the rows-empty
|
||||||
|
// branch re-fires populate, repeats forever. Setting this guard
|
||||||
|
// makes the first fetch attempt also the last for any given
|
||||||
|
// subscription.
|
||||||
|
var coldFetchAttempted = false;
|
||||||
|
|
||||||
await for (final rows in driftStream) {
|
await for (final rows in driftStream) {
|
||||||
if (rows.isNotEmpty) {
|
if (rows.isNotEmpty) {
|
||||||
yield toResult(rows);
|
yield toResult(rows);
|
||||||
|
coldFetchAttempted = true;
|
||||||
// Stale-while-revalidate: yield cache immediately, then kick off
|
// Stale-while-revalidate: yield cache immediately, then kick off
|
||||||
// a REST refresh in the background. Drift watch() picks up the
|
// a REST refresh in the background. Drift watch() picks up the
|
||||||
// resulting writes and re-emits via this same stream loop.
|
// resulting writes and re-emits via this same stream loop.
|
||||||
@@ -62,11 +75,25 @@ Stream<T> cacheFirst<D, T>({
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// rows is empty. If we've already attempted a cold fetch and
|
||||||
|
// drift is still empty for this filter, yield empty so the UI
|
||||||
|
// shows the no-content state instead of spinning forever.
|
||||||
|
if (coldFetchAttempted) {
|
||||||
|
yield toResult(rows);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coldFetchAttempted = true;
|
||||||
if (await isOnline()) {
|
if (await isOnline()) {
|
||||||
try {
|
try {
|
||||||
await fetchAndPopulate();
|
await fetchAndPopulate();
|
||||||
// The drift watch() stream re-emits with the populated rows on
|
// Yield the current (still-empty) rows so the UI moves past
|
||||||
// the next loop iteration. Don't yield here.
|
// loading even if populate was a no-op for this filter
|
||||||
|
// (server returned nothing matching, or all rows were
|
||||||
|
// already in drift via sync). If populate DID write rows
|
||||||
|
// matching this filter, the drift watch's next emission
|
||||||
|
// yields them via the rows.isNotEmpty branch — UI briefly
|
||||||
|
// shows empty then populates, instead of spinning.
|
||||||
|
yield toResult(rows);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
|
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
|
||||||
|
|||||||
Vendored
+54
-1
@@ -142,6 +142,21 @@ class CachedHistorySnapshot extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Single-row cache of the /api/me/system-playlists-status response.
|
||||||
|
/// The home Playlists row reads this every render to pick between
|
||||||
|
/// real cards and placeholder cards for For-You / Discover / Songs-
|
||||||
|
/// like slots, so a fresh-mount cold fetch produces a visible
|
||||||
|
/// "building / pending / failed" flicker. Storing the last result
|
||||||
|
/// as a JSON blob means the home renders with the prior status
|
||||||
|
/// instantly, then SWR refreshes underneath. Schema 7+.
|
||||||
|
class CachedSystemPlaylistsStatus extends Table {
|
||||||
|
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||||
|
TextColumn get json => text()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
/// Section→position→entity-id index for the home screen, populated
|
/// Section→position→entity-id index for the home screen, populated
|
||||||
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
||||||
/// pins one tile slot to an entity; the actual entity data lives in
|
/// pins one tile slot to an entity; the actual entity data lives in
|
||||||
@@ -163,6 +178,30 @@ class CachedHomeIndex extends Table {
|
|||||||
Set<Column> get primaryKey => {section, position};
|
Set<Column> get primaryKey => {section, position};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outbound mutation queue for offline-resilient REST calls. Likes,
|
||||||
|
/// quarantine flag/unflag, playlist appendTracks, Lidarr request
|
||||||
|
/// create/cancel all enqueue here on REST failure so the user's
|
||||||
|
/// intent persists across network loss. MutationReplayer drains on
|
||||||
|
/// connectivity transitions and a 1-minute periodic tick; entries
|
||||||
|
/// with attempts >= 5 are skipped (the server treats them as
|
||||||
|
/// permanent failures and library_changes sync will correct any
|
||||||
|
/// resulting drift drift on next sync). Schema 8+.
|
||||||
|
class CachedMutations extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
/// Stable kind string (see mutation_queue.dart constants). The
|
||||||
|
/// replayer looks up the handler in a kind→Function map; unknown
|
||||||
|
/// kinds are dropped to avoid getting stuck.
|
||||||
|
TextColumn get kind => text()();
|
||||||
|
/// JSON-encoded payload — args needed to replay the REST call.
|
||||||
|
/// Shape depends on kind; see mutation_queue.dart.
|
||||||
|
TextColumn get payload => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
/// When the last drain attempt fired against this row. Null until
|
||||||
|
/// the first attempt. Useful for diagnostic queries.
|
||||||
|
DateTimeColumn get lastAttemptAt => dateTime().nullable()();
|
||||||
|
IntColumn get attempts => integer().withDefault(const Constant(0))();
|
||||||
|
}
|
||||||
|
|
||||||
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
|
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
|
||||||
/// /api/quarantine/mine — fully denormalized (track + album + artist
|
/// /api/quarantine/mine — fully denormalized (track + album + artist
|
||||||
/// fields inline) because the Hidden tab renders straight from this row
|
/// fields inline) because the Hidden tab renders straight from this row
|
||||||
@@ -201,12 +240,14 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
|||||||
CachedHistorySnapshot,
|
CachedHistorySnapshot,
|
||||||
CachedQuarantineMine,
|
CachedQuarantineMine,
|
||||||
CachedHomeIndex,
|
CachedHomeIndex,
|
||||||
|
CachedSystemPlaylistsStatus,
|
||||||
|
CachedMutations,
|
||||||
])
|
])
|
||||||
class AppDb extends _$AppDb {
|
class AppDb extends _$AppDb {
|
||||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 6;
|
int get schemaVersion => 8;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -247,6 +288,18 @@ class AppDb extends _$AppDb {
|
|||||||
// builds still read from it as a fallback path.
|
// builds still read from it as a fallback path.
|
||||||
await m.createTable(cachedHomeIndex);
|
await m.createTable(cachedHomeIndex);
|
||||||
}
|
}
|
||||||
|
if (from < 7) {
|
||||||
|
// Schema 7: cached_system_playlists_status. Single-row
|
||||||
|
// snapshot of /api/me/system-playlists-status used by the
|
||||||
|
// home Playlists row. Empty on upgrade; first fetch
|
||||||
|
// populates.
|
||||||
|
await m.createTable(cachedSystemPlaylistsStatus);
|
||||||
|
}
|
||||||
|
if (from < 8) {
|
||||||
|
// Schema 8: cached_mutations outbound queue. Empty on
|
||||||
|
// upgrade; populated by controllers when REST calls fail.
|
||||||
|
await m.createTable(cachedMutations);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
// Outbound mutation queue for offline-resilient REST calls.
|
||||||
|
//
|
||||||
|
// Controllers (LikesController, MyQuarantineController, the add-to-
|
||||||
|
// playlist sheet, the Discover request flow, the Requests cancel
|
||||||
|
// action) write their optimistic local state to drift first and try
|
||||||
|
// the corresponding REST call. On failure they enqueue here. The
|
||||||
|
// replayer drains the queue when connectivity comes back; the user's
|
||||||
|
// intent persists across network loss without rolling back their
|
||||||
|
// visible action.
|
||||||
|
//
|
||||||
|
// Why this is separate from SyncController: sync ingests AUTHORITATIVE
|
||||||
|
// SERVER state into drift. The mutation queue carries USER INTENT
|
||||||
|
// outbound to the server. They flow in opposite directions and
|
||||||
|
// shouldn't be conflated — sync wins on conflict (server is the
|
||||||
|
// source of truth), and a mutation that fails forever (5 attempts)
|
||||||
|
// is dropped on the trust that next sync will reconcile drift to
|
||||||
|
// match the server's actual state.
|
||||||
|
//
|
||||||
|
// Drop semantics: 5 attempts then drop. The drop is intentional —
|
||||||
|
// holding onto a forever-failing mutation just delays sync's
|
||||||
|
// reconciliation. The user's optimistic drift state will diverge
|
||||||
|
// from server, and the next library_changes delta corrects it.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart' show DioException, DioExceptionType;
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/discover.dart';
|
||||||
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../likes/likes_provider.dart' show likesApiProvider;
|
||||||
|
import '../models/lidarr.dart' show LidarrRequestKind;
|
||||||
|
import '../playlists/playlists_provider.dart' show playlistsApiProvider;
|
||||||
|
import '../quarantine/quarantine_provider.dart' show quarantineApiProvider;
|
||||||
|
import '../requests/requests_provider.dart' show requestsApiProvider;
|
||||||
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import 'connectivity_provider.dart';
|
||||||
|
import 'db.dart';
|
||||||
|
|
||||||
|
/// Stable kind constants. Each controller emits one of these via
|
||||||
|
/// MutationQueue.enqueue; the replayer dispatches by kind.
|
||||||
|
class MutationKinds {
|
||||||
|
MutationKinds._();
|
||||||
|
static const likeAdd = 'like.add';
|
||||||
|
static const likeRemove = 'like.remove';
|
||||||
|
static const quarantineFlag = 'quarantine.flag';
|
||||||
|
static const quarantineUnflag = 'quarantine.unflag';
|
||||||
|
static const playlistAppend = 'playlist.append';
|
||||||
|
static const requestCreate = 'request.create';
|
||||||
|
static const requestCancel = 'request.cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MutationQueue {
|
||||||
|
MutationQueue(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Persist a mutation for replay. Caller has already done the
|
||||||
|
/// optimistic local mutation (drift write, in-memory state update)
|
||||||
|
/// — this only records the REST call that needs to fire eventually.
|
||||||
|
Future<void> enqueue(String kind, Map<String, dynamic> payload) async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
await db.into(db.cachedMutations).insert(
|
||||||
|
CachedMutationsCompanion.insert(
|
||||||
|
kind: kind,
|
||||||
|
payload: jsonEncode(payload),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Nudge the replayer in case we're online right now.
|
||||||
|
unawaited(_ref.read(mutationReplayerProvider).drain());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of pending mutations (attempts < 5). Diagnostic / future
|
||||||
|
/// UI surface for a "syncing N pending changes" indicator.
|
||||||
|
Future<int> pendingCount() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'SELECT COUNT(*) AS c FROM cached_mutations WHERE attempts < 5',
|
||||||
|
).get();
|
||||||
|
return rows.first.read<int>('c');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mutationQueueProvider =
|
||||||
|
Provider<MutationQueue>((ref) => MutationQueue(ref));
|
||||||
|
|
||||||
|
class MutationReplayer {
|
||||||
|
MutationReplayer(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Timer? _initialTimer;
|
||||||
|
Timer? _intervalTimer;
|
||||||
|
bool _running = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Periodic poll cadence. The connectivity listener catches most
|
||||||
|
/// transitions; this is the belt-and-suspenders for cases where
|
||||||
|
/// connectivity_plus misses a state change (e.g. flaky Wi-Fi).
|
||||||
|
static const _interval = Duration(minutes: 1);
|
||||||
|
|
||||||
|
/// Max replay attempts before we drop a mutation. 5 is enough to
|
||||||
|
/// span a few reconnection cycles without holding onto a forever-
|
||||||
|
/// broken request. See file header for the drop rationale.
|
||||||
|
static const _maxAttempts = 5;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
// Drain shortly after launch in case there are queued mutations
|
||||||
|
// from a prior session that died offline. 3s lets the auth +
|
||||||
|
// server-url + dio providers finish their async warmup.
|
||||||
|
_initialTimer = Timer(const Duration(seconds: 3), drain);
|
||||||
|
// Periodic ticker is the only reconnect signal. We previously had
|
||||||
|
// a ref.listen(connectivityProvider, …) edge trigger here, but
|
||||||
|
// subscribing at start-time eagerly mounted the connectivity
|
||||||
|
// StreamProvider and leaked its initial-timeout Timer through
|
||||||
|
// tests that never reach the auth state. The drain() loop itself
|
||||||
|
// gates on connectivity via the .future read below.
|
||||||
|
_intervalTimer = Timer.periodic(_interval, (_) => drain());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the queue oldest-first, replaying each mutation. Stops on
|
||||||
|
/// any transient network error (we'll retry on the next tick); a
|
||||||
|
/// permanent error (404 etc.) increments the attempt counter and
|
||||||
|
/// moves on to the next mutation. This is public so MutationQueue.
|
||||||
|
/// enqueue can trigger an immediate attempt after writing.
|
||||||
|
Future<void> drain() async {
|
||||||
|
if (_disposed || _running) return;
|
||||||
|
final online = await _ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
if (!online) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
while (!_disposed) {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final next = await (db.select(db.cachedMutations)
|
||||||
|
..where((t) => t.attempts.isSmallerThanValue(_maxAttempts))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (next == null) break;
|
||||||
|
|
||||||
|
final handler = _handlers[next.kind];
|
||||||
|
if (handler == null) {
|
||||||
|
debugPrint('mutation_replayer: unknown kind ${next.kind}, dropping');
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> payload;
|
||||||
|
try {
|
||||||
|
payload = jsonDecode(next.payload) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('mutation_replayer: bad payload for ${next.kind}: $e');
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler(_ref, payload);
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
await (db.update(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.write(CachedMutationsCompanion(
|
||||||
|
attempts: Value(next.attempts + 1),
|
||||||
|
lastAttemptAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
if (_isTransient(e)) {
|
||||||
|
// Network is flaky again — bail and try the whole queue
|
||||||
|
// later. The current mutation will be retried next tick.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Permanent failure (4xx/5xx with a real response). Leave
|
||||||
|
// the row with bumped attempts and move on to the next so
|
||||||
|
// one bad mutation doesn't block the others.
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('mutation_replayer: unexpected error: $e\n$st');
|
||||||
|
await (db.update(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.write(CachedMutationsCompanion(
|
||||||
|
attempts: Value(next.attempts + 1),
|
||||||
|
lastAttemptAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTransient(DioException e) {
|
||||||
|
switch (e.type) {
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_initialTimer?.cancel();
|
||||||
|
_intervalTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mutationReplayerProvider = Provider<MutationReplayer>((ref) {
|
||||||
|
final r = MutationReplayer(ref);
|
||||||
|
ref.onDispose(r.dispose);
|
||||||
|
r.start();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Type signature for kind-specific replay handlers.
|
||||||
|
typedef _Handler = Future<void> Function(Ref, Map<String, dynamic>);
|
||||||
|
|
||||||
|
/// Kind → handler dispatch. Each handler decodes its payload and
|
||||||
|
/// re-fires the original REST call. Throws on failure (caught by the
|
||||||
|
/// replayer's drain loop above).
|
||||||
|
///
|
||||||
|
/// Payload shapes (see also enqueue call sites for the writer side):
|
||||||
|
/// * like.add / like.remove: {'kind': 'track'|'album'|'artist', 'id': uuid}
|
||||||
|
/// * quarantine.flag: {'trackId': uuid, 'reason': str, 'notes': str}
|
||||||
|
/// * quarantine.unflag: {'trackId': uuid}
|
||||||
|
/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]}
|
||||||
|
/// * request.create: {full createRequest args; see DiscoverApi}
|
||||||
|
/// * request.cancel: {'id': uuid}
|
||||||
|
final Map<String, _Handler> _handlers = {
|
||||||
|
MutationKinds.likeAdd: (ref, p) async {
|
||||||
|
final api = await ref.read(likesApiProvider.future);
|
||||||
|
await api.like(_likeKindFromString(p['kind'] as String), p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.likeRemove: (ref, p) async {
|
||||||
|
final api = await ref.read(likesApiProvider.future);
|
||||||
|
await api.unlike(
|
||||||
|
_likeKindFromString(p['kind'] as String), p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.quarantineFlag: (ref, p) async {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
|
await api.flag(
|
||||||
|
p['trackId'] as String,
|
||||||
|
p['reason'] as String,
|
||||||
|
notes: (p['notes'] as String?) ?? '',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MutationKinds.quarantineUnflag: (ref, p) async {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
|
await api.unflag(p['trackId'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.playlistAppend: (ref, p) async {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
final ids = (p['trackIds'] as List).cast<String>();
|
||||||
|
await api.appendTracks(p['playlistId'] as String, ids);
|
||||||
|
},
|
||||||
|
MutationKinds.requestCreate: (ref, p) async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final api = DiscoverApi(dio);
|
||||||
|
await api.createRequest(
|
||||||
|
kind: _lidarrKindFromString(p['kind'] as String),
|
||||||
|
artistMbid: p['artistMbid'] as String,
|
||||||
|
artistName: p['artistName'] as String,
|
||||||
|
albumMbid: p['albumMbid'] as String?,
|
||||||
|
albumTitle: p['albumTitle'] as String?,
|
||||||
|
trackMbid: p['trackMbid'] as String?,
|
||||||
|
trackTitle: p['trackTitle'] as String?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MutationKinds.requestCancel: (ref, p) async {
|
||||||
|
final api = await ref.read(requestsApiProvider.future);
|
||||||
|
await api.cancel(p['id'] as String);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
LikeKind _likeKindFromString(String s) => switch (s) {
|
||||||
|
'album' => LikeKind.album,
|
||||||
|
'artist' => LikeKind.artist,
|
||||||
|
_ => LikeKind.track,
|
||||||
|
};
|
||||||
|
|
||||||
|
LidarrRequestKind _lidarrKindFromString(String s) => switch (s) {
|
||||||
|
'album' => LidarrRequestKind.album,
|
||||||
|
'track' => LidarrRequestKind.track,
|
||||||
|
_ => LidarrRequestKind.artist,
|
||||||
|
};
|
||||||
+43
-6
@@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../player/album_color_extractor.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
import 'audio_cache_manager.dart';
|
import 'audio_cache_manager.dart';
|
||||||
import 'cache_settings_provider.dart';
|
import 'cache_settings_provider.dart';
|
||||||
@@ -39,14 +42,48 @@ class Prefetcher {
|
|||||||
(currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1);
|
(currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1);
|
||||||
|
|
||||||
final mgr = _ref.read(audioCacheManagerProvider);
|
final mgr = _ref.read(audioCacheManagerProvider);
|
||||||
|
final coverCache = _ref.read(albumCoverCacheProvider);
|
||||||
|
final colorCache = _ref.read(albumColorCacheProvider);
|
||||||
|
|
||||||
|
// Walk the window. For each upcoming track we want THREE things
|
||||||
|
// ready when the player transitions into it:
|
||||||
|
//
|
||||||
|
// 1. Audio file on disk (otherwise playback stalls on stream
|
||||||
|
// load — the original prefetcher concern).
|
||||||
|
// 2. Cover bytes on disk under AlbumCoverCache (otherwise
|
||||||
|
// _toMediaItem's peekCached returns null, mediaItem
|
||||||
|
// broadcasts with artUri=null, and the now-playing screen
|
||||||
|
// stalls in _scheduleSwap awaiting precacheImage of a file
|
||||||
|
// that doesn't exist yet).
|
||||||
|
// 3. Palette color extracted and memoized in AlbumColorCache
|
||||||
|
// (otherwise the gradient backdrop has to wait for
|
||||||
|
// PaletteGenerator to run after the cover lands —
|
||||||
|
// visible as the cover snapping in before the gradient).
|
||||||
|
//
|
||||||
|
// Each call is idempotent (cache-aware): if already cached, it's
|
||||||
|
// a no-op. Everything fire-and-forget so the reconcile completes
|
||||||
|
// quickly even on a fresh queue.
|
||||||
for (var i = currentIdx; i <= endIdx; i++) {
|
for (var i = currentIdx; i <= endIdx; i++) {
|
||||||
final trackId = queue[i].id;
|
final media = queue[i];
|
||||||
if (await mgr.isCached(trackId)) continue;
|
final trackId = media.id;
|
||||||
// Fire-and-forget; downloads happen in the background. Errors
|
final albumId = media.extras?['album_id'] as String?;
|
||||||
// inside pin() are caught + cleaned up internally.
|
|
||||||
// ignore: unawaited_futures
|
if (!await mgr.isCached(trackId)) {
|
||||||
mgr.pin(trackId, source: CacheSource.autoPrefetch);
|
// ignore: unawaited_futures
|
||||||
|
mgr.pin(trackId, source: CacheSource.autoPrefetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
// Cover bytes: getOrFetch returns the file path; the side
|
||||||
|
// effect (writing to disk) is what we care about.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
coverCache.getOrFetch(albumId);
|
||||||
|
// Palette: getOrExtract chains off coverCache.getOrFetch so
|
||||||
|
// it'll wait for the cover before sampling — safe to call
|
||||||
|
// in parallel here.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
colorCache.getOrExtract(albumId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eviction pass after pinning new files.
|
// Eviction pass after pinning new files.
|
||||||
|
|||||||
+19
-2
@@ -18,6 +18,7 @@
|
|||||||
// subscriptions; drift handles this fine in practice but worth
|
// subscriptions; drift handles this fine in practice but worth
|
||||||
// measuring if a future surface scales to hundreds.
|
// measuring if a future surface scales to hundreds.
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift show OrderingTerm;
|
||||||
import 'package:drift/drift.dart' show leftOuterJoin;
|
import 'package:drift/drift.dart' show leftOuterJoin;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@ final albumTileProvider =
|
|||||||
|
|
||||||
/// Watches the cached_artists row for [id]. Triggers background
|
/// Watches the cached_artists row for [id]. Triggers background
|
||||||
/// hydration on miss; yields the row once it lands.
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
///
|
||||||
|
/// LEFT JOIN cached_albums ordered by sort_title so the first row per
|
||||||
|
/// artist carries a representative album id; toRef() reconstructs
|
||||||
|
/// `/api/albums/<id>/cover` from it. Without this join the ArtistRef
|
||||||
|
/// has an empty coverUrl and tiles fall back to the music-notes
|
||||||
|
/// placeholder even though the server has the cover available.
|
||||||
final artistTileProvider =
|
final artistTileProvider =
|
||||||
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
||||||
if (id.isEmpty) {
|
if (id.isEmpty) {
|
||||||
@@ -70,7 +77,12 @@ final artistTileProvider =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final db = ref.watch(appDbProvider);
|
final db = ref.watch(appDbProvider);
|
||||||
final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id));
|
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
var enqueued = false;
|
var enqueued = false;
|
||||||
await for (final rows in query.watch()) {
|
await for (final rows in query.watch()) {
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
@@ -83,7 +95,12 @@ final artistTileProvider =
|
|||||||
yield null;
|
yield null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
yield rows.first.toRef();
|
// First row has the alphabetically-first album (or null if artist
|
||||||
|
// has no albums in drift yet — yields a coverless ArtistRef which
|
||||||
|
// the UI falls back to the placeholder icon for).
|
||||||
|
final artist = rows.first.readTable(db.cachedArtists);
|
||||||
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||||
|
yield artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../api/endpoints/discover.dart';
|
import '../api/endpoints/discover.dart';
|
||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/lidarr.dart';
|
import '../models/lidarr.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
@@ -48,14 +49,23 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
|
|
||||||
Future<void> _request(LidarrSearchResult row) async {
|
Future<void> _request(LidarrSearchResult row) async {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final args = {
|
||||||
|
'kind': _kind.wire,
|
||||||
|
'artistMbid':
|
||||||
|
_kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
|
||||||
|
'artistName':
|
||||||
|
_kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
|
||||||
|
'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null,
|
||||||
|
'albumTitle': _kind == LidarrRequestKind.album ? row.name : null,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
final api = await ref.read(_discoverApiProvider.future);
|
final api = await ref.read(_discoverApiProvider.future);
|
||||||
await api.createRequest(
|
await api.createRequest(
|
||||||
kind: _kind,
|
kind: _kind,
|
||||||
artistMbid: _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
|
artistMbid: args['artistMbid'] as String,
|
||||||
artistName: _kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
|
artistName: args['artistName'] as String,
|
||||||
albumMbid: _kind == LidarrRequestKind.album ? row.mbid : null,
|
albumMbid: args['albumMbid'],
|
||||||
albumTitle: _kind == LidarrRequestKind.album ? row.name : null,
|
albumTitle: args['albumTitle'],
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
@@ -65,12 +75,18 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
// Re-run search to refresh the `requested` flag on the row.
|
// Re-run search to refresh the `requested` flag on the row.
|
||||||
_runSearch();
|
_runSearch();
|
||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
} on DioException catch (_) {
|
||||||
|
// Queue for replay so the user's request persists across
|
||||||
|
// network loss. We don't have a drift table for in-flight
|
||||||
|
// requests yet, so the row won't show on the Requests screen
|
||||||
|
// until replay succeeds — accept that for v1.
|
||||||
|
await ref
|
||||||
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.requestCreate, args);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final code = ApiError.fromDio(e).code;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
content: Text('Request failed: $code'),
|
content: Text('Request queued: ${row.name}'),
|
||||||
backgroundColor: fs.error,
|
backgroundColor: fs.iron,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,17 +233,28 @@ Map<String, dynamic> _trackToJson(TrackRef t) => {
|
|||||||
final artistProvider =
|
final artistProvider =
|
||||||
StreamProvider.family<ArtistRef, String>((ref, id) {
|
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||||
final db = ref.watch(appDbProvider);
|
final db = ref.watch(appDbProvider);
|
||||||
return cacheFirst<CachedArtist, ArtistRef>(
|
// LEFT JOIN cached_albums for cover-URL reconstruction (see
|
||||||
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
// CachedArtistAdapter.toRef). First row carries the alphabetically-
|
||||||
.watch(),
|
// first album.
|
||||||
|
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
drift.leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
|
return cacheFirst<drift.TypedResult, ArtistRef>(
|
||||||
|
driftStream: query.watch(),
|
||||||
fetchAndPopulate: () async {
|
fetchAndPopulate: () async {
|
||||||
final api = await ref.read(libraryApiProvider.future);
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
final fresh = await api.getArtist(id);
|
final fresh = await api.getArtist(id);
|
||||||
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||||
},
|
},
|
||||||
toResult: (rows) => rows.isEmpty
|
toResult: (rows) {
|
||||||
? const ArtistRef(id: '', name: '')
|
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
|
||||||
: rows.first.toRef(),
|
final artist = rows.first.readTable(db.cachedArtists);
|
||||||
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||||
|
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||||
|
},
|
||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../api/endpoints/library_lists.dart';
|
|||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../api/endpoints/me.dart';
|
import '../api/endpoints/me.dart';
|
||||||
import '../auth/auth_provider.dart' show authControllerProvider;
|
import '../auth/auth_provider.dart' show authControllerProvider;
|
||||||
|
import '../cache/adapters.dart';
|
||||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
import '../cache/cache_first.dart';
|
import '../cache/cache_first.dart';
|
||||||
import '../cache/connectivity_provider.dart';
|
import '../cache/connectivity_provider.dart';
|
||||||
@@ -19,9 +20,6 @@ import '../library/library_providers.dart' show dioProvider;
|
|||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
import '../models/history_event.dart';
|
import '../models/history_event.dart';
|
||||||
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
|
|
||||||
// route descriptor) that conflicts with our wire-format Page<T>.
|
|
||||||
import '../models/page.dart' as wire;
|
|
||||||
import '../models/quarantine_mine.dart';
|
import '../models/quarantine_mine.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
@@ -51,89 +49,106 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|||||||
return MeApi(await ref.watch(dioProvider.future));
|
return MeApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
/// Drift-first all-artists list. Reads cached_artists ordered by
|
||||||
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
/// sortName; GridView.builder takes care of lazy widget construction
|
||||||
/// pages append to the existing items rather than replacing them.
|
/// so loading the full set up front is fine for typical library sizes.
|
||||||
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
/// SWR refresh on every subscription hits /api/artists with a generous
|
||||||
static const _pageSize = 50;
|
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
|
||||||
bool _loadingMore = false;
|
///
|
||||||
|
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
|
||||||
@override
|
/// previous "fetch one page at a time as you scroll" felt like the
|
||||||
Future<wire.Paged<ArtistRef>> build() async {
|
/// rest of the app was buffering when this tab was the slow surface,
|
||||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
/// and SyncController already populates the full set of artist rows
|
||||||
return api.listArtists(limit: _pageSize, offset: 0);
|
/// via /api/library/sync.
|
||||||
}
|
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
Future<void> loadMore() async {
|
// LEFT JOIN cached_albums so each artist row carries a representative
|
||||||
if (_loadingMore) return;
|
// album id for cover-URL reconstruction. Sorted by artist sortName
|
||||||
final cur = state.value;
|
// then album sortTitle: the first row per artist gets the
|
||||||
if (cur == null) return;
|
// alphabetically-first album. Dedup happens in toResult.
|
||||||
if (cur.items.length >= cur.total) return;
|
final query = db.select(db.cachedArtists).join([
|
||||||
_loadingMore = true;
|
drift.leftOuterJoin(db.cachedAlbums,
|
||||||
try {
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([
|
||||||
|
drift.OrderingTerm.asc(db.cachedArtists.sortName),
|
||||||
|
drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
|
||||||
|
]);
|
||||||
|
return cacheFirst<drift.TypedResult, List<ArtistRef>>(
|
||||||
|
driftStream: query.watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
// Cold-cache fallback: sync should have populated drift already,
|
||||||
|
// but a fresh install + first Library visit before sync completes
|
||||||
|
// will be empty. Fetch a generous chunk via /api/artists and
|
||||||
|
// persist; subsequent SWR refreshes keep things current.
|
||||||
final api = await ref.read(_libraryListsApiProvider.future);
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
final next = await api.listArtists(
|
final fresh = await api.listArtists(limit: 1000, offset: 0);
|
||||||
limit: _pageSize,
|
await db.batch((b) {
|
||||||
offset: cur.items.length,
|
b.insertAllOnConflictUpdate(
|
||||||
);
|
db.cachedArtists,
|
||||||
state = AsyncData(wire.Paged(
|
fresh.items.map((a) => a.toDrift()).toList(),
|
||||||
items: [...cur.items, ...next.items],
|
);
|
||||||
total: next.total,
|
});
|
||||||
limit: next.limit,
|
},
|
||||||
offset: 0,
|
toResult: (rows) {
|
||||||
));
|
// The join multiplies rows by album count; dedup by artist id
|
||||||
} catch (_) {
|
// keeping the first occurrence so we get one ArtistRef per
|
||||||
// Swallow — the next near-bottom event will retry. The current
|
// artist with the alphabetically-first album's id for the
|
||||||
// partial list stays visible.
|
// cover-URL projection. Artists with no albums in drift yet
|
||||||
} finally {
|
// come through as a single row with null cachedAlbums and an
|
||||||
_loadingMore = false;
|
// empty coverUrl — UI falls back to the placeholder icon.
|
||||||
}
|
final seen = <String>{};
|
||||||
}
|
final out = <ArtistRef>[];
|
||||||
}
|
for (final r in rows) {
|
||||||
|
final a = r.readTable(db.cachedArtists);
|
||||||
|
if (!seen.add(a.id)) continue;
|
||||||
|
final album = r.readTableOrNull(db.cachedAlbums);
|
||||||
|
out.add(a.toRef(coverAlbumId: album?.id ?? ''));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'libraryArtists',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
final _libraryArtistsProvider = AsyncNotifierProvider<
|
/// Drift-first all-albums list. Joins cached_albums with cached_artists
|
||||||
_LibraryArtistsNotifier,
|
/// for the artistName field. Same cold-cache fallback + SWR refresh
|
||||||
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
/// pattern as libraryArtistsProvider.
|
||||||
|
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
|
||||||
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
final db = ref.watch(appDbProvider);
|
||||||
static const _pageSize = 50;
|
final query = db.select(db.cachedAlbums).join([
|
||||||
bool _loadingMore = false;
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
@override
|
])
|
||||||
Future<wire.Paged<AlbumRef>> build() async {
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||||
return api.listAlbums(limit: _pageSize, offset: 0);
|
driftStream: query.watch(),
|
||||||
}
|
fetchAndPopulate: () async {
|
||||||
|
|
||||||
Future<void> loadMore() async {
|
|
||||||
if (_loadingMore) return;
|
|
||||||
final cur = state.value;
|
|
||||||
if (cur == null) return;
|
|
||||||
if (cur.items.length >= cur.total) return;
|
|
||||||
_loadingMore = true;
|
|
||||||
try {
|
|
||||||
final api = await ref.read(_libraryListsApiProvider.future);
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
final next = await api.listAlbums(
|
final fresh = await api.listAlbums(limit: 1000, offset: 0);
|
||||||
limit: _pageSize,
|
await db.batch((b) {
|
||||||
offset: cur.items.length,
|
b.insertAllOnConflictUpdate(
|
||||||
);
|
db.cachedAlbums,
|
||||||
state = AsyncData(wire.Paged(
|
fresh.items.map((a) => a.toDrift()).toList(),
|
||||||
items: [...cur.items, ...next.items],
|
);
|
||||||
total: next.total,
|
});
|
||||||
limit: next.limit,
|
},
|
||||||
offset: 0,
|
toResult: (rows) => rows.map((r) {
|
||||||
));
|
final album = r.readTable(db.cachedAlbums);
|
||||||
} catch (_) {
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
// Swallow — next scroll event will retry.
|
return album.toRef(artistName: artist?.name ?? '');
|
||||||
} finally {
|
}).toList(),
|
||||||
_loadingMore = false;
|
isOnline: () async => (await ref
|
||||||
}
|
.read(connectivityProvider.future)
|
||||||
}
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
}
|
alwaysRefresh: true,
|
||||||
|
tag: 'libraryAlbums',
|
||||||
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
);
|
||||||
_LibraryAlbumsNotifier,
|
});
|
||||||
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
|
||||||
|
|
||||||
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
||||||
// last /api/me/history page as JSON in a single-row drift table, yield
|
// last /api/me/history page as JSON in a single-row drift table, yield
|
||||||
@@ -227,7 +242,9 @@ final _likedTrackIdsProvider = StreamProvider<List<String>>((ref) {
|
|||||||
driftStream: query,
|
driftStream: query,
|
||||||
fetchAndPopulate: () => _populateLikeIds(ref),
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
toResult: _idsForEntity,
|
toResult: _idsForEntity,
|
||||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
alwaysRefresh: true,
|
alwaysRefresh: true,
|
||||||
tag: 'likedTrackIds',
|
tag: 'likedTrackIds',
|
||||||
);
|
);
|
||||||
@@ -243,7 +260,9 @@ final _likedAlbumIdsProvider = StreamProvider<List<String>>((ref) {
|
|||||||
driftStream: query,
|
driftStream: query,
|
||||||
fetchAndPopulate: () => _populateLikeIds(ref),
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
toResult: _idsForEntity,
|
toResult: _idsForEntity,
|
||||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
alwaysRefresh: true,
|
alwaysRefresh: true,
|
||||||
tag: 'likedAlbumIds',
|
tag: 'likedAlbumIds',
|
||||||
);
|
);
|
||||||
@@ -259,7 +278,9 @@ final _likedArtistIdsProvider = StreamProvider<List<String>>((ref) {
|
|||||||
driftStream: query,
|
driftStream: query,
|
||||||
fetchAndPopulate: () => _populateLikeIds(ref),
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
toResult: _idsForEntity,
|
toResult: _idsForEntity,
|
||||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
alwaysRefresh: true,
|
alwaysRefresh: true,
|
||||||
tag: 'likedArtistIds',
|
tag: 'likedArtistIds',
|
||||||
);
|
);
|
||||||
@@ -337,6 +358,19 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// Pre-warm every tab's provider on Library mount so swiping
|
||||||
|
// between tabs feels instant rather than each tab paying its
|
||||||
|
// own cold-cache cost on first visit. ref.listen subscribes
|
||||||
|
// without rebuilding LibraryScreen on emit; subscriptions stay
|
||||||
|
// alive for the lifetime of this widget. cacheFirst handles
|
||||||
|
// dedupe of concurrent fetchAndPopulate triggers.
|
||||||
|
ref.listen(_libraryArtistsProvider, (_, __) {});
|
||||||
|
ref.listen(_libraryAlbumsProvider, (_, __) {});
|
||||||
|
ref.listen(_historyProvider, (_, __) {});
|
||||||
|
ref.listen(_likedTrackIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedAlbumIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedArtistIdsProvider, (_, __) {});
|
||||||
|
ref.listen(myQuarantineProvider, (_, __) {});
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -383,51 +417,56 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryArtistsProvider).when(
|
return ref.watch(_libraryArtistsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) {
|
data: (artists) {
|
||||||
// Warm details for the first screenful so taps are instant.
|
// Warm details for the first screenful so taps are instant.
|
||||||
ref
|
ref
|
||||||
.read(metadataPrefetcherProvider)
|
.read(metadataPrefetcherProvider)
|
||||||
.warmArtists(page.items.map((a) => a.id));
|
.warmArtists(artists.map((a) => a.id));
|
||||||
return page.items.isEmpty
|
return artists.isEmpty
|
||||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||||
ref.invalidate(_libraryArtistsProvider);
|
// LayoutBuilder + cell-aware ArtistCard width mirrors
|
||||||
await ref.read(_libraryArtistsProvider.future);
|
// the AlbumsTab pattern so the circular avatar stays
|
||||||
},
|
// a true circle on narrow grid cells.
|
||||||
// Listen for scroll-near-bottom and ask the notifier
|
//
|
||||||
// to fetch the next page. 800px lookahead so the next
|
// Drift-first: GridView holds the full sorted list;
|
||||||
// page lands before the user reaches the visible end.
|
// .builder lazily realizes only visible cells, so
|
||||||
child: NotificationListener<ScrollNotification>(
|
// even on large libraries the up-front cost is just
|
||||||
onNotification: (n) {
|
// a sort over cached_artists, not N network round
|
||||||
if (n.metrics.pixels >=
|
// trips.
|
||||||
n.metrics.maxScrollExtent - 800) {
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
ref
|
const cols = 3;
|
||||||
.read(_libraryArtistsProvider.notifier)
|
const sidePad = 8.0;
|
||||||
.loadMore();
|
const gap = 8.0;
|
||||||
}
|
final cellW = (constraints.maxWidth -
|
||||||
return false;
|
sidePad * 2 -
|
||||||
},
|
gap * (cols - 1)) /
|
||||||
child: GridView.builder(
|
cols;
|
||||||
padding: const EdgeInsets.all(8),
|
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
|
||||||
|
// + slack matched to AlbumsTab's overflow guard.
|
||||||
|
final cellH = (cellW - 16) + 8 + 18 + 8;
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(sidePad),
|
||||||
gridDelegate:
|
gridDelegate:
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3,
|
crossAxisCount: cols,
|
||||||
mainAxisSpacing: 8,
|
mainAxisExtent: cellH,
|
||||||
crossAxisSpacing: 8,
|
mainAxisSpacing: gap,
|
||||||
childAspectRatio: 0.78,
|
crossAxisSpacing: gap,
|
||||||
),
|
),
|
||||||
itemCount: page.items.length,
|
itemCount: artists.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final artist = page.items[i];
|
final artist = artists[i];
|
||||||
return ArtistCard(
|
return ArtistCard(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
|
width: cellW,
|
||||||
onTap: () => ctx.push('/artists/${artist.id}',
|
onTap: () => ctx.push('/artists/${artist.id}',
|
||||||
extra: artist),
|
extra: artist),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -443,17 +482,16 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryAlbumsProvider).when(
|
return ref.watch(_libraryAlbumsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) {
|
data: (albums) {
|
||||||
return page.items.isEmpty
|
return albums.isEmpty
|
||||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||||
ref.invalidate(_libraryAlbumsProvider);
|
|
||||||
await ref.read(_libraryAlbumsProvider.future);
|
|
||||||
},
|
|
||||||
// Same responsive 3-up grid as the artist detail
|
// Same responsive 3-up grid as the artist detail
|
||||||
// album list — LayoutBuilder + AlbumCard sized to the
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
// cell, mainAxisExtent matched to actual card height.
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
|
// Drift-first; full sorted list, lazy realization via
|
||||||
|
// GridView.builder.
|
||||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
const cols = 3;
|
const cols = 3;
|
||||||
const sidePad = 8.0;
|
const sidePad = 8.0;
|
||||||
@@ -468,37 +506,26 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
// would otherwise overflow the cell by a pixel
|
// would otherwise overflow the cell by a pixel
|
||||||
// (logged as a noisy RenderFlex warning).
|
// (logged as a noisy RenderFlex warning).
|
||||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||||
return NotificationListener<ScrollNotification>(
|
return GridView.builder(
|
||||||
onNotification: (n) {
|
padding: const EdgeInsets.all(sidePad),
|
||||||
if (n.metrics.pixels >=
|
gridDelegate:
|
||||||
n.metrics.maxScrollExtent - 800) {
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
ref
|
crossAxisCount: cols,
|
||||||
.read(_libraryAlbumsProvider.notifier)
|
mainAxisExtent: cellH,
|
||||||
.loadMore();
|
mainAxisSpacing: gap,
|
||||||
}
|
crossAxisSpacing: gap,
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: GridView.builder(
|
|
||||||
padding: const EdgeInsets.all(sidePad),
|
|
||||||
gridDelegate:
|
|
||||||
SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: cols,
|
|
||||||
mainAxisExtent: cellH,
|
|
||||||
mainAxisSpacing: gap,
|
|
||||||
crossAxisSpacing: gap,
|
|
||||||
),
|
|
||||||
itemCount: page.items.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final album = page.items[i];
|
|
||||||
return AlbumCard(
|
|
||||||
album: album,
|
|
||||||
width: cellW,
|
|
||||||
titleMaxLines: 2,
|
|
||||||
onTap: () => ctx.push('/albums/${album.id}',
|
|
||||||
extra: album),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final album = albums[i];
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
width: cellW,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
onTap: () => ctx.push('/albums/${album.id}',
|
||||||
|
extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,15 +12,27 @@ import '../library_providers.dart';
|
|||||||
import 'play_circle_button.dart';
|
import 'play_circle_button.dart';
|
||||||
|
|
||||||
class ArtistCard extends ConsumerWidget {
|
class ArtistCard extends ConsumerWidget {
|
||||||
const ArtistCard({required this.artist, required this.onTap, super.key});
|
const ArtistCard({
|
||||||
|
required this.artist,
|
||||||
|
required this.onTap,
|
||||||
|
this.width = 140,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
final ArtistRef artist;
|
final ArtistRef artist;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Outer width of the card. Avatar is a circle of width-16 (8dp
|
||||||
|
/// horizontal padding either side). Default suits horizontal lists;
|
||||||
|
/// grids should pass the cell width so the avatar shrinks
|
||||||
|
/// proportionally and stays a true circle.
|
||||||
|
final double width;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final coverSize = width - 16;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 140,
|
width: width,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -29,15 +41,20 @@ class ArtistCard extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
// Stack: circular avatar + overlaid play button at bottom-right.
|
// Stack: circular avatar + overlaid play button at bottom-right.
|
||||||
// The avatar is a circle (ClipOval) — bottom-right of its
|
// Avatar size derives from the [width] parameter so the
|
||||||
// bounding box still places the button inside the visible area
|
// Library Artists 3-column grid can pass its cell width
|
||||||
// because the button is small relative to the radius.
|
// (~109dp on typical phones) and the avatar stays a
|
||||||
|
// perfect circle. Previous hardcoded 124×124 got squeezed
|
||||||
|
// horizontally in the grid (cell narrower than 124+16
|
||||||
|
// padding) but kept its 124dp height — ClipOval produced
|
||||||
|
// a visible vertical ellipse. Mirrors AlbumCard's width-
|
||||||
|
// parameter convention.
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipOval(
|
ClipOval(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 124,
|
width: coverSize,
|
||||||
height: 124,
|
height: coverSize,
|
||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
child: artist.coverUrl.isEmpty
|
child: artist.coverUrl.isEmpty
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../cache/audio_cache_manager.dart' show appDbProvider;
|
|||||||
import '../cache/cache_first.dart';
|
import '../cache/cache_first.dart';
|
||||||
import '../cache/connectivity_provider.dart';
|
import '../cache/connectivity_provider.dart';
|
||||||
import '../cache/db.dart';
|
import '../cache/db.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart';
|
import '../library/library_providers.dart';
|
||||||
|
|
||||||
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||||
@@ -147,26 +148,18 @@ class LikesController {
|
|||||||
} else {
|
} else {
|
||||||
await api.like(kind, id);
|
await api.like(kind, id);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
// Rollback drift
|
// REST failed (network or HTTP). Don't roll back drift — the
|
||||||
if (wasLiked) {
|
// user's intent is to like/unlike, and we want that visible to
|
||||||
await db.into(db.cachedLikes).insert(
|
// them even when offline. Queue the call for replay; the
|
||||||
CachedLikesCompanion.insert(
|
// MutationReplayer will retry on next connectivity transition.
|
||||||
userId: user.id,
|
// If retries exhaust (5 attempts), the row drops and the next
|
||||||
entityType: entityType,
|
// SyncController.sync brings drift back in line with the
|
||||||
entityId: id,
|
// server's authoritative state.
|
||||||
),
|
await _ref.read(mutationQueueProvider).enqueue(
|
||||||
mode: drift.InsertMode.insertOrIgnore,
|
wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd,
|
||||||
);
|
{'kind': entityType, 'id': id},
|
||||||
} else {
|
);
|
||||||
await (db.delete(db.cachedLikes)
|
|
||||||
..where((t) =>
|
|
||||||
t.userId.equals(user.id) &
|
|
||||||
t.entityType.equals(entityType) &
|
|
||||||
t.entityId.equals(id)))
|
|
||||||
.go();
|
|
||||||
}
|
|
||||||
Error.throwWithStackTrace(e, st);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ class AlbumColorCache {
|
|||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synchronous peek. Returns the previously-extracted color if this
|
||||||
|
/// album has been resolved this process; otherwise null. Used by
|
||||||
|
/// the now-playing fast-path swap so a warm cache transitions the
|
||||||
|
/// gradient in lockstep with the audio, instead of awaiting the
|
||||||
|
/// async [getOrExtract] future. A null return means "either no
|
||||||
|
/// extraction yet or extraction returned null" — caller falls back
|
||||||
|
/// to the async path.
|
||||||
|
Color? peekColor(String albumId) => _cache[albumId];
|
||||||
|
|
||||||
Future<Color?> _extract(String albumId) async {
|
Future<Color?> _extract(String albumId) async {
|
||||||
try {
|
try {
|
||||||
final coverCache = _ref.read(albumCoverCacheProvider);
|
final coverCache = _ref.read(albumCoverCacheProvider);
|
||||||
|
|||||||
@@ -27,6 +27,32 @@ class AlbumCoverCache {
|
|||||||
/// same album dedupe to one fetch.
|
/// same album dedupe to one fetch.
|
||||||
final Map<String, Future<String?>> _inflight = {};
|
final Map<String, Future<String?>> _inflight = {};
|
||||||
|
|
||||||
|
/// Cached covers directory path resolved once on the first
|
||||||
|
/// async cacheDir call, then reused for sync existsSync() checks
|
||||||
|
/// in [peekCached]. Without this every "is the cover already on
|
||||||
|
/// disk?" check would have to await path_provider, blocking the
|
||||||
|
/// MediaItem broadcast on every track change.
|
||||||
|
String? _coversDirPath;
|
||||||
|
|
||||||
|
/// Returns the local file path for [albumId]'s cover if it's
|
||||||
|
/// already cached on disk, or null otherwise. Synchronous — uses
|
||||||
|
/// File.existsSync() against a path computed from the directory
|
||||||
|
/// resolved by an earlier async [getOrFetch] call. Returns null
|
||||||
|
/// until at least one getOrFetch has populated _coversDirPath.
|
||||||
|
///
|
||||||
|
/// The audio handler uses this to seed MediaItem.artUri on the
|
||||||
|
/// initial broadcast so external media controllers (Wear, Android
|
||||||
|
/// Auto, Bluetooth) see the cover on the first frame for warm-cache
|
||||||
|
/// tracks. Cold-cache tracks still fall back to the async
|
||||||
|
/// _loadArtForCurrentItem path.
|
||||||
|
String? peekCached(String albumId) {
|
||||||
|
if (albumId.isEmpty) return null;
|
||||||
|
final dir = _coversDirPath;
|
||||||
|
if (dir == null) return null;
|
||||||
|
final path = '$dir/$albumId.jpg';
|
||||||
|
return File(path).existsSync() ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns local file path to the album cover, or null on any
|
/// Returns local file path to the album cover, or null on any
|
||||||
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
||||||
Future<String?> getOrFetch(String albumId) {
|
Future<String?> getOrFetch(String albumId) {
|
||||||
@@ -44,6 +70,9 @@ class AlbumCoverCache {
|
|||||||
final dir = await _cacheDirFactory();
|
final dir = await _cacheDirFactory();
|
||||||
final coversDir = Directory('${dir.path}/album_covers');
|
final coversDir = Directory('${dir.path}/album_covers');
|
||||||
await coversDir.create(recursive: true);
|
await coversDir.create(recursive: true);
|
||||||
|
// Cache the resolved directory so [peekCached] can do its
|
||||||
|
// existsSync() check without re-awaiting path_provider.
|
||||||
|
_coversDirPath = coversDir.path;
|
||||||
final filePath = '${coversDir.path}/$albumId.jpg';
|
final filePath = '${coversDir.path}/$albumId.jpg';
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
if (await file.exists()) return filePath;
|
if (await file.exists()) return filePath;
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
_player.playbackEventStream.listen(
|
_player.playbackEventStream.listen(
|
||||||
_broadcastState,
|
_broadcastState,
|
||||||
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
||||||
// failure, network drop) here. Without an error sink, the
|
// failure, network drop) here. Logging alone leaves the UI
|
||||||
// player just goes quiet — exactly the "starts then stops"
|
// claiming "now playing X" while audio is silent — the user-
|
||||||
// symptom we hit.
|
// observable mismatch between visual and audio state. Skip
|
||||||
|
// forward so the queue advances past the failed track and
|
||||||
|
// mediaItem updates to whatever's actually playing. If the
|
||||||
|
// failure is at the queue tail, just_audio will go idle on its
|
||||||
|
// own and _broadcastState reflects that.
|
||||||
onError: (Object e, StackTrace st) {
|
onError: (Object e, StackTrace st) {
|
||||||
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
||||||
|
unawaited(_handlePlaybackError());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
||||||
@@ -40,6 +45,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
String? _token;
|
String? _token;
|
||||||
AlbumCoverCache? _coverCache;
|
AlbumCoverCache? _coverCache;
|
||||||
AudioCacheManager? _audioCacheManager;
|
AudioCacheManager? _audioCacheManager;
|
||||||
|
LikeBridge? _likeBridge;
|
||||||
|
|
||||||
/// Trackers to dedupe registration — once we've inserted an index row
|
/// Trackers to dedupe registration — once we've inserted an index row
|
||||||
/// for a trackId, don't repeat the work on every buffered-position
|
/// for a trackId, don't repeat the work on every buffered-position
|
||||||
@@ -68,6 +74,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
/// new queue, leaving the player "locked" to a corrupted state.
|
/// new queue, leaving the player "locked" to a corrupted state.
|
||||||
int _queueGeneration = 0;
|
int _queueGeneration = 0;
|
||||||
|
|
||||||
|
/// Tracks the most recent setQueueFromTracks() input so
|
||||||
|
/// skipToQueueItem can reconstruct the source list. just_audio
|
||||||
|
/// requires every source to be built before it can be a skip
|
||||||
|
/// target, but setQueueFromTracks only builds the initial source
|
||||||
|
/// and fills the rest in the background — so a skip to an index
|
||||||
|
/// past the fill front needs to rebuild from the stored tracks.
|
||||||
|
List<TrackRef> _lastTracks = const [];
|
||||||
|
|
||||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||||
/// volume directly; set via setVolume(double).
|
/// volume directly; set via setVolume(double).
|
||||||
Stream<double> get volumeStream => _player.volumeStream;
|
Stream<double> get volumeStream => _player.volumeStream;
|
||||||
@@ -86,10 +100,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
required String? token,
|
required String? token,
|
||||||
AlbumCoverCache? coverCache,
|
AlbumCoverCache? coverCache,
|
||||||
AudioCacheManager? audioCacheManager,
|
AudioCacheManager? audioCacheManager,
|
||||||
|
LikeBridge? likeBridge,
|
||||||
}) {
|
}) {
|
||||||
_baseUrl = baseUrl;
|
_baseUrl = baseUrl;
|
||||||
_token = token;
|
_token = token;
|
||||||
if (coverCache != null) _coverCache = coverCache;
|
if (coverCache != null) _coverCache = coverCache;
|
||||||
|
if (likeBridge != null) _likeBridge = likeBridge;
|
||||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,34 +118,82 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
// check and stop calling player mutations — important so a stale
|
// check and stop calling player mutations — important so a stale
|
||||||
// fill doesn't append old-playlist tracks into the new queue.
|
// fill doesn't append old-playlist tracks into the new queue.
|
||||||
final myGen = ++_queueGeneration;
|
final myGen = ++_queueGeneration;
|
||||||
// Reset suppress flag in case a prior backward-fill bailed on
|
_lastTracks = tracks;
|
||||||
// gen check before reaching its `finally`.
|
|
||||||
_suppressIndexUpdates = false;
|
|
||||||
|
|
||||||
// Populate the visible queue + current mediaItem immediately so
|
// Pause the old source immediately so the previous track stops
|
||||||
// the player UI reflects the user's tap before any source has
|
// audibly the moment the user taps, instead of bleeding through
|
||||||
// been built. Source list at the just_audio layer fills in
|
// until the new source finishes building. setAudioSources below
|
||||||
// asynchronously below.
|
// will swap the source list cleanly; pause is the simplest way
|
||||||
|
// to silence the player during the (possibly multi-100ms) build.
|
||||||
|
if (_player.playing) {
|
||||||
|
await _player.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build MediaItems up front (pure — no side effects); we'll
|
||||||
|
// broadcast queue/mediaItem only AFTER setAudioSources resolves
|
||||||
|
// so the audio engine and the UI flip together. If the build
|
||||||
|
// throws, the UI stays on the previous track (correct: audio
|
||||||
|
// also stays on the previous track since setAudioSources never
|
||||||
|
// ran).
|
||||||
final items = tracks.map(_toMediaItem).toList();
|
final items = tracks.map(_toMediaItem).toList();
|
||||||
queue.add(items);
|
|
||||||
mediaItem.add(items[clampedInitial]);
|
|
||||||
|
|
||||||
// Fast path: build only the initial source so the player can
|
// Build only the initial source for fast start. Remaining
|
||||||
// start. Remaining sources stream in via _fillRemainingSources()
|
// sources stream in via _fillRemainingSources() — addAudioSource
|
||||||
// in the background — addAudioSource for next/auto-advance
|
// for next/auto-advance tracks, insertAudioSource for skipPrev.
|
||||||
// tracks, insertAudioSource for skipPrev tracks.
|
final AudioSource initial;
|
||||||
final initial = await _buildAudioSource(tracks[clampedInitial]);
|
try {
|
||||||
|
initial = await _buildAudioSource(tracks[clampedInitial]);
|
||||||
|
} catch (e, st) {
|
||||||
|
// Source build failed (bad URL, missing baseUrl, etc.). Don't
|
||||||
|
// broadcast the new state — leaving queue/mediaItem on the
|
||||||
|
// previous track keeps UI in sync with what the player is
|
||||||
|
// actually doing (which is "still on the previous track,
|
||||||
|
// paused").
|
||||||
|
debugPrint('audio_handler: _buildAudioSource failed: $e\n$st');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (myGen != _queueGeneration) {
|
if (myGen != _queueGeneration) {
|
||||||
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
|
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _player.setAudioSources([initial], initialIndex: 0);
|
// Suppress _onCurrentIndexChanged side effects while the source
|
||||||
|
// list is being swapped — without this, a transient currentIndex
|
||||||
|
// emission during setAudioSources could broadcast the OLD queue's
|
||||||
|
// entry at the NEW index. Re-enabled after the broadcasts land.
|
||||||
|
_suppressIndexUpdates = true;
|
||||||
|
try {
|
||||||
|
await _player.setAudioSources([initial], initialIndex: 0);
|
||||||
|
// Broadcast in this order: queue first (so any consumer that
|
||||||
|
// reacts to mediaItem and reads queue.value sees the consistent
|
||||||
|
// pair), then mediaItem.
|
||||||
|
queue.add(items);
|
||||||
|
mediaItem.add(items[clampedInitial]);
|
||||||
|
} finally {
|
||||||
|
_suppressIndexUpdates = false;
|
||||||
|
}
|
||||||
|
|
||||||
unawaited(_loadArtForCurrentItem());
|
unawaited(_loadArtForCurrentItem());
|
||||||
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switches playback to the [index]th queue item. The full
|
||||||
|
/// just_audio source list isn't necessarily built yet
|
||||||
|
/// (_fillRemainingSources runs in the background), so we
|
||||||
|
/// reconstruct from the stored TrackRef list rather than calling
|
||||||
|
/// _player.seek(index: ...) on a possibly-missing source. Calling
|
||||||
|
/// setQueueFromTracks again is the safe path: it bumps the
|
||||||
|
/// generation, cancels any in-flight fill, rebuilds source[0] as
|
||||||
|
/// the target, and re-fills around. play() restarts playback so
|
||||||
|
/// queue taps feel like "jump to this song" rather than "set the
|
||||||
|
/// pointer and wait for me to press play."
|
||||||
|
@override
|
||||||
|
Future<void> skipToQueueItem(int index) async {
|
||||||
|
if (index < 0 || index >= _lastTracks.length) return;
|
||||||
|
await setQueueFromTracks(_lastTracks, initialIndex: index);
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
|
||||||
/// Background fill of the rest of the just_audio source list after
|
/// Background fill of the rest of the just_audio source list after
|
||||||
/// the initial source is playing. Forward direction first (most
|
/// the initial source is playing. Forward direction first (most
|
||||||
/// common skipNext target). Backward inserts shift the player's
|
/// common skipNext target). Backward inserts shift the player's
|
||||||
@@ -266,12 +330,32 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
final extras = <String, dynamic>{};
|
final extras = <String, dynamic>{};
|
||||||
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||||
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
||||||
|
// Sync-peek the album cover cache so warm-cache tracks broadcast
|
||||||
|
// with artUri populated on the first frame. External media
|
||||||
|
// controllers (Android Wear, Bluetooth dashes, Auto, lock screen)
|
||||||
|
// can only render the cover bytes that audio_service hands them
|
||||||
|
// at MediaItem broadcast time; the later async _loadArtForCurrent
|
||||||
|
// Item path repopulates for cold-cache tracks. Without this seed,
|
||||||
|
// every track change starts with a generic icon on the watch and
|
||||||
|
// only gets the real cover after one or two seconds.
|
||||||
|
final coverPath = (t.albumId.isNotEmpty && _coverCache != null)
|
||||||
|
? _coverCache!.peekCached(t.albumId)
|
||||||
|
: null;
|
||||||
|
// MediaItem.rating intentionally NOT set: audio_service propagates
|
||||||
|
// it to MediaSession.setRating(), but the Android session also
|
||||||
|
// needs setRatingType(RATING_HEART) configured to expose that to
|
||||||
|
// controllers — audio_service doesn't surface that config knob,
|
||||||
|
// and broadcasting an unanchored rating made Wear OS reject the
|
||||||
|
// session entirely. The LikeBridge wiring stays in place so
|
||||||
|
// setRating can still fire from any surface that DOES route it,
|
||||||
|
// we just don't advertise it.
|
||||||
return MediaItem(
|
return MediaItem(
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artistName,
|
artist: t.artistName,
|
||||||
album: t.albumTitle,
|
album: t.albumTitle,
|
||||||
duration: Duration(seconds: t.durationSec),
|
duration: Duration(seconds: t.durationSec),
|
||||||
|
artUri: coverPath != null ? Uri.file(coverPath) : null,
|
||||||
extras: extras.isEmpty ? null : extras,
|
extras: extras.isEmpty ? null : extras,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -307,6 +391,37 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
await mgr.registerStreamCache(trackId, path, size);
|
await mgr.registerStreamCache(trackId, path, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called when playbackEventStream emits an error. Skips past the
|
||||||
|
/// failing track so the UI and audio re-converge — without this,
|
||||||
|
/// _player goes silent on a 404 / decoder failure / EOS but
|
||||||
|
/// mediaItem stays on the failed track and the user sees a "now
|
||||||
|
/// playing" header for something that isn't.
|
||||||
|
///
|
||||||
|
/// If we're at the last track, seekToNext is a no-op; the state
|
||||||
|
/// drops to idle and _broadcastState reflects that.
|
||||||
|
Future<void> _handlePlaybackError() async {
|
||||||
|
final currentIdx = _player.currentIndex;
|
||||||
|
final queueLen = queue.value.length;
|
||||||
|
if (currentIdx == null || currentIdx + 1 >= queueLen) {
|
||||||
|
// Last track or no queue context — just pause; _broadcastState
|
||||||
|
// already reflects the idle/error processingState.
|
||||||
|
try {
|
||||||
|
await _player.pause();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _player.seekToNext();
|
||||||
|
} catch (_) {
|
||||||
|
// If seekToNext fails too (next source not built yet, etc.),
|
||||||
|
// there's nothing safe to do — pause and let the user recover
|
||||||
|
// manually.
|
||||||
|
try {
|
||||||
|
await _player.pause();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onCurrentIndexChanged(int? idx) {
|
void _onCurrentIndexChanged(int? idx) {
|
||||||
if (idx == null) return;
|
if (idx == null) return;
|
||||||
if (_suppressIndexUpdates) return;
|
if (_suppressIndexUpdates) return;
|
||||||
@@ -356,6 +471,45 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
@override
|
@override
|
||||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||||
|
|
||||||
|
/// Heart rating from external surfaces (Wear's favorite button,
|
||||||
|
/// lock-screen like) → LikesController.toggle(track). We only
|
||||||
|
/// route through the bridge when the rating actually flips relative
|
||||||
|
/// to the current state, so repeated taps from a flaky controller
|
||||||
|
/// don't ping-pong the like.
|
||||||
|
@override
|
||||||
|
Future<void> setRating(Rating rating, [Map<String, dynamic>? extras]) async {
|
||||||
|
final media = mediaItem.value;
|
||||||
|
final bridge = _likeBridge;
|
||||||
|
if (media == null || bridge == null) return;
|
||||||
|
final currentlyLiked = bridge.isTrackLiked(media.id);
|
||||||
|
final wantLiked = rating.hasHeart();
|
||||||
|
if (currentlyLiked == wantLiked) return;
|
||||||
|
try {
|
||||||
|
await bridge.toggleTrackLike(media.id);
|
||||||
|
} catch (_) {
|
||||||
|
// LikesController already rolls back on REST failure; nothing
|
||||||
|
// to do here beyond letting the broadcast skip.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-emit so the watch's heart icon updates immediately.
|
||||||
|
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-emits the current mediaItem with a fresh rating pulled from
|
||||||
|
/// the LikeBridge. Called by PlayerActions on likedIdsProvider
|
||||||
|
/// changes so the watch / lock-screen heart icon updates when the
|
||||||
|
/// user toggles a like from TrackRow, the kebab menu, or another
|
||||||
|
/// device's playback (SSE-routed). No-op if no track is playing
|
||||||
|
/// or the like state didn't change.
|
||||||
|
void refreshCurrentRating() {
|
||||||
|
final media = mediaItem.value;
|
||||||
|
final bridge = _likeBridge;
|
||||||
|
if (media == null || bridge == null) return;
|
||||||
|
final liked = bridge.isTrackLiked(media.id);
|
||||||
|
if (media.rating?.hasHeart() == liked) return;
|
||||||
|
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked)));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||||
await _player
|
await _player
|
||||||
@@ -398,6 +552,18 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
// systemActions enumerates which actions the system can invoke
|
// systemActions enumerates which actions the system can invoke
|
||||||
// on us — without play/pause/skip in here, taps on lock-screen
|
// on us — without play/pause/skip in here, taps on lock-screen
|
||||||
// controls don't route back to the handler on Android 13+.
|
// controls don't route back to the handler on Android 13+.
|
||||||
|
//
|
||||||
|
// v2026.05.13.3 added stop, skipToQueueItem, setShuffleMode,
|
||||||
|
// setRepeatMode, and setRating to this set; reverted because
|
||||||
|
// Pixel Watch 2 stopped showing controls entirely after that
|
||||||
|
// change. The MediaSession contract on Wear OS requires more
|
||||||
|
// setup than just advertising the action (e.g. setRatingType
|
||||||
|
// for setRating) and audio_service doesn't expose those knobs.
|
||||||
|
// Keep the override methods themselves (skipToQueueItem is
|
||||||
|
// still routed via QueueScreen's direct handler call;
|
||||||
|
// setRating is harmless if never invoked) so we don't lose
|
||||||
|
// the underlying functionality — just don't tell the system
|
||||||
|
// we support them.
|
||||||
systemActions: const {
|
systemActions: const {
|
||||||
MediaAction.play,
|
MediaAction.play,
|
||||||
MediaAction.pause,
|
MediaAction.pause,
|
||||||
@@ -428,3 +594,27 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adapter that lets the audio handler call into the app's
|
||||||
|
/// LikesController without depending on Riverpod directly. Constructed
|
||||||
|
/// in PlayerActions where ref is available; consumed inside the audio
|
||||||
|
/// handler's setRating override and MediaItem builder to keep external
|
||||||
|
/// media controllers (Wear, lock screen, Auto) in sync with the
|
||||||
|
/// likedIds drift cache.
|
||||||
|
class LikeBridge {
|
||||||
|
const LikeBridge({
|
||||||
|
required this.toggleTrackLike,
|
||||||
|
required this.isTrackLiked,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Flip the like state for [trackId]. Returns a Future that resolves
|
||||||
|
/// once the underlying LikesController has both updated drift
|
||||||
|
/// optimistically and rolled the change through the REST API
|
||||||
|
/// (errors result in drift rollback inside LikesController).
|
||||||
|
final Future<void> Function(String trackId) toggleTrackLike;
|
||||||
|
|
||||||
|
/// Read the current like state for [trackId] from the drift-backed
|
||||||
|
/// likedIdsProvider. Synchronous because the audio handler needs
|
||||||
|
/// to populate MediaItem.rating on the broadcast hot path.
|
||||||
|
final bool Function(String trackId) isTrackLiked;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,133 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
// dismiss. Reset on each drag start.
|
// dismiss. Reset on each drag start.
|
||||||
double _dragOffset = 0;
|
double _dragOffset = 0;
|
||||||
|
|
||||||
|
/// The MediaItem currently displayed on the screen. Held separately
|
||||||
|
/// from the live mediaItemProvider so we can preload the new track's
|
||||||
|
/// cover bytes + dominant color BEFORE flipping the visible state.
|
||||||
|
/// Without this gate the full-player UI swapped to the new track's
|
||||||
|
/// title/cover slot immediately while the image was still decoding,
|
||||||
|
/// producing a visible "pop in" once the bytes arrived.
|
||||||
|
MediaItem? _displayedMedia;
|
||||||
|
|
||||||
|
/// Dominant color of [_displayedMedia]'s cover. Tweened by the
|
||||||
|
/// backdrop AnimatedContainer when it changes.
|
||||||
|
Color? _displayedDominant;
|
||||||
|
|
||||||
|
/// The id of the most-recent track we kicked a preload for. Used to
|
||||||
|
/// drop stale preload completions when the user skips rapidly past
|
||||||
|
/// a track whose cover hadn't finished decoding yet.
|
||||||
|
String? _pendingPreloadId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Seed displayed state from the current mediaItem if a track is
|
||||||
|
// already playing when the full player is opened. ref.listen
|
||||||
|
// below only fires on CHANGES after subscription, so without
|
||||||
|
// this seed the screen sits on "Nothing playing." even though
|
||||||
|
// the mini bar shows a live track — the listener never gets a
|
||||||
|
// mediaItem emission because the stream's current value hasn't
|
||||||
|
// changed.
|
||||||
|
final current = ref.read(mediaItemProvider).value;
|
||||||
|
if (current == null) return;
|
||||||
|
_displayedMedia = current;
|
||||||
|
// Best-effort synchronous color seed: if albumColorProvider has
|
||||||
|
// already extracted this album's dominant color earlier in the
|
||||||
|
// session, pull it now so the backdrop renders correctly on
|
||||||
|
// first frame. Otherwise the post-frame _scheduleSwap below
|
||||||
|
// resolves it asynchronously and AnimatedContainer tweens.
|
||||||
|
final albumId = current.extras?['album_id'] as String?;
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
_displayedDominant =
|
||||||
|
ref.read(albumColorProvider(albumId)).asData?.value;
|
||||||
|
}
|
||||||
|
// Kick the preload pipeline post-frame so the cover bytes are
|
||||||
|
// decoded + the dominant color is resolved even when the user
|
||||||
|
// opens the player to a track that hasn't yet flowed through
|
||||||
|
// ref.listen.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_scheduleSwap(current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preload the new track's cover bytes + dominant color, then
|
||||||
|
/// atomically flip _displayedMedia / _displayedDominant. Until both
|
||||||
|
/// resolve, the screen continues to render the previous track —
|
||||||
|
/// the new title/cover/gradient appear together in one frame.
|
||||||
|
///
|
||||||
|
/// Concurrency: if a second track change arrives while the first
|
||||||
|
/// preload is still in flight, the older preload's
|
||||||
|
/// _pendingPreloadId check fails and its completion is dropped.
|
||||||
|
Future<void> _scheduleSwap(MediaItem newMedia) async {
|
||||||
|
_pendingPreloadId = newMedia.id;
|
||||||
|
|
||||||
|
// Fast path: when Prefetcher has already warmed both the cover
|
||||||
|
// bytes (file:// artUri populated via _toMediaItem's peekCached)
|
||||||
|
// AND the palette color (AlbumColorCache.peekColor returns
|
||||||
|
// non-null), we can commit synchronously. The slow async path
|
||||||
|
// exists for genuine cold-cache moments (first play of an album
|
||||||
|
// that sync hasn't seen yet); the fast path is what makes the
|
||||||
|
// common in-queue auto-advance flip the visual in lockstep with
|
||||||
|
// the audio transition instead of lagging a tick behind.
|
||||||
|
final artUri = newMedia.artUri;
|
||||||
|
final albumId = newMedia.extras?['album_id'] as String?;
|
||||||
|
if (artUri != null &&
|
||||||
|
artUri.isScheme('file') &&
|
||||||
|
albumId != null &&
|
||||||
|
albumId.isNotEmpty) {
|
||||||
|
final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId);
|
||||||
|
if (cachedColor != null) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_pendingPreloadId != newMedia.id) return;
|
||||||
|
setState(() {
|
||||||
|
_displayedMedia = newMedia;
|
||||||
|
_displayedDominant = cachedColor;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: cover and/or color are not yet cached. Hold the
|
||||||
|
// current displayed state, preload, then atomic-commit.
|
||||||
|
//
|
||||||
|
// 1. Precache the cover image bytes so when _AlbumArt mounts with
|
||||||
|
// the new media, FileImage paints synchronously. Non-file
|
||||||
|
// artUris fall through to ServerImage which handles its own
|
||||||
|
// network load + 120ms fade — they won't snap.
|
||||||
|
if (artUri != null && artUri.isScheme('file') && context.mounted) {
|
||||||
|
try {
|
||||||
|
await precacheImage(FileImage(File.fromUri(artUri)), context);
|
||||||
|
} catch (_) {
|
||||||
|
// Decode failed (missing file, corrupt bytes) — proceed
|
||||||
|
// anyway; _AlbumArt's errorBuilder shows the slate fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wait for the dominant-color extraction so the gradient is
|
||||||
|
// populated when we swap. PaletteGenerator runs against the
|
||||||
|
// same FileImage, typically resolving within ~50ms once the
|
||||||
|
// decode completes.
|
||||||
|
Color? newDominant;
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
newDominant = await ref.read(albumColorProvider(albumId).future);
|
||||||
|
} catch (_) {
|
||||||
|
// Color extraction failed — keep the previous dominant so the
|
||||||
|
// backdrop doesn't drop to obsidian on the swap.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Atomic flip. Bail if a newer swap superseded us, or the
|
||||||
|
// screen was disposed mid-preload.
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_pendingPreloadId != newMedia.id) return;
|
||||||
|
setState(() {
|
||||||
|
_displayedMedia = newMedia;
|
||||||
|
if (newDominant != null) _displayedDominant = newDominant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onDragStart(DragStartDetails _) {
|
void _onDragStart(DragStartDetails _) {
|
||||||
_dragOffset = 0;
|
_dragOffset = 0;
|
||||||
}
|
}
|
||||||
@@ -67,10 +194,61 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final media = ref.watch(mediaItemProvider).value;
|
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
|
||||||
|
|
||||||
if (media == null) {
|
// Listen for mediaItem changes and schedule a preload-then-swap.
|
||||||
|
// Build renders _displayedMedia / _displayedDominant; the live
|
||||||
|
// mediaItemProvider only drives the swap pipeline.
|
||||||
|
//
|
||||||
|
// Decision process:
|
||||||
|
// - On the first non-null mediaItem after mount, seed
|
||||||
|
// _displayedMedia immediately so the screen has something to
|
||||||
|
// paint.
|
||||||
|
// - On a track id change, kick off a preload. The new track's
|
||||||
|
// cover bytes + dominant color must both resolve before we
|
||||||
|
// flip the displayed state. The user sees the previous track
|
||||||
|
// in full until the new one is fully ready, then a clean
|
||||||
|
// atomic swap (no fade, no placeholder flash).
|
||||||
|
// - On the same track id but the artUri-bearing rebroadcast
|
||||||
|
// (audio_handler sends MediaItem twice on track change),
|
||||||
|
// refresh through the same preload pipeline so the displayed
|
||||||
|
// cover reflects the freshly-written AlbumCoverCache file.
|
||||||
|
ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (prev, next) {
|
||||||
|
final newMedia = next.asData?.value;
|
||||||
|
if (newMedia == null) {
|
||||||
|
if (_displayedMedia != null) {
|
||||||
|
setState(() {
|
||||||
|
_displayedMedia = null;
|
||||||
|
_displayedDominant = null;
|
||||||
|
_pendingPreloadId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_displayedMedia == null) {
|
||||||
|
// First mount with a track playing — seed immediately, then
|
||||||
|
// kick the preload pipeline to update the dominant color and
|
||||||
|
// ensure the cover is decoded.
|
||||||
|
setState(() => _displayedMedia = newMedia);
|
||||||
|
_scheduleSwap(newMedia);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final isNewTrack = newMedia.id != _displayedMedia!.id;
|
||||||
|
final coverGotPopulated =
|
||||||
|
newMedia.id == _displayedMedia!.id &&
|
||||||
|
newMedia.artUri != null &&
|
||||||
|
_displayedMedia!.artUri == null;
|
||||||
|
if (isNewTrack || coverGotPopulated) {
|
||||||
|
_scheduleSwap(newMedia);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
|
final displayedMedia = _displayedMedia;
|
||||||
|
|
||||||
|
if (displayedMedia == null) {
|
||||||
|
// Either nothing playing, or the very first mount before a
|
||||||
|
// mediaItem has been emitted. The ref.listen above will seed
|
||||||
|
// _displayedMedia as soon as a non-null MediaItem arrives.
|
||||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,23 +257,20 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
// only fires on state transitions and would leave the bar frozen
|
// only fires on state transitions and would leave the bar frozen
|
||||||
// between them.
|
// between them.
|
||||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||||
final dur = media.duration ?? Duration.zero;
|
final dur = displayedMedia.duration ?? Duration.zero;
|
||||||
final isPlaying = playback?.playing == true;
|
final isPlaying = playback?.playing == true;
|
||||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||||
final actions = ref.read(playerActionsProvider);
|
final actions = ref.read(playerActionsProvider);
|
||||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
final albumId = (displayedMedia.extras?['album_id'] as String?) ?? '';
|
||||||
|
|
||||||
// Dominant-color gradient backdrop seeded from the current track's
|
// Backdrop color: render the dominant we've already committed to
|
||||||
// album cover. While the color resolves (or when no cover exists),
|
// _displayedDominant. AnimatedContainer tweens between successive
|
||||||
// the gradient collapses to a flat fs.obsidian — same look as
|
// values, so the only color changes the user sees are the
|
||||||
// before the polish landed. Tweens to the new color on track change
|
// atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
|
||||||
// via AnimatedContainer.
|
// the gradient present without overwhelming the title/artist
|
||||||
final dominantAsync = ref.watch(albumColorProvider(albumId));
|
// text below.
|
||||||
final dominant = dominantAsync.asData?.value ?? fs.obsidian;
|
final dominant = _displayedDominant ?? fs.obsidian;
|
||||||
// 0.55 alpha keeps the color present without overwhelming contrast
|
|
||||||
// on the title / artist text below. The lower half of the screen
|
|
||||||
// stays obsidian for control legibility.
|
|
||||||
final gradientTop = dominant.withValues(alpha: 0.55);
|
final gradientTop = dominant.withValues(alpha: 0.55);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -128,46 +303,36 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
AnimatedSwitcher(
|
// No AnimatedSwitcher: _displayedMedia only
|
||||||
duration: _trackChangeDuration,
|
// advances after the preload pipeline has the
|
||||||
child: KeyedSubtree(
|
// new cover + color ready, so a track change
|
||||||
key: ValueKey('cover-${media.id}'),
|
// is an atomic swap. Fading would just smear a
|
||||||
child: _AlbumArt(
|
// transition the user explicitly asked us not
|
||||||
media: media, albumId: albumId, fs: fs),
|
// to add.
|
||||||
),
|
_AlbumArt(
|
||||||
),
|
media: displayedMedia, albumId: albumId, fs: fs),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
AnimatedSwitcher(
|
_TitleRow(media: displayedMedia, fs: fs),
|
||||||
duration: _trackChangeDuration,
|
|
||||||
child: KeyedSubtree(
|
|
||||||
key: ValueKey('title-${media.id}'),
|
|
||||||
child: _TitleRow(media: media, fs: fs),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
AnimatedSwitcher(
|
Column(
|
||||||
duration: _trackChangeDuration,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
children: [
|
||||||
key: ValueKey('artist-album-${media.id}'),
|
Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
displayedMedia.artist ?? '',
|
||||||
children: [
|
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if ((displayedMedia.album ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
media.artist ?? '',
|
displayedMedia.album!,
|
||||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if ((media.album ?? '').isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
media.album!,
|
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_SecondaryControls(
|
_SecondaryControls(
|
||||||
@@ -175,7 +340,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
actions: actions,
|
actions: actions,
|
||||||
shuffleOn: shuffleOn,
|
shuffleOn: shuffleOn,
|
||||||
repeatMode: repeatMode,
|
repeatMode: repeatMode,
|
||||||
media: media,
|
media: displayedMedia,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||||
|
|||||||
@@ -28,16 +28,37 @@ import 'player_provider.dart';
|
|||||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||||
/// the drag-down dismissal on the full screen.
|
/// the drag-down dismissal on the full screen.
|
||||||
class PlayerBar extends ConsumerWidget {
|
class PlayerBar extends ConsumerStatefulWidget {
|
||||||
const PlayerBar({super.key});
|
const PlayerBar({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PlayerBar> createState() => _PlayerBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerBarState extends ConsumerState<PlayerBar> {
|
||||||
|
/// Last non-null artUri we've seen from the mediaItem stream. Held
|
||||||
|
/// across the audio_handler's two-broadcast track-change pattern
|
||||||
|
/// (bare MediaItem first, then with artUri once AlbumCoverCache
|
||||||
|
/// resolves) so the mini bar keeps showing the previous track's
|
||||||
|
/// cover until the new one is known. Without this hold the bar
|
||||||
|
/// flickered to the slate placeholder for ~100ms on every track
|
||||||
|
/// change.
|
||||||
|
Uri? _lastArtUri;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final media = ref.watch(mediaItemProvider).value;
|
final media = ref.watch(mediaItemProvider).value;
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
if (media == null) return const SizedBox.shrink();
|
if (media == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Capture the latest non-null artUri so _TrackInfo's cover stays
|
||||||
|
// continuous across track changes.
|
||||||
|
if (media.artUri != null) _lastArtUri = media.artUri;
|
||||||
|
final displayMedia = media.artUri == null && _lastArtUri != null
|
||||||
|
? media.copyWith(artUri: _lastArtUri)
|
||||||
|
: media;
|
||||||
|
|
||||||
// positionProvider is just_audio's positionStream (~200ms cadence)
|
// positionProvider is just_audio's positionStream (~200ms cadence)
|
||||||
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
||||||
// only updates on event transitions and would leave it frozen.
|
// only updates on event transitions and would leave it frozen.
|
||||||
@@ -65,7 +86,7 @@ class PlayerBar extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _TrackInfo(media: media)),
|
Expanded(child: _TrackInfo(media: displayMedia)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_PlayControls(playback: playback, ref: ref),
|
_PlayControls(playback: playback, ref: ref),
|
||||||
],
|
],
|
||||||
@@ -95,17 +116,28 @@ class _TrackInfo extends StatelessWidget {
|
|||||||
// artwork from this 48dp footprint to the full-screen size rather
|
// artwork from this 48dp footprint to the full-screen size rather
|
||||||
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
|
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
|
||||||
// so the transition works regardless of what's playing.
|
// so the transition works regardless of what's playing.
|
||||||
Widget cover;
|
//
|
||||||
if (media.artUri != null) {
|
// Why no transition / placeholder handling: the audio_handler
|
||||||
|
// broadcasts MediaItem twice on track change — once with artUri
|
||||||
|
// null (the new track's bare metadata), then with artUri set once
|
||||||
|
// AlbumCoverCache resolves. The widget tree above hands us the
|
||||||
|
// most-recent non-null artUri (`displayArtUri`), so the previous
|
||||||
|
// track's cover stays visible across that null gap and the new
|
||||||
|
// cover snaps in the moment its artUri arrives. Rapid change is
|
||||||
|
// fine here per operator preference; AnimatedSwitcher previously
|
||||||
|
// smeared the swap and made the slate placeholder visible.
|
||||||
|
final displayArtUri = media.artUri;
|
||||||
|
final Widget cover;
|
||||||
|
if (displayArtUri != null) {
|
||||||
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
|
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
|
||||||
// hits the same disk cache the rest of the UI uses (ServerImage,
|
// hits the same disk cache the rest of the UI uses (ServerImage,
|
||||||
// discover thumbnails). FileImage stays for the file:// branch
|
// discover thumbnails). FileImage stays for the file:// branch
|
||||||
// populated by AlbumCoverCache — bytes are already on disk and a
|
// populated by AlbumCoverCache — bytes are already on disk and a
|
||||||
// second cache layer would only burn duplicate space.
|
// second cache layer would only burn duplicate space.
|
||||||
cover = Image(
|
cover = Image(
|
||||||
image: media.artUri!.isScheme('file')
|
image: displayArtUri.isScheme('file')
|
||||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
? FileImage(File.fromUri(displayArtUri)) as ImageProvider
|
||||||
: CachedNetworkImageProvider(media.artUri.toString()),
|
: CachedNetworkImageProvider(displayArtUri.toString()),
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
// Without a fit, Image paints the source at its intrinsic
|
// Without a fit, Image paints the source at its intrinsic
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/likes.dart' show LikeKind;
|
||||||
import '../api/endpoints/radio.dart';
|
import '../api/endpoints/radio.dart';
|
||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../likes/likes_provider.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import 'album_cover_cache.dart';
|
import 'album_cover_cache.dart';
|
||||||
import 'audio_handler.dart';
|
import 'audio_handler.dart';
|
||||||
@@ -49,7 +51,18 @@ final positionProvider = StreamProvider<Duration>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
class PlayerActions {
|
class PlayerActions {
|
||||||
PlayerActions(this._ref);
|
PlayerActions(this._ref) {
|
||||||
|
// Keep MediaItem.rating in sync with the drift-backed likedIds
|
||||||
|
// cache so external media surfaces (Wear's heart button, lock-
|
||||||
|
// screen favorite, Auto's like icon) reflect the current state
|
||||||
|
// even when the user toggles a like from somewhere other than
|
||||||
|
// the watch itself — e.g. tapping the heart on a TrackRow, the
|
||||||
|
// kebab menu, or another logged-in device propagating via SSE.
|
||||||
|
_ref.listen<AsyncValue<LikedIds>>(likedIdsProvider, (_, next) {
|
||||||
|
if (next.value == null) return;
|
||||||
|
_ref.read(audioHandlerProvider).refreshCurrentRating();
|
||||||
|
});
|
||||||
|
}
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||||
@@ -63,11 +76,28 @@ class PlayerActions {
|
|||||||
token: token,
|
token: token,
|
||||||
coverCache: cache,
|
coverCache: cache,
|
||||||
audioCacheManager: audioCache,
|
audioCacheManager: audioCache,
|
||||||
|
likeBridge: _buildLikeBridge(),
|
||||||
);
|
);
|
||||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||||
await h.play();
|
await h.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the adapter the audio handler uses to read + flip the
|
||||||
|
/// current track's like state in response to external media-
|
||||||
|
/// controller events (Wear's heart button, lock-screen favorite).
|
||||||
|
/// Constructed here because the audio handler is initialized in
|
||||||
|
/// main.dart before the ProviderScope exists; passing the bridge
|
||||||
|
/// in via configure() keeps the handler Riverpod-agnostic while
|
||||||
|
/// still letting it call into LikesController + likedIdsProvider.
|
||||||
|
LikeBridge _buildLikeBridge() {
|
||||||
|
return LikeBridge(
|
||||||
|
toggleTrackLike: (id) =>
|
||||||
|
_ref.read(likesControllerProvider).toggle(LikeKind.track, id),
|
||||||
|
isTrackLiked: (id) =>
|
||||||
|
_ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> playNext(TrackRef track) async {
|
Future<void> playNext(TrackRef track) async {
|
||||||
final h = _ref.read(audioHandlerProvider);
|
final h = _ref.read(audioHandlerProvider);
|
||||||
await h.playNext(track);
|
await h.playNext(track);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
@@ -362,8 +363,41 @@ class _TrackRefRow {
|
|||||||
final int durationSec;
|
final int durationSec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drift-first per #357 pattern. Reads from cached_system_playlists_
|
||||||
|
/// status (single-row JSON blob populated by /api/me/system-playlists-
|
||||||
|
/// status). Home Playlists row reads this every render to choose
|
||||||
|
/// between real cards and "building / pending / failed" placeholders
|
||||||
|
/// — drift-first means the row paints with the prior status instantly
|
||||||
|
/// rather than flickering through SystemPlaylistsStatus.empty() while
|
||||||
|
/// the REST round-trip resolves. SWR refresh on every visit keeps it
|
||||||
|
/// current.
|
||||||
final systemPlaylistsStatusProvider =
|
final systemPlaylistsStatusProvider =
|
||||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
StreamProvider<SystemPlaylistsStatus>((ref) {
|
||||||
final dio = await ref.watch(dioProvider.future);
|
final db = ref.watch(appDbProvider);
|
||||||
return MeApi(dio).systemPlaylistsStatus();
|
return cacheFirst<CachedSystemPlaylistsStatusData, SystemPlaylistsStatus>(
|
||||||
|
driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final fresh = await MeApi(dio).systemPlaylistsStatus();
|
||||||
|
await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate(
|
||||||
|
CachedSystemPlaylistsStatusCompanion.insert(
|
||||||
|
json: jsonEncode({
|
||||||
|
'in_flight': fresh.inFlight,
|
||||||
|
'last_run_at': fresh.lastRunAt,
|
||||||
|
'last_error': fresh.lastError,
|
||||||
|
}),
|
||||||
|
updatedAt: drift.Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.isEmpty
|
||||||
|
? SystemPlaylistsStatus.empty()
|
||||||
|
: SystemPlaylistsStatus.fromJson(
|
||||||
|
jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'systemPlaylistsStatus',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../api/endpoints/quarantine.dart';
|
|||||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
import '../cache/connectivity_provider.dart';
|
import '../cache/connectivity_provider.dart';
|
||||||
import '../cache/db.dart';
|
import '../cache/db.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/quarantine_mine.dart';
|
import '../models/quarantine_mine.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
@@ -110,17 +111,18 @@ class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
|
|||||||
try {
|
try {
|
||||||
final api = await ref.read(quarantineApiProvider.future);
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
await api.flag(track.id, reason, notes: notes);
|
await api.flag(track.id, reason, notes: notes);
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
// Rollback the optimistic insert; watch() re-emits the prior set.
|
// REST failed — don't roll back; queue for replay so the user's
|
||||||
await (db.delete(db.cachedQuarantineMine)
|
// "hide this track" intent persists offline.
|
||||||
..where((t) => t.trackId.equals(track.id)))
|
await ref.read(mutationQueueProvider).enqueue(
|
||||||
.go();
|
MutationKinds.quarantineFlag,
|
||||||
Error.throwWithStackTrace(e, st);
|
{'trackId': track.id, 'reason': reason, 'notes': notes},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optimistic unflag: removes the drift row, calls server, restores
|
/// Optimistic unflag: removes the drift row, calls server, queues
|
||||||
/// on error.
|
/// the call on failure so the unhide intent persists offline.
|
||||||
Future<void> unflag(String trackId) async {
|
Future<void> unflag(String trackId) async {
|
||||||
final db = ref.read(appDbProvider);
|
final db = ref.read(appDbProvider);
|
||||||
final existing = await (db.select(db.cachedQuarantineMine)
|
final existing = await (db.select(db.cachedQuarantineMine)
|
||||||
@@ -135,13 +137,11 @@ class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
|
|||||||
try {
|
try {
|
||||||
final api = await ref.read(quarantineApiProvider.future);
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
await api.unflag(trackId);
|
await api.unflag(trackId);
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
// Restore the row we just deleted — toCompanion(true) preserves
|
// Don't restore the drift row; queue the unflag for replay.
|
||||||
// every field including the original fetchedAt timestamp.
|
await ref
|
||||||
await db
|
.read(mutationQueueProvider)
|
||||||
.into(db.cachedQuarantineMine)
|
.enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId});
|
||||||
.insertOnConflictUpdate(existing.toCompanion(true));
|
|
||||||
Error.throwWithStackTrace(e, st);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/requests.dart';
|
import '../api/endpoints/requests.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/admin_request.dart';
|
import '../models/admin_request.dart';
|
||||||
|
|
||||||
@@ -41,17 +42,20 @@ class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optimistic remove + REST cancel. Restores the row on failure so
|
/// Optimistic remove + REST cancel. On failure the row stays
|
||||||
/// the user can retry — silent failure would lie about success.
|
/// removed in-memory and the cancel is queued for replay — this
|
||||||
|
/// keeps the user's intent visible across network loss instead of
|
||||||
|
/// restoring a row they explicitly asked to remove.
|
||||||
Future<void> cancel(String id) async {
|
Future<void> cancel(String id) async {
|
||||||
final api = await ref.read(requestsApiProvider.future);
|
final api = await ref.read(requestsApiProvider.future);
|
||||||
final current = state.value ?? const <AdminRequest>[];
|
final current = state.value ?? const <AdminRequest>[];
|
||||||
state = AsyncData(current.where((r) => r.id != id).toList());
|
state = AsyncData(current.where((r) => r.id != id).toList());
|
||||||
try {
|
try {
|
||||||
await api.cancel(id);
|
await api.cancel(id);
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
state = AsyncData(current);
|
await ref
|
||||||
Error.throwWithStackTrace(e, st);
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.requestCancel, {'id': id});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
|
||||||
|
show HttpExceptionWithStatus;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../auth/auth_provider.dart';
|
import '../../auth/auth_provider.dart';
|
||||||
@@ -71,6 +73,19 @@ class ServerImage extends ConsumerWidget {
|
|||||||
// Keep failures local — a single 401/timeout shouldn't dump
|
// Keep failures local — a single 401/timeout shouldn't dump
|
||||||
// a stack trace or replace the parent Container's background.
|
// a stack trace or replace the parent Container's background.
|
||||||
errorWidget: (_, __, ___) => empty,
|
errorWidget: (_, __, ___) => empty,
|
||||||
|
// Filter expected 404s out of dev console noise: playlist
|
||||||
|
// collages aren't built until the playlist has tracks and
|
||||||
|
// the build job runs (system playlists like For-You /
|
||||||
|
// Discover hit this on first-render). The errorWidget
|
||||||
|
// already renders the fallback for the user; this just
|
||||||
|
// keeps the dev console clean. Non-404 errors still
|
||||||
|
// surface so auth/connectivity issues remain visible.
|
||||||
|
errorListener: (err) {
|
||||||
|
if (err is HttpExceptionWithStatus && err.statusCode == 404) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint('ServerImage: $err');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../api/endpoints/likes.dart';
|
import '../../../api/endpoints/likes.dart';
|
||||||
|
import '../../../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../../../cache/db.dart';
|
||||||
|
import '../../../cache/mutation_queue.dart';
|
||||||
import '../../../likes/likes_provider.dart';
|
import '../../../likes/likes_provider.dart';
|
||||||
import '../../../models/track.dart';
|
import '../../../models/track.dart';
|
||||||
import '../../../player/player_provider.dart';
|
import '../../../player/player_provider.dart';
|
||||||
@@ -255,7 +259,38 @@ typedef AddToPlaylistAction = Future<void> Function(String playlistId, String tr
|
|||||||
|
|
||||||
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
|
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
|
||||||
return (playlistId, trackId) async {
|
return (playlistId, trackId) async {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
final db = ref.read(appDbProvider);
|
||||||
await api.appendTracks(playlistId, [trackId]);
|
// Optimistic drift write so the playlist detail screen shows the
|
||||||
|
// new track instantly. The position is best-effort — append after
|
||||||
|
// the current max for this playlist. The server's authoritative
|
||||||
|
// position lands on next playlistDetailProvider fetch (SWR), or
|
||||||
|
// when the queued mutation replays.
|
||||||
|
final maxPos = await (db.selectOnly(db.cachedPlaylistTracks)
|
||||||
|
..addColumns([db.cachedPlaylistTracks.position.max()])
|
||||||
|
..where(db.cachedPlaylistTracks.playlistId.equals(playlistId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final nextPos =
|
||||||
|
((maxPos?.read(db.cachedPlaylistTracks.position.max()) ?? -1) + 1);
|
||||||
|
await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate(
|
||||||
|
CachedPlaylistTracksCompanion.insert(
|
||||||
|
playlistId: playlistId,
|
||||||
|
trackId: trackId,
|
||||||
|
position: drift.Value(nextPos),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
await api.appendTracks(playlistId, [trackId]);
|
||||||
|
} catch (_) {
|
||||||
|
// REST failed — keep the optimistic drift row; queue the call.
|
||||||
|
await ref.read(mutationQueueProvider).enqueue(
|
||||||
|
MutationKinds.playlistAppend,
|
||||||
|
{
|
||||||
|
'playlistId': playlistId,
|
||||||
|
'trackIds': [trackId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: minstrel
|
name: minstrel
|
||||||
description: Minstrel mobile client
|
description: Minstrel mobile client
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2026.5.13+1
|
version: 2026.5.14+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.5.0 <4.0.0'
|
sdk: '>=3.5.0 <4.0.0'
|
||||||
|
|||||||
+9
-2
@@ -32,9 +32,16 @@ void main() {
|
|||||||
isOnline: () async => true,
|
isOnline: () async => true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final firstFuture = results.first;
|
// Post-coldFetchAttempted guard, cacheFirst yields the current
|
||||||
|
// (still-empty) rows after the fetch returns, so the stream
|
||||||
|
// never hangs when populate is a no-op for this filter. The
|
||||||
|
// simulated drift re-emit then yields the populated rows.
|
||||||
|
// Capture the Future *before* feeding the controller so the
|
||||||
|
// listener is subscribed when the first add() lands.
|
||||||
|
final emissionsFuture = results.take(2).toList();
|
||||||
controller.add([]);
|
controller.add([]);
|
||||||
expect(await firstFuture, 42);
|
final emissions = await emissionsFuture;
|
||||||
|
expect(emissions, [0, 42]);
|
||||||
expect(fetchCalled, 1);
|
expect(fetchCalled, 1);
|
||||||
await controller.close();
|
await controller.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ void main() {
|
|||||||
(ref) => Stream.value(PlaylistsList.empty()),
|
(ref) => Stream.value(PlaylistsList.empty()),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
@@ -49,7 +49,7 @@ void main() {
|
|||||||
(ref) => Stream.value(PlaylistsList.empty()),
|
(ref) => Stream.value(PlaylistsList.empty()),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
@@ -91,7 +91,7 @@ void main() {
|
|||||||
const PlaylistsList(owned: [forYou], public: [])),
|
const PlaylistsList(owned: [forYou], public: [])),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ void main() {
|
|||||||
expect(rows.first.reason, 'bad_rip');
|
expect(rows.first.reason, 'bad_rip');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('flag rolls back the drift insert on server failure', skip: _skipDrift,
|
test('flag keeps drift optimistic + queues mutation on server failure',
|
||||||
() async {
|
skip: _skipDrift, () async {
|
||||||
final db = AppDb(NativeDatabase.memory());
|
final db = AppDb(NativeDatabase.memory());
|
||||||
addTearDown(db.close);
|
addTearDown(db.close);
|
||||||
final api = _StubQuarantineApi(shouldThrow: true);
|
final api = _StubQuarantineApi(shouldThrow: true);
|
||||||
@@ -101,14 +101,18 @@ void main() {
|
|||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container.read(myQuarantineProvider.future);
|
await container.read(myQuarantineProvider.future);
|
||||||
await expectLater(
|
// No longer rethrows — flag swallows the REST failure and queues
|
||||||
() => container
|
// for replay so the user's intent persists offline.
|
||||||
.read(myQuarantineProvider.notifier)
|
await container
|
||||||
.flag(_track, 'bad_rip', ''),
|
.read(myQuarantineProvider.notifier)
|
||||||
throwsException,
|
.flag(_track, 'bad_rip', '');
|
||||||
);
|
|
||||||
final rows = await db.select(db.cachedQuarantineMine).get();
|
final rows = await db.select(db.cachedQuarantineMine).get();
|
||||||
expect(rows, isEmpty);
|
expect(rows, hasLength(1),
|
||||||
|
reason: 'optimistic drift row should persist across REST failure');
|
||||||
|
final mutations = await db.select(db.cachedMutations).get();
|
||||||
|
expect(mutations, hasLength(1),
|
||||||
|
reason: 'failed flag should have been enqueued for replay');
|
||||||
|
expect(mutations.first.kind, 'quarantine.flag');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unflag deletes the drift row and calls the server', skip: _skipDrift,
|
test('unflag deletes the drift row and calls the server', skip: _skipDrift,
|
||||||
|
|||||||
@@ -254,18 +254,41 @@ func redistributeSlots(buckets []bucketRequest) []int {
|
|||||||
|
|
||||||
// interleaveBuckets round-robins items from the buckets in order. The
|
// interleaveBuckets round-robins items from the buckets in order. The
|
||||||
// first item of each bucket appears before the second of any bucket.
|
// first item of each bucket appears before the second of any bucket.
|
||||||
|
// interleaveBuckets walks each bucket round-robin, emitting one track
|
||||||
|
// per bucket per pass. Tracks already seen in an earlier bucket (or an
|
||||||
|
// earlier pass) are skipped — single-user servers hit this often
|
||||||
|
// because the empty crossUser bucket redistributes slots to dormant +
|
||||||
|
// random, and a dormant-artist track is also a valid random-unheard
|
||||||
|
// pick. Without dedup the same track lands at two interleaved
|
||||||
|
// positions, which on a 2-bucket round-robin (dormant+random)
|
||||||
|
// produces ADJACENT duplicates in the playlist — exactly the
|
||||||
|
// "every song has a duplicate right after it" report from v2026.05.13.0.
|
||||||
|
//
|
||||||
|
// Dedup priority is bucket order (caller-supplied), so a track in both
|
||||||
|
// dormant and random is taken from dormant.
|
||||||
func interleaveBuckets(buckets ...[]discoverTrack) []discoverTrack {
|
func interleaveBuckets(buckets ...[]discoverTrack) []discoverTrack {
|
||||||
total := 0
|
total := 0
|
||||||
for _, b := range buckets {
|
for _, b := range buckets {
|
||||||
total += len(b)
|
total += len(b)
|
||||||
}
|
}
|
||||||
out := make([]discoverTrack, 0, total)
|
out := make([]discoverTrack, 0, total)
|
||||||
|
seen := make(map[pgtype.UUID]struct{}, total)
|
||||||
indices := make([]int, len(buckets))
|
indices := make([]int, len(buckets))
|
||||||
for {
|
for {
|
||||||
anyAppended := false
|
anyAppended := false
|
||||||
for bi, b := range buckets {
|
for bi, b := range buckets {
|
||||||
|
// Advance past tracks already taken from an earlier bucket
|
||||||
|
// or an earlier pass.
|
||||||
|
for indices[bi] < len(b) {
|
||||||
|
if _, dup := seen[b[indices[bi]].ID]; !dup {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
indices[bi]++
|
||||||
|
}
|
||||||
if indices[bi] < len(b) {
|
if indices[bi] < len(b) {
|
||||||
out = append(out, b[indices[bi]])
|
t := b[indices[bi]]
|
||||||
|
out = append(out, t)
|
||||||
|
seen[t.ID] = struct{}{}
|
||||||
indices[bi]++
|
indices[bi]++
|
||||||
anyAppended = true
|
anyAppended = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,3 +137,27 @@ func TestInterleaveBuckets_RoundRobin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
|
||||||
|
// Single-user server scenario: crossUser bucket empty, dormant +
|
||||||
|
// random share tracks. Without dedup, shared tracks land at
|
||||||
|
// adjacent interleaved positions — the duplication users reported
|
||||||
|
// on the Discover playlist in v2026.05.13.0.
|
||||||
|
dormant := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(3)}}
|
||||||
|
crossUser := []discoverTrack{} // empty bucket, single-user server
|
||||||
|
random := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(4)}}
|
||||||
|
got := interleaveBuckets(dormant, crossUser, random)
|
||||||
|
|
||||||
|
// Tracks 1 and 2 appear in both dormant and random — must be
|
||||||
|
// emitted once each (from dormant, the earlier bucket). Track 3
|
||||||
|
// is dormant-only, track 4 is random-only.
|
||||||
|
if len(got) != 4 {
|
||||||
|
t.Fatalf("len = %d, want 4 (3 unique from dormant + 1 unique from random)", len(got))
|
||||||
|
}
|
||||||
|
wantOrder := []byte{1, 2, 3, 4}
|
||||||
|
for i, w := range wantOrder {
|
||||||
|
if got[i].ID.Bytes[15] != w {
|
||||||
|
t.Errorf("got[%d].ID = %d, want %d", i, got[i].ID.Bytes[15], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user