2a18e91c39
Four-part change to push more surfaces onto the drift cache and eliminate cold-tab-visit latency on the Library screen. * **Library Artists tab** — _libraryArtistsProvider migrates from REST-paginated AsyncNotifier with infinite-scroll loadMore to a drift-first StreamProvider over cached_artists ordered by sortName. Sync already populates the full set; cacheFirst's fetchAndPopulate covers the fresh-install + sync-not-yet-done cold case via /api/artists?limit=1000. SWR refresh on every visit. GridView.builder lazily realizes only visible cells so loading the full list up front is fine for typical libraries. loadMore + NotificationListener gone. * **Library Albums tab** — same migration, drift-first over cached_albums joined with cached_artists for the artistName field. * **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot) for the home Playlists row's "building / pending / failed" placeholder logic. Drift-first means the row paints with the prior status instantly instead of flickering through SystemPlaylistsStatus.empty() while the REST call resolves. * **Library screen tab pre-warm** — ref.listen on all 5 tab providers in _LibraryScreenState.build subscribes them upfront so swiping between tabs feels instant rather than each tab paying its own cold-cache cost on first visit. cacheFirst handles dedupe of concurrent fetchAndPopulate triggers. Test mock updated for the StreamProvider type change on systemPlaylistsStatusProvider.
883 lines
34 KiB
Dart
883 lines
34 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../api/endpoints/library_lists.dart';
|
|
import '../api/endpoints/likes.dart';
|
|
import '../api/endpoints/me.dart';
|
|
import '../auth/auth_provider.dart' show authControllerProvider;
|
|
import '../cache/adapters.dart';
|
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
|
import '../cache/cache_first.dart';
|
|
import '../cache/connectivity_provider.dart';
|
|
import '../cache/db.dart';
|
|
import '../cache/metadata_prefetcher.dart';
|
|
import '../cache/tile_providers.dart';
|
|
import '../library/library_providers.dart' show dioProvider;
|
|
import '../models/album.dart';
|
|
import '../models/artist.dart';
|
|
import '../models/history_event.dart';
|
|
import '../models/quarantine_mine.dart';
|
|
import '../models/track.dart';
|
|
import '../player/player_provider.dart';
|
|
import '../quarantine/quarantine_provider.dart';
|
|
import '../shared/live_events_provider.dart';
|
|
import '../shared/widgets/main_app_bar_actions.dart';
|
|
import '../shared/widgets/skeletons.dart';
|
|
import '../shared/widgets/server_image.dart';
|
|
import '../theme/theme_extension.dart';
|
|
import 'widgets/album_card.dart';
|
|
import 'widgets/artist_card.dart';
|
|
import 'widgets/track_row.dart';
|
|
|
|
// Providers scoped to this screen. Each tab gets its own first page.
|
|
// Infinite scroll is a future enhancement; for v1 phone testing the
|
|
// first 50 rows per tab is enough.
|
|
|
|
final _libraryListsApiProvider = FutureProvider<LibraryListsApi>((ref) async {
|
|
return LibraryListsApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final _likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
|
return LikesApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|
return MeApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
/// Drift-first all-artists list. Reads cached_artists ordered by
|
|
/// sortName; GridView.builder takes care of lazy widget construction
|
|
/// so loading the full set up front is fine for typical library sizes.
|
|
/// SWR refresh on every subscription hits /api/artists with a generous
|
|
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
|
|
///
|
|
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
|
|
/// previous "fetch one page at a time as you scroll" felt like the
|
|
/// rest of the app was buffering when this tab was the slow surface,
|
|
/// and SyncController already populates the full set of artist rows
|
|
/// via /api/library/sync.
|
|
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = db.select(db.cachedArtists)
|
|
..orderBy([(t) => drift.OrderingTerm.asc(t.sortName)]);
|
|
return cacheFirst<CachedArtist, 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 fresh = await api.listArtists(limit: 1000, offset: 0);
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedArtists,
|
|
fresh.items.map((a) => a.toDrift()).toList(),
|
|
);
|
|
});
|
|
},
|
|
toResult: (rows) => rows.map((r) => r.toRef()).toList(),
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
alwaysRefresh: true,
|
|
tag: 'libraryArtists',
|
|
);
|
|
});
|
|
|
|
/// Drift-first all-albums list. Joins cached_albums with cached_artists
|
|
/// for the artistName field. Same cold-cache fallback + SWR refresh
|
|
/// pattern as libraryArtistsProvider.
|
|
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = db.select(db.cachedAlbums).join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
|
])
|
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
|
driftStream: query.watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(_libraryListsApiProvider.future);
|
|
final fresh = await api.listAlbums(limit: 1000, offset: 0);
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedAlbums,
|
|
fresh.items.map((a) => a.toDrift()).toList(),
|
|
);
|
|
});
|
|
},
|
|
toResult: (rows) => rows.map((r) {
|
|
final album = r.readTable(db.cachedAlbums);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
return album.toRef(artistName: artist?.name ?? '');
|
|
}).toList(),
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
alwaysRefresh: true,
|
|
tag: 'libraryAlbums',
|
|
);
|
|
});
|
|
|
|
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
|
// last /api/me/history page as JSON in a single-row drift table, yield
|
|
// it immediately on subscribe (so the tab paints from disk on cold
|
|
// open), then SWR-refresh in the background. Also gives basic offline
|
|
// scrollback — the last fetched page survives connectivity loss.
|
|
//
|
|
// JSON blob (vs columnar) because the page is small, always read whole,
|
|
// and the HistoryPage.fromJson constructor already accepts the wire
|
|
// shape — no schema-evolution pain when server-side fields change.
|
|
final _historyProvider = StreamProvider<HistoryPage>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
return cacheFirst<CachedHistorySnapshotData, HistoryPage>(
|
|
driftStream: db.select(db.cachedHistorySnapshot).watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(_meApiProvider.future);
|
|
final fresh = await api.history();
|
|
await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate(
|
|
CachedHistorySnapshotCompanion.insert(
|
|
json: _encodeHistoryPage(fresh),
|
|
updatedAt: drift.Value(DateTime.now()),
|
|
),
|
|
);
|
|
},
|
|
toResult: (rows) => rows.isEmpty
|
|
? const HistoryPage(events: [], hasMore: false)
|
|
: HistoryPage.fromJson(
|
|
jsonDecode(rows.first.json) as Map<String, dynamic>),
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
// SWR: yield cache instantly, then refresh in the background so the
|
|
// tab reflects the freshest plays. Matches homeProvider behavior.
|
|
alwaysRefresh: true,
|
|
tag: 'history',
|
|
);
|
|
});
|
|
|
|
/// Encodes HistoryPage back to the wire-format JSON shape
|
|
/// /api/me/history emits, so HistoryPage.fromJson can round-trip
|
|
/// through the drift cache.
|
|
String _encodeHistoryPage(HistoryPage h) => jsonEncode({
|
|
'events': h.events
|
|
.map((e) => {
|
|
'id': e.id,
|
|
'played_at': e.playedAt,
|
|
'track': {
|
|
'id': e.track.id,
|
|
'title': e.track.title,
|
|
'album_id': e.track.albumId,
|
|
'album_title': e.track.albumTitle,
|
|
'artist_id': e.track.artistId,
|
|
'artist_name': e.track.artistName,
|
|
'track_number': e.track.trackNumber,
|
|
'disc_number': e.track.discNumber,
|
|
'duration_sec': e.track.durationSec,
|
|
'stream_url': e.track.streamUrl,
|
|
},
|
|
})
|
|
.toList(),
|
|
'has_more': h.hasMore,
|
|
});
|
|
|
|
// Per-item Liked tabs (Slice E of the per-item rendering pass).
|
|
// Each provider yields just the ordered list of entity IDs; the UI
|
|
// then renders per-tile widgets that hydrate each entity individually
|
|
// via albumTileProvider / artistTileProvider / trackTileProvider.
|
|
//
|
|
// Reads come from cached_likes (sync- and optimistic-write-populated),
|
|
// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the
|
|
// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints
|
|
// that returned fully denormalized entities are no longer needed for
|
|
// this path since tile providers handle entity hydration themselves.
|
|
//
|
|
// likedAt ordering note: cached_likes.likedAt is whatever drift
|
|
// assigned via currentDateAndTime when the row was first inserted
|
|
// (either by sync or LikesController). insertOrIgnore on subsequent
|
|
// fetches preserves the existing likedAt so ordering stays stable.
|
|
// Approximate but acceptable — matches prior Slice 3 behavior.
|
|
|
|
List<String> _idsForEntity(List<CachedLike> rows) =>
|
|
rows.map((r) => r.entityId).toList(growable: false);
|
|
|
|
final _likedTrackIdsProvider = StreamProvider<List<String>>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = (db.select(db.cachedLikes)
|
|
..where((t) => t.entityType.equals('track'))
|
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
|
.watch();
|
|
return cacheFirst<CachedLike, List<String>>(
|
|
driftStream: query,
|
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
|
toResult: _idsForEntity,
|
|
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
|
alwaysRefresh: true,
|
|
tag: 'likedTrackIds',
|
|
);
|
|
});
|
|
|
|
final _likedAlbumIdsProvider = StreamProvider<List<String>>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = (db.select(db.cachedLikes)
|
|
..where((t) => t.entityType.equals('album'))
|
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
|
.watch();
|
|
return cacheFirst<CachedLike, List<String>>(
|
|
driftStream: query,
|
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
|
toResult: _idsForEntity,
|
|
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
|
alwaysRefresh: true,
|
|
tag: 'likedAlbumIds',
|
|
);
|
|
});
|
|
|
|
final _likedArtistIdsProvider = StreamProvider<List<String>>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = (db.select(db.cachedLikes)
|
|
..where((t) => t.entityType.equals('artist'))
|
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
|
.watch();
|
|
return cacheFirst<CachedLike, List<String>>(
|
|
driftStream: query,
|
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
|
toResult: _idsForEntity,
|
|
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
|
alwaysRefresh: true,
|
|
tag: 'likedArtistIds',
|
|
);
|
|
});
|
|
|
|
/// Shared cold-cache populator: hits /api/likes/ids once and writes
|
|
/// rows for all three entity types via insertOrIgnore. The three
|
|
/// providers above all trigger this on their first empty-drift
|
|
/// emission; the dedup in cacheFirst's revalidate state plus drift's
|
|
/// insertOrIgnore semantics make the multiple-trigger case cheap.
|
|
Future<void> _populateLikeIds(Ref ref) async {
|
|
final api = await ref.read(_likesApiProvider.future);
|
|
final user = ref.read(authControllerProvider).value;
|
|
if (user == null) return;
|
|
final fresh = await api.ids();
|
|
final db = ref.read(appDbProvider);
|
|
await db.batch((b) {
|
|
for (final id in fresh.tracks) {
|
|
b.insert(
|
|
db.cachedLikes,
|
|
CachedLikesCompanion.insert(
|
|
userId: user.id,
|
|
entityType: 'track',
|
|
entityId: id,
|
|
),
|
|
mode: drift.InsertMode.insertOrIgnore,
|
|
);
|
|
}
|
|
for (final id in fresh.albums) {
|
|
b.insert(
|
|
db.cachedLikes,
|
|
CachedLikesCompanion.insert(
|
|
userId: user.id,
|
|
entityType: 'album',
|
|
entityId: id,
|
|
),
|
|
mode: drift.InsertMode.insertOrIgnore,
|
|
);
|
|
}
|
|
for (final id in fresh.artists) {
|
|
b.insert(
|
|
db.cachedLikes,
|
|
CachedLikesCompanion.insert(
|
|
userId: user.id,
|
|
entityType: 'artist',
|
|
entityId: id,
|
|
),
|
|
mode: drift.InsertMode.insertOrIgnore,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
|
|
// optimistic flag/unflag) so flagging from any kebab and unflagging from
|
|
// the Hidden tab keep one source of truth.
|
|
|
|
class LibraryScreen extends ConsumerStatefulWidget {
|
|
const LibraryScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
|
|
}
|
|
|
|
class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|
with TickerProviderStateMixin {
|
|
late final TabController _ctrl = TabController(length: 5, vsync: this);
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(
|
|
backgroundColor: fs.obsidian,
|
|
appBar: AppBar(
|
|
backgroundColor: fs.obsidian,
|
|
elevation: 0,
|
|
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
|
actions: const [MainAppBarActions(currentRoute: '/library')],
|
|
bottom: TabBar(
|
|
controller: _ctrl,
|
|
isScrollable: true,
|
|
tabAlignment: TabAlignment.start,
|
|
labelColor: fs.parchment,
|
|
unselectedLabelColor: fs.ash,
|
|
indicatorColor: fs.accent,
|
|
tabs: const [
|
|
Tab(text: 'Artists'),
|
|
Tab(text: 'Albums'),
|
|
Tab(text: 'History'),
|
|
Tab(text: 'Liked'),
|
|
Tab(text: 'Hidden'),
|
|
],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _ctrl,
|
|
children: const [
|
|
_ArtistsTab(),
|
|
_AlbumsTab(),
|
|
_HistoryTab(),
|
|
_LikedTab(),
|
|
_HiddenTab(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ArtistsTab extends ConsumerWidget {
|
|
const _ArtistsTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(_libraryArtistsProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (artists) {
|
|
// Warm details for the first screenful so taps are instant.
|
|
ref
|
|
.read(metadataPrefetcherProvider)
|
|
.warmArtists(artists.map((a) => a.id));
|
|
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))
|
|
: RefreshIndicator(
|
|
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
|
// LayoutBuilder + cell-aware ArtistCard width mirrors
|
|
// the AlbumsTab pattern so the circular avatar stays
|
|
// a true circle on narrow grid cells.
|
|
//
|
|
// Drift-first: GridView holds the full sorted list;
|
|
// .builder lazily realizes only visible cells, so
|
|
// even on large libraries the up-front cost is just
|
|
// a sort over cached_artists, not N network round
|
|
// trips.
|
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
|
const cols = 3;
|
|
const sidePad = 8.0;
|
|
const gap = 8.0;
|
|
final cellW = (constraints.maxWidth -
|
|
sidePad * 2 -
|
|
gap * (cols - 1)) /
|
|
cols;
|
|
// 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:
|
|
SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: cols,
|
|
mainAxisExtent: cellH,
|
|
mainAxisSpacing: gap,
|
|
crossAxisSpacing: gap,
|
|
),
|
|
itemCount: artists.length,
|
|
itemBuilder: (ctx, i) {
|
|
final artist = artists[i];
|
|
return ArtistCard(
|
|
artist: artist,
|
|
width: cellW,
|
|
onTap: () => ctx.push('/artists/${artist.id}',
|
|
extra: artist),
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AlbumsTab extends ConsumerWidget {
|
|
const _AlbumsTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(_libraryAlbumsProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (albums) {
|
|
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))
|
|
: RefreshIndicator(
|
|
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
|
// Same responsive 3-up grid as the artist detail
|
|
// album list — LayoutBuilder + AlbumCard sized to the
|
|
// cell, mainAxisExtent matched to actual card height.
|
|
// Drift-first; full sorted list, lazy realization via
|
|
// GridView.builder.
|
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
|
const cols = 3;
|
|
const sidePad = 8.0;
|
|
const gap = 8.0;
|
|
final cellW = (constraints.maxWidth -
|
|
sidePad * 2 -
|
|
gap * (cols - 1)) /
|
|
cols;
|
|
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
|
// + artist line (~16) + slack. Slack is generous on
|
|
// purpose — line-height + font scaling variations
|
|
// would otherwise overflow the cell by a pixel
|
|
// (logged as a noisy RenderFlex warning).
|
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
|
return GridView.builder(
|
|
padding: const EdgeInsets.all(sidePad),
|
|
gridDelegate:
|
|
SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: cols,
|
|
mainAxisExtent: cellH,
|
|
mainAxisSpacing: gap,
|
|
crossAxisSpacing: gap,
|
|
),
|
|
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),
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryTab extends ConsumerWidget {
|
|
const _HistoryTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(_historyProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (page) => page.events.isEmpty
|
|
? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash)))
|
|
: RefreshIndicator(
|
|
onRefresh: () async => ref.refresh(_historyProvider.future),
|
|
child: ListView.separated(
|
|
itemCount: page.events.length,
|
|
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
|
itemBuilder: (ctx, i) {
|
|
final event = page.events[i];
|
|
return TrackRow(
|
|
track: event.track,
|
|
onTap: () => ref
|
|
.read(playerActionsProvider)
|
|
.playTracks([event.track]),
|
|
trailing: Text(
|
|
_relativeTime(event.playedAt),
|
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LikedTab extends ConsumerWidget {
|
|
const _LikedTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
// SSE wire-up: any cross-device like / unlike triggers a refresh
|
|
// of the discovery providers. LikesController handles local
|
|
// mutations optimistically through the same cached_likes table,
|
|
// so toggling a like locally re-emits the streams instantly
|
|
// without needing an invalidate here.
|
|
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
|
final e = next.asData?.value;
|
|
if (e == null) return;
|
|
switch (e.kind) {
|
|
case 'track.liked':
|
|
case 'track.unliked':
|
|
case 'album.liked':
|
|
case 'album.unliked':
|
|
case 'artist.liked':
|
|
case 'artist.unliked':
|
|
ref.invalidate(_likedTrackIdsProvider);
|
|
ref.invalidate(_likedAlbumIdsProvider);
|
|
ref.invalidate(_likedArtistIdsProvider);
|
|
}
|
|
});
|
|
final tracksA = ref.watch(_likedTrackIdsProvider);
|
|
final albumsA = ref.watch(_likedAlbumIdsProvider);
|
|
final artistsA = ref.watch(_likedArtistIdsProvider);
|
|
|
|
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
final trackIds = tracksA.value;
|
|
final albumIds = albumsA.value;
|
|
final artistIds = artistsA.value;
|
|
if (trackIds == null || albumIds == null || artistIds == null) {
|
|
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
|
}
|
|
if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) {
|
|
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
|
}
|
|
return RefreshIndicator(
|
|
onRefresh: () async {
|
|
await Future.wait([
|
|
ref.refresh(_likedTrackIdsProvider.future),
|
|
ref.refresh(_likedAlbumIdsProvider.future),
|
|
ref.refresh(_likedArtistIdsProvider.future),
|
|
]);
|
|
},
|
|
child: ListView(children: [
|
|
if (artistIds.isNotEmpty) ...[
|
|
_SectionHeader(label: 'Artists', count: artistIds.length),
|
|
SizedBox(
|
|
height: 168,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemCount: artistIds.length,
|
|
itemBuilder: (ctx, i) =>
|
|
_LikedArtistTile(id: artistIds[i]),
|
|
),
|
|
),
|
|
],
|
|
if (albumIds.isNotEmpty) ...[
|
|
_SectionHeader(label: 'Albums', count: albumIds.length),
|
|
SizedBox(
|
|
height: 200,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemCount: albumIds.length,
|
|
itemBuilder: (ctx, i) =>
|
|
_LikedAlbumTile(id: albumIds[i]),
|
|
),
|
|
),
|
|
],
|
|
if (trackIds.isNotEmpty) ...[
|
|
_SectionHeader(label: 'Tracks', count: trackIds.length),
|
|
...trackIds.map(
|
|
(id) => _LikedTrackRow(id: id, sectionIds: trackIds),
|
|
),
|
|
],
|
|
const SizedBox(height: 96),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Skeleton→content cross-fade duration. Matches home_screen so the
|
|
/// reveal feel is consistent across surfaces. See _tileRevealDuration
|
|
/// in home_screen.dart for the rationale.
|
|
const Duration _likedTileReveal = Duration(milliseconds: 220);
|
|
|
|
/// Liked-Artists carousel tile. Skeleton until artistTileProvider
|
|
/// yields a populated row.
|
|
class _LikedArtistTile extends ConsumerWidget {
|
|
const _LikedArtistTile({required this.id});
|
|
final String id;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final artist = ref.watch(artistTileProvider(id)).asData?.value;
|
|
return AnimatedSwitcher(
|
|
duration: _likedTileReveal,
|
|
switchInCurve: Curves.easeOut,
|
|
child: artist == null
|
|
? const SkeletonArtistTile(key: ValueKey('skeleton'))
|
|
: ArtistCard(
|
|
key: ValueKey('artist-${artist.id}'),
|
|
artist: artist,
|
|
onTap: () =>
|
|
context.push('/artists/${artist.id}', extra: artist),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Liked-Albums carousel tile. Skeleton until albumTileProvider
|
|
/// yields a populated row.
|
|
class _LikedAlbumTile extends ConsumerWidget {
|
|
const _LikedAlbumTile({required this.id});
|
|
final String id;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final album = ref.watch(albumTileProvider(id)).asData?.value;
|
|
return AnimatedSwitcher(
|
|
duration: _likedTileReveal,
|
|
switchInCurve: Curves.easeOut,
|
|
child: album == null
|
|
? const SkeletonAlbumTile(key: ValueKey('skeleton'))
|
|
: AlbumCard(
|
|
key: ValueKey('album-${album.id}'),
|
|
album: album,
|
|
onTap: () => context.push('/albums/${album.id}', extra: album),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Liked-Tracks list row. Skeleton until trackTileProvider yields a
|
|
/// populated row. Tap plays the section starting at this track,
|
|
/// using whichever tracks are currently hydrated; still-loading
|
|
/// tracks are skipped from the play queue and join on next rebuild.
|
|
class _LikedTrackRow extends ConsumerWidget {
|
|
const _LikedTrackRow({required this.id, required this.sectionIds});
|
|
final String id;
|
|
final List<String> sectionIds;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final track = ref.watch(trackTileProvider(id)).asData?.value;
|
|
return AnimatedSwitcher(
|
|
duration: _likedTileReveal,
|
|
switchInCurve: Curves.easeOut,
|
|
child: track == null
|
|
? const SkeletonTrackRow(key: ValueKey('skeleton'))
|
|
: TrackRow(
|
|
key: ValueKey('track-${track.id}'),
|
|
track: track,
|
|
onTap: () {
|
|
final hydrated = <TrackRef>[];
|
|
for (final i in sectionIds) {
|
|
final v = ref.read(trackTileProvider(i)).asData?.value;
|
|
if (v != null) hydrated.add(v);
|
|
}
|
|
final start = hydrated.indexWhere((t) => t.id == id);
|
|
ref.read(playerActionsProvider).playTracks(
|
|
hydrated,
|
|
initialIndex: start < 0 ? 0 : start,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HiddenTab extends ConsumerWidget {
|
|
const _HiddenTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(myQuarantineProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (rows) => rows.isEmpty
|
|
? Center(
|
|
child: Text(
|
|
'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: fs.ash),
|
|
),
|
|
)
|
|
: RefreshIndicator(
|
|
onRefresh: () async => ref.refresh(myQuarantineProvider.future),
|
|
child: ListView.separated(
|
|
itemCount: rows.length,
|
|
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
|
itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QuarantineTile extends ConsumerWidget {
|
|
const _QuarantineTile({required this.row});
|
|
final QuarantineMineRow row;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final reasonLabel = switch (row.reason) {
|
|
'bad_rip' => 'Bad rip',
|
|
'wrong_file' => 'Wrong file',
|
|
'wrong_tags' => 'Wrong tags',
|
|
'duplicate' => 'Duplicate',
|
|
_ => 'Other',
|
|
};
|
|
final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : '';
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: SizedBox(
|
|
width: 56,
|
|
height: 56,
|
|
child: coverUrl.isEmpty
|
|
? Container(color: fs.slate)
|
|
: ServerImage(
|
|
url: coverUrl,
|
|
fit: BoxFit.cover,
|
|
fallback: Container(color: fs.slate),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(
|
|
row.trackTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
|
),
|
|
Text(
|
|
'${row.artistName} · ${row.albumTitle}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Row(children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: fs.iron,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
|
|
),
|
|
if (row.createdAt.isNotEmpty) ...[
|
|
const SizedBox(width: 8),
|
|
Text(_relativeTime(row.createdAt),
|
|
style: TextStyle(color: fs.ash, fontSize: 11)),
|
|
],
|
|
]),
|
|
),
|
|
]),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Unhide',
|
|
icon: Icon(Icons.restore, color: fs.ash, size: 20),
|
|
onPressed: () async {
|
|
try {
|
|
await ref.read(myQuarantineProvider.notifier).unflag(row.trackId);
|
|
} catch (_) {
|
|
// Optimistic rollback handled by the notifier; surface
|
|
// the failure only if the user kept the tab open.
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Could not unhide; try again.')),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader({required this.label, required this.count});
|
|
final String label;
|
|
final int count;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Row(children: [
|
|
Text(label,
|
|
style: TextStyle(
|
|
color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)),
|
|
const SizedBox(width: 8),
|
|
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago"
|
|
/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025".
|
|
/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime.
|
|
String _relativeTime(String iso) {
|
|
final t = DateTime.tryParse(iso);
|
|
if (t == null) return '';
|
|
final now = DateTime.now();
|
|
final diff = now.difference(t);
|
|
if (diff.inMinutes < 60) {
|
|
final m = diff.inMinutes < 1 ? 1 : diff.inMinutes;
|
|
return '${m}m ago';
|
|
}
|
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
|
if (diff.inDays < 7) {
|
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
final hh = t.hour.toString().padLeft(2, '0');
|
|
final mm = t.minute.toString().padLeft(2, '0');
|
|
return '${days[t.weekday - 1]} $hh:$mm';
|
|
}
|
|
const months = [
|
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
];
|
|
if (t.year == now.year) return '${months[t.month - 1]} ${t.day}';
|
|
return '${months[t.month - 1]} ${t.day}, ${t.year}';
|
|
}
|