import 'package:flutter/widgets.dart' show 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 '../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 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? servers}) => ConnectionState( status: status, client: client, credentials: credentials, error: error, servers: servers ?? this.servers, ); } final credentialStoreProvider = Provider((_) => 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 { ConnectionController(this._store) : super(const ConnectionState(status: ConnStatus.disconnected)) { _restore(); } final CredentialStore _store; List _servers = const []; String? _activeId; Future _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 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 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 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 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 _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 disconnect() async { _servers = const []; _activeId = null; try { await _store.clear(); } catch (_) {} state = const ConnectionState(status: ConnStatus.disconnected); } } final connectionProvider = StateNotifierProvider( (ref) => ConnectionController(ref.watch(credentialStoreProvider)), ); /// The active client, or null when not connected. final subsonicClientProvider = Provider( (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((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((_) => 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.artists); // ---- Library ------------------------------------------------------------ final artistsProvider = FutureProvider>((ref) async { final client = ref.watch(subsonicClientProvider); if (client == null) return const []; 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>((ref) async { final client = ref.watch(subsonicClientProvider); if (client == null) return const []; const pageSize = 500; final all = []; 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((ref) { final controller = LibraryIndexController(() => ref.read(subsonicClientProvider)); ref.listen(connectionProvider, (_, _) { controller.onConnectionChanged(); }); return controller; }); // ---- Browse filtering / sorting ----------------------------------------- /// Session-only genre/year filter for the Albums grid (resets on restart). final albumFilterProvider = StateProvider((_) => const BrowseFilter()); /// Session-only genre/year filter for the Tracks list (resets on restart). final trackFilterProvider = StateProvider((_) => const BrowseFilter()); /// Distinct genres present across all albums, for the album genre picker. final albumGenresProvider = Provider>((ref) { final albums = ref.watch(albumsProvider).valueOrNull ?? const []; return distinctGenres(albums.map((a) => a.genre)); }); /// Distinct release years present across all albums, newest first. final albumYearsProvider = Provider>((ref) { final albums = ref.watch(albumsProvider).valueOrNull ?? const []; 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>>((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. final trackGenresProvider = Provider>((ref) { final songs = ref.watch(libraryIndexProvider).songs; return distinctGenres(songs.map((s) => s.genre)); }); /// Distinct release years present across all indexed tracks, newest first. final trackYearsProvider = Provider>((ref) { final songs = ref.watch(libraryIndexProvider).songs; return distinctYears(songs.map((s) => s.year)); }); /// Indexed tracks after applying the session filter and the persisted sort. final visibleTracksProvider = Provider>((ref) { final filter = ref.watch(trackFilterProvider); final sort = ref.watch(settingsProvider.select((s) => s.trackSort)); final songs = ref.watch(libraryIndexProvider).songs; return applyTrackQuery(songs, filter, sort); }); /// 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>((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>((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((ref, id) async { final client = ref.watch(subsonicClientProvider); if (client == null) throw StateError('Not connected'); return client.getArtist(id); }); final albumProvider = FutureProvider.family((ref, id) async { final client = ref.watch(subsonicClientProvider); if (client == null) throw StateError('Not connected'); return client.getAlbum(id); }); final searchProvider = FutureProvider.family((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>( (ref) => HistoryController(), ); final recentSongsProvider = Provider>( (ref) => recentSongs(ref.watch(playHistoryProvider)), ); final recentAlbumsProvider = Provider>( (ref) => recentAlbums(ref.watch(playHistoryProvider)), ); /// Bump to re-roll the rediscover suggestions. final rediscoverSeedProvider = StateProvider((_) => 0); final rediscoverProvider = Provider>((ref) { final history = ref.watch(playHistoryProvider); final seed = ref.watch(rediscoverSeedProvider); return rediscover(history, seed: seed); }); // ---- Favorites / ratings ------------------------------------------------ final favoritesProvider = StateNotifierProvider((ref) { final controller = FavoritesController(() => ref.read(subsonicClientProvider)); // Re-hydrate on connect, clear on disconnect. ref.listen(connectionProvider, (prev, next) { if (next.isOnline) { controller.hydrate(); } else { controller.clear(); } }); return controller; }); /// Full starred set for the Favorites screen. final starredProvider = FutureProvider((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((ref) { final controller = DownloadController( clientGetter: () => ref.read(subsonicClientProvider), settingsGetter: () => ref.read(settingsProvider), serverKeyGetter: () => ref.read(serverKeyProvider), ); ref.listen(serverKeyProvider, (_, _) { controller.reloadForServer(); }); // Raising the concurrency cap should launch queued downloads right away. ref.listen( 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((ref) { final controller = PlaylistsController( clientGetter: () => ref.read(subsonicClientProvider), serverKeyGetter: () => ref.read(serverKeyProvider), ); ref.listen(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>((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>( (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( (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>((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>((ref) { final me = ref.watch(currentUsernameProvider); return ref .watch(realPlaylistsProvider) .where((p) => !isOwnedBy(p, me)) .toList(); }); // ---- Playback ----------------------------------------------------------- final playbackProvider = StateNotifierProvider((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; return client.streamUri( s.id, maxBitRate: ref.read(settingsProvider).streamMaxBitRate, ); } Uri? coverArtUriFor(Song s) { final client = ref.read(subsonicClientProvider); if (client == null || s.coverArt == null) return null; return client.coverArtUri(s.coverArt!, size: 512); } final controller = PlaybackController( streamUriFor: streamUriFor, coverArtUriFor: coverArtUriFor, serverKeyGetter: () => ref.read(serverKeyProvider), onArt: (artUri) async { // Skip extraction entirely when the user has pinned a static accent. if (ref.read(settingsProvider).useStaticAccent) return; final color = await extractAccent(NetworkImage(artUri.toString())); 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(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((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((ref) { ref.listen( remoteControlProvider.select( (s) => s.isAttached ? s.remoteState?.current?.coverArt : null, ), (prev, coverArt) async { if (ref.read(settingsProvider).useStaticAccent) 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((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); });