init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
98
lib/state/favorites.dart
Normal file
98
lib/state/favorites.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../subsonic/models.dart';
|
||||
import '../subsonic/subsonic_client.dart';
|
||||
|
||||
/// Client-side mirror of the server's stars + ratings, so toggles feel instant
|
||||
/// (optimistic update, reverted on failure). Hydrated from `getStarred2`.
|
||||
class FavoritesState {
|
||||
const FavoritesState({
|
||||
this.songIds = const {},
|
||||
this.albumIds = const {},
|
||||
this.artistIds = const {},
|
||||
this.ratings = const {},
|
||||
});
|
||||
|
||||
final Set<String> songIds;
|
||||
final Set<String> albumIds;
|
||||
final Set<String> artistIds;
|
||||
final Map<String, int> ratings;
|
||||
|
||||
bool isSongStarred(String id) => songIds.contains(id);
|
||||
int ratingFor(String id) => ratings[id] ?? 0;
|
||||
|
||||
FavoritesState copyWith({
|
||||
Set<String>? songIds,
|
||||
Set<String>? albumIds,
|
||||
Set<String>? artistIds,
|
||||
Map<String, int>? ratings,
|
||||
}) =>
|
||||
FavoritesState(
|
||||
songIds: songIds ?? this.songIds,
|
||||
albumIds: albumIds ?? this.albumIds,
|
||||
artistIds: artistIds ?? this.artistIds,
|
||||
ratings: ratings ?? this.ratings,
|
||||
);
|
||||
}
|
||||
|
||||
class FavoritesController extends StateNotifier<FavoritesState> {
|
||||
FavoritesController(this._clientGetter) : super(const FavoritesState()) {
|
||||
if (_clientGetter() != null) hydrate();
|
||||
}
|
||||
|
||||
final SubsonicClient? Function() _clientGetter;
|
||||
|
||||
Future<void> hydrate() async {
|
||||
final client = _clientGetter();
|
||||
if (client == null) return;
|
||||
try {
|
||||
final starred = await client.getStarred2();
|
||||
final ratings = <String, int>{};
|
||||
for (final s in starred.songs) {
|
||||
if (s.userRating != null) ratings[s.id] = s.userRating!;
|
||||
}
|
||||
state = FavoritesState(
|
||||
songIds: starred.songs.map((s) => s.id).toSet(),
|
||||
albumIds: starred.albums.map((a) => a.id).toSet(),
|
||||
artistIds: starred.artists.map((a) => a.id).toSet(),
|
||||
ratings: ratings,
|
||||
);
|
||||
} catch (_) {
|
||||
// Leave current state on failure.
|
||||
}
|
||||
}
|
||||
|
||||
void clear() => state = const FavoritesState();
|
||||
|
||||
Future<void> toggleSong(Song song) async {
|
||||
final client = _clientGetter();
|
||||
if (client == null) return;
|
||||
final wasStarred = state.isSongStarred(song.id);
|
||||
final next = Set<String>.from(state.songIds);
|
||||
wasStarred ? next.remove(song.id) : next.add(song.id);
|
||||
state = state.copyWith(songIds: next); // optimistic
|
||||
try {
|
||||
await client.setStarred(starred: !wasStarred, songId: song.id);
|
||||
} catch (_) {
|
||||
// Revert on failure.
|
||||
final reverted = Set<String>.from(state.songIds);
|
||||
wasStarred ? reverted.add(song.id) : reverted.remove(song.id);
|
||||
state = state.copyWith(songIds: reverted);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> rateSong(String songId, int rating) async {
|
||||
final client = _clientGetter();
|
||||
if (client == null) return;
|
||||
final previous = state.ratings[songId] ?? 0;
|
||||
final next = Map<String, int>.from(state.ratings)..[songId] = rating;
|
||||
state = state.copyWith(ratings: next); // optimistic
|
||||
try {
|
||||
await client.setRating(songId, rating);
|
||||
} catch (_) {
|
||||
final reverted = Map<String, int>.from(state.ratings)
|
||||
..[songId] = previous;
|
||||
state = state.copyWith(ratings: reverted);
|
||||
}
|
||||
}
|
||||
}
|
||||
340
lib/state/providers.dart
Normal file
340
lib/state/providers.dart
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
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>((_) => CredentialStore());
|
||||
|
||||
/// Owns the active server connection: builds the client, pings, persists
|
||||
/// credentials, and auto-restores on launch.
|
||||
class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
ConnectionController(this._store)
|
||||
: super(const ConnectionState(status: ConnStatus.disconnected)) {
|
||||
_restore();
|
||||
}
|
||||
|
||||
final CredentialStore _store;
|
||||
|
||||
Future<void> _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<bool> 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<void> disconnect() async {
|
||||
await _store.clear();
|
||||
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) {
|
||||
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<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 }
|
||||
|
||||
final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
|
||||
|
||||
// ---- Library ------------------------------------------------------------
|
||||
|
||||
final artistsProvider = FutureProvider<List<Artist>>((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<List<Album>>((ref) async {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null) return const [];
|
||||
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;
|
||||
});
|
||||
|
||||
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null) throw StateError('Not connected');
|
||||
return client.getArtist(id);
|
||||
});
|
||||
|
||||
final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
if (client == null) throw StateError('Not connected');
|
||||
return client.getAlbum(id);
|
||||
});
|
||||
|
||||
final searchProvider =
|
||||
FutureProvider.family<SearchResult3, String>((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<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();
|
||||
});
|
||||
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;
|
||||
});
|
||||
|
||||
// ---- 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;
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue