This commit is contained in:
Forrest 2026-07-29 22:41:38 -04:00
parent 981b4836f9
commit ed910748cb
34 changed files with 2054 additions and 153 deletions

View file

@ -1,6 +1,3 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/widgets.dart' show NetworkImage;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -17,6 +14,11 @@ import '../theme/accent.dart';
import '../theme/accent_extract.dart';
import 'favorites.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;
// ---- Connection ---------------------------------------------------------
enum ConnStatus { disconnected, connecting, online, error }
@ -27,6 +29,7 @@ class ConnectionState {
this.client,
this.credentials,
this.error,
this.servers = const [],
});
final ConnStatus status;
@ -34,14 +37,32 @@ class ConnectionState {
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: builds the client, pings, persists
/// credentials, and auto-restores on launch.
/// 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)) {
@ -49,13 +70,21 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
final CredentialStore _store;
List<SubsonicCredentials> _servers = const [];
String? _activeId;
Future<void> _restore() async {
try {
final creds = await _store.load();
if (creds != null) {
await connect(creds, persist: false);
}
_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.
@ -63,7 +92,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
Future<bool> connect(SubsonicCredentials creds, {bool persist = true}) async {
state = const ConnectionState(status: ConnStatus.connecting);
state = ConnectionState(status: ConnStatus.connecting, servers: _servers);
final client = SubsonicClient(
baseUrl: creds.url,
username: creds.username,
@ -79,20 +108,25 @@ class ConnectionController extends StateNotifier<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 _store.save(creds);
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) {
@ -102,6 +136,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
status: ConnStatus.error,
credentials: e.isAuthFailure ? null : creds,
error: e.isAuthFailure ? 'Wrong username or password.' : e.message,
servers: _servers,
);
return false;
} catch (e) {
@ -109,13 +144,85 @@ class ConnectionController extends StateNotifier<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 {
await _store.clear();
_servers = const [];
_activeId = null;
try {
await _store.clear();
} catch (_) {}
state = const ConnectionState(status: ConnStatus.disconnected);
}
}
@ -135,12 +242,7 @@ final subsonicClientProvider = Provider<SubsonicClient?>(
/// 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) {
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();
return ref.watch(connectionProvider).credentials?.id;
});
// ---- Navigation / browse mode -------------------------------------------
@ -152,9 +254,8 @@ 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.
enum BrowseMode { artists, albums, tracks }
/// 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 ------------------------------------------------------------
@ -227,12 +328,30 @@ final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
final searchProvider =
FutureProvider.family<SearchResult3, String>((ref, query) async {
final client = ref.watch(subsonicClientProvider);
if (client == null || query.trim().isEmpty) {
final q = query.trim();
if (client == null || q.isEmpty) {
return SearchResult3(artists: const [], albums: const [], songs: const []);
}
return client.search3(query.trim());
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 =
@ -318,6 +437,20 @@ final playlistsProvider =
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());
// ---- Playback -----------------------------------------------------------
final playbackProvider =
@ -345,6 +478,8 @@ final playbackProvider =
streamUriFor: streamUriFor,
coverArtUriFor: coverArtUriFor,
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);
},