offline updates and playhead fix

This commit is contained in:
Forrest 2026-08-16 12:46:30 -04:00
parent 7a199fe4df
commit 6663330260
14 changed files with 1673 additions and 545 deletions

View file

@ -1,10 +1,14 @@
import 'package:flutter/widgets.dart' show NetworkImage;
import 'dart:io';
import 'package:flutter/widgets.dart'
show FileImage, ImageProvider, NetworkImage;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../history/play_history.dart';
import '../library/browse_query.dart';
import '../library/library_index.dart';
import '../library/offline_library.dart';
import '../playback/playback_engine.dart';
import '../playlists/playlists.dart';
import '../settings/settings_store.dart';
@ -61,15 +65,16 @@ class ConnectionState {
);
}
final credentialStoreProvider =
Provider<CredentialStore>((_) => CredentialStore());
final credentialStoreProvider = Provider<CredentialStore>(
(_) => CredentialStore(),
);
/// Owns the active server connection and the list of saved servers: builds the
/// client, pings, persists credentials, auto-restores on launch, and switches
/// between servers (one active at a time — TODO #2).
class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController(this._store)
: super(const ConnectionState(status: ConnStatus.disconnected)) {
: super(const ConnectionState(status: ConnStatus.disconnected)) {
_restore();
}
@ -206,8 +211,10 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
}
Future<void> _addOrUpdate(SubsonicCredentials creds,
{required bool makeActive}) async {
Future<void> _addOrUpdate(
SubsonicCredentials creds, {
required bool makeActive,
}) async {
final idx = _servers.indexWhere((s) => s.id == creds.id);
final next = [..._servers];
if (idx >= 0) {
@ -233,8 +240,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
final connectionProvider =
StateNotifierProvider<ConnectionController, ConnectionState>(
(ref) => ConnectionController(ref.watch(credentialStoreProvider)),
);
(ref) => ConnectionController(ref.watch(credentialStoreProvider)),
);
/// The active client, or null when not connected.
final subsonicClientProvider = Provider<SubsonicClient?>(
@ -264,9 +271,20 @@ final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
// ---- Library ------------------------------------------------------------
/// The downloaded tracks as full [Song]s — the single source that backs every
/// offline browse view so Albums / Artists / Tracks stay consistent.
final downloadedSongsProvider = Provider<List<Song>>(
(ref) =>
ref.watch(downloadManagerProvider).completed.map((d) => d.song).toList(),
);
final artistsProvider = FutureProvider<List<Artist>>((ref) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
// Offline: synthesize the artist list from what's downloaded, so it
// repopulates as downloads complete and recomputes on connect/disconnect.
if (client == null) {
return artistsFromSongs(ref.watch(downloadedSongsProvider));
}
final result = await client.getArtists();
return result.all;
});
@ -275,7 +293,11 @@ final artistsProvider = FutureProvider<List<Artist>>((ref) async {
/// truncated. Backs the Albums cover-art grid.
final albumsProvider = FutureProvider<List<Album>>((ref) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
// Offline: synthesize the album grid from downloaded songs (watched
// synchronously up-front, before any await, so it recomputes as they land).
if (client == null) {
return albumsFromSongs(ref.watch(downloadedSongsProvider));
}
const pageSize = 500;
final all = <Album>[];
var offset = 0;
@ -292,23 +314,26 @@ final albumsProvider = FutureProvider<List<Album>>((ref) async {
/// (and any in-flight build cancelled) whenever the server changes.
final libraryIndexProvider =
StateNotifierProvider<LibraryIndexController, LibraryIndexState>((ref) {
final controller =
LibraryIndexController(() => ref.read(subsonicClientProvider));
ref.listen<ConnectionState>(connectionProvider, (_, _) {
controller.onConnectionChanged();
});
return controller;
});
final controller = LibraryIndexController(
() => ref.read(subsonicClientProvider),
);
ref.listen<ConnectionState>(connectionProvider, (_, _) {
controller.onConnectionChanged();
});
return controller;
});
// ---- Browse filtering / sorting -----------------------------------------
/// Session-only genre/year filter for the Albums grid (resets on restart).
final albumFilterProvider =
StateProvider<BrowseFilter>((_) => const BrowseFilter());
final albumFilterProvider = StateProvider<BrowseFilter>(
(_) => const BrowseFilter(),
);
/// Session-only genre/year filter for the Tracks list (resets on restart).
final trackFilterProvider =
StateProvider<BrowseFilter>((_) => const BrowseFilter());
final trackFilterProvider = StateProvider<BrowseFilter>(
(_) => const BrowseFilter(),
);
/// Distinct genres present across all albums, for the album genre picker.
final albumGenresProvider = Provider<List<String>>((ref) {
@ -332,23 +357,36 @@ final visibleAlbumsProvider = Provider<AsyncValue<List<Album>>>((ref) {
.whenData((albums) => applyAlbumQuery(albums, filter, sort));
});
/// Distinct genres present across all indexed tracks.
/// Distinct genres present across all indexed tracks. Falls back to the
/// downloaded songs when offline (the crawled index is wiped without a server).
final trackGenresProvider = Provider<List<String>>((ref) {
final songs = ref.watch(libraryIndexProvider).songs;
final offline = ref.watch(subsonicClientProvider) == null;
final songs = offline
? ref.watch(downloadedSongsProvider)
: ref.watch(libraryIndexProvider).songs;
return distinctGenres(songs.map((s) => s.genre));
});
/// Distinct release years present across all indexed tracks, newest first.
/// Falls back to the downloaded songs when offline.
final trackYearsProvider = Provider<List<int>>((ref) {
final songs = ref.watch(libraryIndexProvider).songs;
final offline = ref.watch(subsonicClientProvider) == null;
final songs = offline
? ref.watch(downloadedSongsProvider)
: ref.watch(libraryIndexProvider).songs;
return distinctYears(songs.map((s) => s.year));
});
/// Indexed tracks after applying the session filter and the persisted sort.
/// When offline the crawled index is empty, so the Tracks view is backed by the
/// downloaded songs instead — the same filter/sort pipeline applies to both.
final visibleTracksProvider = Provider<List<Song>>((ref) {
final filter = ref.watch(trackFilterProvider);
final sort = ref.watch(settingsProvider.select((s) => s.trackSort));
final songs = ref.watch(libraryIndexProvider).songs;
final offline = ref.watch(subsonicClientProvider) == null;
final songs = offline
? ref.watch(downloadedSongsProvider)
: ref.watch(libraryIndexProvider).songs;
// Live ratings so the Rating sort/filter reacts to star changes immediately.
final ratings = ref.watch(favoritesProvider).ratings;
return applyTrackQuery(songs, filter, sort, ratings: ratings);
@ -373,18 +411,32 @@ final randomAlbumsProvider = FutureProvider<List<Album>>((ref) async {
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) throw StateError('Not connected');
// Offline: rebuild the artist from downloaded songs. [id] is whatever
// [artistsFromSongs] produced (real id or name), so it's passed straight
// through. Keep throwing when absent so the FutureProvider error state works.
if (client == null) {
final artist = artistFromSongs(ref.watch(downloadedSongsProvider), id);
if (artist == null) throw StateError('Not found offline');
return artist;
}
return client.getArtist(id);
});
final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) throw StateError('Not connected');
// Offline: rebuild the album from downloaded songs (see [artistProvider]).
if (client == null) {
final album = albumFromSongs(ref.watch(downloadedSongsProvider), id);
if (album == null) throw StateError('Not found offline');
return album;
}
return client.getAlbum(id);
});
final searchProvider =
FutureProvider.family<SearchResult3, String>((ref, query) async {
final searchProvider = FutureProvider.family<SearchResult3, String>((
ref,
query,
) async {
final client = ref.watch(subsonicClientProvider);
final q = query.trim();
if (client == null || q.isEmpty) {
@ -394,7 +446,9 @@ final searchProvider =
// "Standard" trims the server's broad matches down to name/title hits;
// "Discovery" (default) returns the server result unchanged.
final mode = ref.watch(settingsProvider).searchMode;
return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result;
return mode == SearchMode.standard
? filterSearchToStandard(result, q)
: result;
});
/// Narrows a [SearchResult3] to items whose *own* name/title contains [query]
@ -414,8 +468,8 @@ SearchResult3 filterSearchToStandard(SearchResult3 r, String query) {
final playHistoryProvider =
StateNotifierProvider<HistoryController, List<PlayRecord>>(
(ref) => HistoryController(),
);
(ref) => HistoryController(),
);
final recentSongsProvider = Provider<List<PlayRecord>>(
(ref) => recentSongs(ref.watch(playHistoryProvider)),
@ -438,18 +492,19 @@ final rediscoverProvider = Provider<List<RediscoverArtist>>((ref) {
final favoritesProvider =
StateNotifierProvider<FavoritesController, FavoritesState>((ref) {
final controller =
FavoritesController(() => ref.read(subsonicClientProvider));
// Re-hydrate on connect, clear on disconnect.
ref.listen<ConnectionState>(connectionProvider, (prev, next) {
if (next.isOnline) {
controller.hydrate();
} else {
controller.clear();
}
});
return controller;
});
final controller = FavoritesController(
() => ref.read(subsonicClientProvider),
);
// Re-hydrate on connect, clear on disconnect.
ref.listen<ConnectionState>(connectionProvider, (prev, next) {
if (next.isOnline) {
controller.hydrate();
} else {
controller.clear();
}
});
return controller;
});
/// Full starred set for the Favorites screen.
final starredProvider = FutureProvider<Starred2>((ref) async {
@ -468,21 +523,21 @@ final starredProvider = FutureProvider<Starred2>((ref) async {
/// their status. Reloads its manifest whenever the server key changes.
final downloadManagerProvider =
StateNotifierProvider<DownloadController, DownloadState>((ref) {
final controller = DownloadController(
clientGetter: () => ref.read(subsonicClientProvider),
settingsGetter: () => ref.read(settingsProvider),
serverKeyGetter: () => ref.read(serverKeyProvider),
);
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer();
});
// Raising the concurrency cap should launch queued downloads right away.
ref.listen<int>(
settingsProvider.select((s) => s.maxConcurrentDownloads),
(_, _) => controller.onConcurrencyChanged(),
);
return controller;
});
final controller = DownloadController(
clientGetter: () => ref.read(subsonicClientProvider),
settingsGetter: () => ref.read(settingsProvider),
serverKeyGetter: () => ref.read(serverKeyProvider),
);
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer();
});
// Raising the concurrency cap should launch queued downloads right away.
ref.listen<int>(
settingsProvider.select((s) => s.maxConcurrentDownloads),
(_, _) => controller.onConcurrencyChanged(),
);
return controller;
});
// ---- Playlists ----------------------------------------------------------
@ -490,34 +545,38 @@ final downloadManagerProvider =
/// the server key changes (connect / disconnect / server switch).
final playlistsProvider =
StateNotifierProvider<PlaylistsController, PlaylistsState>((ref) {
final controller = PlaylistsController(
clientGetter: () => ref.read(subsonicClientProvider),
serverKeyGetter: () => ref.read(serverKeyProvider),
);
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer();
});
return controller;
});
final controller = PlaylistsController(
clientGetter: () => ref.read(subsonicClientProvider),
serverKeyGetter: () => ref.read(serverKeyProvider),
);
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer();
});
return controller;
});
/// User-facing playlists — everything *not* marked as a Timbre tag. Backs the
/// Playlists screen and the "add to playlist" sheet.
final realPlaylistsProvider = Provider<List<Playlist>>((ref) => ref
.watch(playlistsProvider)
.playlists
.where((p) => !isTagPlaylist(p))
.toList());
final realPlaylistsProvider = Provider<List<Playlist>>(
(ref) => ref
.watch(playlistsProvider)
.playlists
.where((p) => !isTagPlaylist(p))
.toList(),
);
/// Tags — playlists carrying the tag comment marker. Backs the Tags screen and
/// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the
/// partition differs.
final tagsProvider = Provider<List<Playlist>>(
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList());
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList(),
);
/// The signed-in user's name on the active server, or null when disconnected.
/// Used to split owned playlists from ones shared by other users.
final currentUsernameProvider = Provider<String?>(
(ref) => ref.watch(connectionProvider).credentials?.username);
(ref) => ref.watch(connectionProvider).credentials?.username,
);
/// The user's own playlists (owned, or owner unknown). Backs the main list and
/// the "add to playlist" sheet — you can only add tracks to your own playlists.
@ -538,10 +597,53 @@ final sharedPlaylistsProvider = Provider<List<Playlist>>((ref) {
.toList();
});
// ---- Art resolution -----------------------------------------------------
/// Shared implementation for the art resolvers below. Takes the two things it
/// needs as plain values so it can serve both provider (`Ref`) and widget
/// (`WidgetRef`) callers, which share no common ref supertype in Riverpod 2.x.
Uri? _resolveArt(
String? Function(String?) localArtPathFor,
SubsonicClient? client, {
String? coverArt,
int size = 512,
}) {
if (coverArt == null) return null;
final local = localArtPathFor(coverArt);
if (local != null) return Uri.file(local);
if (client == null) return null;
return client.coverArtUri(coverArt, size: size);
}
/// Resolves the best art URI for a cover-art id: a cached local file when the
/// track is downloaded, else the server URL when online, else null (offline &
/// uncached → callers show a placeholder).
///
/// Provider-side entry point (playback closures, other providers have a [Ref]).
/// Widgets, which hold a `WidgetRef`, use [resolveArtUriW] instead.
Uri? resolveArtUri(Ref ref, {String? coverArt, int size = 512}) => _resolveArt(
ref.read(downloadManagerProvider.notifier).localArtPathFor,
ref.read(subsonicClientProvider),
coverArt: coverArt,
size: size,
);
/// Widget-side twin of [resolveArtUri] for callers holding a `WidgetRef`
/// (`WidgetRef` is not a [Ref] in Riverpod 2.x). Phase 3 widgets call this,
/// passing their `ref`.
Uri? resolveArtUriW(WidgetRef ref, {String? coverArt, int size = 512}) =>
_resolveArt(
ref.read(downloadManagerProvider.notifier).localArtPathFor,
ref.read(subsonicClientProvider),
coverArt: coverArt,
size: size,
);
// ---- Playback -----------------------------------------------------------
final playbackProvider =
StateNotifierProvider<PlaybackController, PlaybackState>((ref) {
final playbackProvider = StateNotifierProvider<PlaybackController, PlaybackState>((
ref,
) {
// Prefer a local downloaded file when one exists (works offline / survives
// service interruptions); otherwise stream at the configured bitrate.
Uri? streamUriFor(Song s) {
@ -561,11 +663,11 @@ final playbackProvider =
);
}
Uri? coverArtUriFor(Song s) {
final client = ref.read(subsonicClientProvider);
if (client == null || s.coverArt == null) return null;
return client.coverArtUri(s.coverArt!, size: 512);
}
// Prefer the local cached art file, then fall back to the server URL — this
// makes offline art work in Now Playing / the lock screen where a downloaded
// file exists. Null only when there's no id and no local/remote source.
Uri? coverArtUriFor(Song s) =>
resolveArtUri(ref, coverArt: s.coverArt, size: 512);
final controller = PlaybackController(
streamUriFor: streamUriFor,
@ -575,7 +677,12 @@ final playbackProvider =
// Skip extraction entirely when the accent is pinned (static accent, or a
// theme that locks its accent like Lavender).
if (ref.read(settingsProvider).accentIsFixed) return;
final color = await extractAccent(NetworkImage(artUri.toString()));
// A resolved `file://` art URI (downloaded track) must load from disk, not
// the network — extract from a FileImage in that case, else a NetworkImage.
final ImageProvider image = artUri.isScheme('file')
? FileImage(File(artUri.toFilePath()))
: NetworkImage(artUri.toString());
final color = await extractAccent(image);
if (color != null) ref.read(accentProvider.notifier).set(color);
},
onPlay: (song) {