From 5114a8118ce6f12583dd287c1a9dc2eb80b75af0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 23:23:26 -0400 Subject: [PATCH] feat(flutter): CachedIndicator + Download buttons + app startup wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CachedIndicator (lib/library/widgets/cached_indicator.dart) — small download glyph rendered next to a track row when AudioCacheManager reports the track as cached. FutureBuilder one-shot. Wiring: - track_row.dart: render CachedIndicator before the duration label - playlist_detail: 'Download' OutlinedButton next to Play; pins all playable tracks with source: autoPlaylist + SnackBar feedback - album_detail: 'Download' IconButton in the header; same pin pattern - app.dart: now ConsumerStatefulWidget — initState fires the initial sync + activates the prefetcher provider Together these complete the operator-facing surfaces of the offline slice: visible cache state, explicit download trigger, automatic sync + queue-ahead prefetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/app.dart | 26 +++++++++++++++-- .../lib/library/album_detail_screen.dart | 17 +++++++++++ .../lib/library/widgets/cached_indicator.dart | 29 +++++++++++++++++++ .../lib/library/widgets/track_row.dart | 2 ++ .../lib/playlists/playlist_detail_screen.dart | 24 ++++++++++++++- 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 flutter_client/lib/library/widgets/cached_indicator.dart diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index 4f6b2d65..a456dcd3 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -1,15 +1,37 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'cache/prefetcher.dart'; +import 'cache/sync_controller.dart'; import 'shared/routing.dart'; import 'theme/theme_data.dart'; import 'theme/theme_mode_provider.dart'; -class MinstrelApp extends ConsumerWidget { +class MinstrelApp extends ConsumerStatefulWidget { const MinstrelApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _MinstrelAppState(); +} + +class _MinstrelAppState extends ConsumerState { + @override + void initState() { + super.initState(); + // Activate offline-mode infrastructure once the first frame ships. + // SyncController.sync() is connectivity-aware (no-ops on no auth / + // no server URL), so calling it unconditionally is safe. + // Reading prefetcherProvider runs its constructor, which wires the + // queue + settings listeners. + WidgetsBinding.instance.addPostFrameCallback((_) { + // ignore: unawaited_futures + ref.read(syncControllerProvider.notifier).sync(); + ref.read(prefetcherProvider); + }); + } + + @override + Widget build(BuildContext context) { final router = ref.watch(routerProvider); final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system; return MaterialApp.router( diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart index a563dd0b..90825b90 100644 --- a/flutter_client/lib/library/album_detail_screen.dart +++ b/flutter_client/lib/library/album_detail_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/likes.dart'; +import '../cache/audio_cache_manager.dart'; +import '../cache/db.dart'; import '../likes/like_button.dart'; import '../player/player_provider.dart'; import '../theme/theme_extension.dart'; @@ -38,6 +40,21 @@ class AlbumDetailScreen extends ConsumerWidget { Text(r.album.artistName, style: TextStyle(color: fs.ash)), ], )), + IconButton( + key: const Key('download_album_button'), + icon: Icon(Icons.download, color: fs.ash), + tooltip: 'Download album', + onPressed: () { + final mgr = ref.read(audioCacheManagerProvider); + for (final t in r.tracks) { + // ignore: unawaited_futures + mgr.pin(t.id, source: CacheSource.autoPlaylist); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')), + ); + }, + ), Container( width: 48, height: 48, decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), diff --git a/flutter_client/lib/library/widgets/cached_indicator.dart b/flutter_client/lib/library/widgets/cached_indicator.dart new file mode 100644 index 00000000..8a3c0d84 --- /dev/null +++ b/flutter_client/lib/library/widgets/cached_indicator.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../cache/audio_cache_manager.dart'; +import '../../theme/theme_extension.dart'; + +/// Small download glyph shown next to a track row when the track is +/// cached locally. FutureBuilder one-shot — track rows are short-lived +/// and cache state rarely changes mid-render. +class CachedIndicator extends ConsumerWidget { + const CachedIndicator({required this.trackId, super.key}); + final String trackId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mgr = ref.read(audioCacheManagerProvider); + final fs = Theme.of(context).extension()!; + return FutureBuilder( + future: mgr.isCached(trackId), + builder: (ctx, snap) { + if (snap.data != true) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon(Icons.download_done, size: 14, color: fs.accent), + ); + }, + ); + } +} diff --git a/flutter_client/lib/library/widgets/track_row.dart b/flutter_client/lib/library/widgets/track_row.dart index e0607555..cc73089d 100644 --- a/flutter_client/lib/library/widgets/track_row.dart +++ b/flutter_client/lib/library/widgets/track_row.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../models/track.dart'; import '../../shared/widgets/track_actions/track_actions_button.dart'; import '../../theme/theme_extension.dart'; +import 'cached_indicator.dart'; class TrackRow extends StatelessWidget { const TrackRow({ @@ -55,6 +56,7 @@ class TrackRow extends StatelessWidget { ), ]), ), + CachedIndicator(trackId: track.id), Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), if (trailing != null) Padding( diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index c381bb81..468ce5a2 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../cache/audio_cache_manager.dart'; +import '../cache/db.dart'; import '../models/playlist.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; @@ -138,7 +140,26 @@ class _Header extends ConsumerWidget { style: TextStyle(color: fs.ash, fontSize: 12), ), const Spacer(), - if (playable.isNotEmpty) + if (playable.isNotEmpty) ...[ + OutlinedButton.icon( + key: const Key('download_playlist_button'), + onPressed: () { + final mgr = ref.read(audioCacheManagerProvider); + for (final t in playable) { + final id = t.trackId ?? ''; + if (id.isEmpty) continue; + // Fire-and-forget; downloads happen in background. + // ignore: unawaited_futures + mgr.pin(id, source: CacheSource.autoPlaylist); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Downloading ${playable.length} tracks…')), + ); + }, + icon: const Icon(Icons.download, size: 16), + label: const Text('Download'), + ), + const SizedBox(width: 8), FilledButton.icon( onPressed: () { final refs = playable.map(_toTrackRef).toList(growable: false); @@ -151,6 +172,7 @@ class _Header extends ConsumerWidget { foregroundColor: fs.parchment, ), ), + ], ]), ]), );