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 '../library/library_providers.dart' show dioProvider; import '../models/album.dart'; import '../models/artist.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. import '../models/page.dart' as wire; 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/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((ref) async { return LibraryListsApi(await ref.watch(dioProvider.future)); }); final _likesApiProvider = FutureProvider((ref) async { return LikesApi(await ref.watch(dioProvider.future)); }); final _meApiProvider = FutureProvider((ref) async { return MeApi(await ref.watch(dioProvider.future)); }); /// Paginated artist list. AsyncNotifier so the screen can call /// `loadMore()` when the scroll approaches the bottom; subsequent /// pages append to the existing items rather than replacing them. class _LibraryArtistsNotifier extends AsyncNotifier> { static const _pageSize = 50; bool _loadingMore = false; @override Future> build() async { final api = await ref.watch(_libraryListsApiProvider.future); return api.listArtists(limit: _pageSize, offset: 0); } Future 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 next = await api.listArtists( limit: _pageSize, offset: cur.items.length, ); state = AsyncData(wire.Paged( items: [...cur.items, ...next.items], total: next.total, limit: next.limit, offset: 0, )); } catch (_) { // Swallow — the next near-bottom event will retry. The current // partial list stays visible. } finally { _loadingMore = false; } } } final _libraryArtistsProvider = AsyncNotifierProvider< _LibraryArtistsNotifier, wire.Paged>(_LibraryArtistsNotifier.new); class _LibraryAlbumsNotifier extends AsyncNotifier> { static const _pageSize = 50; bool _loadingMore = false; @override Future> build() async { final api = await ref.watch(_libraryListsApiProvider.future); return api.listAlbums(limit: _pageSize, offset: 0); } Future 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 next = await api.listAlbums( limit: _pageSize, offset: cur.items.length, ); state = AsyncData(wire.Paged( items: [...cur.items, ...next.items], total: next.total, limit: next.limit, offset: 0, )); } catch (_) { // Swallow — next scroll event will retry. } finally { _loadingMore = false; } } } final _libraryAlbumsProvider = AsyncNotifierProvider< _LibraryAlbumsNotifier, wire.Paged>(_LibraryAlbumsNotifier.new); final _historyProvider = FutureProvider((ref) async { return (await ref.watch(_meApiProvider.future)).history(); }); // Drift-first Liked tabs (#357 plan C). Read from cached_likes joined // against cached_tracks / cached_albums / cached_artists. SyncController // keeps both sides fresh; LikesController mutates cached_likes // optimistically, so toggling a like re-emits these streams instantly // for snappy UI. REST cold-cache fallback hits /api/likes/* when drift // is empty (fresh install, first sync still pending). SWR refresh on // every visit catches server-side likes the library_changes log hasn't // shipped yet (e.g. a like from another device that fired just before // this screen mounted). // // The original FutureProvider versions fetched the first 50 rows; drift // returns everything cached_likes knows about. Pagination can come back // when liked lists are big enough to matter — typical libraries are // well under the 50-row ceiling. final _likedTracksProvider = StreamProvider>((ref) { final db = ref.watch(appDbProvider); final query = db.select(db.cachedLikes).join([ drift.innerJoin(db.cachedTracks, db.cachedTracks.id.equalsExp(db.cachedLikes.entityId)), drift.leftOuterJoin(db.cachedArtists, db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), drift.leftOuterJoin(db.cachedAlbums, db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), ]) ..where(db.cachedLikes.entityType.equals('track')) ..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]); return cacheFirst>( driftStream: query.watch(), fetchAndPopulate: () async { final api = await ref.read(_likesApiProvider.future); final user = ref.read(authControllerProvider).value; if (user == null) return; final fresh = await api.listTracks(); await db.batch((b) { // Track metadata via toDrift() — artistName/albumTitle come // from the join, so we don't need to denormalize them here. b.insertAllOnConflictUpdate( db.cachedTracks, fresh.items.map((t) => t.toDrift()).toList(), ); // Like rows via insertOrIgnore so existing rows keep their // original likedAt (preserves the ORDER BY likedAt DESC). for (final t in fresh.items) { b.insert( db.cachedLikes, CachedLikesCompanion.insert( userId: user.id, entityType: 'track', entityId: t.id, ), mode: drift.InsertMode.insertOrIgnore, ); } }); }, toResult: (rows) { final tracks = rows.map((r) { final t = r.readTable(db.cachedTracks); final a = r.readTableOrNull(db.cachedArtists); final al = r.readTableOrNull(db.cachedAlbums); return t.toRef( artistName: a?.name ?? '', albumTitle: al?.title ?? '', ); }).toList(); return wire.Paged( items: tracks, total: tracks.length, limit: tracks.length, offset: 0, ); }, isOnline: () async => (await ref.read(connectivityProvider.future)), alwaysRefresh: true, tag: 'likedTracks', ); }); final _likedAlbumsProvider = StreamProvider>((ref) { final db = ref.watch(appDbProvider); final query = db.select(db.cachedLikes).join([ drift.innerJoin(db.cachedAlbums, db.cachedAlbums.id.equalsExp(db.cachedLikes.entityId)), drift.leftOuterJoin(db.cachedArtists, db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), ]) ..where(db.cachedLikes.entityType.equals('album')) ..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]); return cacheFirst>( driftStream: query.watch(), fetchAndPopulate: () async { final api = await ref.read(_likesApiProvider.future); final user = ref.read(authControllerProvider).value; if (user == null) return; final fresh = await api.listAlbums(); await db.batch((b) { b.insertAllOnConflictUpdate( db.cachedAlbums, fresh.items.map((a) => a.toDrift()).toList(), ); for (final a in fresh.items) { b.insert( db.cachedLikes, CachedLikesCompanion.insert( userId: user.id, entityType: 'album', entityId: a.id, ), mode: drift.InsertMode.insertOrIgnore, ); } }); }, toResult: (rows) { final albums = rows.map((r) { final al = r.readTable(db.cachedAlbums); final a = r.readTableOrNull(db.cachedArtists); return al.toRef(artistName: a?.name ?? ''); }).toList(); return wire.Paged( items: albums, total: albums.length, limit: albums.length, offset: 0, ); }, isOnline: () async => (await ref.read(connectivityProvider.future)), alwaysRefresh: true, tag: 'likedAlbums', ); }); final _likedArtistsProvider = StreamProvider>((ref) { final db = ref.watch(appDbProvider); final query = db.select(db.cachedLikes).join([ drift.innerJoin(db.cachedArtists, db.cachedArtists.id.equalsExp(db.cachedLikes.entityId)), ]) ..where(db.cachedLikes.entityType.equals('artist')) ..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]); return cacheFirst>( driftStream: query.watch(), fetchAndPopulate: () async { final api = await ref.read(_likesApiProvider.future); final user = ref.read(authControllerProvider).value; if (user == null) return; final fresh = await api.listArtists(); await db.batch((b) { b.insertAllOnConflictUpdate( db.cachedArtists, fresh.items.map((a) => a.toDrift()).toList(), ); for (final a in fresh.items) { b.insert( db.cachedLikes, CachedLikesCompanion.insert( userId: user.id, entityType: 'artist', entityId: a.id, ), mode: drift.InsertMode.insertOrIgnore, ); } }); }, toResult: (rows) { final artists = rows.map((r) => r.readTable(db.cachedArtists).toRef()).toList(); return wire.Paged( items: artists, total: artists.length, limit: artists.length, offset: 0, ); }, isOnline: () async => (await ref.read(connectivityProvider.future)), alwaysRefresh: true, tag: 'likedArtists', ); }); // 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 createState() => _LibraryScreenState(); } class _LibraryScreenState extends ConsumerState 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()!; 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()!; return ref.watch(_libraryArtistsProvider).when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), data: (page) { // Warm details for the first screenful so taps are instant. ref .read(metadataPrefetcherProvider) .warmArtists(page.items.map((a) => a.id)); return page.items.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.invalidate(_libraryArtistsProvider); await ref.read(_libraryArtistsProvider.future); }, // Listen for scroll-near-bottom and ask the notifier // to fetch the next page. 800px lookahead so the next // page lands before the user reaches the visible end. child: NotificationListener( onNotification: (n) { if (n.metrics.pixels >= n.metrics.maxScrollExtent - 800) { ref .read(_libraryArtistsProvider.notifier) .loadMore(); } return false; }, child: GridView.builder( padding: const EdgeInsets.all(8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.78, ), itemCount: page.items.length, itemBuilder: (ctx, i) { final artist = page.items[i]; return ArtistCard( artist: artist, 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()!; return ref.watch(_libraryAlbumsProvider).when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), data: (page) { return page.items.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.invalidate(_libraryAlbumsProvider); await ref.read(_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. 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 NotificationListener( onNotification: (n) { if (n.metrics.pixels >= n.metrics.maxScrollExtent - 800) { ref .read(_libraryAlbumsProvider.notifier) .loadMore(); } 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), ); }, ), ); }), ); }, ); } } class _HistoryTab extends ConsumerWidget { const _HistoryTab(); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; 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()!; // #402 wire-up: when any like/unlike event arrives via SSE, // invalidate all three Liked sub-lists. Server-side events are // already user-scoped (publishLikeEvent attaches user_id), so the // dispatcher filters out other users' events upstream. ref.listen>(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(_likedTracksProvider); ref.invalidate(_likedAlbumsProvider); ref.invalidate(_likedArtistsProvider); } }); final tracksA = ref.watch(_likedTracksProvider); final albumsA = ref.watch(_likedAlbumsProvider); final artistsA = ref.watch(_likedArtistsProvider); if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) { return const Center(child: CircularProgressIndicator()); } final t = tracksA.value; final al = albumsA.value; final ar = artistsA.value; if (t == null || al == null || ar == null) { return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error))); } if (t.items.isEmpty && al.items.isEmpty && ar.items.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(_likedTracksProvider.future), ref.refresh(_likedAlbumsProvider.future), ref.refresh(_likedArtistsProvider.future), ]); }, child: ListView(children: [ if (ar.items.isNotEmpty) ...[ _SectionHeader(label: 'Artists', count: ar.total), SizedBox( height: 168, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), itemCount: ar.items.length, itemBuilder: (ctx, i) { final artist = ar.items[i]; return ArtistCard( artist: artist, onTap: () => ctx.push('/artists/${artist.id}', extra: artist), ); }, ), ), ], if (al.items.isNotEmpty) ...[ _SectionHeader(label: 'Albums', count: al.total), SizedBox( height: 200, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), itemCount: al.items.length, itemBuilder: (ctx, i) { final album = al.items[i]; return AlbumCard( album: album, onTap: () => ctx.push('/albums/${album.id}', extra: album), ); }, ), ), ], if (t.items.isNotEmpty) ...[ _SectionHeader(label: 'Tracks', count: t.total), ...t.items.asMap().entries.map((e) { return TrackRow( track: e.value, onTap: () => ref .read(playerActionsProvider) .playTracks(t.items, initialIndex: e.key), ); }), ], const SizedBox(height: 96), ]), ); } } class _HiddenTab extends ConsumerWidget { const _HiddenTab(); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; 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()!; 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()!; 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}'; }