3f61079c02
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>
255 lines
8.7 KiB
Dart
255 lines
8.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../cache/audio_cache_manager.dart';
|
|
import '../cache/cache_settings_provider.dart';
|
|
import '../cache/sync_controller.dart';
|
|
import '../likes/likes_provider.dart' show likedIdsProvider;
|
|
import '../theme/theme_extension.dart';
|
|
|
|
/// Settings card: per-bucket usage, two cap selectors (Liked +
|
|
/// Recently-played), prefetch window, cache-liked toggle, Clear
|
|
/// cache + Sync now. #427 S2/S3: two independent budgets.
|
|
class StorageSection extends ConsumerStatefulWidget {
|
|
const StorageSection({super.key});
|
|
|
|
@override
|
|
ConsumerState<StorageSection> createState() => _StorageSectionState();
|
|
}
|
|
|
|
class _StorageSectionState extends ConsumerState<StorageSection> {
|
|
BucketUsage? _usage;
|
|
bool _syncing = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_refreshUsage();
|
|
}
|
|
|
|
Set<String> _likedSet() =>
|
|
ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
|
|
|
Future<void> _refreshUsage() async {
|
|
final mgr = ref.read(audioCacheManagerProvider);
|
|
final u = await mgr.bucketUsage(_likedSet());
|
|
if (mounted) setState(() => _usage = u);
|
|
}
|
|
|
|
String _fmtBytes(int? n) {
|
|
if (n == null) return '—';
|
|
if (n == 0) return '0 B';
|
|
if (n < 1024) return '$n B';
|
|
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
|
|
if (n < 1024 * 1024 * 1024) {
|
|
return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
|
|
}
|
|
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
|
|
}
|
|
|
|
String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final settings = ref.watch(cacheSettingsProvider);
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
|
child: Card(
|
|
color: fs.iron,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: settings.when(
|
|
loading: () => const Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (e, _) => Text('$e', style: TextStyle(color: fs.error)),
|
|
data: (s) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Storage',
|
|
style: TextStyle(
|
|
color: fs.parchment,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 12),
|
|
_usageRow('Liked', _usage?.liked, s.likedCapBytes, fs),
|
|
const SizedBox(height: 4),
|
|
_usageRow('Recently played', _usage?.rolling,
|
|
s.rollingCapBytes, fs),
|
|
const SizedBox(height: 16),
|
|
_capSelector(
|
|
'Liked cache limit',
|
|
const Key('liked_cap_selector'),
|
|
s.likedCapBytes,
|
|
(v) => ref
|
|
.read(cacheSettingsProvider.notifier)
|
|
.setLikedCapBytes(v),
|
|
s,
|
|
fs,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_capSelector(
|
|
'Recently-played cache limit',
|
|
const Key('rolling_cap_selector'),
|
|
s.rollingCapBytes,
|
|
(v) => ref
|
|
.read(cacheSettingsProvider.notifier)
|
|
.setRollingCapBytes(v),
|
|
s,
|
|
fs,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_prefetchSelector(s, fs),
|
|
const SizedBox(height: 8),
|
|
SwitchListTile(
|
|
key: const Key('cache_liked_toggle'),
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text('Cache liked tracks',
|
|
style: TextStyle(color: fs.parchment)),
|
|
value: s.cacheLikedTracks,
|
|
onChanged: (v) => ref
|
|
.read(cacheSettingsProvider.notifier)
|
|
.setCacheLikedTracks(v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(spacing: 8, runSpacing: 8, children: [
|
|
OutlinedButton(
|
|
key: const Key('clear_cache_button'),
|
|
onPressed: _confirmClear,
|
|
child: const Text('Clear cache'),
|
|
),
|
|
OutlinedButton.icon(
|
|
key: const Key('sync_now_button'),
|
|
onPressed: _syncing
|
|
? null
|
|
: () async {
|
|
setState(() => _syncing = true);
|
|
try {
|
|
await ref
|
|
.read(syncControllerProvider.notifier)
|
|
.sync();
|
|
} finally {
|
|
if (mounted) setState(() => _syncing = false);
|
|
}
|
|
},
|
|
icon: _syncing
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.sync, size: 16),
|
|
label: const Text('Sync now'),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) {
|
|
return Row(children: [
|
|
Text(label, style: TextStyle(color: fs.ash)),
|
|
const Spacer(),
|
|
Text('${_fmtBytes(used)} / ${_cap(cap)}',
|
|
style: TextStyle(color: fs.parchment)),
|
|
]);
|
|
}
|
|
|
|
static const _capOptions = [
|
|
(1024 * 1024 * 1024, '1 GB'),
|
|
(5 * 1024 * 1024 * 1024, '5 GB'),
|
|
(10 * 1024 * 1024 * 1024, '10 GB'),
|
|
(25 * 1024 * 1024 * 1024, '25 GB'),
|
|
(0, 'Unlimited'),
|
|
];
|
|
|
|
Widget _capSelector(
|
|
String label,
|
|
Key key,
|
|
int current,
|
|
Future<void> Function(int) setCap,
|
|
CacheSettings s,
|
|
FabledSwordTheme fs,
|
|
) {
|
|
return Row(children: [
|
|
Expanded(child: Text(label, style: TextStyle(color: fs.ash))),
|
|
DropdownButton<int>(
|
|
key: key,
|
|
value: _capOptions.any((o) => o.$1 == current)
|
|
? current
|
|
: 5 * 1024 * 1024 * 1024,
|
|
items: _capOptions
|
|
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
|
|
.toList(),
|
|
onChanged: (v) async {
|
|
if (v == null) return;
|
|
await setCap(v);
|
|
// Enforce immediately against the freshest settings.
|
|
final fresh = ref.read(cacheSettingsProvider).value;
|
|
if (fresh != null) {
|
|
await ref.read(audioCacheManagerProvider).evictBuckets(
|
|
likedCap: fresh.likedCapBytes,
|
|
rollingCap: fresh.rollingCapBytes,
|
|
liked: _likedSet(),
|
|
);
|
|
}
|
|
await _refreshUsage();
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
|
|
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
|
|
const options = [1, 3, 5, 7, 10];
|
|
return Row(children: [
|
|
Expanded(
|
|
child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
|
|
DropdownButton<int>(
|
|
key: const Key('prefetch_selector'),
|
|
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
|
|
items: options
|
|
.map((n) => DropdownMenuItem(value: n, child: Text('$n tracks')))
|
|
.toList(),
|
|
onChanged: (v) async {
|
|
if (v == null) return;
|
|
await ref.read(cacheSettingsProvider.notifier).setPrefetchWindow(v);
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
|
|
Future<void> _confirmClear() async {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Clear cache?'),
|
|
content: const Text(
|
|
'This deletes all cached audio (liked and recently-played). '
|
|
'The next play of any track re-downloads from the server.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: fs.error),
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: const Text('Clear'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
await ref.read(audioCacheManagerProvider).clearAll();
|
|
await _refreshUsage();
|
|
}
|
|
}
|