4bd069430b
MetadataPrefetcher gains warmAlbums(ids) / warmArtists(ids) public methods so callers can fan out drift-cache warm-ups for whatever collection just landed. Wired into: - Library Artists tab — warms first 8 artists from the page on every data emit (initial + paginate + refresh). - Library Albums tab — same for first 8 albums. - Artist detail album grid — warms first 8 albums from the artist's album list as soon as it loads, so tapping into any of them is a drift hit. Hard-cap of 8 per call (same as the home prefetch). Set spans across calls aren't deduped at this layer because providers themselves short-circuit on cached values. Also instrument the artist-detail play button: try/catch around the artistTracksProvider read + playTracks call, snackbar on empty-tracks or thrown-error so silent failures stop being silent. The current behavior was an early return on tracks.isEmpty with no visible feedback.
581 lines
21 KiB
Dart
581 lines
21 KiB
Dart
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 '../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<T>.
|
|
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<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));
|
|
});
|
|
|
|
/// 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<wire.Paged<ArtistRef>> {
|
|
static const _pageSize = 50;
|
|
bool _loadingMore = false;
|
|
|
|
@override
|
|
Future<wire.Paged<ArtistRef>> build() async {
|
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
|
return api.listArtists(limit: _pageSize, offset: 0);
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
if (_loadingMore) return;
|
|
final cur = state.value;
|
|
if (cur == null) return;
|
|
if (cur.items.length >= cur.total) return;
|
|
_loadingMore = true;
|
|
try {
|
|
final api = await ref.read(_libraryListsApiProvider.future);
|
|
final 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<ArtistRef>>(_LibraryArtistsNotifier.new);
|
|
|
|
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
|
static const _pageSize = 50;
|
|
bool _loadingMore = false;
|
|
|
|
@override
|
|
Future<wire.Paged<AlbumRef>> build() async {
|
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
|
return api.listAlbums(limit: _pageSize, offset: 0);
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
if (_loadingMore) return;
|
|
final cur = state.value;
|
|
if (cur == null) return;
|
|
if (cur.items.length >= cur.total) return;
|
|
_loadingMore = true;
|
|
try {
|
|
final api = await ref.read(_libraryListsApiProvider.future);
|
|
final 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<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
|
|
|
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
|
return (await ref.watch(_meApiProvider.future)).history();
|
|
});
|
|
|
|
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
|
|
return (await ref.watch(_likesApiProvider.future)).listTracks();
|
|
});
|
|
|
|
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
|
return (await ref.watch(_likesApiProvider.future)).listAlbums();
|
|
});
|
|
|
|
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
|
return (await ref.watch(_likesApiProvider.future)).listArtists();
|
|
});
|
|
|
|
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
|
|
return (await ref.watch(_meApiProvider.future)).quarantineMine();
|
|
});
|
|
|
|
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>()!;
|
|
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: (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<ScrollNotification>(
|
|
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<FabledSwordTheme>()!;
|
|
return ref.watch(_libraryAlbumsProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (page) {
|
|
ref
|
|
.read(metadataPrefetcherProvider)
|
|
.warmAlbums(page.items.map((a) => a.id));
|
|
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) + small fudge.
|
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
|
return NotificationListener<ScrollNotification>(
|
|
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<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>()!;
|
|
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<FabledSwordTheme>()!;
|
|
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<FabledSwordTheme>()!;
|
|
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<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}';
|
|
}
|