Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6663330260 | ||
|
|
7a199fe4df | ||
|
|
db33f0764b |
16 changed files with 1969 additions and 566 deletions
64
lib/debug/log_store.dart
Normal file
64
lib/debug/log_store.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Severity of a captured log line, used only to tint it in the Debug tab.
|
||||||
|
enum LogLevel { info, error }
|
||||||
|
|
||||||
|
/// A single captured console line, stamped with the wall-clock time it arrived.
|
||||||
|
@immutable
|
||||||
|
class LogEntry {
|
||||||
|
const LogEntry(this.time, this.text, this.level);
|
||||||
|
|
||||||
|
final DateTime time;
|
||||||
|
final String text;
|
||||||
|
final LogLevel level;
|
||||||
|
|
||||||
|
/// `HH:MM:SS.mmm` — enough resolution to correlate bursts while streaming.
|
||||||
|
String get timeLabel {
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
return '${two(time.hour)}:${two(time.minute)}:${two(time.second)}'
|
||||||
|
'.${time.millisecond.toString().padLeft(3, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory ring buffer of console/system output, surfaced in the Debug tab so
|
||||||
|
/// the app's logs can be read (and copied) on-device during beta testing —
|
||||||
|
/// there's no attached debugger on a TestFlight/sideloaded build.
|
||||||
|
///
|
||||||
|
/// A process-wide singleton because the capture hooks (the zone `print`
|
||||||
|
/// override and `FlutterError.onError`) are installed in `main()`, outside the
|
||||||
|
/// widget/provider tree. The UI listens via [ListenableBuilder].
|
||||||
|
class LogStore extends ChangeNotifier {
|
||||||
|
LogStore._();
|
||||||
|
static final LogStore instance = LogStore._();
|
||||||
|
|
||||||
|
/// Keep the tail bounded so a chatty session can't grow memory without limit.
|
||||||
|
static const int _maxEntries = 3000;
|
||||||
|
|
||||||
|
final List<LogEntry> _entries = <LogEntry>[];
|
||||||
|
|
||||||
|
/// Newest-last, read-only view for the UI.
|
||||||
|
List<LogEntry> get entries => List.unmodifiable(_entries);
|
||||||
|
|
||||||
|
int get length => _entries.length;
|
||||||
|
|
||||||
|
void add(String text, {LogLevel level = LogLevel.info}) {
|
||||||
|
// A single print can carry embedded newlines; split so each shows as its
|
||||||
|
// own row (and the timestamp lines up per line).
|
||||||
|
final now = DateTime.now();
|
||||||
|
for (final line in text.split('\n')) {
|
||||||
|
_entries.add(LogEntry(now, line, level));
|
||||||
|
}
|
||||||
|
final overflow = _entries.length - _maxEntries;
|
||||||
|
if (overflow > 0) _entries.removeRange(0, overflow);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_entries.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole buffer as plain text, for copy-to-clipboard / sharing.
|
||||||
|
String asText() =>
|
||||||
|
_entries.map((e) => '${e.timeLabel} ${e.text}').join('\n');
|
||||||
|
}
|
||||||
|
|
@ -55,8 +55,7 @@ class DownloadInfo {
|
||||||
int? sizeBytes,
|
int? sizeBytes,
|
||||||
double? progress,
|
double? progress,
|
||||||
String? error,
|
String? error,
|
||||||
}) =>
|
}) => DownloadInfo(
|
||||||
DownloadInfo(
|
|
||||||
song: song,
|
song: song,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
|
|
@ -95,10 +94,16 @@ class DownloadInfo {
|
||||||
|
|
||||||
/// Snapshot of all known downloads for the active server, keyed by song id.
|
/// Snapshot of all known downloads for the active server, keyed by song id.
|
||||||
class DownloadState {
|
class DownloadState {
|
||||||
const DownloadState({this.byId = const {}});
|
const DownloadState({this.byId = const {}, this.artById = const {}});
|
||||||
|
|
||||||
final Map<String, DownloadInfo> byId;
|
final Map<String, DownloadInfo> byId;
|
||||||
|
|
||||||
|
/// Cover-art id -> absolute path of the cached art file on disk. This is
|
||||||
|
/// in-memory only: it is *rebuilt* on load by scanning disk (see
|
||||||
|
/// [DownloadController.reloadForServer]) and is never written to the manifest.
|
||||||
|
/// Multiple songs sharing a cover-art id map to the same deduped file.
|
||||||
|
final Map<String, String> artById;
|
||||||
|
|
||||||
DownloadInfo? operator [](String id) => byId[id];
|
DownloadInfo? operator [](String id) => byId[id];
|
||||||
|
|
||||||
bool isDownloaded(String id) => byId[id]?.isDone ?? false;
|
bool isDownloaded(String id) => byId[id]?.isDone ?? false;
|
||||||
|
|
@ -106,10 +111,16 @@ class DownloadState {
|
||||||
List<DownloadInfo> get completed =>
|
List<DownloadInfo> get completed =>
|
||||||
byId.values.where((d) => d.isDone).toList();
|
byId.values.where((d) => d.isDone).toList();
|
||||||
|
|
||||||
|
/// Total bytes of downloaded *audio* only — cached cover art is intentionally
|
||||||
|
/// excluded (art is small and shared across tracks; counting it would make
|
||||||
|
/// per-track/total sizes misleading).
|
||||||
int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0));
|
int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0));
|
||||||
|
|
||||||
DownloadState copyWith({Map<String, DownloadInfo>? byId}) =>
|
DownloadState copyWith({
|
||||||
DownloadState(byId: byId ?? this.byId);
|
Map<String, DownloadInfo>? byId,
|
||||||
|
Map<String, String>? artById,
|
||||||
|
}) =>
|
||||||
|
DownloadState(byId: byId ?? this.byId, artById: artById ?? this.artById);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads tracks to disk for offline playback. Files live under
|
/// Downloads tracks to disk for offline playback. Files live under
|
||||||
|
|
@ -133,10 +144,12 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
final AppSettings Function() _settingsGetter;
|
final AppSettings Function() _settingsGetter;
|
||||||
final String? Function() _serverKeyGetter;
|
final String? Function() _serverKeyGetter;
|
||||||
|
|
||||||
final Dio _dio = Dio(BaseOptions(
|
final Dio _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
receiveTimeout: const Duration(minutes: 5),
|
receiveTimeout: const Duration(minutes: 5),
|
||||||
headers: {'User-Agent': 'timbre'},
|
headers: {'User-Agent': 'timbre'},
|
||||||
));
|
),
|
||||||
|
);
|
||||||
|
|
||||||
int _generation = 0;
|
int _generation = 0;
|
||||||
String? _loadedKey;
|
String? _loadedKey;
|
||||||
|
|
@ -186,6 +199,15 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
return info != null && info.isDone ? info.path : null;
|
return info != null && info.isDone ? info.path : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Absolute path to the cached cover-art file for [coverArtId], or null if no
|
||||||
|
/// art has been cached for it. Read synchronously by offline browse / Now
|
||||||
|
/// Playing to show real artwork without a network round-trip. Parallel to
|
||||||
|
/// [localPathFor]; the caller passes the song's `coverArt` id directly.
|
||||||
|
String? localArtPathFor(String? coverArtId) {
|
||||||
|
if (coverArtId == null) return null;
|
||||||
|
return state.artById[coverArtId];
|
||||||
|
}
|
||||||
|
|
||||||
bool isDownloaded(String id) => state.isDownloaded(id);
|
bool isDownloaded(String id) => state.isDownloaded(id);
|
||||||
|
|
||||||
// ---- Server switching / manifest load ----------------------------------
|
// ---- Server switching / manifest load ----------------------------------
|
||||||
|
|
@ -197,6 +219,25 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directory holding cached cover art for [key], created on demand. Sits
|
||||||
|
/// beside the audio files at `<downloadsDir>/art`. Art filenames are derived
|
||||||
|
/// from the cover-art id via [_sanitizeArtId] so tracks sharing an id share a
|
||||||
|
/// single file (dedup).
|
||||||
|
Future<Directory> _artDir(String key) async {
|
||||||
|
final base = await _downloadsDir(key);
|
||||||
|
final d = Directory('${base.path}/art');
|
||||||
|
if (!await d.exists()) await d.create(recursive: true);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a Subsonic cover-art id into a filesystem-safe filename stem. Any
|
||||||
|
/// character outside `[A-Za-z0-9._-]` becomes `_`. This is a pure function of
|
||||||
|
/// the id, so the same id always resolves to the same file — that lets us
|
||||||
|
/// dedup shared art and reverse-derive art paths on load without persisting
|
||||||
|
/// them (see [reloadForServer]).
|
||||||
|
String _sanitizeArtId(String coverArtId) =>
|
||||||
|
coverArtId.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
|
||||||
|
|
||||||
Future<File> _manifestFile(String key) async {
|
Future<File> _manifestFile(String key) async {
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
return File('${dir.path}/downloads_$key.json');
|
return File('${dir.path}/downloads_$key.json');
|
||||||
|
|
@ -238,8 +279,20 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
if (map['relPath'] == null) migrated = true;
|
if (map['relPath'] == null) migrated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rediscover cached cover art. The manifest never stores art paths, so we
|
||||||
|
// reverse-derive them from each completed song's `coverArt` id + the
|
||||||
|
// [_sanitizeArtId] scheme and keep only ids whose file actually exists.
|
||||||
|
final artById = <String, String>{};
|
||||||
|
final artDir = await _artDir(key);
|
||||||
|
for (final info in byId.values) {
|
||||||
|
final coverArt = info.song.coverArt;
|
||||||
|
if (coverArt == null || artById.containsKey(coverArt)) continue;
|
||||||
|
final artPath = '${artDir.path}/${_sanitizeArtId(coverArt)}.jpg';
|
||||||
|
if (await File(artPath).exists()) artById[coverArt] = artPath;
|
||||||
|
}
|
||||||
|
|
||||||
if (gen == _generation) {
|
if (gen == _generation) {
|
||||||
state = DownloadState(byId: byId);
|
state = DownloadState(byId: byId, artById: artById);
|
||||||
// Self-migrate the manifest to relative paths.
|
// Self-migrate the manifest to relative paths.
|
||||||
if (migrated) await _persist();
|
if (migrated) await _persist();
|
||||||
}
|
}
|
||||||
|
|
@ -277,8 +330,9 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
// Re-read the concurrency cap each pump so a settings change takes effect
|
// Re-read the concurrency cap each pump so a settings change takes effect
|
||||||
// mid-session: raising it starts more downloads immediately; lowering it
|
// mid-session: raising it starts more downloads immediately; lowering it
|
||||||
// stops launching new ones while in-flight downloads drain naturally.
|
// stops launching new ones while in-flight downloads drain naturally.
|
||||||
final maxConcurrent =
|
final maxConcurrent = AppSettings.clampConcurrentDownloads(
|
||||||
AppSettings.clampConcurrentDownloads(_settingsGetter().maxConcurrentDownloads);
|
_settingsGetter().maxConcurrentDownloads,
|
||||||
|
);
|
||||||
while (_active < maxConcurrent && _queue.isNotEmpty) {
|
while (_active < maxConcurrent && _queue.isNotEmpty) {
|
||||||
final id = _queue.removeAt(0);
|
final id = _queue.removeAt(0);
|
||||||
final info = state.byId[id];
|
final info = state.byId[id];
|
||||||
|
|
@ -296,8 +350,12 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
final client = _clientGetter();
|
final client = _clientGetter();
|
||||||
final key = _serverKeyGetter();
|
final key = _serverKeyGetter();
|
||||||
if (client == null || key == null) {
|
if (client == null || key == null) {
|
||||||
_put(state.byId[song.id]!
|
_put(
|
||||||
.copyWith(status: DownloadStatus.failed, error: 'Not connected'));
|
state.byId[song.id]!.copyWith(
|
||||||
|
status: DownloadStatus.failed,
|
||||||
|
error: 'Not connected',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final settings = _settingsGetter();
|
final settings = _settingsGetter();
|
||||||
|
|
@ -309,8 +367,12 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
final ext = format ?? (rate > 0 ? 'mp3' : (song.suffix ?? 'mp3'));
|
final ext = format ?? (rate > 0 ? 'mp3' : (song.suffix ?? 'mp3'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_put(state.byId[song.id]!
|
_put(
|
||||||
.copyWith(status: DownloadStatus.downloading, progress: 0));
|
state.byId[song.id]!.copyWith(
|
||||||
|
status: DownloadStatus.downloading,
|
||||||
|
progress: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final dir = await _downloadsDir(key);
|
final dir = await _downloadsDir(key);
|
||||||
final finalPath = '${dir.path}/${song.id}.$ext';
|
final finalPath = '${dir.path}/${song.id}.$ext';
|
||||||
|
|
@ -346,7 +408,8 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
await tmp.rename(finalPath);
|
await tmp.rename(finalPath);
|
||||||
final size = await File(finalPath).length();
|
final size = await File(finalPath).length();
|
||||||
|
|
||||||
_put(DownloadInfo(
|
_put(
|
||||||
|
DownloadInfo(
|
||||||
song: song,
|
song: song,
|
||||||
status: DownloadStatus.done,
|
status: DownloadStatus.done,
|
||||||
path: finalPath,
|
path: finalPath,
|
||||||
|
|
@ -354,8 +417,15 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
format: format,
|
format: format,
|
||||||
sizeBytes: size,
|
sizeBytes: size,
|
||||||
progress: 1,
|
progress: 1,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
await _persist();
|
await _persist();
|
||||||
|
|
||||||
|
// Cache the cover art too, so offline browse / Now Playing can show real
|
||||||
|
// artwork. This is strictly best-effort and runs *after* the audio is
|
||||||
|
// already committed: any failure here is swallowed and must never fail or
|
||||||
|
// regress the (successful) audio download.
|
||||||
|
await _cacheArt(song, client, key, gen);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen != _generation) return;
|
if (gen != _generation) return;
|
||||||
final cur = state.byId[song.id];
|
final cur = state.byId[song.id];
|
||||||
|
|
@ -365,6 +435,56 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort fetch of [song]'s cover art to `<artDir>/<sanitizedId>.jpg`,
|
||||||
|
/// mirroring the audio temp+rename pattern. Soft-fails: any error is logged
|
||||||
|
/// only by being swallowed — the audio download stays `done` regardless.
|
||||||
|
/// Skips the network entirely if the art file already exists (dedup by
|
||||||
|
/// cover-art id) and honors the [gen] guard so a server switch mid-fetch
|
||||||
|
/// discards the partial file instead of surfacing another server's art.
|
||||||
|
Future<void> _cacheArt(
|
||||||
|
Song song,
|
||||||
|
SubsonicClient client,
|
||||||
|
String key,
|
||||||
|
int gen,
|
||||||
|
) async {
|
||||||
|
final coverArt = song.coverArt;
|
||||||
|
if (coverArt == null) return;
|
||||||
|
try {
|
||||||
|
final artDir = await _artDir(key);
|
||||||
|
final finalPath = '${artDir.path}/${_sanitizeArtId(coverArt)}.jpg';
|
||||||
|
if (await File(finalPath).exists()) {
|
||||||
|
// Already cached (possibly by a sibling track) — just ensure the map
|
||||||
|
// reflects it and skip the fetch.
|
||||||
|
if (gen == _generation && state.artById[coverArt] != finalPath) {
|
||||||
|
state = state.copyWith(
|
||||||
|
artById: {...state.artById, coverArt: finalPath},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tmpPath = '$finalPath.part';
|
||||||
|
await _dio.downloadUri(client.coverArtUri(coverArt, size: 512), tmpPath);
|
||||||
|
|
||||||
|
if (gen != _generation) {
|
||||||
|
// Server switched mid-fetch — discard the partial art file.
|
||||||
|
await File(tmpPath).delete().catchError((_) => File(tmpPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await File(tmpPath).rename(finalPath);
|
||||||
|
|
||||||
|
// Surface the new art immediately to the UI.
|
||||||
|
if (gen == _generation) {
|
||||||
|
state = state.copyWith(
|
||||||
|
artById: {...state.artById, coverArt: finalPath},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Art is optional; never fail the audio download because of it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Remove -------------------------------------------------------------
|
// ---- Remove -------------------------------------------------------------
|
||||||
|
|
||||||
/// Delete a single download (file + manifest entry).
|
/// Delete a single download (file + manifest entry).
|
||||||
|
|
@ -379,7 +499,28 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
final next = Map<String, DownloadInfo>.from(state.byId)..remove(songId);
|
final next = Map<String, DownloadInfo>.from(state.byId)..remove(songId);
|
||||||
state = state.copyWith(byId: next);
|
|
||||||
|
// Drop the cached art too, but only if no *other* remaining completed
|
||||||
|
// download still references the same cover-art id (art is shared/deduped).
|
||||||
|
var nextArt = state.artById;
|
||||||
|
final coverArt = info.song.coverArt;
|
||||||
|
if (coverArt != null) {
|
||||||
|
final stillUsed = next.values.any(
|
||||||
|
(d) => d.isDone && d.song.coverArt == coverArt,
|
||||||
|
);
|
||||||
|
if (!stillUsed) {
|
||||||
|
final artPath = state.artById[coverArt];
|
||||||
|
if (artPath != null) {
|
||||||
|
try {
|
||||||
|
final f = File(artPath);
|
||||||
|
if (await f.exists()) await f.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
nextArt = Map<String, String>.from(state.artById)..remove(coverArt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(byId: next, artById: nextArt);
|
||||||
await _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,6 +536,14 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Wipe the whole art directory in one shot (resets artById implicitly via
|
||||||
|
// the `const DownloadState()` assignment below).
|
||||||
|
if (key != null) {
|
||||||
|
try {
|
||||||
|
final artDir = await _artDir(key);
|
||||||
|
if (await artDir.exists()) await artDir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
state = const DownloadState();
|
state = const DownloadState();
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -407,9 +556,7 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
// ---- Internals ----------------------------------------------------------
|
// ---- Internals ----------------------------------------------------------
|
||||||
|
|
||||||
void _put(DownloadInfo info) {
|
void _put(DownloadInfo info) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(byId: {...state.byId, info.song.id: info});
|
||||||
byId: {...state.byId, info.song.id: info},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic write of the completed-downloads manifest (temp + rename).
|
/// Atomic write of the completed-downloads manifest (temp + rename).
|
||||||
|
|
@ -421,12 +568,14 @@ class DownloadController extends StateNotifier<DownloadState> {
|
||||||
final file = await _manifestFile(key);
|
final file = await _manifestFile(key);
|
||||||
final tmp = File('${file.path}.tmp');
|
final tmp = File('${file.path}.tmp');
|
||||||
await tmp.writeAsString(
|
await tmp.writeAsString(
|
||||||
jsonEncode(state.completed.map((d) {
|
jsonEncode(
|
||||||
|
state.completed.map((d) {
|
||||||
final j = d.toJson();
|
final j = d.toJson();
|
||||||
final rel = _relativize(d.path);
|
final rel = _relativize(d.path);
|
||||||
if (rel != null) j['relPath'] = rel;
|
if (rel != null) j['relPath'] = rel;
|
||||||
return j;
|
return j;
|
||||||
}).toList()),
|
}).toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tmp.rename(file.path);
|
await tmp.rename(file.path);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
|
||||||
153
lib/library/offline_library.dart
Normal file
153
lib/library/offline_library.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import '../subsonic/models.dart';
|
||||||
|
|
||||||
|
/// Reconstructs [Album]s and [Artist]s from a flat list of downloaded [Song]s.
|
||||||
|
/// The offline library only ever has tracks (that's all that's cached on disk),
|
||||||
|
/// so album/artist entities are synthesized on demand. Pure — no I/O.
|
||||||
|
|
||||||
|
/// Compare two strings case-insensitively, treating null as empty (sorts first).
|
||||||
|
int _byString(String? a, String? b) =>
|
||||||
|
(a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase());
|
||||||
|
|
||||||
|
/// Compare where a null [a]/[b] always sorts *last*. Takes bare [Comparable] so
|
||||||
|
/// `int` (`Comparable<num>`) works for disc/track ordering.
|
||||||
|
int _nullsLast(Comparable? a, Comparable? b) {
|
||||||
|
if (a == null && b == null) return 0;
|
||||||
|
if (a == null) return 1;
|
||||||
|
if (b == null) return -1;
|
||||||
|
return a.compareTo(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for a present, non-blank id/name.
|
||||||
|
bool _has(String? v) => v != null && v.trim().isNotEmpty;
|
||||||
|
|
||||||
|
/// First non-blank value in [values], or null if none.
|
||||||
|
String? _firstNonNull(Iterable<String?> values) {
|
||||||
|
for (final v in values) {
|
||||||
|
if (_has(v)) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First non-null value in [values], or null if none. For nullable ints.
|
||||||
|
int? _firstNonNullInt(Iterable<int?> values) {
|
||||||
|
for (final v in values) {
|
||||||
|
if (v != null) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable album key: prefer [Song.albumId], else fall back to the album NAME.
|
||||||
|
/// The synthesized [Album.id] mirrors this exactly (see [albumsFromSongs]), so
|
||||||
|
/// Phase 2 providers can look an album up by the same key it was built under.
|
||||||
|
String? _albumKey(Song s) => _has(s.albumId) ? s.albumId : s.album;
|
||||||
|
|
||||||
|
/// Stable artist key: prefer [Song.artistId], else fall back to the artist NAME.
|
||||||
|
String? _artistKey(Song s) => _has(s.artistId) ? s.artistId : s.artist;
|
||||||
|
|
||||||
|
/// Synthesize [Album]s from downloaded [Song]s, grouped by [_albumKey].
|
||||||
|
///
|
||||||
|
/// Songs with neither an albumId nor an album name are skipped — with no album
|
||||||
|
/// identity they can't form a meaningful album. The synthesized [Album.id] is
|
||||||
|
/// the albumId when present, else the album *name* itself (the same string used
|
||||||
|
/// as the grouping key), so lookups by id stay stable across rebuilds.
|
||||||
|
///
|
||||||
|
/// Returned albums are sorted by name (case-insensitive ascending) for a stable
|
||||||
|
/// default order.
|
||||||
|
List<Album> albumsFromSongs(List<Song> songs) {
|
||||||
|
// Preserve first-seen insertion order within groups; output order is imposed
|
||||||
|
// by the final sort, so the map's own ordering only needs to be deterministic.
|
||||||
|
final groups = <String, List<Song>>{};
|
||||||
|
for (final s in songs) {
|
||||||
|
final key = _albumKey(s);
|
||||||
|
if (key == null) continue; // no album identity → skip
|
||||||
|
groups.putIfAbsent(key, () => []).add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = <Album>[];
|
||||||
|
groups.forEach((key, group) {
|
||||||
|
// id: albumId if any song carries one, else the name-derived key.
|
||||||
|
final albumId = _firstNonNull(group.map((s) => s.albumId));
|
||||||
|
final id = albumId ?? key;
|
||||||
|
|
||||||
|
final sorted = [...group]
|
||||||
|
..sort((a, b) {
|
||||||
|
final d = _nullsLast(a.discNumber, b.discNumber);
|
||||||
|
if (d != 0) return d;
|
||||||
|
final t = _nullsLast(a.track, b.track);
|
||||||
|
return t != 0 ? t : _byString(a.title, b.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
out.add(
|
||||||
|
Album(
|
||||||
|
id: id,
|
||||||
|
name: _firstNonNull(group.map((s) => s.album)),
|
||||||
|
artist: _firstNonNull(group.map((s) => s.artist)),
|
||||||
|
artistId: _firstNonNull(group.map((s) => s.artistId)),
|
||||||
|
coverArt: _firstNonNull(group.map((s) => s.coverArt)),
|
||||||
|
year: _firstNonNullInt(group.map((s) => s.year)),
|
||||||
|
genre: _firstNonNull(group.map((s) => s.genre)),
|
||||||
|
songCount: group.length,
|
||||||
|
songs: sorted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
out.sort((a, b) => _byString(a.name, b.name));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthesize [Artist]s from downloaded [Song]s, grouped by [_artistKey].
|
||||||
|
///
|
||||||
|
/// Songs with neither an artistId nor an artist name are skipped. Each artist's
|
||||||
|
/// [Artist.albums] is built by running [albumsFromSongs] over that artist's own
|
||||||
|
/// songs, and its [Artist.id] follows the same id/name fallback as albums.
|
||||||
|
///
|
||||||
|
/// Returned artists are sorted by name (case-insensitive ascending).
|
||||||
|
List<Artist> artistsFromSongs(List<Song> songs) {
|
||||||
|
final groups = <String, List<Song>>{};
|
||||||
|
for (final s in songs) {
|
||||||
|
final key = _artistKey(s);
|
||||||
|
if (key == null) continue; // no artist identity → skip
|
||||||
|
groups.putIfAbsent(key, () => []).add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = <Artist>[];
|
||||||
|
groups.forEach((key, group) {
|
||||||
|
// id: artistId if any song carries one, else the name-derived key.
|
||||||
|
final artistId = _firstNonNull(group.map((s) => s.artistId));
|
||||||
|
final id = artistId ?? key;
|
||||||
|
|
||||||
|
final albums = albumsFromSongs(group);
|
||||||
|
|
||||||
|
out.add(
|
||||||
|
Artist(
|
||||||
|
id: id,
|
||||||
|
name: _firstNonNull(group.map((s) => s.artist)),
|
||||||
|
coverArt: _firstNonNull(albums.map((a) => a.coverArt)),
|
||||||
|
albums: albums,
|
||||||
|
albumCount: albums.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
out.sort((a, b) => _byString(a.name, b.name));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The synthesized album whose id == [id], or null. Keyed lookup for the album
|
||||||
|
/// detail provider.
|
||||||
|
Album? albumFromSongs(List<Song> songs, String id) {
|
||||||
|
for (final a in albumsFromSongs(songs)) {
|
||||||
|
if (a.id == id) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The synthesized artist whose id == [id], or null. Keyed lookup for the artist
|
||||||
|
/// detail provider.
|
||||||
|
Artist? artistFromSongs(List<Song> songs, String id) {
|
||||||
|
for (final a in artistsFromSongs(songs)) {
|
||||||
|
if (a.id == id) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ class PlaybackState {
|
||||||
this.queue = const [],
|
this.queue = const [],
|
||||||
this.currentIndex,
|
this.currentIndex,
|
||||||
this.playing = false,
|
this.playing = false,
|
||||||
|
this.buffering = false,
|
||||||
this.position = Duration.zero,
|
this.position = Duration.zero,
|
||||||
this.duration = Duration.zero,
|
this.duration = Duration.zero,
|
||||||
this.shuffle = false,
|
this.shuffle = false,
|
||||||
|
|
@ -43,6 +44,13 @@ class PlaybackState {
|
||||||
final List<Song> queue;
|
final List<Song> queue;
|
||||||
final int? currentIndex;
|
final int? currentIndex;
|
||||||
final bool playing;
|
final bool playing;
|
||||||
|
|
||||||
|
/// True while the player is loading/buffering a source (not yet `ready`). A
|
||||||
|
/// streamed source that is buffering legitimately reports position 0; this
|
||||||
|
/// lets the UI show a spinner instead of a frozen 0:00 bar. Transient — never
|
||||||
|
/// persisted.
|
||||||
|
final bool buffering;
|
||||||
|
|
||||||
final Duration position;
|
final Duration position;
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
final bool shuffle;
|
final bool shuffle;
|
||||||
|
|
@ -83,6 +91,7 @@ class PlaybackState {
|
||||||
List<Song>? queue,
|
List<Song>? queue,
|
||||||
int? currentIndex,
|
int? currentIndex,
|
||||||
bool? playing,
|
bool? playing,
|
||||||
|
bool? buffering,
|
||||||
Duration? position,
|
Duration? position,
|
||||||
Duration? duration,
|
Duration? duration,
|
||||||
bool? shuffle,
|
bool? shuffle,
|
||||||
|
|
@ -95,6 +104,7 @@ class PlaybackState {
|
||||||
queue: queue ?? this.queue,
|
queue: queue ?? this.queue,
|
||||||
currentIndex: currentIndex ?? this.currentIndex,
|
currentIndex: currentIndex ?? this.currentIndex,
|
||||||
playing: playing ?? this.playing,
|
playing: playing ?? this.playing,
|
||||||
|
buffering: buffering ?? this.buffering,
|
||||||
position: position ?? this.position,
|
position: position ?? this.position,
|
||||||
duration: duration ?? this.duration,
|
duration: duration ?? this.duration,
|
||||||
shuffle: shuffle ?? this.shuffle,
|
shuffle: shuffle ?? this.shuffle,
|
||||||
|
|
@ -183,6 +193,24 @@ class PlaybackController extends StateNotifier<PlaybackState>
|
||||||
/// one disk write per window.
|
/// one disk write per window.
|
||||||
Timer? _saveTimer;
|
Timer? _saveTimer;
|
||||||
|
|
||||||
|
// ---- Position interpolation -----------------------------------------
|
||||||
|
//
|
||||||
|
// just_audio's `positionStream`/`position` getter clamps the playing position
|
||||||
|
// to the reported duration; on iOS an unknown-length stream reports
|
||||||
|
// `duration == Duration.zero` (not null), so the clamp pins the playhead to
|
||||||
|
// 0:00 while playing (paused reads the raw value — hence "0:00 playing,
|
||||||
|
// correct when paused"). We sidestep the clamp entirely by anchoring on the
|
||||||
|
// raw, unclamped `updatePosition` from `playbackEventStream` and advancing it
|
||||||
|
// ourselves against the wall clock while actually playing.
|
||||||
|
|
||||||
|
/// Last unclamped position reported by the platform, and the wall-clock time
|
||||||
|
/// it was sampled (`PlaybackEvent.updateTime`).
|
||||||
|
Duration _posAnchor = Duration.zero;
|
||||||
|
DateTime _posAnchorAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
/// Ticks the interpolated position forward while playing.
|
||||||
|
Timer? _positionTicker;
|
||||||
|
|
||||||
/// Server key whose queue we've already restored (or adopted). Gates saves so
|
/// Server key whose queue we've already restored (or adopted). Gates saves so
|
||||||
/// the empty launch state can't clobber a snapshot before restore runs.
|
/// the empty launch state can't clobber a snapshot before restore runs.
|
||||||
String? _restoredKey;
|
String? _restoredKey;
|
||||||
|
|
@ -215,22 +243,68 @@ class PlaybackController extends StateNotifier<PlaybackState>
|
||||||
_maybeSlideWindow();
|
_maybeSlideWindow();
|
||||||
});
|
});
|
||||||
player.playerStateStream.listen((s) {
|
player.playerStateStream.listen((s) {
|
||||||
state = state.copyWith(playing: s.playing);
|
final ps = s.processingState;
|
||||||
});
|
state = state.copyWith(
|
||||||
player.positionStream.listen((p) {
|
playing: s.playing,
|
||||||
state = state.copyWith(position: p);
|
buffering: ps == ProcessingState.loading ||
|
||||||
|
ps == ProcessingState.buffering,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
player.durationStream.listen((d) {
|
player.durationStream.listen((d) {
|
||||||
if (d != null) state = state.copyWith(duration: d);
|
if (d != null) state = state.copyWith(duration: d);
|
||||||
});
|
});
|
||||||
// just_audio surfaces load/decode failures (e.g. an unreachable remote
|
// The event stream carries the raw, unclamped `updatePosition`; anchor on it
|
||||||
// source after the network drops) as errors on the event stream. Without a
|
// (and re-anchor on every seek / pause / track change) and reflect it
|
||||||
// handler the platform player runs its own recovery — restarting the item
|
// immediately so paused/seeked positions are exact. Steady-state advancing
|
||||||
// at 0 or auto-advancing — which is the reported "scrub back / skip" bug.
|
// is done by the ticker below. We also handle load/decode failures here:
|
||||||
|
// without a handler the platform player runs its own recovery — restarting
|
||||||
|
// the item at 0 or auto-advancing — the reported "scrub back / skip" bug.
|
||||||
player.playbackEventStream.listen(
|
player.playbackEventStream.listen(
|
||||||
(_) {},
|
(event) {
|
||||||
|
_posAnchor = event.updatePosition;
|
||||||
|
_posAnchorAt = event.updateTime;
|
||||||
|
// `updatePosition` is sampled at `updateTime`, i.e. slightly in the
|
||||||
|
// past. While playing, the ticker has already advanced the displayed
|
||||||
|
// position to ~now; writing the raw sample here would snap it backward
|
||||||
|
// every time an event fires (they fire periodically), then the ticker
|
||||||
|
// re-advances it — a visible flicker. So reflect the *interpolated*
|
||||||
|
// value (continuous with the ticker) while playing, and the raw value
|
||||||
|
// only when paused/buffering, where it's exact and nothing is ticking.
|
||||||
|
final pos = (state.playing && !state.buffering)
|
||||||
|
? _interpolatedPosition()
|
||||||
|
: event.updatePosition;
|
||||||
|
state = state.copyWith(position: pos);
|
||||||
|
},
|
||||||
onError: (Object e, StackTrace st) => _onPlayerError(e),
|
onError: (Object e, StackTrace st) => _onPlayerError(e),
|
||||||
);
|
);
|
||||||
|
_positionTicker?.cancel();
|
||||||
|
_positionTicker = Timer.periodic(
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
(_) => _tickPosition(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the displayed position off [_posAnchor] against the wall clock,
|
||||||
|
/// bypassing just_audio's duration-zero clamp. Only runs while genuinely
|
||||||
|
/// playing (not buffering/stalled) so the playhead never drifts ahead of the
|
||||||
|
/// audio; clamped to [PlaybackState.effectiveDuration] (which falls back to
|
||||||
|
/// the Subsonic metadata length) so it can't run past the end.
|
||||||
|
void _tickPosition() {
|
||||||
|
if (!state.playing || state.buffering) return;
|
||||||
|
state = state.copyWith(position: _interpolatedPosition());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current position interpolated off [_posAnchor] against the wall clock,
|
||||||
|
/// clamped to [PlaybackState.effectiveDuration] (falls back to the Subsonic
|
||||||
|
/// metadata length) so it can't run past the end. Shared by the ticker and
|
||||||
|
/// the event listener so both agree — a mismatch between them is what causes
|
||||||
|
/// the playhead to visibly jump.
|
||||||
|
Duration _interpolatedPosition() {
|
||||||
|
final elapsed = DateTime.now().difference(_posAnchorAt);
|
||||||
|
var pos = elapsed.isNegative ? _posAnchor : _posAnchor + elapsed;
|
||||||
|
final total = state.effectiveDuration;
|
||||||
|
if (total > Duration.zero && pos > total) pos = total;
|
||||||
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Id of the song we last ran play side effects for. Queue edits shift
|
/// Id of the song we last ran play side effects for. Queue edits shift
|
||||||
|
|
@ -306,11 +380,10 @@ class PlaybackController extends StateNotifier<PlaybackState>
|
||||||
|
|
||||||
AudioSource _sourceFor(Song song) {
|
AudioSource _sourceFor(Song song) {
|
||||||
final uri = _streamUriFor(song)!;
|
final uri = _streamUriFor(song)!;
|
||||||
if (!uri.isScheme('file')) _remoteSourceIds.add(song.id);
|
final isRemote = !uri.isScheme('file');
|
||||||
|
if (isRemote) _remoteSourceIds.add(song.id);
|
||||||
final art = _coverArtUriFor(song);
|
final art = _coverArtUriFor(song);
|
||||||
return AudioSource.uri(
|
final tag = MediaItem(
|
||||||
uri,
|
|
||||||
tag: MediaItem(
|
|
||||||
id: '${song.id}#${_tagSeq++}',
|
id: '${song.id}#${_tagSeq++}',
|
||||||
title: song.title ?? 'Unknown',
|
title: song.title ?? 'Unknown',
|
||||||
album: song.album,
|
album: song.album,
|
||||||
|
|
@ -318,8 +391,17 @@ class PlaybackController extends StateNotifier<PlaybackState>
|
||||||
duration:
|
duration:
|
||||||
song.duration != null ? Duration(seconds: song.duration!) : null,
|
song.duration != null ? Duration(seconds: song.duration!) : null,
|
||||||
artUri: art,
|
artUri: art,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
// Remote streams go straight to the native player (AVPlayer / ExoPlayer),
|
||||||
|
// which fetches the origin directly via its own networking stack. We
|
||||||
|
// deliberately do NOT wrap in LockCachingAudioSource nor set a player
|
||||||
|
// userAgent: both route the fetch through just_audio's localhost proxy,
|
||||||
|
// whose bare dart:io HttpClient sends a default User-Agent and demands an
|
||||||
|
// exact HTTP 200 — off-LAN edges (reverse proxy / WAF) reject or redirect
|
||||||
|
// that, breaking streaming while dio downloads still work. The proxy also
|
||||||
|
// hides the stream's Content-Length, which leaves the native duration
|
||||||
|
// indefinite and freezes the playhead (see the _wireStreams ticker).
|
||||||
|
return AudioSource.uri(uri, tag: tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the queue with [songs] and start at [startIndex].
|
/// Replace the queue with [songs] and start at [startIndex].
|
||||||
|
|
@ -867,9 +949,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
|
||||||
void resyncFromPlayer() {
|
void resyncFromPlayer() {
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player == null) return;
|
if (player == null) return;
|
||||||
final pos = player.position;
|
|
||||||
final dur = player.duration ?? state.duration;
|
|
||||||
final playing = player.playing;
|
final playing = player.playing;
|
||||||
|
// `player.position` is clamped to the reported duration, which is
|
||||||
|
// `Duration.zero` for unknown-length streams and would snap the playhead to
|
||||||
|
// 0 while playing. Use our unclamped anchor when playing; the raw getter is
|
||||||
|
// correct when paused.
|
||||||
|
final pos = playing
|
||||||
|
? _posAnchor + DateTime.now().difference(_posAnchorAt)
|
||||||
|
: player.position;
|
||||||
|
final dur = player.duration ?? state.duration;
|
||||||
// player.currentIndex is a window-relative index; map it back to logical.
|
// player.currentIndex is a window-relative index; map it back to logical.
|
||||||
final idx = player.currentIndex != null
|
final idx = player.currentIndex != null
|
||||||
? _windowStart + player.currentIndex!
|
? _windowStart + player.currentIndex!
|
||||||
|
|
@ -1035,6 +1123,7 @@ class PlaybackController extends StateNotifier<PlaybackState>
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
|
_positionTicker?.cancel();
|
||||||
_player?.dispose();
|
_player?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../downloads/download_manager.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../subsonic/models.dart';
|
import '../subsonic/models.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import '../widgets/art_image.dart';
|
||||||
import '../widgets/hairline_panel.dart';
|
import '../widgets/hairline_panel.dart';
|
||||||
import '../widgets/toast.dart';
|
import '../widgets/toast.dart';
|
||||||
import 'add_tag_sheet.dart';
|
import 'add_tag_sheet.dart';
|
||||||
|
|
@ -24,8 +25,9 @@ class BrowserScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
|
||||||
final mode = ref.watch(browseModeProvider);
|
final mode = ref.watch(browseModeProvider);
|
||||||
|
final offline = ref.watch(subsonicClientProvider) == null;
|
||||||
|
final hasDownloads = ref.watch(downloadedSongsProvider).isNotEmpty;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
|
@ -44,9 +46,9 @@ class BrowserScreen extends ConsumerWidget {
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.search,
|
icon: Icons.search,
|
||||||
label: 'Search',
|
label: 'Search',
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
context,
|
||||||
),
|
).push(MaterialPageRoute(builder: (_) => const SearchScreen())),
|
||||||
),
|
),
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.favorite_border,
|
icon: Icons.favorite_border,
|
||||||
|
|
@ -65,9 +67,9 @@ class BrowserScreen extends ConsumerWidget {
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.label_outline,
|
icon: Icons.label_outline,
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => const TagsScreen()),
|
context,
|
||||||
),
|
).push(MaterialPageRoute(builder: (_) => const TagsScreen())),
|
||||||
),
|
),
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.download,
|
icon: Icons.download,
|
||||||
|
|
@ -82,7 +84,7 @@ class BrowserScreen extends ConsumerWidget {
|
||||||
_ModeSelector(mode: mode),
|
_ModeSelector(mode: mode),
|
||||||
const SizedBox(height: TimbreSpacing.lg),
|
const SizedBox(height: TimbreSpacing.lg),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: client == null
|
child: offline && !hasDownloads
|
||||||
? const HairlinePanel(
|
? const HairlinePanel(
|
||||||
title: 'Browse',
|
title: 'Browse',
|
||||||
active: true,
|
active: true,
|
||||||
|
|
@ -115,8 +117,9 @@ class _ModeSelector extends ConsumerWidget {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => ref.read(browseModeProvider.notifier).state = m,
|
onTap: () => ref.read(browseModeProvider.notifier).state = m,
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints:
|
constraints: const BoxConstraints(
|
||||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
minHeight: TimbreSpacing.minTouchTarget,
|
||||||
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
|
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -124,8 +127,9 @@ class _ModeSelector extends ConsumerWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: active ? TimbreColors.foreground : TimbreColors.dimmed,
|
color: active ? TimbreColors.foreground : TimbreColors.dimmed,
|
||||||
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
|
||||||
decoration:
|
decoration: active
|
||||||
active ? TextDecoration.underline : TextDecoration.none,
|
? TextDecoration.underline
|
||||||
|
: TextDecoration.none,
|
||||||
decorationColor: accent,
|
decorationColor: accent,
|
||||||
decorationThickness: 2,
|
decorationThickness: 2,
|
||||||
),
|
),
|
||||||
|
|
@ -191,7 +195,6 @@ class _AlbumsPanel extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final albums = ref.watch(visibleAlbumsProvider);
|
final albums = ref.watch(visibleAlbumsProvider);
|
||||||
final filter = ref.watch(albumFilterProvider);
|
final filter = ref.watch(albumFilterProvider);
|
||||||
final client = ref.watch(subsonicClientProvider);
|
|
||||||
return HairlinePanel(
|
return HairlinePanel(
|
||||||
title: 'Albums',
|
title: 'Albums',
|
||||||
active: true,
|
active: true,
|
||||||
|
|
@ -215,18 +218,20 @@ class _AlbumsPanel extends ConsumerWidget {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: list.isEmpty
|
child: list.isEmpty
|
||||||
? _Centered(
|
? _Centered(
|
||||||
child: _ErrorText(filter.isActive
|
child: _ErrorText(
|
||||||
|
filter.isActive
|
||||||
? 'No albums match these filters.'
|
? 'No albums match these filters.'
|
||||||
: 'No albums on this server.'),
|
: 'No albums on this server.',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: LayoutBuilder(
|
: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final cols =
|
final cols = (constraints.maxWidth / 180)
|
||||||
(constraints.maxWidth / 180).floor().clamp(2, 6);
|
.floor()
|
||||||
|
.clamp(2, 6);
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
gridDelegate:
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: cols,
|
crossAxisCount: cols,
|
||||||
mainAxisSpacing: TimbreSpacing.md,
|
mainAxisSpacing: TimbreSpacing.md,
|
||||||
crossAxisSpacing: TimbreSpacing.md,
|
crossAxisSpacing: TimbreSpacing.md,
|
||||||
|
|
@ -237,13 +242,11 @@ class _AlbumsPanel extends ConsumerWidget {
|
||||||
itemCount: list.length,
|
itemCount: list.length,
|
||||||
itemBuilder: (context, i) => _AlbumTile(
|
itemBuilder: (context, i) => _AlbumTile(
|
||||||
album: list[i],
|
album: list[i],
|
||||||
artUri:
|
artUri: resolveArtUriW(
|
||||||
(client != null && list[i].coverArt != null)
|
ref,
|
||||||
? client
|
coverArt: list[i].coverArt,
|
||||||
.coverArtUri(list[i].coverArt!,
|
size: 300,
|
||||||
size: 300)
|
)?.toString(),
|
||||||
.toString()
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -266,25 +269,18 @@ class _AlbumTile extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
|
context,
|
||||||
),
|
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id))),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: ColoredBox(
|
child: ArtImage(
|
||||||
color: TimbreColors.surface,
|
artUri,
|
||||||
child: artUri != null
|
|
||||||
? Image.network(
|
|
||||||
artUri!,
|
|
||||||
key: ValueKey(artUri),
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
placeholder: const _AlbumArtFallback(),
|
||||||
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
|
|
||||||
)
|
|
||||||
: const _AlbumArtFallback(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TimbreSpacing.xs),
|
const SizedBox(height: TimbreSpacing.xs),
|
||||||
|
|
@ -299,8 +295,7 @@ class _AlbumTile extends StatelessWidget {
|
||||||
album.artist!,
|
album.artist!,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||||
color: TimbreColors.dimmed, fontSize: 12),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -312,8 +307,7 @@ class _AlbumArtFallback extends StatelessWidget {
|
||||||
const _AlbumArtFallback();
|
const _AlbumArtFallback();
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Center(
|
||||||
child: Icon(Icons.album_outlined,
|
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 32),
|
||||||
color: TimbreColors.dimmed, size: 32),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,7 +325,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// Offline the tracks come from the provider fallback (downloaded songs);
|
||||||
|
// only crawl the live library when we actually have a server connection.
|
||||||
|
if (ref.read(subsonicClientProvider) != null) {
|
||||||
ref.read(libraryIndexProvider.notifier).ensureBuilt();
|
ref.read(libraryIndexProvider.notifier).ensureBuilt();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,10 +338,12 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
final index = ref.watch(libraryIndexProvider);
|
final index = ref.watch(libraryIndexProvider);
|
||||||
final visible = ref.watch(visibleTracksProvider);
|
final visible = ref.watch(visibleTracksProvider);
|
||||||
final playback = ref.read(playbackCommandsProvider);
|
final playback = ref.read(playbackCommandsProvider);
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final offline = ref.watch(subsonicClientProvider) == null;
|
||||||
|
|
||||||
final Widget body;
|
final Widget body;
|
||||||
if (index.building) {
|
// Offline the crawled index is empty; `visible` is backed by the downloaded
|
||||||
|
// songs instead, so skip the online-only indexing / empty-index branches.
|
||||||
|
if (!offline && index.building) {
|
||||||
final total = index.total;
|
final total = index.total;
|
||||||
final label = total > 0
|
final label = total > 0
|
||||||
? 'Indexing ${index.done}/$total albums…'
|
? 'Indexing ${index.done}/$total albums…'
|
||||||
|
|
@ -358,11 +358,13 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (index.songs.isEmpty) {
|
} else if (!offline && index.songs.isEmpty) {
|
||||||
body = _Centered(
|
body = _Centered(
|
||||||
child: _ErrorText(index.error != null
|
child: _ErrorText(
|
||||||
|
index.error != null
|
||||||
? 'Could not build the track index.'
|
? 'Could not build the track index.'
|
||||||
: 'No tracks indexed yet.'),
|
: 'No tracks indexed yet.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final downloads = ref.watch(downloadManagerProvider);
|
final downloads = ref.watch(downloadManagerProvider);
|
||||||
|
|
@ -373,24 +375,24 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: visible.isEmpty
|
child: visible.isEmpty
|
||||||
? const _Centered(
|
? const _Centered(
|
||||||
child: _ErrorText('No tracks match these filters.'))
|
child: _ErrorText('No tracks match these filters.'),
|
||||||
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
itemCount: visible.length,
|
itemCount: visible.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final song = visible[i];
|
final song = visible[i];
|
||||||
final artUri = (client != null && song.coverArt != null)
|
final artUri = resolveArtUriW(
|
||||||
? client
|
ref,
|
||||||
.coverArtUri(song.coverArt!, size: 128)
|
coverArt: song.coverArt,
|
||||||
.toString()
|
size: 128,
|
||||||
: null;
|
)?.toString();
|
||||||
return BrowseRow(
|
return BrowseRow(
|
||||||
title: song.title ?? 'Untitled',
|
title: song.title ?? 'Untitled',
|
||||||
subtitle: song.artist,
|
subtitle: song.artist,
|
||||||
artUri: artUri,
|
artUri: artUri,
|
||||||
downloadStatus: downloads.byId[song.id]?.status,
|
downloadStatus: downloads.byId[song.id]?.status,
|
||||||
onTap: () =>
|
onTap: () => playback.playSongs(visible, startIndex: i),
|
||||||
playback.playSongs(visible, startIndex: i),
|
|
||||||
onPlayNext: () => playback.playNext(song),
|
onPlayNext: () => playback.playNext(song),
|
||||||
onAddToQueue: () => playback.addToQueue(song),
|
onAddToQueue: () => playback.addToQueue(song),
|
||||||
onAddToPlaylist: () =>
|
onAddToPlaylist: () =>
|
||||||
|
|
@ -414,7 +416,9 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
return HairlinePanel(
|
return HairlinePanel(
|
||||||
title: 'Tracks',
|
title: 'Tracks',
|
||||||
active: true,
|
active: true,
|
||||||
trailing: index.songs.isNotEmpty ? '(${visible.length})' : null,
|
trailing: index.songs.isNotEmpty || visible.isNotEmpty
|
||||||
|
? '(${visible.length})'
|
||||||
|
: null,
|
||||||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||||||
action: Row(
|
action: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -425,8 +429,10 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
: () => ref.read(libraryIndexProvider.notifier).refresh(),
|
: () => ref.read(libraryIndexProvider.notifier).refresh(),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
|
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
|
||||||
child: Text('↻ refresh',
|
child: Text(
|
||||||
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
|
'↻ refresh',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (visible.isNotEmpty)
|
if (visible.isNotEmpty)
|
||||||
|
|
@ -445,19 +451,27 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.download,
|
Icon(
|
||||||
size: 16, color: TimbreColors.foreground),
|
Icons.download,
|
||||||
|
size: 16,
|
||||||
|
color: TimbreColors.foreground,
|
||||||
|
),
|
||||||
SizedBox(width: TimbreSpacing.sm),
|
SizedBox(width: TimbreSpacing.sm),
|
||||||
Text('Download all',
|
Text(
|
||||||
style: TextStyle(color: TimbreColors.foreground)),
|
'Download all',
|
||||||
|
style: TextStyle(color: TimbreColors.foreground),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(TimbreSpacing.xs),
|
padding: const EdgeInsets.all(TimbreSpacing.xs),
|
||||||
child: Icon(Icons.more_vert,
|
child: Icon(
|
||||||
size: 18, color: TimbreColors.dimmed),
|
Icons.more_vert,
|
||||||
|
size: 18,
|
||||||
|
color: TimbreColors.dimmed,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -470,7 +484,9 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
/// library, so it's gated behind a dialog unlike per-album download-all.
|
/// library, so it's gated behind a dialog unlike per-album download-all.
|
||||||
/// [songs] is the currently-visible (filtered/sorted) set.
|
/// [songs] is the currently-visible (filtered/sorted) set.
|
||||||
Future<void> _confirmDownloadAll(
|
Future<void> _confirmDownloadAll(
|
||||||
BuildContext context, List<Song> songs) async {
|
BuildContext context,
|
||||||
|
List<Song> songs,
|
||||||
|
) async {
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -478,14 +494,17 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
title: const Text('Download these tracks?'),
|
title: const Text('Download these tracks?'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'This queues all ${songs.length} listed tracks for offline '
|
'This queues all ${songs.length} listed tracks for offline '
|
||||||
'download. It may use significant storage and data.'),
|
'download. It may use significant storage and data.',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Cancel')),
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
child: const Text('Download all')),
|
child: const Text('Download all'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -543,9 +562,7 @@ class ArtistScreen extends ConsumerWidget {
|
||||||
title: album.name ?? 'Unknown album',
|
title: album.name ?? 'Unknown album',
|
||||||
trailing: album.year?.toString(),
|
trailing: album.year?.toString(),
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
|
||||||
builder: (_) => AlbumScreen(id: album.id),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -574,16 +591,13 @@ class AlbumScreen extends ConsumerWidget {
|
||||||
: [
|
: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Add to playlist',
|
tooltip: 'Add to playlist',
|
||||||
onPressed: () =>
|
onPressed: () => showAddToPlaylistSheet(context, songs: songs),
|
||||||
showAddToPlaylistSheet(context, songs: songs),
|
|
||||||
icon: const Icon(Icons.playlist_add),
|
icon: const Icon(Icons.playlist_add),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Download album',
|
tooltip: 'Download album',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref
|
ref.read(downloadManagerProvider.notifier).downloadAll(songs);
|
||||||
.read(downloadManagerProvider.notifier)
|
|
||||||
.downloadAll(songs);
|
|
||||||
showToast(context, 'Downloading album…');
|
showToast(context, 'Downloading album…');
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download),
|
||||||
|
|
@ -678,39 +692,36 @@ class BrowseRow extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final isDone = downloadStatus == DownloadStatus.done;
|
final isDone = downloadStatus == DownloadStatus.done;
|
||||||
final isActive = downloadStatus == DownloadStatus.queued ||
|
final isActive =
|
||||||
|
downloadStatus == DownloadStatus.queued ||
|
||||||
downloadStatus == DownloadStatus.downloading;
|
downloadStatus == DownloadStatus.downloading;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints:
|
constraints: const BoxConstraints(
|
||||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
minHeight: TimbreSpacing.minTouchTarget,
|
||||||
|
),
|
||||||
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (artUri != null) ...[
|
if (artUri != null) ...[
|
||||||
SizedBox(
|
ArtImage(
|
||||||
|
artUri,
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: ColoredBox(
|
|
||||||
color: TimbreColors.surface,
|
|
||||||
child: Image.network(
|
|
||||||
artUri!,
|
|
||||||
key: ValueKey(artUri),
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
placeholder: const _AlbumArtFallback(),
|
||||||
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: TimbreSpacing.md),
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
],
|
],
|
||||||
if (leading != null)
|
if (leading != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 28,
|
width: 28,
|
||||||
child: Text(leading!,
|
child: Text(
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
leading!,
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -729,7 +740,9 @@ class BrowseRow extends StatelessWidget {
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: TimbreColors.dimmed, fontSize: 12),
|
color: TimbreColors.dimmed,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -737,8 +750,7 @@ class BrowseRow extends StatelessWidget {
|
||||||
if (isDone)
|
if (isDone)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: TimbreSpacing.sm),
|
padding: const EdgeInsets.only(left: TimbreSpacing.sm),
|
||||||
child:
|
child: Icon(Icons.download_done, size: 14, color: accent),
|
||||||
Icon(Icons.download_done, size: 14, color: accent),
|
|
||||||
)
|
)
|
||||||
else if (isActive)
|
else if (isActive)
|
||||||
const Padding(
|
const Padding(
|
||||||
|
|
@ -751,8 +763,7 @@ class BrowseRow extends StatelessWidget {
|
||||||
),
|
),
|
||||||
if (trailing != null) ...[
|
if (trailing != null) ...[
|
||||||
const SizedBox(width: TimbreSpacing.md),
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
Text(trailing!,
|
Text(trailing!, style: TextStyle(color: TimbreColors.dimmed)),
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
|
||||||
],
|
],
|
||||||
if (onPlayNext != null)
|
if (onPlayNext != null)
|
||||||
_RowIcon(
|
_RowIcon(
|
||||||
|
|
@ -812,8 +823,7 @@ class _RowMenu extends StatelessWidget {
|
||||||
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
|
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
|
||||||
color: TimbreColors.surface,
|
color: TimbreColors.surface,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints:
|
constraints: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
|
||||||
const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
|
|
||||||
onSelected: (v) {
|
onSelected: (v) {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
|
|
@ -829,17 +839,22 @@ class _RowMenu extends StatelessWidget {
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
if (onAddToPlaylist != null)
|
if (onAddToPlaylist != null)
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'playlist', child: Text('Add to playlist')),
|
value: 'playlist',
|
||||||
|
child: Text('Add to playlist'),
|
||||||
|
),
|
||||||
if (onAddToTag != null)
|
if (onAddToTag != null)
|
||||||
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
|
const PopupMenuItem(value: 'tag', child: Text('Add tag…')),
|
||||||
if (isDownloaded && onRemoveDownload != null)
|
if (isDownloaded && onRemoveDownload != null)
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'remove_download', child: Text('Remove download'))
|
value: 'remove_download',
|
||||||
|
child: Text('Remove download'),
|
||||||
|
)
|
||||||
else if (onDownload != null)
|
else if (onDownload != null)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'download',
|
value: 'download',
|
||||||
enabled: !isDownloading,
|
enabled: !isDownloading,
|
||||||
child: Text(isDownloading ? 'Downloading…' : 'Download')),
|
child: Text(isDownloading ? 'Downloading…' : 'Download'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -881,10 +896,12 @@ class _DetailScaffold extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(title,
|
title: Text(
|
||||||
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
),
|
),
|
||||||
body: SafeArea(child: child),
|
body: SafeArea(child: child),
|
||||||
|
|
@ -901,12 +918,16 @@ class _NotConnected extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('Not connected.',
|
Text(
|
||||||
style: TextStyle(color: TimbreColors.foreground)),
|
"You're offline.",
|
||||||
|
style: TextStyle(color: TimbreColors.foreground),
|
||||||
|
),
|
||||||
SizedBox(height: TimbreSpacing.sm),
|
SizedBox(height: TimbreSpacing.sm),
|
||||||
Text('Tap the status bar to add a Subsonic server.',
|
Text(
|
||||||
|
'Download music to browse it here.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
190
lib/screens/debug_screen.dart
Normal file
190
lib/screens/debug_screen.dart
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../debug/log_store.dart';
|
||||||
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
|
/// Beta-testing console: shows the app's captured `print`/`debugPrint` output
|
||||||
|
/// and uncaught errors (see [LogStore], wired up in `main`). Read-only view
|
||||||
|
/// with copy-all / clear / follow controls — no debugger needed on-device.
|
||||||
|
class DebugScreen extends StatefulWidget {
|
||||||
|
const DebugScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DebugScreen> createState() => _DebugScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DebugScreenState extends State<DebugScreen> {
|
||||||
|
static const _errorColor = Color(0xFFE06C75);
|
||||||
|
|
||||||
|
final _controller = ScrollController();
|
||||||
|
|
||||||
|
/// When true, new lines keep the view pinned to the bottom (tail -f style).
|
||||||
|
/// Flipped off automatically when the user scrolls up to read history.
|
||||||
|
bool _follow = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.addListener(_onScroll);
|
||||||
|
LogStore.instance.addListener(_onLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
LogStore.instance.removeListener(_onLog);
|
||||||
|
_controller.removeListener(_onScroll);
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (!_controller.hasClients) return;
|
||||||
|
final atBottom =
|
||||||
|
_controller.offset >= _controller.position.maxScrollExtent - 24;
|
||||||
|
if (atBottom != _follow) setState(() => _follow = atBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLog() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
if (_follow) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToBottom());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _jumpToBottom() {
|
||||||
|
if (!_controller.hasClients) return;
|
||||||
|
_controller.jumpTo(_controller.position.maxScrollExtent);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _copyAll() async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: LogStore.instance.asText()));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Log copied to clipboard')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: TimbreColors.background,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_header(),
|
||||||
|
Expanded(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: LogStore.instance,
|
||||||
|
builder: (context, _) {
|
||||||
|
final entries = LogStore.instance.entries;
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No output captured yet.',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Scrollbar(
|
||||||
|
controller: _controller,
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _controller,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TimbreSpacing.lg,
|
||||||
|
vertical: TimbreSpacing.sm,
|
||||||
|
),
|
||||||
|
itemCount: entries.length,
|
||||||
|
itemBuilder: (context, i) => _line(entries[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _header() {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: TimbreColors.surface,
|
||||||
|
border: Border(bottom: BorderSide(color: TimbreColors.border)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TimbreSpacing.lg,
|
||||||
|
vertical: TimbreSpacing.sm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: LogStore.instance,
|
||||||
|
builder: (context, _) => Text(
|
||||||
|
'console · ${LogStore.instance.length} lines',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_action(
|
||||||
|
_follow ? Icons.vertical_align_bottom : Icons.pause,
|
||||||
|
_follow ? 'follow' : 'paused',
|
||||||
|
() {
|
||||||
|
setState(() => _follow = !_follow);
|
||||||
|
if (_follow) _jumpToBottom();
|
||||||
|
},
|
||||||
|
active: _follow,
|
||||||
|
),
|
||||||
|
_action(Icons.copy, 'copy', _copyAll),
|
||||||
|
_action(Icons.delete_outline, 'clear', LogStore.instance.clear),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _action(IconData icon, String label, VoidCallback onTap,
|
||||||
|
{bool active = false}) {
|
||||||
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
|
final color = active ? accent : TimbreColors.dimmed;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: TimbreSpacing.xs),
|
||||||
|
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _line(LogEntry e) {
|
||||||
|
final isError = e.level == LogLevel.error;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '${e.timeLabel} ',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: e.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isError ? _errorColor : TimbreColors.foreground,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../downloads/download_manager.dart';
|
import '../downloads/download_manager.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import '../widgets/art_image.dart';
|
||||||
import '../widgets/hairline_panel.dart';
|
import '../widgets/hairline_panel.dart';
|
||||||
|
import '../widgets/toast.dart';
|
||||||
|
|
||||||
/// Manage offline downloads: what's saved, how much space it uses, and any
|
/// Manage offline downloads: what's saved, how much space it uses, and any
|
||||||
/// in-flight transfers. Tapping a completed track plays it.
|
/// in-flight transfers. Tapping a completed track plays it.
|
||||||
|
|
@ -19,25 +21,33 @@ class DownloadsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
final active = downloads.byId.values.where((d) => d.isActive).toList();
|
final active = downloads.byId.values.where((d) => d.isActive).toList();
|
||||||
final completed = downloads.completed;
|
final completed = downloads.completed;
|
||||||
|
// Ordered play list — index i here matches the i-th rendered saved row.
|
||||||
|
final savedSongs = completed.map((d) => d.song).toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Downloads',
|
title: const Text(
|
||||||
style: TextStyle(fontWeight: FontWeight.w700)),
|
'Downloads',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (completed.isNotEmpty)
|
if (completed.isNotEmpty)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _confirmClear(context, controller),
|
onPressed: () => _confirmClear(context, controller),
|
||||||
child: Text('Clear all',
|
child: Text(
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
'Clear all',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: (active.isEmpty && completed.isEmpty)
|
child: (active.isEmpty && completed.isEmpty)
|
||||||
? Center(
|
? Center(
|
||||||
child: Text('No downloads yet.',
|
child: Text(
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
'No downloads yet.',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(TimbreSpacing.lg),
|
padding: const EdgeInsets.all(TimbreSpacing.lg),
|
||||||
|
|
@ -47,11 +57,10 @@ class DownloadsScreen extends ConsumerWidget {
|
||||||
title: 'Downloading',
|
title: 'Downloading',
|
||||||
trailing: '(${active.length})',
|
trailing: '(${active.length})',
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
vertical: TimbreSpacing.md),
|
vertical: TimbreSpacing.md,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [for (final d in active) _ActiveRow(info: d)],
|
||||||
for (final d in active) _ActiveRow(info: d),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TimbreSpacing.xl),
|
const SizedBox(height: TimbreSpacing.xl),
|
||||||
|
|
@ -62,21 +71,68 @@ class DownloadsScreen extends ConsumerWidget {
|
||||||
trailing: completed.isEmpty
|
trailing: completed.isEmpty
|
||||||
? null
|
? null
|
||||||
: '${completed.length} · ${_fmtBytes(downloads.totalBytes)}',
|
: '${completed.length} · ${_fmtBytes(downloads.totalBytes)}',
|
||||||
padding:
|
action: Row(
|
||||||
const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: savedSongs.isEmpty
|
||||||
|
? null
|
||||||
|
: () => playback.playSongs(savedSongs),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TimbreSpacing.xs),
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
size: 18,
|
||||||
|
color: TimbreColors.dimmed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: savedSongs.isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
playback.toggleShuffle();
|
||||||
|
playback.playSongs(savedSongs);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TimbreSpacing.xs),
|
||||||
|
child: Icon(
|
||||||
|
Icons.shuffle,
|
||||||
|
size: 18,
|
||||||
|
color: TimbreColors.dimmed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TimbreSpacing.md,
|
||||||
|
),
|
||||||
child: completed.isEmpty
|
child: completed.isEmpty
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: EdgeInsets.all(TimbreSpacing.lg),
|
padding: EdgeInsets.all(TimbreSpacing.lg),
|
||||||
child: Text('Nothing saved for offline yet.',
|
child: Text(
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
'Nothing saved for offline yet.',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final d in completed)
|
for (final (i, d) in completed.indexed)
|
||||||
_SavedRow(
|
_SavedRow(
|
||||||
info: d,
|
info: d,
|
||||||
onPlay: () =>
|
artUri: resolveArtUriW(
|
||||||
playback.playSongs([d.song]),
|
ref,
|
||||||
|
coverArt: d.song.coverArt,
|
||||||
|
size: 128,
|
||||||
|
)?.toString(),
|
||||||
|
onPlay: () => playback.playSongs(
|
||||||
|
savedSongs,
|
||||||
|
startIndex: i,
|
||||||
|
),
|
||||||
|
onPlayNext: () => playback.playNext(d.song),
|
||||||
|
onAddToQueue: () =>
|
||||||
|
playback.addToQueue(d.song),
|
||||||
onRemove: () => controller.remove(d.song.id),
|
onRemove: () => controller.remove(d.song.id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -89,21 +145,26 @@ class DownloadsScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmClear(
|
Future<void> _confirmClear(
|
||||||
BuildContext context, DownloadController controller) async {
|
BuildContext context,
|
||||||
|
DownloadController controller,
|
||||||
|
) async {
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: TimbreColors.surface,
|
backgroundColor: TimbreColors.surface,
|
||||||
title: const Text('Remove all downloads?'),
|
title: const Text('Remove all downloads?'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'This deletes every saved file for this server. It cannot be undone.'),
|
'This deletes every saved file for this server. It cannot be undone.',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Cancel')),
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
child: const Text('Remove all')),
|
child: const Text('Remove all'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -121,21 +182,27 @@ class _ActiveRow extends StatelessWidget {
|
||||||
final failed = info.status == DownloadStatus.failed;
|
final failed = info.status == DownloadStatus.failed;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.xs),
|
horizontal: TimbreSpacing.lg,
|
||||||
|
vertical: TimbreSpacing.xs,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(info.song.title ?? 'Untitled',
|
Text(
|
||||||
|
info.song.title ?? 'Untitled',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: TimbreColors.foreground)),
|
style: TextStyle(color: TimbreColors.foreground),
|
||||||
|
),
|
||||||
const SizedBox(height: TimbreSpacing.xs),
|
const SizedBox(height: TimbreSpacing.xs),
|
||||||
if (failed)
|
if (failed)
|
||||||
const Text('Failed',
|
const Text(
|
||||||
style: TextStyle(color: Color(0xFFE06C75), fontSize: 12))
|
'Failed',
|
||||||
|
style: TextStyle(color: Color(0xFFE06C75), fontSize: 12),
|
||||||
|
)
|
||||||
else
|
else
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: info.progress > 0 ? info.progress : null,
|
value: info.progress > 0 ? info.progress : null,
|
||||||
|
|
@ -162,35 +229,55 @@ class _ActiveRow extends StatelessWidget {
|
||||||
class _SavedRow extends StatelessWidget {
|
class _SavedRow extends StatelessWidget {
|
||||||
const _SavedRow({
|
const _SavedRow({
|
||||||
required this.info,
|
required this.info,
|
||||||
|
required this.artUri,
|
||||||
required this.onPlay,
|
required this.onPlay,
|
||||||
|
required this.onPlayNext,
|
||||||
|
required this.onAddToQueue,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DownloadInfo info;
|
final DownloadInfo info;
|
||||||
|
|
||||||
|
/// Resolved cover-art URI (downloaded art is local, so it shows offline).
|
||||||
|
final String? artUri;
|
||||||
final VoidCallback onPlay;
|
final VoidCallback onPlay;
|
||||||
|
final VoidCallback onPlayNext;
|
||||||
|
final VoidCallback onAddToQueue;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final quality = info.format ??
|
final quality =
|
||||||
|
info.format ??
|
||||||
(info.bitRate != null ? '${info.bitRate} kbps' : 'Original');
|
(info.bitRate != null ? '${info.bitRate} kbps' : 'Original');
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onPlay,
|
onTap: onPlay,
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints:
|
constraints: const BoxConstraints(
|
||||||
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
|
minHeight: TimbreSpacing.minTouchTarget,
|
||||||
|
),
|
||||||
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
ArtImage(
|
||||||
|
artUri,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(info.song.title ?? 'Untitled',
|
Text(
|
||||||
|
info.song.title ?? 'Untitled',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: TimbreColors.foreground)),
|
style: TextStyle(color: TimbreColors.foreground),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
[
|
[
|
||||||
info.song.artist,
|
info.song.artist,
|
||||||
|
|
@ -198,21 +285,37 @@ class _SavedRow extends StatelessWidget {
|
||||||
].where((e) => e != null && e.isNotEmpty).join(' · '),
|
].where((e) => e != null && e.isNotEmpty).join(' · '),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style:
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||||
TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
InkWell(
|
PopupMenuButton<String>(
|
||||||
onTap: onRemove,
|
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed),
|
||||||
customBorder: const CircleBorder(),
|
color: TimbreColors.surface,
|
||||||
child: SizedBox(
|
onSelected: (v) {
|
||||||
width: TimbreSpacing.minTouchTarget,
|
switch (v) {
|
||||||
height: TimbreSpacing.minTouchTarget,
|
case 'next':
|
||||||
child: Icon(Icons.delete_outline,
|
onPlayNext();
|
||||||
size: 20, color: TimbreColors.dimmed),
|
showToast(context, 'Playing next', icon: Icons.check);
|
||||||
|
case 'queue':
|
||||||
|
onAddToQueue();
|
||||||
|
showToast(context, 'Added to queue', icon: Icons.check);
|
||||||
|
case 'remove':
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(value: 'next', child: Text('Play next')),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'queue',
|
||||||
|
child: Text('Add to queue'),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'remove',
|
||||||
|
child: Text('Remove download'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../history/play_history.dart';
|
||||||
import '../subsonic/models.dart';
|
import '../subsonic/models.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import '../widgets/art_image.dart';
|
||||||
import '../widgets/block_progress_bar.dart';
|
import '../widgets/block_progress_bar.dart';
|
||||||
import 'browser_screen.dart';
|
import 'browser_screen.dart';
|
||||||
|
|
||||||
|
|
@ -22,9 +23,7 @@ class HomeScreen extends ConsumerWidget {
|
||||||
final random = ref.watch(randomAlbumsProvider);
|
final random = ref.watch(randomAlbumsProvider);
|
||||||
|
|
||||||
String? artFor(String? coverArt, {int size = 300}) =>
|
String? artFor(String? coverArt, {int size = 300}) =>
|
||||||
(client != null && coverArt != null)
|
resolveArtUriW(ref, coverArt: coverArt, size: size)?.toString();
|
||||||
? client.coverArtUri(coverArt, size: size).toString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
|
@ -55,11 +54,7 @@ class HomeScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Recently Added — server discovery shelf.
|
// Recently Added — server discovery shelf.
|
||||||
_AlbumShelf(
|
_AlbumShelf(title: 'Recently Added', albums: newest, artFor: artFor),
|
||||||
title: 'Recently Added',
|
|
||||||
albums: newest,
|
|
||||||
artFor: artFor,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Random — a single spotlighted album, re-rolled via the shuffle action.
|
// Random — a single spotlighted album, re-rolled via the shuffle action.
|
||||||
_RandomAlbum(
|
_RandomAlbum(
|
||||||
|
|
@ -81,9 +76,9 @@ class HomeScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _pushAlbum(BuildContext context, String albumId) {
|
static void _pushAlbum(BuildContext context, String albumId) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)),
|
context,
|
||||||
);
|
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,7 +91,6 @@ class _HeroCard extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final client = ref.watch(subsonicClientProvider);
|
|
||||||
final current = ref.watch(activePlaybackProvider.select((s) => s.current));
|
final current = ref.watch(activePlaybackProvider.select((s) => s.current));
|
||||||
|
|
||||||
// Fall back to the most recent track so the hero is useful before playback.
|
// Fall back to the most recent track so the hero is useful before playback.
|
||||||
|
|
@ -104,9 +98,11 @@ class _HeroCard extends ConsumerWidget {
|
||||||
final PlayRecord? fallback = recent.isEmpty ? null : recent.first;
|
final PlayRecord? fallback = recent.isEmpty ? null : recent.first;
|
||||||
|
|
||||||
final String? coverArt = current?.coverArt ?? fallback?.coverArt;
|
final String? coverArt = current?.coverArt ?? fallback?.coverArt;
|
||||||
final artUri = (client != null && coverArt != null)
|
final artUri = resolveArtUriW(
|
||||||
? client.coverArtUri(coverArt, size: 240).toString()
|
ref,
|
||||||
: null;
|
coverArt: coverArt,
|
||||||
|
size: 240,
|
||||||
|
)?.toString();
|
||||||
|
|
||||||
final title = current?.title ?? fallback?.title;
|
final title = current?.title ?? fallback?.title;
|
||||||
final subtitle = current?.artist ?? fallback?.artist;
|
final subtitle = current?.artist ?? fallback?.artist;
|
||||||
|
|
@ -118,12 +114,17 @@ class _HeroCard extends ConsumerWidget {
|
||||||
onTap: () => ref.read(selectedTabProvider.notifier).state = 1,
|
onTap: () => ref.read(selectedTabProvider.notifier).state = 1,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.library_music_outlined,
|
Icon(
|
||||||
color: TimbreColors.dimmed, size: 40),
|
Icons.library_music_outlined,
|
||||||
|
color: TimbreColors.dimmed,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
const SizedBox(width: TimbreSpacing.lg),
|
const SizedBox(width: TimbreSpacing.lg),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text('Browse your library to start listening',
|
child: Text(
|
||||||
style: TextStyle(color: TimbreColors.foreground)),
|
'Browse your library to start listening',
|
||||||
|
style: TextStyle(color: TimbreColors.foreground),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -145,18 +146,7 @@ class _HeroCard extends ConsumerWidget {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
child: ColoredBox(
|
child: ArtImage(artUri, fit: BoxFit.cover),
|
||||||
color: TimbreColors.surface,
|
|
||||||
child: artUri != null
|
|
||||||
? Image.network(artUri,
|
|
||||||
key: ValueKey(artUri),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (_, _, _) => Icon(
|
|
||||||
Icons.album_outlined, color: TimbreColors.dimmed))
|
|
||||||
: Icon(Icons.album_outlined,
|
|
||||||
color: TimbreColors.dimmed),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: TimbreSpacing.lg),
|
const SizedBox(width: TimbreSpacing.lg),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -166,29 +156,40 @@ class _HeroCard extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(hasCurrent ? Icons.play_arrow : Icons.history,
|
Icon(
|
||||||
size: 14, color: accent),
|
hasCurrent ? Icons.play_arrow : Icons.history,
|
||||||
|
size: 14,
|
||||||
|
color: accent,
|
||||||
|
),
|
||||||
const SizedBox(width: TimbreSpacing.xs),
|
const SizedBox(width: TimbreSpacing.xs),
|
||||||
Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
|
Text(
|
||||||
|
hasCurrent ? 'NOW PLAYING' : 'RESUME',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: accent,
|
color: accent,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
fontWeight: FontWeight.w700)),
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: TimbreSpacing.xs),
|
const SizedBox(height: TimbreSpacing.xs),
|
||||||
Text(title,
|
Text(
|
||||||
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: TimbreColors.foreground,
|
color: TimbreColors.foreground,
|
||||||
fontWeight: FontWeight.w700)),
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (subtitle != null)
|
if (subtitle != null)
|
||||||
Text(subtitle,
|
Text(
|
||||||
|
subtitle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
if (hasCurrent) ...[
|
if (hasCurrent) ...[
|
||||||
const SizedBox(height: TimbreSpacing.md),
|
const SizedBox(height: TimbreSpacing.md),
|
||||||
const _HeroProgress(),
|
const _HeroProgress(),
|
||||||
|
|
@ -210,14 +211,19 @@ class _HeroProgress extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
|
final progress = ref.watch(
|
||||||
|
activePlaybackProvider.select((s) => s.progress),
|
||||||
|
);
|
||||||
return BlockProgressBar(progress: progress, cells: 32, height: 6);
|
return BlockProgressBar(progress: progress, cells: 32, height: 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HeroShell extends StatelessWidget {
|
class _HeroShell extends StatelessWidget {
|
||||||
const _HeroShell(
|
const _HeroShell({
|
||||||
{required this.child, required this.accent, required this.onTap});
|
required this.child,
|
||||||
|
required this.accent,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final Color accent;
|
final Color accent;
|
||||||
|
|
@ -282,11 +288,14 @@ class _ShelfHeader extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Text(title,
|
Text(
|
||||||
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: TimbreColors.foreground,
|
color: TimbreColors.foreground,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.5)),
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (onShuffle != null)
|
if (onShuffle != null)
|
||||||
InkWell(
|
InkWell(
|
||||||
|
|
@ -321,7 +330,11 @@ class _AlbumShelf extends StatelessWidget {
|
||||||
return albums.when(
|
return albums.when(
|
||||||
loading: () => _Shelf(
|
loading: () => _Shelf(
|
||||||
title: title,
|
title: title,
|
||||||
cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
|
cards: const [
|
||||||
|
_ArtCardSkeleton(),
|
||||||
|
_ArtCardSkeleton(),
|
||||||
|
_ArtCardSkeleton(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
error: (_, _) => const SizedBox.shrink(),
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
data: (list) => _Shelf(
|
data: (list) => _Shelf(
|
||||||
|
|
@ -332,9 +345,9 @@ class _AlbumShelf extends StatelessWidget {
|
||||||
artUri: artFor(a.coverArt),
|
artUri: artFor(a.coverArt),
|
||||||
title: a.name ?? 'Unknown album',
|
title: a.name ?? 'Unknown album',
|
||||||
subtitle: a.artist,
|
subtitle: a.artist,
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
|
context,
|
||||||
),
|
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -378,9 +391,9 @@ class _RandomAlbum extends StatelessWidget {
|
||||||
const _RandomSkeleton()
|
const _RandomSkeleton()
|
||||||
else
|
else
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
|
context,
|
||||||
),
|
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))),
|
||||||
child: _RandomBody(album: a, artUri: artFor(a.coverArt)),
|
child: _RandomBody(album: a, artUri: artFor(a.coverArt)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -402,20 +415,11 @@ class _RandomBody extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: SizedBox(
|
child: ArtImage(
|
||||||
|
artUri,
|
||||||
|
fit: BoxFit.cover,
|
||||||
width: _RandomAlbum._size,
|
width: _RandomAlbum._size,
|
||||||
height: _RandomAlbum._size,
|
height: _RandomAlbum._size,
|
||||||
child: ColoredBox(
|
|
||||||
color: TimbreColors.surface,
|
|
||||||
child: artUri != null
|
|
||||||
? Image.network(artUri!,
|
|
||||||
key: ValueKey(artUri),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (_, _, _) => Icon(
|
|
||||||
Icons.album_outlined, color: TimbreColors.dimmed))
|
|
||||||
: Icon(Icons.album_outlined, color: TimbreColors.dimmed),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: TimbreSpacing.lg),
|
const SizedBox(width: TimbreSpacing.lg),
|
||||||
|
|
@ -424,28 +428,35 @@ class _RandomBody extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(album.name ?? 'Unknown album',
|
Text(
|
||||||
|
album.name ?? 'Unknown album',
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: TimbreColors.foreground,
|
color: TimbreColors.foreground,
|
||||||
fontWeight: FontWeight.w700)),
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: TimbreSpacing.xs),
|
const SizedBox(height: TimbreSpacing.xs),
|
||||||
if (album.artist != null)
|
if (album.artist != null)
|
||||||
Text(album.artist!,
|
Text(
|
||||||
|
album.artist!,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
if (album.year != null)
|
if (album.year != null)
|
||||||
Text('${album.year}',
|
Text(
|
||||||
style: TextStyle(
|
'${album.year}',
|
||||||
color: TimbreColors.dimmed, fontSize: 12)),
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||||
|
),
|
||||||
if (album.genre != null)
|
if (album.genre != null)
|
||||||
Text(album.genre!,
|
Text(
|
||||||
|
album.genre!,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||||
color: TimbreColors.dimmed, fontSize: 12)),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -497,34 +508,27 @@ class _ArtCard extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: SizedBox(
|
child: ArtImage(
|
||||||
|
artUri,
|
||||||
|
fit: BoxFit.cover,
|
||||||
width: _size,
|
width: _size,
|
||||||
height: _size,
|
height: _size,
|
||||||
child: ColoredBox(
|
|
||||||
color: TimbreColors.surface,
|
|
||||||
child: artUri != null
|
|
||||||
? Image.network(artUri!,
|
|
||||||
key: ValueKey(artUri),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (_, _, _) => Icon(
|
|
||||||
Icons.album_outlined, color: TimbreColors.dimmed))
|
|
||||||
: Icon(Icons.album_outlined,
|
|
||||||
color: TimbreColors.dimmed),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TimbreSpacing.sm),
|
const SizedBox(height: TimbreSpacing.sm),
|
||||||
Text(title,
|
Text(
|
||||||
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: TimbreColors.foreground)),
|
style: TextStyle(color: TimbreColors.foreground),
|
||||||
|
),
|
||||||
if (subtitle != null)
|
if (subtitle != null)
|
||||||
Text(subtitle!,
|
Text(
|
||||||
|
subtitle!,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style:
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
||||||
TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../state/providers.dart';
|
||||||
import '../state/remote_providers.dart';
|
import '../state/remote_providers.dart';
|
||||||
import '../subsonic/models.dart';
|
import '../subsonic/models.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import '../widgets/art_image.dart';
|
||||||
import '../widgets/block_progress_bar.dart';
|
import '../widgets/block_progress_bar.dart';
|
||||||
import '../widgets/cassette_view.dart';
|
import '../widgets/cassette_view.dart';
|
||||||
import '../widgets/hairline_panel.dart';
|
import '../widgets/hairline_panel.dart';
|
||||||
|
|
@ -57,20 +58,25 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||||
final current = state.current;
|
final current = state.current;
|
||||||
|
|
||||||
// Re-seed the favorites store whenever the track changes.
|
// Re-seed the favorites store whenever the track changes.
|
||||||
ref.listen(activePlaybackProvider.select((s) => s.current?.id),
|
ref.listen(
|
||||||
(_, _) => _seedFavorites());
|
activePlaybackProvider.select((s) => s.current?.id),
|
||||||
|
(_, _) => _seedFavorites(),
|
||||||
|
);
|
||||||
|
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Nothing playing.',
|
child: Text(
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
'Nothing playing.',
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-device control is offered next to the queue toggle (compact) or
|
// Cross-device control is offered next to the queue toggle (compact) or
|
||||||
// beneath the transport (wide); hidden where the platform can't host/browse.
|
// beneath the transport (wide); hidden where the platform can't host/browse.
|
||||||
final remoteSupported =
|
final remoteSupported = ref.watch(
|
||||||
ref.watch(remoteControlProvider.select((s) => s.supported));
|
remoteControlProvider.select((s) => s.supported),
|
||||||
|
);
|
||||||
|
|
||||||
// Everything below the art region — shared by both layouts. The queue
|
// Everything below the art region — shared by both layouts. The queue
|
||||||
// toggle is deliberately excluded: it belongs only to the compact layout
|
// toggle is deliberately excluded: it belongs only to the compact layout
|
||||||
|
|
@ -288,8 +294,10 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
|
||||||
if (index == null || index < 0) return;
|
if (index == null || index < 0) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!_controller.hasClients) return;
|
if (!_controller.hasClients) return;
|
||||||
final target =
|
final target = (index * _rowExtent).clamp(
|
||||||
(index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent);
|
0.0,
|
||||||
|
_controller.position.maxScrollExtent,
|
||||||
|
);
|
||||||
_controller.animateTo(
|
_controller.animateTo(
|
||||||
target,
|
target,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
|
|
@ -375,8 +383,9 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: titleColor,
|
color: titleColor,
|
||||||
fontWeight:
|
fontWeight: isCurrent
|
||||||
isCurrent ? FontWeight.w700 : FontWeight.w400,
|
? FontWeight.w700
|
||||||
|
: FontWeight.w400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (song.artist != null)
|
if (song.artist != null)
|
||||||
|
|
@ -392,17 +401,21 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(_fmt(song.duration),
|
Text(
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
_fmt(song.duration),
|
||||||
|
style: TextStyle(color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () =>
|
onTap: () => ref.read(playbackCommandsProvider).removeAt(i),
|
||||||
ref.read(playbackCommandsProvider).removeAt(i),
|
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: TimbreSpacing.minTouchTarget,
|
width: TimbreSpacing.minTouchTarget,
|
||||||
height: TimbreSpacing.minTouchTarget,
|
height: TimbreSpacing.minTouchTarget,
|
||||||
child: Icon(Icons.close,
|
child: Icon(
|
||||||
size: 18, color: TimbreColors.dimmed),
|
Icons.close,
|
||||||
|
size: 18,
|
||||||
|
color: TimbreColors.dimmed,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -430,8 +443,9 @@ class _FittedArt extends StatelessWidget {
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
final side =
|
final side = c.maxHeight.isFinite
|
||||||
c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth;
|
? c.maxHeight.clamp(0.0, c.maxWidth)
|
||||||
|
: c.maxWidth;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: side,
|
width: side,
|
||||||
height: side,
|
height: side,
|
||||||
|
|
@ -452,14 +466,17 @@ class _AlbumArtPanel extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final coverArt =
|
final coverArt = ref.watch(
|
||||||
ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt));
|
activePlaybackProvider.select((s) => s.current?.coverArt),
|
||||||
final client = ref.watch(subsonicClientProvider);
|
);
|
||||||
final cassette =
|
final cassette = ref.watch(
|
||||||
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
|
settingsProvider.select((s) => s.nowPlayingCassette),
|
||||||
final artUri = (client != null && coverArt != null)
|
);
|
||||||
? client.coverArtUri(coverArt, size: 512).toString()
|
final artUri = resolveArtUriW(
|
||||||
: null;
|
ref,
|
||||||
|
coverArt: coverArt,
|
||||||
|
size: 512,
|
||||||
|
)?.toString();
|
||||||
|
|
||||||
return HairlinePanel(
|
return HairlinePanel(
|
||||||
title: cassette ? 'Cassette' : 'Album Art',
|
title: cassette ? 'Cassette' : 'Album Art',
|
||||||
|
|
@ -468,17 +485,10 @@ class _AlbumArtPanel extends ConsumerWidget {
|
||||||
? Center(child: CassetteView(artUri: artUri))
|
? Center(child: CassetteView(artUri: artUri))
|
||||||
: AspectRatio(
|
: AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: ColoredBox(
|
child: ArtImage(
|
||||||
color: TimbreColors.surface,
|
|
||||||
child: artUri != null
|
|
||||||
? Image.network(
|
|
||||||
artUri,
|
artUri,
|
||||||
key: ValueKey(artUri),
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
placeholder: const _ArtFallback(),
|
||||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
|
||||||
)
|
|
||||||
: const _ArtFallback(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -497,13 +507,16 @@ class _FavRating extends ConsumerWidget {
|
||||||
final fav = ref.watch(favoritesProvider);
|
final fav = ref.watch(favoritesProvider);
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final starred = fav.isSongStarred(song.id);
|
final starred = fav.isSongStarred(song.id);
|
||||||
final rating =
|
final rating = fav.ratingFor(song.id) != 0
|
||||||
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
|
? fav.ratingFor(song.id)
|
||||||
|
: (song.userRating ?? 0);
|
||||||
|
|
||||||
final downloadStatus =
|
final downloadStatus = ref.watch(
|
||||||
ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status));
|
downloadManagerProvider.select((s) => s.byId[song.id]?.status),
|
||||||
|
);
|
||||||
final isDownloaded = downloadStatus == DownloadStatus.done;
|
final isDownloaded = downloadStatus == DownloadStatus.done;
|
||||||
final isDownloading = downloadStatus == DownloadStatus.queued ||
|
final isDownloading =
|
||||||
|
downloadStatus == DownloadStatus.queued ||
|
||||||
downloadStatus == DownloadStatus.downloading;
|
downloadStatus == DownloadStatus.downloading;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
|
|
@ -541,8 +554,11 @@ class _FavRating extends ConsumerWidget {
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(TimbreSpacing.sm),
|
padding: const EdgeInsets.all(TimbreSpacing.sm),
|
||||||
child: Icon(Icons.playlist_add,
|
child: Icon(
|
||||||
size: 22, color: TimbreColors.dimmed),
|
Icons.playlist_add,
|
||||||
|
size: 22,
|
||||||
|
color: TimbreColors.dimmed,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
InkWell(
|
InkWell(
|
||||||
|
|
@ -637,12 +653,16 @@ class _InfoStrip extends StatelessWidget {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(song.title ?? 'Untitled',
|
Text(
|
||||||
|
song.title ?? 'Untitled',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
|
style: TextStyle(color: accent, fontWeight: FontWeight.w700),
|
||||||
Text(song.artist ?? 'Unknown artist',
|
),
|
||||||
style: TextStyle(color: TimbreColors.foreground)),
|
Text(
|
||||||
|
song.artist ?? 'Unknown artist',
|
||||||
|
style: TextStyle(color: TimbreColors.foreground),
|
||||||
|
),
|
||||||
if (album.isNotEmpty)
|
if (album.isNotEmpty)
|
||||||
Text(album, style: TextStyle(color: TimbreColors.dimmed)),
|
Text(album, style: TextStyle(color: TimbreColors.dimmed)),
|
||||||
],
|
],
|
||||||
|
|
@ -659,29 +679,35 @@ class _NowPlayingProgress extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final position = ref.watch(activePlaybackProvider.select((s) => s.position));
|
final position = ref.watch(
|
||||||
final duration =
|
activePlaybackProvider.select((s) => s.position),
|
||||||
ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration));
|
);
|
||||||
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
|
final duration = ref.watch(
|
||||||
|
activePlaybackProvider.select((s) => s.effectiveDuration),
|
||||||
|
);
|
||||||
|
final progress = ref.watch(
|
||||||
|
activePlaybackProvider.select((s) => s.progress),
|
||||||
|
);
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_fmtDur(position),
|
Text(_fmtDur(position), style: TextStyle(color: TimbreColors.dimmed)),
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
|
||||||
const SizedBox(width: TimbreSpacing.md),
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlockProgressBar(progress: progress, cells: 28, height: 18),
|
child: BlockProgressBar(progress: progress, cells: 28, height: 18),
|
||||||
),
|
),
|
||||||
const SizedBox(width: TimbreSpacing.md),
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
Text(_fmtDur(duration),
|
Text(_fmtDur(duration), style: TextStyle(color: TimbreColors.dimmed)),
|
||||||
style: TextStyle(color: TimbreColors.dimmed)),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Transport extends StatelessWidget {
|
class _Transport extends StatelessWidget {
|
||||||
const _Transport(
|
const _Transport({
|
||||||
{required this.state, required this.ref, required this.accent});
|
required this.state,
|
||||||
|
required this.ref,
|
||||||
|
required this.accent,
|
||||||
|
});
|
||||||
|
|
||||||
final PlaybackState state;
|
final PlaybackState state;
|
||||||
final WidgetRef ref;
|
final WidgetRef ref;
|
||||||
|
|
@ -697,21 +723,34 @@ class _Transport extends StatelessWidget {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
_btn(Icons.shuffle, controller.toggleShuffle,
|
_btn(
|
||||||
color: state.shuffle ? accent : TimbreColors.dimmed),
|
Icons.shuffle,
|
||||||
|
controller.toggleShuffle,
|
||||||
|
color: state.shuffle ? accent : TimbreColors.dimmed,
|
||||||
|
),
|
||||||
_btn(Icons.skip_previous, controller.previous),
|
_btn(Icons.skip_previous, controller.previous),
|
||||||
_btn(state.playing ? Icons.pause : Icons.play_arrow,
|
_btn(
|
||||||
|
state.playing ? Icons.pause : Icons.play_arrow,
|
||||||
controller.togglePlayPause,
|
controller.togglePlayPause,
|
||||||
color: accent, size: 40),
|
color: accent,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
_btn(Icons.skip_next, controller.next),
|
_btn(Icons.skip_next, controller.next),
|
||||||
_btn(loopIcon, controller.cycleLoop,
|
_btn(
|
||||||
color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed),
|
loopIcon,
|
||||||
|
controller.cycleLoop,
|
||||||
|
color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _btn(IconData icon, VoidCallback onTap,
|
Widget _btn(
|
||||||
{Color? color, double size = 28}) {
|
IconData icon,
|
||||||
|
VoidCallback onTap, {
|
||||||
|
Color? color,
|
||||||
|
double size = 28,
|
||||||
|
}) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
icon: Icon(icon, color: color ?? TimbreColors.foreground, size: size),
|
icon: Icon(icon, color: color ?? TimbreColors.foreground, size: size),
|
||||||
|
|
@ -723,8 +762,7 @@ class _ArtFallback extends StatelessWidget {
|
||||||
const _ArtFallback();
|
const _ArtFallback();
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Center(
|
||||||
child: Icon(Icons.album_outlined,
|
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 48),
|
||||||
color: TimbreColors.dimmed, size: 48),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import 'package:flutter/widgets.dart' show NetworkImage;
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart'
|
||||||
|
show FileImage, ImageProvider, NetworkImage;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../downloads/download_manager.dart';
|
import '../downloads/download_manager.dart';
|
||||||
import '../history/play_history.dart';
|
import '../history/play_history.dart';
|
||||||
import '../library/browse_query.dart';
|
import '../library/browse_query.dart';
|
||||||
import '../library/library_index.dart';
|
import '../library/library_index.dart';
|
||||||
|
import '../library/offline_library.dart';
|
||||||
import '../playback/playback_engine.dart';
|
import '../playback/playback_engine.dart';
|
||||||
import '../playlists/playlists.dart';
|
import '../playlists/playlists.dart';
|
||||||
import '../settings/settings_store.dart';
|
import '../settings/settings_store.dart';
|
||||||
|
|
@ -61,8 +65,9 @@ class ConnectionState {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final credentialStoreProvider =
|
final credentialStoreProvider = Provider<CredentialStore>(
|
||||||
Provider<CredentialStore>((_) => CredentialStore());
|
(_) => CredentialStore(),
|
||||||
|
);
|
||||||
|
|
||||||
/// Owns the active server connection and the list of saved servers: builds the
|
/// Owns the active server connection and the list of saved servers: builds the
|
||||||
/// client, pings, persists credentials, auto-restores on launch, and switches
|
/// client, pings, persists credentials, auto-restores on launch, and switches
|
||||||
|
|
@ -206,8 +211,10 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addOrUpdate(SubsonicCredentials creds,
|
Future<void> _addOrUpdate(
|
||||||
{required bool makeActive}) async {
|
SubsonicCredentials creds, {
|
||||||
|
required bool makeActive,
|
||||||
|
}) async {
|
||||||
final idx = _servers.indexWhere((s) => s.id == creds.id);
|
final idx = _servers.indexWhere((s) => s.id == creds.id);
|
||||||
final next = [..._servers];
|
final next = [..._servers];
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
|
@ -264,9 +271,20 @@ final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
|
||||||
|
|
||||||
// ---- Library ------------------------------------------------------------
|
// ---- Library ------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The downloaded tracks as full [Song]s — the single source that backs every
|
||||||
|
/// offline browse view so Albums / Artists / Tracks stay consistent.
|
||||||
|
final downloadedSongsProvider = Provider<List<Song>>(
|
||||||
|
(ref) =>
|
||||||
|
ref.watch(downloadManagerProvider).completed.map((d) => d.song).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
final artistsProvider = FutureProvider<List<Artist>>((ref) async {
|
final artistsProvider = FutureProvider<List<Artist>>((ref) async {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
if (client == null) return const [];
|
// Offline: synthesize the artist list from what's downloaded, so it
|
||||||
|
// repopulates as downloads complete and recomputes on connect/disconnect.
|
||||||
|
if (client == null) {
|
||||||
|
return artistsFromSongs(ref.watch(downloadedSongsProvider));
|
||||||
|
}
|
||||||
final result = await client.getArtists();
|
final result = await client.getArtists();
|
||||||
return result.all;
|
return result.all;
|
||||||
});
|
});
|
||||||
|
|
@ -275,7 +293,11 @@ final artistsProvider = FutureProvider<List<Artist>>((ref) async {
|
||||||
/// truncated. Backs the Albums cover-art grid.
|
/// truncated. Backs the Albums cover-art grid.
|
||||||
final albumsProvider = FutureProvider<List<Album>>((ref) async {
|
final albumsProvider = FutureProvider<List<Album>>((ref) async {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
if (client == null) return const [];
|
// Offline: synthesize the album grid from downloaded songs (watched
|
||||||
|
// synchronously up-front, before any await, so it recomputes as they land).
|
||||||
|
if (client == null) {
|
||||||
|
return albumsFromSongs(ref.watch(downloadedSongsProvider));
|
||||||
|
}
|
||||||
const pageSize = 500;
|
const pageSize = 500;
|
||||||
final all = <Album>[];
|
final all = <Album>[];
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|
@ -292,8 +314,9 @@ final albumsProvider = FutureProvider<List<Album>>((ref) async {
|
||||||
/// (and any in-flight build cancelled) whenever the server changes.
|
/// (and any in-flight build cancelled) whenever the server changes.
|
||||||
final libraryIndexProvider =
|
final libraryIndexProvider =
|
||||||
StateNotifierProvider<LibraryIndexController, LibraryIndexState>((ref) {
|
StateNotifierProvider<LibraryIndexController, LibraryIndexState>((ref) {
|
||||||
final controller =
|
final controller = LibraryIndexController(
|
||||||
LibraryIndexController(() => ref.read(subsonicClientProvider));
|
() => ref.read(subsonicClientProvider),
|
||||||
|
);
|
||||||
ref.listen<ConnectionState>(connectionProvider, (_, _) {
|
ref.listen<ConnectionState>(connectionProvider, (_, _) {
|
||||||
controller.onConnectionChanged();
|
controller.onConnectionChanged();
|
||||||
});
|
});
|
||||||
|
|
@ -303,12 +326,14 @@ final libraryIndexProvider =
|
||||||
// ---- Browse filtering / sorting -----------------------------------------
|
// ---- Browse filtering / sorting -----------------------------------------
|
||||||
|
|
||||||
/// Session-only genre/year filter for the Albums grid (resets on restart).
|
/// Session-only genre/year filter for the Albums grid (resets on restart).
|
||||||
final albumFilterProvider =
|
final albumFilterProvider = StateProvider<BrowseFilter>(
|
||||||
StateProvider<BrowseFilter>((_) => const BrowseFilter());
|
(_) => const BrowseFilter(),
|
||||||
|
);
|
||||||
|
|
||||||
/// Session-only genre/year filter for the Tracks list (resets on restart).
|
/// Session-only genre/year filter for the Tracks list (resets on restart).
|
||||||
final trackFilterProvider =
|
final trackFilterProvider = StateProvider<BrowseFilter>(
|
||||||
StateProvider<BrowseFilter>((_) => const BrowseFilter());
|
(_) => const BrowseFilter(),
|
||||||
|
);
|
||||||
|
|
||||||
/// Distinct genres present across all albums, for the album genre picker.
|
/// Distinct genres present across all albums, for the album genre picker.
|
||||||
final albumGenresProvider = Provider<List<String>>((ref) {
|
final albumGenresProvider = Provider<List<String>>((ref) {
|
||||||
|
|
@ -332,23 +357,36 @@ final visibleAlbumsProvider = Provider<AsyncValue<List<Album>>>((ref) {
|
||||||
.whenData((albums) => applyAlbumQuery(albums, filter, sort));
|
.whenData((albums) => applyAlbumQuery(albums, filter, sort));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Distinct genres present across all indexed tracks.
|
/// Distinct genres present across all indexed tracks. Falls back to the
|
||||||
|
/// downloaded songs when offline (the crawled index is wiped without a server).
|
||||||
final trackGenresProvider = Provider<List<String>>((ref) {
|
final trackGenresProvider = Provider<List<String>>((ref) {
|
||||||
final songs = ref.watch(libraryIndexProvider).songs;
|
final offline = ref.watch(subsonicClientProvider) == null;
|
||||||
|
final songs = offline
|
||||||
|
? ref.watch(downloadedSongsProvider)
|
||||||
|
: ref.watch(libraryIndexProvider).songs;
|
||||||
return distinctGenres(songs.map((s) => s.genre));
|
return distinctGenres(songs.map((s) => s.genre));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Distinct release years present across all indexed tracks, newest first.
|
/// Distinct release years present across all indexed tracks, newest first.
|
||||||
|
/// Falls back to the downloaded songs when offline.
|
||||||
final trackYearsProvider = Provider<List<int>>((ref) {
|
final trackYearsProvider = Provider<List<int>>((ref) {
|
||||||
final songs = ref.watch(libraryIndexProvider).songs;
|
final offline = ref.watch(subsonicClientProvider) == null;
|
||||||
|
final songs = offline
|
||||||
|
? ref.watch(downloadedSongsProvider)
|
||||||
|
: ref.watch(libraryIndexProvider).songs;
|
||||||
return distinctYears(songs.map((s) => s.year));
|
return distinctYears(songs.map((s) => s.year));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Indexed tracks after applying the session filter and the persisted sort.
|
/// Indexed tracks after applying the session filter and the persisted sort.
|
||||||
|
/// When offline the crawled index is empty, so the Tracks view is backed by the
|
||||||
|
/// downloaded songs instead — the same filter/sort pipeline applies to both.
|
||||||
final visibleTracksProvider = Provider<List<Song>>((ref) {
|
final visibleTracksProvider = Provider<List<Song>>((ref) {
|
||||||
final filter = ref.watch(trackFilterProvider);
|
final filter = ref.watch(trackFilterProvider);
|
||||||
final sort = ref.watch(settingsProvider.select((s) => s.trackSort));
|
final sort = ref.watch(settingsProvider.select((s) => s.trackSort));
|
||||||
final songs = ref.watch(libraryIndexProvider).songs;
|
final offline = ref.watch(subsonicClientProvider) == null;
|
||||||
|
final songs = offline
|
||||||
|
? ref.watch(downloadedSongsProvider)
|
||||||
|
: ref.watch(libraryIndexProvider).songs;
|
||||||
// Live ratings so the Rating sort/filter reacts to star changes immediately.
|
// Live ratings so the Rating sort/filter reacts to star changes immediately.
|
||||||
final ratings = ref.watch(favoritesProvider).ratings;
|
final ratings = ref.watch(favoritesProvider).ratings;
|
||||||
return applyTrackQuery(songs, filter, sort, ratings: ratings);
|
return applyTrackQuery(songs, filter, sort, ratings: ratings);
|
||||||
|
|
@ -373,18 +411,32 @@ final randomAlbumsProvider = FutureProvider<List<Album>>((ref) async {
|
||||||
|
|
||||||
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
|
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
if (client == null) throw StateError('Not connected');
|
// Offline: rebuild the artist from downloaded songs. [id] is whatever
|
||||||
|
// [artistsFromSongs] produced (real id or name), so it's passed straight
|
||||||
|
// through. Keep throwing when absent so the FutureProvider error state works.
|
||||||
|
if (client == null) {
|
||||||
|
final artist = artistFromSongs(ref.watch(downloadedSongsProvider), id);
|
||||||
|
if (artist == null) throw StateError('Not found offline');
|
||||||
|
return artist;
|
||||||
|
}
|
||||||
return client.getArtist(id);
|
return client.getArtist(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
|
final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
if (client == null) throw StateError('Not connected');
|
// Offline: rebuild the album from downloaded songs (see [artistProvider]).
|
||||||
|
if (client == null) {
|
||||||
|
final album = albumFromSongs(ref.watch(downloadedSongsProvider), id);
|
||||||
|
if (album == null) throw StateError('Not found offline');
|
||||||
|
return album;
|
||||||
|
}
|
||||||
return client.getAlbum(id);
|
return client.getAlbum(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
final searchProvider =
|
final searchProvider = FutureProvider.family<SearchResult3, String>((
|
||||||
FutureProvider.family<SearchResult3, String>((ref, query) async {
|
ref,
|
||||||
|
query,
|
||||||
|
) async {
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
final q = query.trim();
|
final q = query.trim();
|
||||||
if (client == null || q.isEmpty) {
|
if (client == null || q.isEmpty) {
|
||||||
|
|
@ -394,7 +446,9 @@ final searchProvider =
|
||||||
// "Standard" trims the server's broad matches down to name/title hits;
|
// "Standard" trims the server's broad matches down to name/title hits;
|
||||||
// "Discovery" (default) returns the server result unchanged.
|
// "Discovery" (default) returns the server result unchanged.
|
||||||
final mode = ref.watch(settingsProvider).searchMode;
|
final mode = ref.watch(settingsProvider).searchMode;
|
||||||
return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result;
|
return mode == SearchMode.standard
|
||||||
|
? filterSearchToStandard(result, q)
|
||||||
|
: result;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Narrows a [SearchResult3] to items whose *own* name/title contains [query]
|
/// Narrows a [SearchResult3] to items whose *own* name/title contains [query]
|
||||||
|
|
@ -438,8 +492,9 @@ final rediscoverProvider = Provider<List<RediscoverArtist>>((ref) {
|
||||||
|
|
||||||
final favoritesProvider =
|
final favoritesProvider =
|
||||||
StateNotifierProvider<FavoritesController, FavoritesState>((ref) {
|
StateNotifierProvider<FavoritesController, FavoritesState>((ref) {
|
||||||
final controller =
|
final controller = FavoritesController(
|
||||||
FavoritesController(() => ref.read(subsonicClientProvider));
|
() => ref.read(subsonicClientProvider),
|
||||||
|
);
|
||||||
// Re-hydrate on connect, clear on disconnect.
|
// Re-hydrate on connect, clear on disconnect.
|
||||||
ref.listen<ConnectionState>(connectionProvider, (prev, next) {
|
ref.listen<ConnectionState>(connectionProvider, (prev, next) {
|
||||||
if (next.isOnline) {
|
if (next.isOnline) {
|
||||||
|
|
@ -502,22 +557,26 @@ final playlistsProvider =
|
||||||
|
|
||||||
/// User-facing playlists — everything *not* marked as a Timbre tag. Backs the
|
/// User-facing playlists — everything *not* marked as a Timbre tag. Backs the
|
||||||
/// Playlists screen and the "add to playlist" sheet.
|
/// Playlists screen and the "add to playlist" sheet.
|
||||||
final realPlaylistsProvider = Provider<List<Playlist>>((ref) => ref
|
final realPlaylistsProvider = Provider<List<Playlist>>(
|
||||||
|
(ref) => ref
|
||||||
.watch(playlistsProvider)
|
.watch(playlistsProvider)
|
||||||
.playlists
|
.playlists
|
||||||
.where((p) => !isTagPlaylist(p))
|
.where((p) => !isTagPlaylist(p))
|
||||||
.toList());
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
/// Tags — playlists carrying the tag comment marker. Backs the Tags screen and
|
/// Tags — playlists carrying the tag comment marker. Backs the Tags screen and
|
||||||
/// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the
|
/// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the
|
||||||
/// partition differs.
|
/// partition differs.
|
||||||
final tagsProvider = Provider<List<Playlist>>(
|
final tagsProvider = Provider<List<Playlist>>(
|
||||||
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList());
|
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
/// The signed-in user's name on the active server, or null when disconnected.
|
/// 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.
|
/// Used to split owned playlists from ones shared by other users.
|
||||||
final currentUsernameProvider = Provider<String?>(
|
final currentUsernameProvider = Provider<String?>(
|
||||||
(ref) => ref.watch(connectionProvider).credentials?.username);
|
(ref) => ref.watch(connectionProvider).credentials?.username,
|
||||||
|
);
|
||||||
|
|
||||||
/// The user's own playlists (owned, or owner unknown). Backs the main list and
|
/// 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.
|
/// the "add to playlist" sheet — you can only add tracks to your own playlists.
|
||||||
|
|
@ -538,10 +597,53 @@ final sharedPlaylistsProvider = Provider<List<Playlist>>((ref) {
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Art resolution -----------------------------------------------------
|
||||||
|
|
||||||
|
/// Shared implementation for the art resolvers below. Takes the two things it
|
||||||
|
/// needs as plain values so it can serve both provider (`Ref`) and widget
|
||||||
|
/// (`WidgetRef`) callers, which share no common ref supertype in Riverpod 2.x.
|
||||||
|
Uri? _resolveArt(
|
||||||
|
String? Function(String?) localArtPathFor,
|
||||||
|
SubsonicClient? client, {
|
||||||
|
String? coverArt,
|
||||||
|
int size = 512,
|
||||||
|
}) {
|
||||||
|
if (coverArt == null) return null;
|
||||||
|
final local = localArtPathFor(coverArt);
|
||||||
|
if (local != null) return Uri.file(local);
|
||||||
|
if (client == null) return null;
|
||||||
|
return client.coverArtUri(coverArt, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the best art URI for a cover-art id: a cached local file when the
|
||||||
|
/// track is downloaded, else the server URL when online, else null (offline &
|
||||||
|
/// uncached → callers show a placeholder).
|
||||||
|
///
|
||||||
|
/// Provider-side entry point (playback closures, other providers have a [Ref]).
|
||||||
|
/// Widgets, which hold a `WidgetRef`, use [resolveArtUriW] instead.
|
||||||
|
Uri? resolveArtUri(Ref ref, {String? coverArt, int size = 512}) => _resolveArt(
|
||||||
|
ref.read(downloadManagerProvider.notifier).localArtPathFor,
|
||||||
|
ref.read(subsonicClientProvider),
|
||||||
|
coverArt: coverArt,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Widget-side twin of [resolveArtUri] for callers holding a `WidgetRef`
|
||||||
|
/// (`WidgetRef` is not a [Ref] in Riverpod 2.x). Phase 3 widgets call this,
|
||||||
|
/// passing their `ref`.
|
||||||
|
Uri? resolveArtUriW(WidgetRef ref, {String? coverArt, int size = 512}) =>
|
||||||
|
_resolveArt(
|
||||||
|
ref.read(downloadManagerProvider.notifier).localArtPathFor,
|
||||||
|
ref.read(subsonicClientProvider),
|
||||||
|
coverArt: coverArt,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Playback -----------------------------------------------------------
|
// ---- Playback -----------------------------------------------------------
|
||||||
|
|
||||||
final playbackProvider =
|
final playbackProvider = StateNotifierProvider<PlaybackController, PlaybackState>((
|
||||||
StateNotifierProvider<PlaybackController, PlaybackState>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
// Prefer a local downloaded file when one exists (works offline / survives
|
// Prefer a local downloaded file when one exists (works offline / survives
|
||||||
// service interruptions); otherwise stream at the configured bitrate.
|
// service interruptions); otherwise stream at the configured bitrate.
|
||||||
Uri? streamUriFor(Song s) {
|
Uri? streamUriFor(Song s) {
|
||||||
|
|
@ -549,17 +651,23 @@ final playbackProvider =
|
||||||
if (local != null) return Uri.file(local);
|
if (local != null) return Uri.file(local);
|
||||||
final client = ref.read(subsonicClientProvider);
|
final client = ref.read(subsonicClientProvider);
|
||||||
if (client == null) return null;
|
if (client == null) return null;
|
||||||
|
final rate = ref.read(settingsProvider).streamMaxBitRate;
|
||||||
|
// When transcoding, ask the server to advertise a Content-Length so the
|
||||||
|
// native player can derive a duration and hold position (otherwise the
|
||||||
|
// playhead freezes at 0:00 and the track restarts). Harmless to omit for
|
||||||
|
// original streams, which already carry a real length.
|
||||||
return client.streamUri(
|
return client.streamUri(
|
||||||
s.id,
|
s.id,
|
||||||
maxBitRate: ref.read(settingsProvider).streamMaxBitRate,
|
maxBitRate: rate,
|
||||||
|
estimateContentLength: rate > 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri? coverArtUriFor(Song s) {
|
// Prefer the local cached art file, then fall back to the server URL — this
|
||||||
final client = ref.read(subsonicClientProvider);
|
// makes offline art work in Now Playing / the lock screen where a downloaded
|
||||||
if (client == null || s.coverArt == null) return null;
|
// file exists. Null only when there's no id and no local/remote source.
|
||||||
return client.coverArtUri(s.coverArt!, size: 512);
|
Uri? coverArtUriFor(Song s) =>
|
||||||
}
|
resolveArtUri(ref, coverArt: s.coverArt, size: 512);
|
||||||
|
|
||||||
final controller = PlaybackController(
|
final controller = PlaybackController(
|
||||||
streamUriFor: streamUriFor,
|
streamUriFor: streamUriFor,
|
||||||
|
|
@ -569,7 +677,12 @@ final playbackProvider =
|
||||||
// Skip extraction entirely when the accent is pinned (static accent, or a
|
// Skip extraction entirely when the accent is pinned (static accent, or a
|
||||||
// theme that locks its accent like Lavender).
|
// theme that locks its accent like Lavender).
|
||||||
if (ref.read(settingsProvider).accentIsFixed) return;
|
if (ref.read(settingsProvider).accentIsFixed) return;
|
||||||
final color = await extractAccent(NetworkImage(artUri.toString()));
|
// A resolved `file://` art URI (downloaded track) must load from disk, not
|
||||||
|
// the network — extract from a FileImage in that case, else a NetworkImage.
|
||||||
|
final ImageProvider image = artUri.isScheme('file')
|
||||||
|
? FileImage(File(artUri.toFilePath()))
|
||||||
|
: NetworkImage(artUri.toString());
|
||||||
|
final color = await extractAccent(image);
|
||||||
if (color != null) ref.read(accentProvider.notifier).set(color);
|
if (color != null) ref.read(accentProvider.notifier).set(color);
|
||||||
},
|
},
|
||||||
onPlay: (song) {
|
onPlay: (song) {
|
||||||
|
|
|
||||||
|
|
@ -302,11 +302,25 @@ class SubsonicClient {
|
||||||
/// manager, which fetches these bytes to disk). `maxBitRate == 0` means
|
/// manager, which fetches these bytes to disk). `maxBitRate == 0` means
|
||||||
/// original / no transcode; [format] requests a specific transcode container
|
/// original / no transcode; [format] requests a specific transcode container
|
||||||
/// (e.g. `mp3`, `opus`), or null for the server default / original.
|
/// (e.g. `mp3`, `opus`), or null for the server default / original.
|
||||||
Uri streamUri(String id, {int maxBitRate = 0, String? format}) =>
|
///
|
||||||
|
/// [estimateContentLength] asks the server to send an (estimated)
|
||||||
|
/// `Content-Length` header even for on-the-fly transcodes. Transcoded
|
||||||
|
/// responses are otherwise chunked with no length and no byte ranges, so the
|
||||||
|
/// native player reports `duration == null`, never reaches `ready`, and the
|
||||||
|
/// playhead freezes at 0:00 (then restarts). Only meaningful when transcoding
|
||||||
|
/// (`maxBitRate > 0` or a [format]); original streams already carry a real
|
||||||
|
/// length.
|
||||||
|
Uri streamUri(
|
||||||
|
String id, {
|
||||||
|
int maxBitRate = 0,
|
||||||
|
String? format,
|
||||||
|
bool estimateContentLength = false,
|
||||||
|
}) =>
|
||||||
_uri('stream', {
|
_uri('stream', {
|
||||||
'id': id,
|
'id': id,
|
||||||
if (maxBitRate > 0) 'maxBitRate': '$maxBitRate',
|
if (maxBitRate > 0) 'maxBitRate': '$maxBitRate',
|
||||||
if (format != null && format.isNotEmpty) 'format': format,
|
if (format != null && format.isNotEmpty) 'format': format,
|
||||||
|
if (estimateContentLength) 'estimateContentLength': 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.
|
/// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.
|
||||||
|
|
|
||||||
95
lib/widgets/art_image.dart
Normal file
95
lib/widgets/art_image.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
|
/// Cover art that renders from EITHER a local file (`file://` URI) or a network
|
||||||
|
/// URL, picking [FileImage] vs [NetworkImage] purely by the URI scheme. This is
|
||||||
|
/// the single place all art-rendering call sites go through, so a downloaded
|
||||||
|
/// track's art shows offline (the caller hands us the already-built URI string;
|
||||||
|
/// resolving download-vs-network happens upstream in `providers.dart`).
|
||||||
|
///
|
||||||
|
/// Behaviour preserved from the old scattered `Image.network(...)` call sites:
|
||||||
|
/// * `fit: BoxFit.cover`, `gaplessPlayback: true`, and a `ValueKey(uri)` so
|
||||||
|
/// switching tracks keeps the previous frame until the new art decodes (no
|
||||||
|
/// flash) and rebuilds cleanly.
|
||||||
|
/// * a surface-filled [placeholder] with a muted album icon for the null,
|
||||||
|
/// loading-error, and missing-file cases (via `errorBuilder`).
|
||||||
|
///
|
||||||
|
/// Sizing is never hardcoded — [width]/[height]/[fit] are respected as passed.
|
||||||
|
/// Optional [borderRadius] clips the art with a [ClipRRect]; callers that pass
|
||||||
|
/// it should drop their own outer `ClipRRect` so we don't double-clip.
|
||||||
|
class ArtImage extends StatelessWidget {
|
||||||
|
const ArtImage(
|
||||||
|
this.uri, {
|
||||||
|
super.key,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.placeholder,
|
||||||
|
this.borderRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The already-built art URI. A `file://` URI loads from disk; anything else
|
||||||
|
/// (http/https) loads over the network. `null` → [placeholder].
|
||||||
|
final String? uri;
|
||||||
|
|
||||||
|
final BoxFit fit;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
/// Shown for null/error/missing art. Defaults to [_ArtPlaceholder].
|
||||||
|
final Widget? placeholder;
|
||||||
|
|
||||||
|
/// If set, the art (and placeholder) are clipped to these rounded corners.
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fallback = placeholder ?? const _ArtPlaceholder();
|
||||||
|
|
||||||
|
Widget child;
|
||||||
|
if (uri == null) {
|
||||||
|
child = fallback;
|
||||||
|
} else {
|
||||||
|
final parsed = Uri.tryParse(uri!);
|
||||||
|
final ImageProvider provider = (parsed != null && parsed.scheme == 'file')
|
||||||
|
? FileImage(File(parsed.toFilePath()))
|
||||||
|
: NetworkImage(uri!);
|
||||||
|
child = Image(
|
||||||
|
image: provider,
|
||||||
|
key: ValueKey(uri),
|
||||||
|
fit: fit,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) => fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back the art with the surface fill so transparent/loading gaps read as
|
||||||
|
// a panel rather than the bare canvas (matches the old ColoredBox wrap).
|
||||||
|
child = ColoredBox(color: TimbreColors.surface, child: child);
|
||||||
|
|
||||||
|
if (width != null || height != null) {
|
||||||
|
child = SizedBox(width: width, height: height, child: child);
|
||||||
|
}
|
||||||
|
if (borderRadius != null) {
|
||||||
|
child = ClipRRect(borderRadius: borderRadius!, child: child);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default art placeholder: a muted album glyph on the surface fill. Used
|
||||||
|
/// for null art and as the loading/error fallback.
|
||||||
|
class _ArtPlaceholder extends StatelessWidget {
|
||||||
|
const _ArtPlaceholder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ColoredBox(
|
||||||
|
color: TimbreColors.surface,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import 'art_image.dart';
|
||||||
|
|
||||||
/// Animated cassette for the Now Playing screen. Composites, in the shell's
|
/// Animated cassette for the Now Playing screen. Composites, in the shell's
|
||||||
/// `469×298` coordinate space, from back to front:
|
/// `469×298` coordinate space, from back to front:
|
||||||
|
|
@ -44,13 +45,18 @@ class _CassetteViewState extends ConsumerState<CassetteView>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTick(Duration elapsed) {
|
void _onTick(Duration elapsed) {
|
||||||
final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
|
final dt =
|
||||||
|
(elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
|
||||||
_last = elapsed;
|
_last = elapsed;
|
||||||
if (dt <= 0) return;
|
if (dt <= 0) return;
|
||||||
// Read (not watch) inside the ticker: the model drives repaints itself, and
|
// Read (not watch) inside the ticker: the model drives repaints itself, and
|
||||||
// watching here would rebuild the whole widget every position tick.
|
// watching here would rebuild the whole widget every position tick.
|
||||||
final s = ref.read(activePlaybackProvider);
|
final s = ref.read(activePlaybackProvider);
|
||||||
_model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress);
|
_model.update(
|
||||||
|
dt: dt,
|
||||||
|
playing: s.playing && s.supported,
|
||||||
|
progress: s.progress,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -118,8 +124,7 @@ class _CassetteViewState extends ConsumerState<CassetteView>
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _model,
|
animation: _model,
|
||||||
child: cog,
|
child: cog,
|
||||||
builder: (_, child) =>
|
builder: (_, child) => Transform.rotate(angle: angle(), child: child),
|
||||||
Transform.rotate(angle: angle(), child: child),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -133,15 +138,12 @@ class _LabelArt extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (artUri == null) {
|
// Square art → wide label: crop the sides/top. A plain surface fill backs
|
||||||
return ColoredBox(color: TimbreColors.surface);
|
// the null/error cases (no album glyph here — the shell frames the label).
|
||||||
}
|
return ArtImage(
|
||||||
return Image.network(
|
artUri,
|
||||||
artUri!,
|
fit: BoxFit.cover,
|
||||||
key: ValueKey(artUri),
|
placeholder: ColoredBox(color: TimbreColors.surface),
|
||||||
fit: BoxFit.cover, // square art → wide label: crop the sides/top
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (_, _, _) => ColoredBox(color: TimbreColors.surface),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,9 +195,15 @@ class _TapePainter extends CustomPainter {
|
||||||
canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor);
|
canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor);
|
||||||
final tape = Paint()..color = cfg.tapeColor;
|
final tape = Paint()..color = cfg.tapeColor;
|
||||||
canvas.drawCircle(
|
canvas.drawCircle(
|
||||||
cfg.leftReel, cfg.radius(model.progress, supply: true), tape);
|
cfg.leftReel,
|
||||||
|
cfg.radius(model.progress, supply: true),
|
||||||
|
tape,
|
||||||
|
);
|
||||||
canvas.drawCircle(
|
canvas.drawCircle(
|
||||||
cfg.rightReel, cfg.radius(model.progress, supply: false), tape);
|
cfg.rightReel,
|
||||||
|
cfg.radius(model.progress, supply: false),
|
||||||
|
tape,
|
||||||
|
);
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,7 +253,8 @@ class _CassetteConfig {
|
||||||
/// reel is full at p=0 and empty at p=1; the take-up reel is the reverse.
|
/// reel is full at p=0 and empty at p=1; the take-up reel is the reverse.
|
||||||
double radius(double progress, {required bool supply}) {
|
double radius(double progress, {required bool supply}) {
|
||||||
final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0);
|
final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0);
|
||||||
final r2 = hubRadius * hubRadius +
|
final r2 =
|
||||||
|
hubRadius * hubRadius +
|
||||||
(fullRadius * fullRadius - hubRadius * hubRadius) * frac;
|
(fullRadius * fullRadius - hubRadius * hubRadius) * frac;
|
||||||
return math.sqrt(r2);
|
return math.sqrt(r2);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import 'art_image.dart';
|
||||||
|
|
||||||
/// Persistent mini-player pinned above the tab bar. Visible only while a track
|
/// Persistent mini-player pinned above the tab bar. Visible only while a track
|
||||||
/// is loaded; tapping the body jumps to the Now Playing tab.
|
/// is loaded; tapping the body jumps to the Now Playing tab.
|
||||||
|
|
@ -23,11 +24,12 @@ class MiniPlayer extends ConsumerWidget {
|
||||||
|
|
||||||
final playing = ref.watch(activePlaybackProvider.select((s) => s.playing));
|
final playing = ref.watch(activePlaybackProvider.select((s) => s.playing));
|
||||||
final controller = ref.read(playbackCommandsProvider);
|
final controller = ref.read(playbackCommandsProvider);
|
||||||
final client = ref.watch(subsonicClientProvider);
|
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final artUri = (client != null && current.coverArt != null)
|
final artUri = resolveArtUriW(
|
||||||
? client.coverArtUri(current.coverArt!, size: 128).toString()
|
ref,
|
||||||
: null;
|
coverArt: current.coverArt,
|
||||||
|
size: 128,
|
||||||
|
)?.toString();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -45,17 +47,16 @@ class MiniPlayer extends ConsumerWidget {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: ColoredBox(
|
// Keep the mini player's darker `background` fill behind the
|
||||||
color: TimbreColors.background,
|
// art (ArtImage's own fill is `surface`) by handing it a
|
||||||
child: artUri != null
|
// background-tinted placeholder for the null/error cases.
|
||||||
? Image.network(
|
child: ArtImage(
|
||||||
artUri,
|
artUri,
|
||||||
key: ValueKey(artUri),
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
placeholder: ColoredBox(
|
||||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
color: TimbreColors.background,
|
||||||
)
|
child: const _ArtFallback(),
|
||||||
: const _ArtFallback(),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: TimbreSpacing.md),
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
|
|
@ -83,9 +84,11 @@ class MiniPlayer extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_btn(Icons.skip_previous, controller.previous),
|
_btn(Icons.skip_previous, controller.previous),
|
||||||
_btn(playing ? Icons.pause : Icons.play_arrow,
|
_btn(
|
||||||
|
playing ? Icons.pause : Icons.play_arrow,
|
||||||
controller.togglePlayPause,
|
controller.togglePlayPause,
|
||||||
color: accent),
|
color: accent,
|
||||||
|
),
|
||||||
_btn(Icons.skip_next, controller.next),
|
_btn(Icons.skip_next, controller.next),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -111,7 +114,9 @@ class _MiniProgress extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
|
final progress = ref.watch(
|
||||||
|
activePlaybackProvider.select((s) => s.progress),
|
||||||
|
);
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 2,
|
height: 2,
|
||||||
|
|
@ -129,7 +134,6 @@ class _ArtFallback extends StatelessWidget {
|
||||||
const _ArtFallback();
|
const _ArtFallback();
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Center(
|
||||||
child: Icon(Icons.album_outlined,
|
child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 22),
|
||||||
color: TimbreColors.dimmed, size: 22),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
184
offline-parity-plan.md
Normal file
184
offline-parity-plan.md
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
# Offline Parity: Browse, Artwork & Downloads Queue
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
When the device is offline, the app is far less usable than when streaming, even
|
||||||
|
though downloaded content exists on disk:
|
||||||
|
|
||||||
|
- **Albums / Artists / Tracks load blank.** `browser_screen.dart` hard-gates the
|
||||||
|
whole Browse area behind a live `SubsonicClient`, and the backing providers
|
||||||
|
(`artistsProvider`, `albumsProvider`) return `const []` while
|
||||||
|
`LibraryIndexController.ensureBuilt()` bails before touching its cache when
|
||||||
|
`client == null`. So the only way to reach downloaded music offline is the
|
||||||
|
Downloads tab.
|
||||||
|
- **The Downloads tab plays one song at a time.** Tapping a saved track calls
|
||||||
|
`playback.playSongs([d.song])` — a single-element queue — and the screen has no
|
||||||
|
shuffle / play-all / play-next / add-to-queue controls that every streaming
|
||||||
|
screen has.
|
||||||
|
- **No offline artwork.** Downloads save only the audio file and the `coverArt`
|
||||||
|
*id*; the image itself is never cached, and all art URIs are built inline via
|
||||||
|
`client.coverArtUri(...)` guarded by `client != null`, so offline every cover
|
||||||
|
goes blank.
|
||||||
|
|
||||||
|
**Decisions made with the user:**
|
||||||
|
1. Offline browse views show **downloaded content only** (reconstructed from the
|
||||||
|
downloads manifest) — everything shown is guaranteed playable. Online browse is
|
||||||
|
unchanged (server-backed).
|
||||||
|
2. Downloading a track **also caches its cover art** to disk so offline browse and
|
||||||
|
Now Playing show real artwork.
|
||||||
|
|
||||||
|
**Intended outcome:** offline, the Albums / Artists / Tracks tabs, their detail
|
||||||
|
screens, artwork, and the Downloads tab all behave like a first-class local
|
||||||
|
library with full queue controls — parity with the streaming experience, scoped to
|
||||||
|
what's been downloaded.
|
||||||
|
|
||||||
|
The key enabler already exists: the downloads manifest persists the **full
|
||||||
|
`Song`** per completed track (`DownloadInfo.song` → `song.toJson()`), carrying
|
||||||
|
`albumId`, `artistId`, `album`, `artist`, `coverArt`, `year`, `genre`, `track`,
|
||||||
|
`discNumber`. That is enough to reconstruct Albums/Artists/Tracks. The
|
||||||
|
`playlists.dart` controller is an in-repo precedent for the offline-mirror pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Reconstruct the offline library from downloads
|
||||||
|
|
||||||
|
### A1. New pure module: `lib/library/offline_library.dart`
|
||||||
|
Pure, I/O-free grouping functions over `List<Song>` (mirrors the style of
|
||||||
|
`library/browse_query.dart`):
|
||||||
|
- `List<Album> albumsFromSongs(List<Song> songs)` — group by `albumId` (fallback:
|
||||||
|
album name), synthesize an `Album` (id, name, artist, artistId, coverArt, year,
|
||||||
|
genre, `songCount`, and `songs:` sorted by disc/track).
|
||||||
|
- `List<Artist> artistsFromSongs(List<Song> songs)` — group by `artistId`
|
||||||
|
(fallback: artist name), synthesize an `Artist` (id, name, coverArt from any
|
||||||
|
album, `albumCount`, and `albums:` built via `albumsFromSongs`).
|
||||||
|
- `Album? albumFromSongs(...)` / `Artist? artistFromSongs(...)` helpers keyed by id
|
||||||
|
for the detail providers.
|
||||||
|
|
||||||
|
These reuse the existing `Album`/`Artist`/`Song` models in `subsonic/models.dart`.
|
||||||
|
|
||||||
|
### A2. Providers gain an offline branch — `lib/state/providers.dart`
|
||||||
|
Introduce one shared source-of-songs seam so all three views stay consistent:
|
||||||
|
- `downloadedSongsProvider` → `ref.watch(downloadManagerProvider).completed.map((d) => d.song)`.
|
||||||
|
|
||||||
|
Then wire offline fallbacks (offline = `subsonicClientProvider == null`):
|
||||||
|
- `artistsProvider`: when `client == null`, return `artistsFromSongs(downloadedSongs)`
|
||||||
|
instead of `const []`.
|
||||||
|
- `albumsProvider`: when `client == null`, return `albumsFromSongs(downloadedSongs)`.
|
||||||
|
- Tracks: add offline handling to `visibleTracksProvider`, `trackGenresProvider`,
|
||||||
|
`trackYearsProvider` so that when offline they derive from `downloadedSongs`
|
||||||
|
rather than the (empty, wiped-on-disconnect) `libraryIndexProvider`. Keep
|
||||||
|
reusing `applyTrackQuery` / `applyAlbumQuery` / `distinctGenres` / `distinctYears`
|
||||||
|
for sort+filter so offline behaves identically to online.
|
||||||
|
- Detail families `artistProvider` / `albumProvider`: when `client == null`, build
|
||||||
|
from `albumFromSongs` / `artistFromSongs` instead of throwing `StateError`.
|
||||||
|
|
||||||
|
Each affected provider must also `watch` `downloadManagerProvider` so the lists
|
||||||
|
populate as downloads complete and recompute on connect/disconnect.
|
||||||
|
|
||||||
|
### A3. Un-gate the Browse screen — `lib/screens/browser_screen.dart`
|
||||||
|
- Replace the blanket `client == null → _NotConnected` gate (≈ lines 84–96) with:
|
||||||
|
render the `_ArtistsPanel` / `_AlbumsPanel` / `_TracksPanel` whenever there is
|
||||||
|
data to show; keep `_NotConnected` only when offline **and** there are zero
|
||||||
|
downloads (message tuned to "You're offline — download music to browse it here").
|
||||||
|
- `_TracksPanelState.initState`: only call `ensureBuilt()` when online; offline the
|
||||||
|
songs come from the A2 fallback, so skip the crawl.
|
||||||
|
- Leave `LibraryIndexController` untouched — the offline path deliberately does not
|
||||||
|
use the full cached catalog (decision: downloaded-only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — Cache cover art on download + offline art resolver
|
||||||
|
|
||||||
|
### B1. Save artwork with each download — `lib/downloads/download_manager.dart`
|
||||||
|
- In `_run(...)`, after the audio file lands, if `song.coverArt != null` and that
|
||||||
|
art id isn't already cached, fetch `client.coverArtUri(song.coverArt!, size: 512)`
|
||||||
|
via the existing `_dio` and save to `downloads/<key>/art/<sanitized coverArt>.jpg`
|
||||||
|
(temp `.part` + rename, like the audio path). Dedup by `coverArt` id so all tracks
|
||||||
|
of an album share one file. Art failure is soft (never fails the audio download).
|
||||||
|
- Track cached art in state: add `Map<String,String> artById` (coverArt id →
|
||||||
|
absolute path) to `DownloadState`, populated in `reloadForServer` (scan the `art/`
|
||||||
|
dir or re-derive from completed songs' `coverArt`) and on each completed download.
|
||||||
|
- Public accessor `String? localArtPathFor(String? coverArtId)` (sync, reads state)
|
||||||
|
parallel to the existing `localPathFor(id)`.
|
||||||
|
- `remove` / `clearAll`: delete an album's art only when no remaining download
|
||||||
|
references that `coverArt` id (and wipe the `art/` dir on `clearAll`).
|
||||||
|
|
||||||
|
### B2. Central art resolver + dual-source image widget
|
||||||
|
- Add a resolver in `providers.dart` — a function/provider
|
||||||
|
`resolveArtUri(ref, {String? coverArt, int size})` that returns:
|
||||||
|
`localArtPathFor(coverArt)` as `Uri.file(...)` if cached → else
|
||||||
|
`client.coverArtUri(coverArt, size)` if online → else `null`.
|
||||||
|
- New widget `lib/widgets/art_image.dart` (`ArtImage(uri, ...)`) that picks
|
||||||
|
`FileImage` vs `NetworkImage` by URI scheme, preserving the current
|
||||||
|
`Image.network` styling/`ValueKey(artUri)`/placeholder behavior.
|
||||||
|
- Replace the inline `client.coverArtUri(...)` art-URI construction and the raw
|
||||||
|
`Image.network(artUri, ...)` calls with the resolver + `ArtImage` in:
|
||||||
|
`browser_screen.dart` (album tiles ≈240, track rows ≈382), `mini_player.dart`
|
||||||
|
(≈28/51), `now_playing_screen.dart` (≈460), `home_screen.dart` (`artFor`, ≈24–26
|
||||||
|
and its `Image.network` sites), and `cassette_view.dart` (≈139). This makes
|
||||||
|
offline artwork appear everywhere a downloaded track's art is cached.
|
||||||
|
- In the playback closure `coverArtUriFor` (`providers.dart` ≈564) prefer the local
|
||||||
|
art file too, and switch the `onArt` accent extraction to `FileImage` when the
|
||||||
|
resolved art URI is a `file://` (so accent extraction works offline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part C — Downloads tab: queue parity
|
||||||
|
|
||||||
|
### `lib/screens/downloads_screen.dart` (template: `playlists_screen.dart`)
|
||||||
|
- Tapping a saved row plays the **whole** saved list from that index:
|
||||||
|
`playback.playSongs(completed.map((d) => d.song).toList(), startIndex: i)`
|
||||||
|
(replacing `playSongs([d.song])` at line 79). Keep the list order stable and
|
||||||
|
consistent between what's shown and what's enqueued.
|
||||||
|
- Add header controls to the "Saved" panel mirroring `playlists_screen.dart`
|
||||||
|
(≈346–356): **Play all** (`playSongs(songs)`), **Shuffle**
|
||||||
|
(`toggleShuffle()` then `playSongs(songs)`, or set shuffle + play). Reuse the same
|
||||||
|
button widgets/tokens the playlists header uses.
|
||||||
|
- Add a per-row `PopupMenuButton` (like `playlists_screen.dart` ≈494–496):
|
||||||
|
**Play next** (`playback.playNext(d.song)`), **Add to queue**
|
||||||
|
(`playback.addToQueue(d.song)`), alongside the existing delete action.
|
||||||
|
- Render each row's artwork via the B2 resolver + `ArtImage` (downloaded art is
|
||||||
|
always local, so covers show offline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
- New: `lib/library/offline_library.dart`, `lib/widgets/art_image.dart`
|
||||||
|
- `lib/state/providers.dart` — offline provider branches, `downloadedSongsProvider`,
|
||||||
|
art resolver, `coverArtUriFor`/`onArt`.
|
||||||
|
- `lib/downloads/download_manager.dart` — art download + `artById` + `localArtPathFor`.
|
||||||
|
- `lib/screens/browser_screen.dart` — un-gate offline, art via resolver.
|
||||||
|
- `lib/screens/downloads_screen.dart` — full-list enqueue + queue controls + art.
|
||||||
|
- `lib/screens/{home_screen,now_playing_screen}.dart`, `lib/widgets/{mini_player,cassette_view}.dart`
|
||||||
|
— swap art rendering to resolver + `ArtImage`.
|
||||||
|
|
||||||
|
## Reused, not rebuilt
|
||||||
|
|
||||||
|
- `applyAlbumQuery` / `applyTrackQuery` / `distinctGenres` / `distinctYears`
|
||||||
|
(`library/browse_query.dart`) — offline sort/filter.
|
||||||
|
- `DownloadState.completed`, `localPathFor` pattern (`downloads/download_manager.dart`).
|
||||||
|
- `PlaybackCommands.playSongs / toggleShuffle / playNext / addToQueue`
|
||||||
|
(`playback/playback_engine.dart`) — already fully local-file aware.
|
||||||
|
- `playlists_screen.dart` header + row-menu widgets — copy for the Downloads screen.
|
||||||
|
- Playback already prefers local files (`streamUriFor`, `providers.dart` ≈547).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `flutter analyze` clean; run existing tests (`flutter test`).
|
||||||
|
2. **Online seed:** connect to a server, download a few tracks spanning ≥2 albums
|
||||||
|
and ≥2 artists (some sharing an album).
|
||||||
|
3. **Go offline** (airplane mode, or stop the server / an unreachable URL so
|
||||||
|
`ping` fails and `client == null`).
|
||||||
|
4. Browse tab:
|
||||||
|
- Artists / Albums / Tracks list exactly the downloaded content, sorted/filtered
|
||||||
|
like online; artwork shows (from cached art).
|
||||||
|
- Open an album and an artist detail — populated, all rows playable.
|
||||||
|
- With zero downloads offline, the friendly offline-empty state shows (not blank).
|
||||||
|
5. Tap a track in an offline Album → whole album enqueues starting at that track;
|
||||||
|
Shuffle / Play next / Add to queue behave like the streaming screens.
|
||||||
|
6. Downloads tab: tapping a saved song enqueues the full saved list at that index;
|
||||||
|
Play all / Shuffle header + per-row Play next / Add to queue work; covers render.
|
||||||
|
7. **Reconnect** → Browse returns to the full server catalog; nothing regressed.
|
||||||
|
8. Confirm downloaded-track artwork also renders offline in the mini-player and Now
|
||||||
|
Playing, and the accent color still extracts from the local art.
|
||||||
173
test/offline_library_test.dart
Normal file
173
test/offline_library_test.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timbre/library/offline_library.dart';
|
||||||
|
import 'package:timbre/subsonic/models.dart';
|
||||||
|
|
||||||
|
/// Builds a downloaded-track [Song] with just the fields the offline
|
||||||
|
/// reconstruction reads. Everything else defaults to null.
|
||||||
|
Song _song(
|
||||||
|
String id, {
|
||||||
|
String? title,
|
||||||
|
String? album,
|
||||||
|
String? albumId,
|
||||||
|
String? artist,
|
||||||
|
String? artistId,
|
||||||
|
String? coverArt,
|
||||||
|
int? track,
|
||||||
|
int? discNumber,
|
||||||
|
int? year,
|
||||||
|
String? genre,
|
||||||
|
}) =>
|
||||||
|
Song(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
album: album,
|
||||||
|
albumId: albumId,
|
||||||
|
artist: artist,
|
||||||
|
artistId: artistId,
|
||||||
|
coverArt: coverArt,
|
||||||
|
track: track,
|
||||||
|
discNumber: discNumber,
|
||||||
|
year: year,
|
||||||
|
genre: genre,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('offline_library — album reconstruction', () {
|
||||||
|
test('groups songs by albumId and synthesizes album metadata', () {
|
||||||
|
final songs = [
|
||||||
|
_song('1',
|
||||||
|
title: 'A',
|
||||||
|
album: 'Rumours',
|
||||||
|
albumId: 'alb1',
|
||||||
|
artist: 'Fleetwood Mac',
|
||||||
|
artistId: 'art1',
|
||||||
|
coverArt: 'cov1',
|
||||||
|
track: 2,
|
||||||
|
year: 1977,
|
||||||
|
genre: 'Rock'),
|
||||||
|
_song('2',
|
||||||
|
title: 'B',
|
||||||
|
album: 'Rumours',
|
||||||
|
albumId: 'alb1',
|
||||||
|
artist: 'Fleetwood Mac',
|
||||||
|
artistId: 'art1',
|
||||||
|
coverArt: 'cov1',
|
||||||
|
track: 1,
|
||||||
|
year: 1977,
|
||||||
|
genre: 'Rock'),
|
||||||
|
];
|
||||||
|
final albums = albumsFromSongs(songs);
|
||||||
|
expect(albums, hasLength(1));
|
||||||
|
final a = albums.single;
|
||||||
|
expect(a.id, 'alb1');
|
||||||
|
expect(a.name, 'Rumours');
|
||||||
|
expect(a.artist, 'Fleetwood Mac');
|
||||||
|
expect(a.artistId, 'art1');
|
||||||
|
expect(a.coverArt, 'cov1');
|
||||||
|
expect(a.year, 1977);
|
||||||
|
expect(a.genre, 'Rock');
|
||||||
|
expect(a.songCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orders an album by disc then track, nulls last', () {
|
||||||
|
final songs = [
|
||||||
|
_song('1', album: 'X', albumId: 'x', title: 'no-track'),
|
||||||
|
_song('2', album: 'X', albumId: 'x', title: 'd1t2', discNumber: 1, track: 2),
|
||||||
|
_song('3', album: 'X', albumId: 'x', title: 'd2t1', discNumber: 2, track: 1),
|
||||||
|
_song('4', album: 'X', albumId: 'x', title: 'd1t1', discNumber: 1, track: 1),
|
||||||
|
];
|
||||||
|
final ids = albumsFromSongs(songs).single.songs.map((s) => s.title).toList();
|
||||||
|
expect(ids, ['d1t1', 'd1t2', 'd2t1', 'no-track']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to album name as key and id when albumId is missing', () {
|
||||||
|
final songs = [
|
||||||
|
_song('1', album: 'Untitled Sessions', title: 'A'),
|
||||||
|
_song('2', album: 'Untitled Sessions', title: 'B'),
|
||||||
|
];
|
||||||
|
final albums = albumsFromSongs(songs);
|
||||||
|
expect(albums, hasLength(1));
|
||||||
|
expect(albums.single.id, 'Untitled Sessions');
|
||||||
|
expect(albums.single.songCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips songs with no album identity', () {
|
||||||
|
final songs = [
|
||||||
|
_song('1', title: 'orphan'),
|
||||||
|
_song('2', album: 'Real', albumId: 'r', title: 'kept'),
|
||||||
|
];
|
||||||
|
final albums = albumsFromSongs(songs);
|
||||||
|
expect(albums, hasLength(1));
|
||||||
|
expect(albums.single.id, 'r');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sorts albums by name case-insensitively', () {
|
||||||
|
final songs = [
|
||||||
|
_song('1', album: 'zebra', albumId: 'z'),
|
||||||
|
_song('2', album: 'Apple', albumId: 'a'),
|
||||||
|
_song('3', album: 'mango', albumId: 'm'),
|
||||||
|
];
|
||||||
|
final names = albumsFromSongs(songs).map((a) => a.name).toList();
|
||||||
|
expect(names, ['Apple', 'mango', 'zebra']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('offline_library — artist reconstruction', () {
|
||||||
|
test('groups by artistId and nests albums', () {
|
||||||
|
final songs = [
|
||||||
|
_song('1',
|
||||||
|
album: 'One',
|
||||||
|
albumId: 'a1',
|
||||||
|
artist: 'Radiohead',
|
||||||
|
artistId: 'r',
|
||||||
|
coverArt: 'c1'),
|
||||||
|
_song('2',
|
||||||
|
album: 'Two',
|
||||||
|
albumId: 'a2',
|
||||||
|
artist: 'Radiohead',
|
||||||
|
artistId: 'r',
|
||||||
|
coverArt: 'c2'),
|
||||||
|
];
|
||||||
|
final artists = artistsFromSongs(songs);
|
||||||
|
expect(artists, hasLength(1));
|
||||||
|
final a = artists.single;
|
||||||
|
expect(a.id, 'r');
|
||||||
|
expect(a.name, 'Radiohead');
|
||||||
|
expect(a.albumCount, 2);
|
||||||
|
expect(a.albums.map((al) => al.id), containsAll(['a1', 'a2']));
|
||||||
|
expect(a.coverArt, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to artist name as key and id', () {
|
||||||
|
final songs = [_song('1', album: 'X', albumId: 'x', artist: 'Nameless Band')];
|
||||||
|
final artists = artistsFromSongs(songs);
|
||||||
|
expect(artists.single.id, 'Nameless Band');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('offline_library — keyed lookups', () {
|
||||||
|
final songs = [
|
||||||
|
_song('1', album: 'One', albumId: 'a1', artist: 'Band', artistId: 'b1'),
|
||||||
|
_song('2', album: 'Two', albumId: 'a2', artist: 'Band', artistId: 'b1'),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('albumFromSongs returns the matching album or null', () {
|
||||||
|
expect(albumFromSongs(songs, 'a2')?.name, 'Two');
|
||||||
|
expect(albumFromSongs(songs, 'nope'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('artistFromSongs returns the matching artist or null', () {
|
||||||
|
final artist = artistFromSongs(songs, 'b1');
|
||||||
|
expect(artist?.name, 'Band');
|
||||||
|
expect(artist?.albumCount, 2);
|
||||||
|
expect(artistFromSongs(songs, 'nope'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input yields empty output', () {
|
||||||
|
expect(albumsFromSongs(const []), isEmpty);
|
||||||
|
expect(artistsFromSongs(const []), isEmpty);
|
||||||
|
expect(albumFromSongs(const [], 'x'), isNull);
|
||||||
|
expect(artistFromSongs(const [], 'x'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue