395a6efb26
Slice 4 of the smooth-loading pass. Adds CachedHistorySnapshot (schema 4) and rewires _historyProvider through cacheFirst, mirroring the homeProvider pattern: yield the last cached page immediately on subscribe so the tab paints from disk on cold open, then SWR-refresh in the background to surface fresh plays. Also enables basic offline scrollback — the most recent History page survives both app restart and connectivity loss. JSON blob storage (vs columnar) because the page is small, always read whole, and HistoryPage.fromJson already accepts the wire shape, so server-side field additions don't force a migration. History delta sync via library_changes is out of scope here; the next visit's SWR pull is the source of freshness for now.
883 lines
32 KiB
Dart
883 lines
32 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 '../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 '../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<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);
|
|
|
|
// 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,
|
|
});
|
|
|
|
// 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<wire.Paged<TrackRef>>((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<drift.TypedResult, wire.Paged<TrackRef>>(
|
|
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<wire.Paged<AlbumRef>>((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<drift.TypedResult, wire.Paged<AlbumRef>>(
|
|
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<wire.Paged<ArtistRef>>((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<drift.TypedResult, wire.Paged<ArtistRef>>(
|
|
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<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) {
|
|
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<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>()!;
|
|
// #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<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(_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<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}';
|
|
}
|