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 '../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 '../shared/widgets/main_app_bar_actions.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)); }); final _libraryArtistsProvider = FutureProvider>((ref) async { return (await ref.watch(_libraryListsApiProvider.future)).listArtists(); }); final _libraryAlbumsProvider = FutureProvider>((ref) async { return (await ref.watch(_libraryListsApiProvider.future)).listAlbums(); }); final _historyProvider = FutureProvider((ref) async { return (await ref.watch(_meApiProvider.future)).history(); }); final _likedTracksProvider = FutureProvider>((ref) async { return (await ref.watch(_likesApiProvider.future)).listTracks(); }); final _likedAlbumsProvider = FutureProvider>((ref) async { return (await ref.watch(_likesApiProvider.future)).listAlbums(); }); final _likedArtistsProvider = FutureProvider>((ref) async { return (await ref.watch(_likesApiProvider.future)).listArtists(); }); final _quarantineProvider = FutureProvider>((ref) async { return (await ref.watch(_meApiProvider.future)).quarantineMine(); }); 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) => 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.refresh(_libraryArtistsProvider.future), 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) => ArtistCard( artist: page.items[i], onTap: () => ctx.push('/artists/${page.items[i].id}'), ), ), ), ); } } 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) => 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.refresh(_libraryAlbumsProvider.future), child: GridView.builder( padding: const EdgeInsets.all(8), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.78, ), itemCount: page.items.length, itemBuilder: (ctx, i) => AlbumCard( album: page.items[i], onTap: () => ctx.push('/albums/${page.items[i].id}'), ), ), ), ); } } 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()!; 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) => ArtistCard( artist: ar.items[i], onTap: () => ctx.push('/artists/${ar.items[i].id}'), ), ), ), ], 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) => AlbumCard( album: al.items[i], onTap: () => ctx.push('/albums/${al.items[i].id}'), ), ), ), ], 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(_quarantineProvider).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(_quarantineProvider.future), child: ListView.separated( itemCount: rows.length, separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]), ), ), ); } } class _QuarantineTile extends StatelessWidget { const _QuarantineTile({required this.row}); final QuarantineMineRow row; @override Widget build(BuildContext context) { 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', }; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ 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: 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)), ), ), ]), ), ]), ); } } 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}'; }