mobile-music/lib/state/providers.dart
2026-08-16 12:46:30 -04:00

751 lines
28 KiB
Dart

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';
import '../subsonic/credentials.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
import '../theme/accent.dart';
import '../theme/accent_extract.dart';
import 'favorites.dart';
import 'remote_providers.dart';
// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be
// persisted) but historically lived here — re-export so existing importers of
// this file keep resolving them.
export '../settings/settings_store.dart'
show BrowseMode, SearchMode, AlbumSort, TrackSort;
export '../library/browse_query.dart' show BrowseFilter;
// ---- Connection ---------------------------------------------------------
enum ConnStatus { disconnected, connecting, online, error }
class ConnectionState {
const ConnectionState({
required this.status,
this.client,
this.credentials,
this.error,
this.servers = const [],
});
final ConnStatus status;
final SubsonicClient? client;
final SubsonicCredentials? credentials;
final String? error;
/// All saved servers (one may be the active [credentials]). Drives the
/// server switcher and the Settings "Servers" section.
final List<SubsonicCredentials> servers;
bool get isOnline => status == ConnStatus.online;
/// Id of the active server, or null when disconnected with none configured.
String? get activeId => credentials?.id;
/// Only the saved-servers list changes; connection fields are preserved.
ConnectionState copyWith({List<SubsonicCredentials>? servers}) =>
ConnectionState(
status: status,
client: client,
credentials: credentials,
error: error,
servers: servers ?? this.servers,
);
}
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)) {
_restore();
}
final CredentialStore _store;
List<SubsonicCredentials> _servers = const [];
String? _activeId;
Future<void> _restore() async {
try {
_servers = await _store.loadAll();
_activeId = await _store.loadActiveId();
state = state.copyWith(servers: _servers);
if (_servers.isEmpty) return;
final active = _servers.firstWhere(
(s) => s.id == _activeId,
orElse: () => _servers.first,
);
_activeId = active.id;
await connect(active, persist: false);
} catch (_) {
// No secure-storage backend available (e.g. Linux without a running
// keyring daemon) — start disconnected rather than crashing.
}
}
Future<bool> connect(SubsonicCredentials creds, {bool persist = true}) async {
state = ConnectionState(status: ConnStatus.connecting, servers: _servers);
final client = SubsonicClient(
baseUrl: creds.url,
username: creds.username,
password: creds.password,
);
try {
final ok = await client.ping();
if (!ok) {
// Keep the credentials on a reachability failure so the app still knows
// *which* server it is (offline downloads / playlists key off this) and
// can retry — only auth failures below drop them.
state = ConnectionState(
status: ConnStatus.error,
credentials: creds,
error: 'Could not reach the server.',
servers: _servers,
);
return false;
}
// Persistence is best-effort: a locked/absent secure-storage backend
// (e.g. a locked Linux keyring) must not stop this session connecting.
// A successful connect saves the server and makes it active.
if (persist) {
try {
await _addOrUpdate(creds, makeActive: true);
} catch (_) {}
} else {
_activeId = creds.id;
}
state = ConnectionState(
status: ConnStatus.online,
client: client,
credentials: creds,
servers: _servers,
);
return true;
} on SubsonicError catch (e) {
// Auth failures drop the credentials (they're wrong); any other server
// error keeps them so offline features still resolve the server key.
state = ConnectionState(
status: ConnStatus.error,
credentials: e.isAuthFailure ? null : creds,
error: e.isAuthFailure ? 'Wrong username or password.' : e.message,
servers: _servers,
);
return false;
} catch (e) {
state = ConnectionState(
status: ConnStatus.error,
credentials: creds,
error: e.toString(),
servers: _servers,
);
return false;
}
}
/// Switch the active server to [id] and connect. No password re-entry — saved
/// servers keep their credentials. Per-server downloads/playlists reload
/// automatically via [serverKeyProvider] listeners.
Future<bool> switchTo(String id) async {
SubsonicCredentials? creds;
for (final s in _servers) {
if (s.id == id) {
creds = s;
break;
}
}
if (creds == null) return false;
_activeId = id;
try {
await _store.saveAll(_servers, _activeId);
} catch (_) {}
return connect(creds, persist: false);
}
/// Add or update a saved server *without* connecting (used by the add/edit
/// form). Updating an entry with the same [SubsonicCredentials.id] overwrites
/// its password/alias.
Future<void> saveServer(SubsonicCredentials creds) async {
await _addOrUpdate(creds, makeActive: false);
state = state.copyWith(servers: _servers);
}
/// Remove a saved server. If it was the active one, switch to another saved
/// server (or go disconnected when none remain).
Future<void> removeServer(String id) async {
_servers = _servers.where((s) => s.id != id).toList();
final removedActive = id == _activeId;
if (removedActive) {
_activeId = _servers.isEmpty ? null : _servers.first.id;
}
try {
await _store.saveAll(_servers, _activeId);
} catch (_) {}
if (removedActive) {
if (_servers.isNotEmpty) {
await connect(_servers.first, persist: false);
} else {
state = ConnectionState(
status: ConnStatus.disconnected,
servers: _servers,
);
}
} else {
state = state.copyWith(servers: _servers);
}
}
Future<void> _addOrUpdate(
SubsonicCredentials creds, {
required bool makeActive,
}) async {
final idx = _servers.indexWhere((s) => s.id == creds.id);
final next = [..._servers];
if (idx >= 0) {
next[idx] = creds;
} else {
next.add(creds);
}
_servers = next;
if (makeActive) _activeId = creds.id;
await _store.saveAll(_servers, _activeId);
}
/// Forget every saved server and drop the connection.
Future<void> disconnect() async {
_servers = const [];
_activeId = null;
try {
await _store.clear();
} catch (_) {}
state = const ConnectionState(status: ConnStatus.disconnected);
}
}
final connectionProvider =
StateNotifierProvider<ConnectionController, ConnectionState>(
(ref) => ConnectionController(ref.watch(credentialStoreProvider)),
);
/// The active client, or null when not connected.
final subsonicClientProvider = Provider<SubsonicClient?>(
(ref) => ref.watch(connectionProvider).client,
);
/// Stable per-server cache key (`md5(baseUrl|username)`), derived from the
/// current credentials so it also resolves while offline (the error state
/// retains credentials). Null when no server has ever been configured. Used to
/// scope on-disk downloads/playlists to the server they belong to.
final serverKeyProvider = Provider<String?>((ref) {
return ref.watch(connectionProvider).credentials?.id;
});
// ---- Navigation / browse mode -------------------------------------------
/// The active bottom-tab index. Single source of truth shared by the tab bar
/// and the mini-player (which jumps to Now Playing on tap).
final selectedTabProvider = StateProvider<int>((_) => 0);
/// Index of the Now Playing tab in the shell's tab list.
const int nowPlayingTabIndex = 2;
/// Top-level Browse mode selector. Seeded once from the persisted
/// `AppSettings.defaultBrowseMode` by `AppShell`; manual selection then wins.
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);
// 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;
});
/// All albums (alphabetical), paged through fully so nothing is silently
/// truncated. Backs the Albums cover-art grid.
final albumsProvider = FutureProvider<List<Album>>((ref) async {
final client = ref.watch(subsonicClientProvider);
// 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;
while (true) {
final page = await client.getAlbumList2(size: pageSize, offset: offset);
all.addAll(page);
if (page.length < pageSize) break;
offset += pageSize;
}
return all;
});
/// The crawled + cached flat song index that backs the Tracks view. Cleared
/// (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;
});
// ---- Browse filtering / sorting -----------------------------------------
/// Session-only genre/year filter for the Albums grid (resets on restart).
final albumFilterProvider = StateProvider<BrowseFilter>(
(_) => const BrowseFilter(),
);
/// Session-only genre/year filter for the Tracks list (resets on restart).
final trackFilterProvider = StateProvider<BrowseFilter>(
(_) => const BrowseFilter(),
);
/// Distinct genres present across all albums, for the album genre picker.
final albumGenresProvider = Provider<List<String>>((ref) {
final albums = ref.watch(albumsProvider).valueOrNull ?? const <Album>[];
return distinctGenres(albums.map((a) => a.genre));
});
/// Distinct release years present across all albums, newest first.
final albumYearsProvider = Provider<List<int>>((ref) {
final albums = ref.watch(albumsProvider).valueOrNull ?? const <Album>[];
return distinctYears(albums.map((a) => a.year));
});
/// Albums after applying the session filter and the persisted sort. Async so
/// callers keep the underlying load/error states from [albumsProvider].
final visibleAlbumsProvider = Provider<AsyncValue<List<Album>>>((ref) {
final filter = ref.watch(albumFilterProvider);
final sort = ref.watch(settingsProvider.select((s) => s.albumSort));
return ref
.watch(albumsProvider)
.whenData((albums) => applyAlbumQuery(albums, filter, sort));
});
/// 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 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 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 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);
});
/// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on
/// the Home tab. Small page; the Home shelf shows the first handful.
final newestAlbumsProvider = FutureProvider<List<Album>>((ref) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
return client.getAlbumList2(type: 'newest', size: 20);
});
/// A random album shelf (`getAlbumList2` type `random`). Re-rolls when
/// [rediscoverSeedProvider] bumps so it shares the Home "shuffle" affordance.
final randomAlbumsProvider = FutureProvider<List<Album>>((ref) async {
ref.watch(rediscoverSeedProvider);
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
return client.getAlbumList2(type: 'random', size: 20);
});
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
final client = ref.watch(subsonicClientProvider);
// 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);
// 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 client = ref.watch(subsonicClientProvider);
final q = query.trim();
if (client == null || q.isEmpty) {
return SearchResult3(artists: const [], albums: const [], songs: const []);
}
final result = await client.search3(q);
// "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;
});
/// Narrows a [SearchResult3] to items whose *own* name/title contains [query]
/// (case-insensitive) — dropping songs that only matched via their artist or
/// album fields. Backs the "Standard" search mode (TODO #4).
SearchResult3 filterSearchToStandard(SearchResult3 r, String query) {
final q = query.toLowerCase();
bool has(String? s) => s != null && s.toLowerCase().contains(q);
return SearchResult3(
artists: r.artists.where((a) => has(a.name)).toList(),
albums: r.albums.where((a) => has(a.name)).toList(),
songs: r.songs.where((s) => has(s.title)).toList(),
);
}
// ---- History (Home tab) -------------------------------------------------
final playHistoryProvider =
StateNotifierProvider<HistoryController, List<PlayRecord>>(
(ref) => HistoryController(),
);
final recentSongsProvider = Provider<List<PlayRecord>>(
(ref) => recentSongs(ref.watch(playHistoryProvider)),
);
final recentAlbumsProvider = Provider<List<PlayRecord>>(
(ref) => recentAlbums(ref.watch(playHistoryProvider)),
);
/// Bump to re-roll the rediscover suggestions.
final rediscoverSeedProvider = StateProvider<int>((_) => 0);
final rediscoverProvider = Provider<List<RediscoverArtist>>((ref) {
final history = ref.watch(playHistoryProvider);
final seed = ref.watch(rediscoverSeedProvider);
return rediscover(history, seed: seed);
});
// ---- Favorites / ratings ------------------------------------------------
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;
});
/// Full starred set for the Favorites screen.
final starredProvider = FutureProvider<Starred2>((ref) async {
// Re-run when the local favorites set changes (e.g. after a toggle).
ref.watch(favoritesProvider);
final client = ref.watch(subsonicClientProvider);
if (client == null) {
return Starred2(artists: const [], albums: const [], songs: const []);
}
return client.getStarred2();
});
// ---- Downloads ----------------------------------------------------------
/// Offline download store: fetches tracks to disk (per-server) and exposes
/// 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;
});
// ---- Playlists ----------------------------------------------------------
/// Server-backed playlists with an offline mirror. Reloads/refreshes whenever
/// 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;
});
/// 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(),
);
/// 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(),
);
/// 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,
);
/// 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.
final myPlaylistsProvider = Provider<List<Playlist>>((ref) {
final me = ref.watch(currentUsernameProvider);
return ref
.watch(realPlaylistsProvider)
.where((p) => isOwnedBy(p, me))
.toList();
});
/// Playlists shared by *other* users on the same server (public, owner != me).
final sharedPlaylistsProvider = Provider<List<Playlist>>((ref) {
final me = ref.watch(currentUsernameProvider);
return ref
.watch(realPlaylistsProvider)
.where((p) => !isOwnedBy(p, me))
.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,
) {
// Prefer a local downloaded file when one exists (works offline / survives
// service interruptions); otherwise stream at the configured bitrate.
Uri? streamUriFor(Song s) {
final local = ref.read(downloadManagerProvider.notifier).localPathFor(s.id);
if (local != null) return Uri.file(local);
final client = ref.read(subsonicClientProvider);
if (client == null) return null;
final rate = ref.read(settingsProvider).streamMaxBitRate;
// When transcoding, ask the server to advertise a Content-Length so the
// native player can derive a duration and hold position (otherwise the
// playhead freezes at 0:00 and the track restarts). Harmless to omit for
// original streams, which already carry a real length.
return client.streamUri(
s.id,
maxBitRate: rate,
estimateContentLength: rate > 0,
);
}
// 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,
coverArtUriFor: coverArtUriFor,
serverKeyGetter: () => ref.read(serverKeyProvider),
onArt: (artUri) async {
// 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;
// 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) {
// Local history (drives the Home tab) + best-effort server scrobble.
ref.read(playHistoryProvider.notifier).record(song);
ref.read(subsonicClientProvider)?.scrobble(song.id).ignore();
},
);
// Restore the persisted queue on connect / server switch, and catch the
// already-connected case at creation (the listener only fires on changes).
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.restoreForServer();
});
controller.restoreForServer();
return controller;
});
/// The playback state the UI should render: the mirrored state of the device
/// we're controlling as a remote when attached (updates-features.md #3), else
/// the local engine's state. Widgets watch this (with `.select`) instead of
/// [playbackProvider] so the local/remote source is a single swappable seam.
final activePlaybackProvider = Provider<PlaybackState>((ref) {
final remote = ref.watch(
remoteControlProvider.select((s) => s.isAttached ? s.remoteState : null),
);
return remote ?? ref.watch(playbackProvider);
});
/// Keeps the theme accent in sync while acting as a remote (bug #6).
///
/// On the controlling device the local playback engine never loads the track,
/// so its `onArt` extraction (see [playbackControllerProvider]) never fires and
/// the accent would stay at the default. Here we re-extract the accent from the
/// mirrored remote track's cover art — the controlling device is connected to
/// the same server, so it can fetch the art itself. Selecting on `coverArt`
/// (a value-equal String) means this only fires on an actual art change, not on
/// the ~1/s snapshot churn. Watched in `main.dart` so it stays alive.
final remoteAccentSyncProvider = Provider<void>((ref) {
ref.listen<String?>(
remoteControlProvider.select(
(s) => s.isAttached ? s.remoteState?.current?.coverArt : null,
),
(prev, coverArt) async {
if (ref.read(settingsProvider).accentIsFixed) return;
if (coverArt == null) return;
final client = ref.read(subsonicClientProvider);
if (client == null) return;
final uri = client.coverArtUri(coverArt, size: 512);
final color = await extractAccent(NetworkImage(uri.toString()));
if (color != null) ref.read(accentProvider.notifier).set(color);
},
fireImmediately: true,
);
});
/// The command sink the UI should drive — a proxy that serializes commands over
/// the LAN to the attached device when acting as a remote, else the local
/// [PlaybackController]. Widgets read this instead of `playbackProvider.notifier`.
final playbackCommandsProvider = Provider<PlaybackCommands>((ref) {
final attached = ref.watch(remoteControlProvider.select((s) => s.isAttached));
if (attached) {
final remote = ref.read(remoteControlProvider.notifier).remoteCommands;
if (remote != null) return remote;
}
return ref.watch(playbackProvider.notifier);
});