offline updates and playhead fix
This commit is contained in:
parent
7a199fe4df
commit
6663330260
14 changed files with 1673 additions and 545 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue