Files
minstrel/flutter_client/lib/library/album_detail_screen.dart
T
bvandeusen 3f61079c02 feat(offline): #427 S2(+S3) — two-bucket cache model
Replaces the single 5GB capBytes with independent Liked + Rolling
budgets (5GB each default). Bucket = liked-ness, NOT CacheSource:
a cached track currently in the liked set is charged to / evicted
under Liked; everything else is Rolling. Storage-only dedup — it
never filters playback (S4's offline lists query the whole index).

- db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play
  recency for S4 + rolling LRU; migration backfills to cachedAt).
  drift codegen run.
- cache_settings: likedCapBytes + rollingCapBytes (+ setters); old
  cache_cap_bytes key dropped, defaults reapply (not data loss).
- audio_cache_manager: touch(); bucketUsage() counts orphan
  partials (LockCaching files never indexed) as Rolling so the cap
  truly bounds disk; evictBuckets() drains non-liked LRU then
  sweeps orphans, Liked only by its own (large) cap — normal use
  never evicts the user's liked library.
- prefetcher → evictBuckets with the cached liked set.
- storage_section: two cap selectors + per-bucket usage (folds in
  S3 to avoid a broken intermediate).
- Explicit Download dropped: removed album + playlist Download
  buttons, autoPlaylist pins, now-unused imports.
- Tests updated/compiled (drift-cohort tests are CI-skipped).

High blast radius (eviction deletes files) — liked-protective by
design; needs operator device-check before "done".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:07:45 -04:00

139 lines
5.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../models/album.dart';
import '../likes/like_button.dart';
import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/track_row.dart';
class AlbumDetailScreen extends ConsumerWidget {
const AlbumDetailScreen({required this.id, this.seed, super.key});
final String id;
/// Optional album reference passed by the caller (typically the
/// tile they tapped) so the header can render immediately while
/// the full provider loads tracks. Hydration only — provider data
/// still wins once it arrives.
final AlbumRef? seed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final live = ref.watch(albumProvider(id));
// Pick the best header info available: live data > seed > nothing.
final headerTitle = live.value?.album.title.isNotEmpty == true
? live.value!.album.title
: seed?.title ?? '';
final headerArtist = live.value?.album.artistName.isNotEmpty == true
? live.value!.album.artistName
: seed?.artistName ?? '';
Widget header() => Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 96,
height: 96,
child: ServerImage(
url: '/api/albums/$id/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
headerTitle,
style: TextStyle(
color: fs.parchment,
fontFamily: fs.display.fontFamily,
fontSize: 22,
),
),
if (headerArtist.isNotEmpty)
Text(headerArtist, style: TextStyle(color: fs.ash)),
],
),
),
]),
);
return Scaffold(
appBar: AppBar(),
backgroundColor: fs.obsidian,
body: live.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => seed != null
? ListView(children: [
header(),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
),
])
: const Center(child: CircularProgressIndicator()),
data: (r) => ListView(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 96,
height: 96,
child: ServerImage(
url: '/api/albums/$id/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 16),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.album.title, style: TextStyle(
color: fs.parchment,
fontFamily: fs.display.fontFamily,
fontSize: 22,
)),
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
],
)),
Container(
width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
child: IconButton(
icon: Icon(Icons.play_arrow, color: fs.parchment),
onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks),
),
),
LikeButton(kind: LikeKind.album, id: r.album.id, size: 28),
]),
),
for (final t in r.tracks)
TrackRow(
track: t,
onTap: () {
final start = r.tracks.indexOf(t);
ref.read(playerActionsProvider).playTracks(r.tracks, initialIndex: start);
},
trailing: LikeButton(kind: LikeKind.track, id: t.id),
),
]),
),
);
}
}