import 'dart:convert'; import 'package:crypto/crypto.dart'; 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/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'; // ---- Connection --------------------------------------------------------- enum ConnStatus { disconnected, connecting, online, error } class ConnectionState { const ConnectionState({ required this.status, this.client, this.credentials, this.error, }); final ConnStatus status; final SubsonicClient? client; final SubsonicCredentials? credentials; final String? error; bool get isOnline => status == ConnStatus.online; } final credentialStoreProvider = Provider((_) => CredentialStore()); /// Owns the active server connection: builds the client, pings, persists /// credentials, and auto-restores on launch. class ConnectionController extends StateNotifier { ConnectionController(this._store) : super(const ConnectionState(status: ConnStatus.disconnected)) { _restore(); } final CredentialStore _store; Future _restore() async { try { final creds = await _store.load(); if (creds != null) { await connect(creds, 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 = const ConnectionState(status: ConnStatus.connecting); 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.', ); return false; } // Persistence is best-effort: a locked/absent secure-storage backend // (e.g. a locked Linux keyring) must not stop this session connecting. if (persist) { try { await _store.save(creds); } catch (_) {} } state = ConnectionState( status: ConnStatus.online, client: client, credentials: creds, ); 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, ); return false; } catch (e) { state = ConnectionState( status: ConnStatus.error, credentials: creds, error: e.toString(), ); return false; } } Future disconnect() async { await _store.clear(); 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) { final creds = ref.watch(connectionProvider).credentials; if (creds == null) return null; final base = creds.url.endsWith('/') ? creds.url.substring(0, creds.url.length - 1) : creds.url; return md5.convert(utf8.encode('$base|${creds.username}')).toString(); }); // ---- 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. enum BrowseMode { artists, albums, tracks } 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; }); 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); if (client == null || query.trim().isEmpty) { return SearchResult3(artists: const [], albums: const [], songs: const []); } return client.search3(query.trim()); }); // ---- 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(); }); 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; }); // ---- 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); } return PlaybackController( streamUriFor: streamUriFor, coverArtUriFor: coverArtUriFor, onArt: (artUri) async { 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(); }, ); });