import 'dart:convert'; import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.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/shuffle_source.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((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)); }); /// 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>((ref) { final db = ref.watch(appDbProvider); // LEFT JOIN cached_albums so each artist row carries a representative // album id for cover-URL reconstruction. Sorted by artist sortName // then album sortTitle: the first row per artist gets the // alphabetically-first album. Dedup happens in toResult. final query = db.select(db.cachedArtists).join([ drift.leftOuterJoin(db.cachedAlbums, db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), ]) ..orderBy([ drift.OrderingTerm.asc(db.cachedArtists.sortName), drift.OrderingTerm.asc(db.cachedAlbums.sortTitle), ]); return cacheFirst>( 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) { // The join multiplies rows by album count; dedup by artist id // keeping the first occurrence so we get one ArtistRef per // artist with the alphabetically-first album's id for the // cover-URL projection. Artists with no albums in drift yet // come through as a single row with null cachedAlbums and an // empty coverUrl — UI falls back to the placeholder icon. final seen = {}; final out = []; 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', ); }); /// 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>((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>( 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((ref) { final db = ref.watch(appDbProvider); return cacheFirst( 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), 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 _idsForEntity(List rows) => rows.map((r) => r.entityId).toList(growable: false); final _likedTrackIdsProvider = StreamProvider>((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>( driftStream: query, fetchAndPopulate: () => _populateLikeIds(ref), toResult: _idsForEntity, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), alwaysRefresh: true, tag: 'likedTrackIds', ); }); final _likedAlbumIdsProvider = StreamProvider>((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>( driftStream: query, fetchAndPopulate: () => _populateLikeIds(ref), toResult: _idsForEntity, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), alwaysRefresh: true, tag: 'likedAlbumIds', ); }); final _likedArtistIdsProvider = StreamProvider>((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>( driftStream: query, fetchAndPopulate: () => _populateLikeIds(ref), toResult: _idsForEntity, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), 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 _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 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()!; // 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: [ IconButton( key: const Key('shuffle_all_button'), tooltip: 'Shuffle all', icon: Icon(LucideIcons.shuffle, color: fs.parchment), onPressed: () async { final messenger = ScaffoldMessenger.of(context); final refs = await ref.read(shuffleSourceProvider).tracks(); if (refs.isEmpty) { messenger.showSnackBar(const SnackBar( content: Text('Nothing to shuffle yet'), )); return; } await ref .read(playerActionsProvider) .playTracks(refs, shuffle: true); }, ), 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: (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()!; 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()!; 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()!; // 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>(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 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 = []; 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()!; 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(LucideIcons.archive_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}'; }