updates
This commit is contained in:
parent
981b4836f9
commit
ed910748cb
34 changed files with 2054 additions and 153 deletions
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue