Compare commits

..

No commits in common. "main" and "1.0.0" have entirely different histories.
main ... 1.0.0

16 changed files with 566 additions and 1969 deletions

View file

@ -1,64 +0,0 @@
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');
}

View file

@ -55,27 +55,28 @@ class DownloadInfo {
int? sizeBytes, int? sizeBytes,
double? progress, double? progress,
String? error, String? error,
}) => DownloadInfo( }) =>
song: song, DownloadInfo(
status: status ?? this.status, song: song,
path: path ?? this.path, status: status ?? this.status,
bitRate: bitRate ?? this.bitRate, path: path ?? this.path,
format: format ?? this.format, bitRate: bitRate ?? this.bitRate,
sizeBytes: sizeBytes ?? this.sizeBytes, format: format ?? this.format,
progress: progress ?? this.progress, sizeBytes: sizeBytes ?? this.sizeBytes,
error: error, progress: progress ?? this.progress,
); error: error,
);
/// Serialize a completed record. The path is written as *relative* to the /// Serialize a completed record. The path is written as *relative* to the
/// app-support directory by [DownloadController._persist] (key `relPath`) — /// app-support directory by [DownloadController._persist] (key `relPath`) —
/// absolute paths embed the iOS app-container UUID, which changes across app /// absolute paths embed the iOS app-container UUID, which changes across app
/// updates and would orphan every download. See [DownloadController]. /// updates and would orphan every download. See [DownloadController].
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'song': song.toJson(), 'song': song.toJson(),
if (bitRate != null) 'bitRate': bitRate, if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format, if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes, if (sizeBytes != null) 'sizeBytes': sizeBytes,
}; };
/// Rebuild a completed record from the manifest. [path] is the absolute path /// Rebuild a completed record from the manifest. [path] is the absolute path
/// resolved by the controller from the stored relative (or legacy absolute) /// resolved by the controller from the stored relative (or legacy absolute)
@ -94,16 +95,10 @@ 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 {}, this.artById = const {}}); const DownloadState({this.byId = 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;
@ -111,16 +106,10 @@ 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({ DownloadState copyWith({Map<String, DownloadInfo>? byId}) =>
Map<String, DownloadInfo>? byId, DownloadState(byId: byId ?? this.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 +122,10 @@ class DownloadController extends StateNotifier<DownloadState> {
required SubsonicClient? Function() clientGetter, required SubsonicClient? Function() clientGetter,
required AppSettings Function() settingsGetter, required AppSettings Function() settingsGetter,
required String? Function() serverKeyGetter, required String? Function() serverKeyGetter,
}) : _clientGetter = clientGetter, }) : _clientGetter = clientGetter,
_settingsGetter = settingsGetter, _settingsGetter = settingsGetter,
_serverKeyGetter = serverKeyGetter, _serverKeyGetter = serverKeyGetter,
super(const DownloadState()) { super(const DownloadState()) {
reloadForServer(); reloadForServer();
} }
@ -144,12 +133,10 @@ 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( final Dio _dio = Dio(BaseOptions(
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;
@ -199,15 +186,6 @@ 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 ----------------------------------
@ -219,25 +197,6 @@ 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');
@ -279,20 +238,8 @@ 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, artById: artById); state = DownloadState(byId: byId);
// Self-migrate the manifest to relative paths. // Self-migrate the manifest to relative paths.
if (migrated) await _persist(); if (migrated) await _persist();
} }
@ -330,9 +277,8 @@ 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 = AppSettings.clampConcurrentDownloads( final maxConcurrent =
_settingsGetter().maxConcurrentDownloads, AppSettings.clampConcurrentDownloads(_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];
@ -350,12 +296,8 @@ 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( _put(state.byId[song.id]!
state.byId[song.id]!.copyWith( .copyWith(status: DownloadStatus.failed, error: 'Not connected'));
status: DownloadStatus.failed,
error: 'Not connected',
),
);
return; return;
} }
final settings = _settingsGetter(); final settings = _settingsGetter();
@ -367,12 +309,8 @@ 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( _put(state.byId[song.id]!
state.byId[song.id]!.copyWith( .copyWith(status: DownloadStatus.downloading, progress: 0));
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';
@ -408,24 +346,16 @@ 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( _put(DownloadInfo(
DownloadInfo( song: song,
song: song, status: DownloadStatus.done,
status: DownloadStatus.done, path: finalPath,
path: finalPath, bitRate: rate == 0 ? null : rate,
bitRate: rate == 0 ? null : rate, 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];
@ -435,56 +365,6 @@ 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).
@ -499,28 +379,7 @@ 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();
} }
@ -536,14 +395,6 @@ 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 {
@ -556,7 +407,9 @@ class DownloadController extends StateNotifier<DownloadState> {
// ---- Internals ---------------------------------------------------------- // ---- Internals ----------------------------------------------------------
void _put(DownloadInfo info) { void _put(DownloadInfo info) {
state = state.copyWith(byId: {...state.byId, info.song.id: info}); state = state.copyWith(
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).
@ -568,14 +421,12 @@ 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( jsonEncode(state.completed.map((d) {
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 (_) {}

View file

@ -1,153 +0,0 @@
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;
}

View file

@ -32,7 +32,6 @@ 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,
@ -44,13 +43,6 @@ 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;
@ -91,7 +83,6 @@ 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,
@ -104,7 +95,6 @@ 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,
@ -193,24 +183,6 @@ 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;
@ -243,68 +215,22 @@ class PlaybackController extends StateNotifier<PlaybackState>
_maybeSlideWindow(); _maybeSlideWindow();
}); });
player.playerStateStream.listen((s) { player.playerStateStream.listen((s) {
final ps = s.processingState; state = state.copyWith(playing: s.playing);
state = state.copyWith( });
playing: s.playing, player.positionStream.listen((p) {
buffering: ps == ProcessingState.loading || state = state.copyWith(position: p);
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);
}); });
// The event stream carries the raw, unclamped `updatePosition`; anchor on it // just_audio surfaces load/decode failures (e.g. an unreachable remote
// (and re-anchor on every seek / pause / track change) and reflect it // source after the network drops) as errors on the event stream. Without a
// immediately so paused/seeked positions are exact. Steady-state advancing // handler the platform player runs its own recovery — restarting the item
// is done by the ticker below. We also handle load/decode failures here: // at 0 or auto-advancing — which is the reported "scrub back / skip" bug.
// 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
@ -380,28 +306,20 @@ class PlaybackController extends StateNotifier<PlaybackState>
AudioSource _sourceFor(Song song) { AudioSource _sourceFor(Song song) {
final uri = _streamUriFor(song)!; final uri = _streamUriFor(song)!;
final isRemote = !uri.isScheme('file'); if (!uri.isScheme('file')) _remoteSourceIds.add(song.id);
if (isRemote) _remoteSourceIds.add(song.id);
final art = _coverArtUriFor(song); final art = _coverArtUriFor(song);
final tag = MediaItem( return AudioSource.uri(
id: '${song.id}#${_tagSeq++}', uri,
title: song.title ?? 'Unknown', tag: MediaItem(
album: song.album, id: '${song.id}#${_tagSeq++}',
artist: song.artist, title: song.title ?? 'Unknown',
duration: album: song.album,
song.duration != null ? Duration(seconds: song.duration!) : null, artist: song.artist,
artUri: art, duration:
song.duration != null ? Duration(seconds: song.duration!) : null,
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].
@ -949,15 +867,9 @@ class PlaybackController extends StateNotifier<PlaybackState>
void resyncFromPlayer() { void resyncFromPlayer() {
final player = _player; final player = _player;
if (player == null) return; if (player == null) return;
final playing = player.playing; final pos = player.position;
// `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; final dur = player.duration ?? state.duration;
final playing = player.playing;
// 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!
@ -1123,7 +1035,6 @@ 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();
} }

View file

@ -5,7 +5,6 @@ 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';
@ -25,9 +24,8 @@ 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(
@ -46,9 +44,9 @@ class BrowserScreen extends ConsumerWidget {
_Action( _Action(
icon: Icons.search, icon: Icons.search,
label: 'Search', label: 'Search',
onTap: () => Navigator.of( onTap: () => Navigator.of(context).push(
context, MaterialPageRoute(builder: (_) => const SearchScreen()),
).push(MaterialPageRoute(builder: (_) => const SearchScreen())), ),
), ),
_Action( _Action(
icon: Icons.favorite_border, icon: Icons.favorite_border,
@ -67,9 +65,9 @@ class BrowserScreen extends ConsumerWidget {
_Action( _Action(
icon: Icons.label_outline, icon: Icons.label_outline,
label: 'Tags', label: 'Tags',
onTap: () => Navigator.of( onTap: () => Navigator.of(context).push(
context, MaterialPageRoute(builder: (_) => const TagsScreen()),
).push(MaterialPageRoute(builder: (_) => const TagsScreen())), ),
), ),
_Action( _Action(
icon: Icons.download, icon: Icons.download,
@ -84,7 +82,7 @@ class BrowserScreen extends ConsumerWidget {
_ModeSelector(mode: mode), _ModeSelector(mode: mode),
const SizedBox(height: TimbreSpacing.lg), const SizedBox(height: TimbreSpacing.lg),
Expanded( Expanded(
child: offline && !hasDownloads child: client == null
? const HairlinePanel( ? const HairlinePanel(
title: 'Browse', title: 'Browse',
active: true, active: true,
@ -117,9 +115,8 @@ 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: const BoxConstraints( constraints:
minHeight: TimbreSpacing.minTouchTarget, const BoxConstraints(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(
@ -127,9 +124,8 @@ 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: active decoration:
? TextDecoration.underline active ? TextDecoration.underline : TextDecoration.none,
: TextDecoration.none,
decorationColor: accent, decorationColor: accent,
decorationThickness: 2, decorationThickness: 2,
), ),
@ -195,6 +191,7 @@ 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,
@ -218,20 +215,18 @@ class _AlbumsPanel extends ConsumerWidget {
Expanded( Expanded(
child: list.isEmpty child: list.isEmpty
? _Centered( ? _Centered(
child: _ErrorText( child: _ErrorText(filter.isActive
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 = (constraints.maxWidth / 180) final cols =
.floor() (constraints.maxWidth / 180).floor().clamp(2, 6);
.clamp(2, 6);
return GridView.builder( return GridView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols, crossAxisCount: cols,
mainAxisSpacing: TimbreSpacing.md, mainAxisSpacing: TimbreSpacing.md,
crossAxisSpacing: TimbreSpacing.md, crossAxisSpacing: TimbreSpacing.md,
@ -242,11 +237,13 @@ 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: resolveArtUriW( artUri:
ref, (client != null && list[i].coverArt != null)
coverArt: list[i].coverArt, ? client
size: 300, .coverArtUri(list[i].coverArt!,
)?.toString(), size: 300)
.toString()
: null,
), ),
); );
}, },
@ -269,18 +266,25 @@ class _AlbumTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
onTap: () => Navigator.of( onTap: () => Navigator.of(context).push(
context, MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
).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: ArtImage( child: ColoredBox(
artUri, color: TimbreColors.surface,
fit: BoxFit.cover, child: artUri != null
placeholder: const _AlbumArtFallback(), ? Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
)
: const _AlbumArtFallback(),
), ),
), ),
const SizedBox(height: TimbreSpacing.xs), const SizedBox(height: TimbreSpacing.xs),
@ -295,7 +299,8 @@ class _AlbumTile extends StatelessWidget {
album.artist!, album.artist!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), style: TextStyle(
color: TimbreColors.dimmed, fontSize: 12),
), ),
], ],
), ),
@ -307,8 +312,9 @@ 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, color: TimbreColors.dimmed, size: 32), child: Icon(Icons.album_outlined,
); color: TimbreColors.dimmed, size: 32),
);
} }
/// Flat alphabetical list of every song, backed by the crawled+cached library /// Flat alphabetical list of every song, backed by the crawled+cached library
@ -325,11 +331,7 @@ 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); ref.read(libraryIndexProvider.notifier).ensureBuilt();
// only crawl the live library when we actually have a server connection.
if (ref.read(subsonicClientProvider) != null) {
ref.read(libraryIndexProvider.notifier).ensureBuilt();
}
}); });
} }
@ -338,12 +340,10 @@ 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 offline = ref.watch(subsonicClientProvider) == null; final client = ref.watch(subsonicClientProvider);
final Widget body; final Widget body;
// Offline the crawled index is empty; `visible` is backed by the downloaded if (index.building) {
// 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,13 +358,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
], ],
), ),
); );
} else if (!offline && index.songs.isEmpty) { } else if (index.songs.isEmpty) {
body = _Centered( body = _Centered(
child: _ErrorText( child: _ErrorText(index.error != null
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);
@ -375,24 +373,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 = resolveArtUriW( final artUri = (client != null && song.coverArt != null)
ref, ? client
coverArt: song.coverArt, .coverArtUri(song.coverArt!, size: 128)
size: 128, .toString()
)?.toString(); : null;
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: () => playback.playSongs(visible, startIndex: i), onTap: () =>
playback.playSongs(visible, startIndex: i),
onPlayNext: () => playback.playNext(song), onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song), onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () => onAddToPlaylist: () =>
@ -416,9 +414,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
return HairlinePanel( return HairlinePanel(
title: 'Tracks', title: 'Tracks',
active: true, active: true,
trailing: index.songs.isNotEmpty || visible.isNotEmpty trailing: index.songs.isNotEmpty ? '(${visible.length})' : null,
? '(${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,
@ -429,10 +425,8 @@ 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( child: Text('↻ refresh',
'↻ refresh', style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
),
), ),
), ),
if (visible.isNotEmpty) if (visible.isNotEmpty)
@ -451,27 +445,19 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(Icons.download,
Icons.download, size: 16, color: TimbreColors.foreground),
size: 16,
color: TimbreColors.foreground,
),
SizedBox(width: TimbreSpacing.sm), SizedBox(width: TimbreSpacing.sm),
Text( Text('Download all',
'Download all', style: TextStyle(color: TimbreColors.foreground)),
style: TextStyle(color: TimbreColors.foreground),
),
], ],
), ),
), ),
], ],
child: Padding( child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.xs), padding: const EdgeInsets.all(TimbreSpacing.xs),
child: Icon( child: Icon(Icons.more_vert,
Icons.more_vert, size: 18, color: TimbreColors.dimmed),
size: 18,
color: TimbreColors.dimmed,
),
), ),
), ),
], ],
@ -484,27 +470,22 @@ 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, BuildContext context, List<Song> songs) async {
List<Song> songs,
) 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('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')),
),
], ],
), ),
); );
@ -562,7 +543,9 @@ 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(builder: (_) => AlbumScreen(id: album.id)), MaterialPageRoute(
builder: (_) => AlbumScreen(id: album.id),
),
), ),
); );
}, },
@ -591,13 +574,16 @@ class AlbumScreen extends ConsumerWidget {
: [ : [
IconButton( IconButton(
tooltip: 'Add to playlist', tooltip: 'Add to playlist',
onPressed: () => showAddToPlaylistSheet(context, songs: songs), onPressed: () =>
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.read(downloadManagerProvider.notifier).downloadAll(songs); ref
.read(downloadManagerProvider.notifier)
.downloadAll(songs);
showToast(context, 'Downloading album…'); showToast(context, 'Downloading album…');
}, },
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
@ -692,36 +678,39 @@ 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 = final isActive = downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading; downloadStatus == DownloadStatus.downloading;
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
constraints: const BoxConstraints( constraints:
minHeight: TimbreSpacing.minTouchTarget, const BoxConstraints(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) ...[
ArtImage( SizedBox(
artUri,
width: 40, width: 40,
height: 40, height: 40,
fit: BoxFit.cover, child: ColoredBox(
placeholder: const _AlbumArtFallback(), color: TimbreColors.surface,
child: Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
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( child: Text(leading!,
leading!, style: TextStyle(color: TimbreColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed),
),
), ),
Expanded( Expanded(
child: Column( child: Column(
@ -740,9 +729,7 @@ class BrowseRow extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: TimbreColors.dimmed, color: TimbreColors.dimmed, fontSize: 12),
fontSize: 12,
),
), ),
], ],
), ),
@ -750,7 +737,8 @@ 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: Icon(Icons.download_done, size: 14, color: accent), child:
Icon(Icons.download_done, size: 14, color: accent),
) )
else if (isActive) else if (isActive)
const Padding( const Padding(
@ -763,7 +751,8 @@ class BrowseRow extends StatelessWidget {
), ),
if (trailing != null) ...[ if (trailing != null) ...[
const SizedBox(width: TimbreSpacing.md), const SizedBox(width: TimbreSpacing.md),
Text(trailing!, style: TextStyle(color: TimbreColors.dimmed)), Text(trailing!,
style: TextStyle(color: TimbreColors.dimmed)),
], ],
if (onPlayNext != null) if (onPlayNext != null)
_RowIcon( _RowIcon(
@ -823,7 +812,8 @@ 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: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget), constraints:
const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget),
onSelected: (v) { onSelected: (v) {
switch (v) { switch (v) {
case 'playlist': case 'playlist':
@ -839,22 +829,17 @@ class _RowMenu extends StatelessWidget {
itemBuilder: (_) => [ itemBuilder: (_) => [
if (onAddToPlaylist != null) if (onAddToPlaylist != null)
const PopupMenuItem( const PopupMenuItem(
value: 'playlist', value: 'playlist', child: Text('Add to 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', value: 'remove_download', child: Text('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')),
),
], ],
); );
} }
@ -896,12 +881,10 @@ class _DetailScaffold extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(title,
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),
@ -918,16 +901,12 @@ class _NotConnected extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text('Not connected.',
"You're offline.", style: TextStyle(color: TimbreColors.foreground)),
style: TextStyle(color: TimbreColors.foreground),
),
SizedBox(height: TimbreSpacing.sm), SizedBox(height: TimbreSpacing.sm),
Text( Text('Tap the status bar to add a Subsonic server.',
'Download music to browse it here.', textAlign: TextAlign.center,
textAlign: TextAlign.center, style: TextStyle(color: TimbreColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed),
),
], ],
), ),
); );
@ -939,19 +918,19 @@ class _Centered extends StatelessWidget {
final Widget child; final Widget child;
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(TimbreSpacing.xl), padding: const EdgeInsets.all(TimbreSpacing.xl),
child: Center(child: child), child: Center(child: child),
); );
} }
class _Loading extends StatelessWidget { class _Loading extends StatelessWidget {
const _Loading(); const _Loading();
@override @override
Widget build(BuildContext context) => const SizedBox( Widget build(BuildContext context) => const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
); );
} }
class _ErrorText extends StatelessWidget { class _ErrorText extends StatelessWidget {
@ -959,10 +938,10 @@ class _ErrorText extends StatelessWidget {
final String message; final String message;
@override @override
Widget build(BuildContext context) => Text( Widget build(BuildContext context) => Text(
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: TimbreColors.dimmed), style: TextStyle(color: TimbreColors.dimmed),
); );
} }
String? _fmtDuration(int? seconds) { String? _fmtDuration(int? seconds) {

View file

@ -1,190 +0,0 @@
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,
),
),
],
),
),
);
}
}

View file

@ -4,9 +4,7 @@ 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.
@ -21,33 +19,25 @@ 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( title: const Text('Downloads',
'Downloads', style: TextStyle(fontWeight: FontWeight.w700)),
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( child: Text('Clear all',
'Clear all', style: TextStyle(color: TimbreColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed),
),
), ),
], ],
), ),
body: SafeArea( body: SafeArea(
child: (active.isEmpty && completed.isEmpty) child: (active.isEmpty && completed.isEmpty)
? Center( ? Center(
child: Text( child: Text('No downloads yet.',
'No downloads yet.', style: TextStyle(color: TimbreColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed),
),
) )
: ListView( : ListView(
padding: const EdgeInsets.all(TimbreSpacing.lg), padding: const EdgeInsets.all(TimbreSpacing.lg),
@ -57,10 +47,11 @@ 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: [for (final d in active) _ActiveRow(info: d)], children: [
for (final d in active) _ActiveRow(info: d),
],
), ),
), ),
const SizedBox(height: TimbreSpacing.xl), const SizedBox(height: TimbreSpacing.xl),
@ -71,68 +62,21 @@ class DownloadsScreen extends ConsumerWidget {
trailing: completed.isEmpty trailing: completed.isEmpty
? null ? null
: '${completed.length} · ${_fmtBytes(downloads.totalBytes)}', : '${completed.length} · ${_fmtBytes(downloads.totalBytes)}',
action: Row( padding:
mainAxisSize: MainAxisSize.min, const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
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( child: Text('Nothing saved for offline yet.',
'Nothing saved for offline yet.', style: TextStyle(color: TimbreColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed),
),
) )
: Column( : Column(
children: [ children: [
for (final (i, d) in completed.indexed) for (final d in completed)
_SavedRow( _SavedRow(
info: d, info: d,
artUri: resolveArtUriW( onPlay: () =>
ref, playback.playSongs([d.song]),
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),
), ),
], ],
@ -145,26 +89,21 @@ class DownloadsScreen extends ConsumerWidget {
} }
Future<void> _confirmClear( Future<void> _confirmClear(
BuildContext context, BuildContext context, DownloadController controller) async {
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')),
),
], ],
), ),
); );
@ -182,27 +121,21 @@ 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, horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.xs),
vertical: TimbreSpacing.xs,
),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(info.song.title ?? 'Untitled',
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( const Text('Failed',
'Failed', style: TextStyle(color: Color(0xFFE06C75), fontSize: 12))
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,
@ -229,55 +162,35 @@ 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 = final quality = info.format ??
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: const BoxConstraints( constraints:
minHeight: TimbreSpacing.minTouchTarget, const BoxConstraints(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( Text(info.song.title ?? 'Untitled',
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,
@ -285,37 +198,21 @@ 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: TextStyle(color: TimbreColors.dimmed, fontSize: 12), style:
TextStyle(color: TimbreColors.dimmed, fontSize: 12),
), ),
], ],
), ),
), ),
PopupMenuButton<String>( InkWell(
icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), onTap: onRemove,
color: TimbreColors.surface, customBorder: const CircleBorder(),
onSelected: (v) { child: SizedBox(
switch (v) { width: TimbreSpacing.minTouchTarget,
case 'next': height: TimbreSpacing.minTouchTarget,
onPlayNext(); child: Icon(Icons.delete_outline,
showToast(context, 'Playing next', icon: Icons.check); size: 20, color: TimbreColors.dimmed),
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'),
),
],
), ),
], ],
), ),

View file

@ -5,7 +5,6 @@ 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';
@ -23,7 +22,9 @@ 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}) =>
resolveArtUriW(ref, coverArt: coverArt, size: size)?.toString(); (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: size).toString()
: null;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
@ -54,7 +55,11 @@ class HomeScreen extends ConsumerWidget {
), ),
// Recently Added — server discovery shelf. // Recently Added — server discovery shelf.
_AlbumShelf(title: 'Recently Added', albums: newest, artFor: artFor), _AlbumShelf(
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(
@ -76,9 +81,9 @@ class HomeScreen extends ConsumerWidget {
} }
static void _pushAlbum(BuildContext context, String albumId) { static void _pushAlbum(BuildContext context, String albumId) {
Navigator.of( Navigator.of(context).push(
context, MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)),
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId))); );
} }
} }
@ -91,6 +96,7 @@ 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.
@ -98,11 +104,9 @@ 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 = resolveArtUriW( final artUri = (client != null && coverArt != null)
ref, ? client.coverArtUri(coverArt, size: 240).toString()
coverArt: coverArt, : null;
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;
@ -114,17 +118,12 @@ 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( Icon(Icons.library_music_outlined,
Icons.library_music_outlined, color: TimbreColors.dimmed, size: 40),
color: TimbreColors.dimmed,
size: 40,
),
const SizedBox(width: TimbreSpacing.lg), const SizedBox(width: TimbreSpacing.lg),
Expanded( Expanded(
child: Text( child: Text('Browse your library to start listening',
'Browse your library to start listening', style: TextStyle(color: TimbreColors.foreground)),
style: TextStyle(color: TimbreColors.foreground),
),
), ),
], ],
), ),
@ -146,7 +145,18 @@ class _HeroCard extends ConsumerWidget {
SizedBox( SizedBox(
width: 64, width: 64,
height: 64, height: 64,
child: ArtImage(artUri, fit: BoxFit.cover), 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),
Expanded( Expanded(
@ -156,40 +166,29 @@ class _HeroCard extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon( Icon(hasCurrent ? Icons.play_arrow : Icons.history,
hasCurrent ? Icons.play_arrow : Icons.history, size: 14, color: accent),
size: 14,
color: accent,
),
const SizedBox(width: TimbreSpacing.xs), const SizedBox(width: TimbreSpacing.xs),
Text( Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
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( Text(title,
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null)
Text(
subtitle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed), style: TextStyle(
), color: TimbreColors.foreground,
fontWeight: FontWeight.w700)),
if (subtitle != null)
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed)),
if (hasCurrent) ...[ if (hasCurrent) ...[
const SizedBox(height: TimbreSpacing.md), const SizedBox(height: TimbreSpacing.md),
const _HeroProgress(), const _HeroProgress(),
@ -211,19 +210,14 @@ class _HeroProgress extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch( final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
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.child, required this.accent, required this.onTap});
required this.accent,
required this.onTap,
});
final Widget child; final Widget child;
final Color accent; final Color accent;
@ -288,14 +282,11 @@ class _ShelfHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
Text( Text(title,
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(
@ -330,11 +321,7 @@ class _AlbumShelf extends StatelessWidget {
return albums.when( return albums.when(
loading: () => _Shelf( loading: () => _Shelf(
title: title, title: title,
cards: const [ cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
_ArtCardSkeleton(),
_ArtCardSkeleton(),
_ArtCardSkeleton(),
],
), ),
error: (_, _) => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(),
data: (list) => _Shelf( data: (list) => _Shelf(
@ -345,9 +332,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( onTap: () => Navigator.of(context).push(
context, MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))), ),
), ),
], ],
), ),
@ -391,9 +378,9 @@ class _RandomAlbum extends StatelessWidget {
const _RandomSkeleton() const _RandomSkeleton()
else else
InkWell( InkWell(
onTap: () => Navigator.of( onTap: () => Navigator.of(context).push(
context, MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))), ),
child: _RandomBody(album: a, artUri: artFor(a.coverArt)), child: _RandomBody(album: a, artUri: artFor(a.coverArt)),
), ),
], ],
@ -415,11 +402,20 @@ class _RandomBody extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: ArtImage( child: SizedBox(
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),
@ -428,35 +424,28 @@ class _RandomBody extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(album.name ?? 'Unknown album',
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( Text(album.artist!,
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( Text('${album.year}',
'${album.year}', style: TextStyle(
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), color: TimbreColors.dimmed, fontSize: 12)),
),
if (album.genre != null) if (album.genre != null)
Text( Text(album.genre!,
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)),
),
], ],
), ),
), ),
@ -508,27 +497,34 @@ class _ArtCard extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: ArtImage( child: SizedBox(
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( Text(title,
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.foreground),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), style: TextStyle(color: TimbreColors.foreground)),
), if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
], ],
), ),
), ),

View file

@ -10,7 +10,6 @@ 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';
@ -58,25 +57,20 @@ 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( ref.listen(activePlaybackProvider.select((s) => s.current?.id),
activePlaybackProvider.select((s) => s.current?.id), (_, _) => _seedFavorites());
(_, _) => _seedFavorites(),
);
if (current == null) { if (current == null) {
return Center( return Center(
child: Text( child: Text('Nothing playing.',
'Nothing playing.', style: TextStyle(color: TimbreColors.dimmed)),
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 = ref.watch( final remoteSupported =
remoteControlProvider.select((s) => s.supported), ref.watch(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
@ -294,10 +288,8 @@ 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 = (index * _rowExtent).clamp( final target =
0.0, (index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent);
_controller.position.maxScrollExtent,
);
_controller.animateTo( _controller.animateTo(
target, target,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@ -383,9 +375,8 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: titleColor, color: titleColor,
fontWeight: isCurrent fontWeight:
? FontWeight.w700 isCurrent ? FontWeight.w700 : FontWeight.w400,
: FontWeight.w400,
), ),
), ),
if (song.artist != null) if (song.artist != null)
@ -401,21 +392,17 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
], ],
), ),
), ),
Text( Text(_fmt(song.duration),
_fmt(song.duration), style: TextStyle(color: TimbreColors.dimmed)),
style: TextStyle(color: TimbreColors.dimmed),
),
InkWell( InkWell(
onTap: () => ref.read(playbackCommandsProvider).removeAt(i), onTap: () =>
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( child: Icon(Icons.close,
Icons.close, size: 18, color: TimbreColors.dimmed),
size: 18,
color: TimbreColors.dimmed,
),
), ),
), ),
], ],
@ -443,9 +430,8 @@ class _FittedArt extends StatelessWidget {
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, c) { builder: (context, c) {
final side = c.maxHeight.isFinite final side =
? c.maxHeight.clamp(0.0, c.maxWidth) c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth;
: c.maxWidth;
return SizedBox( return SizedBox(
width: side, width: side,
height: side, height: side,
@ -466,17 +452,14 @@ class _AlbumArtPanel extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final coverArt = ref.watch( final coverArt =
activePlaybackProvider.select((s) => s.current?.coverArt), ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt));
); final client = ref.watch(subsonicClientProvider);
final cassette = ref.watch( final cassette =
settingsProvider.select((s) => s.nowPlayingCassette), ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
); final artUri = (client != null && coverArt != null)
final artUri = resolveArtUriW( ? client.coverArtUri(coverArt, size: 512).toString()
ref, : null;
coverArt: coverArt,
size: 512,
)?.toString();
return HairlinePanel( return HairlinePanel(
title: cassette ? 'Cassette' : 'Album Art', title: cassette ? 'Cassette' : 'Album Art',
@ -485,10 +468,17 @@ class _AlbumArtPanel extends ConsumerWidget {
? Center(child: CassetteView(artUri: artUri)) ? Center(child: CassetteView(artUri: artUri))
: AspectRatio( : AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: ArtImage( child: ColoredBox(
artUri, color: TimbreColors.surface,
fit: BoxFit.cover, child: artUri != null
placeholder: const _ArtFallback(), ? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
), ),
), ),
); );
@ -507,16 +497,13 @@ 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 = fav.ratingFor(song.id) != 0 final rating =
? fav.ratingFor(song.id) fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
: (song.userRating ?? 0);
final downloadStatus = ref.watch( final downloadStatus =
downloadManagerProvider.select((s) => s.byId[song.id]?.status), ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status));
);
final isDownloaded = downloadStatus == DownloadStatus.done; final isDownloaded = downloadStatus == DownloadStatus.done;
final isDownloading = final isDownloading = downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading; downloadStatus == DownloadStatus.downloading;
return Row( return Row(
@ -554,11 +541,8 @@ 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( child: Icon(Icons.playlist_add,
Icons.playlist_add, size: 22, color: TimbreColors.dimmed),
size: 22,
color: TimbreColors.dimmed,
),
), ),
), ),
InkWell( InkWell(
@ -653,16 +637,12 @@ class _InfoStrip extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(song.title ?? 'Untitled',
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)),
], ],
@ -679,35 +659,29 @@ class _NowPlayingProgress extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch( final position = ref.watch(activePlaybackProvider.select((s) => s.position));
activePlaybackProvider.select((s) => s.position), final duration =
); ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration));
final duration = ref.watch( final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
activePlaybackProvider.select((s) => s.effectiveDuration),
);
final progress = ref.watch(
activePlaybackProvider.select((s) => s.progress),
);
return Row( return Row(
children: [ children: [
Text(_fmtDur(position), style: TextStyle(color: TimbreColors.dimmed)), Text(_fmtDur(position),
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), style: TextStyle(color: TimbreColors.dimmed)), Text(_fmtDur(duration),
style: TextStyle(color: TimbreColors.dimmed)),
], ],
); );
} }
} }
class _Transport extends StatelessWidget { class _Transport extends StatelessWidget {
const _Transport({ const _Transport(
required this.state, {required this.state, required this.ref, required this.accent});
required this.ref,
required this.accent,
});
final PlaybackState state; final PlaybackState state;
final WidgetRef ref; final WidgetRef ref;
@ -723,34 +697,21 @@ class _Transport extends StatelessWidget {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
_btn( _btn(Icons.shuffle, controller.toggleShuffle,
Icons.shuffle, color: state.shuffle ? accent : TimbreColors.dimmed),
controller.toggleShuffle,
color: state.shuffle ? accent : TimbreColors.dimmed,
),
_btn(Icons.skip_previous, controller.previous), _btn(Icons.skip_previous, controller.previous),
_btn( _btn(state.playing ? Icons.pause : Icons.play_arrow,
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( _btn(loopIcon, controller.cycleLoop,
loopIcon, color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed),
controller.cycleLoop,
color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed,
),
], ],
); );
} }
Widget _btn( Widget _btn(IconData icon, VoidCallback onTap,
IconData icon, {Color? color, double size = 28}) {
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),
@ -762,8 +723,9 @@ 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, color: TimbreColors.dimmed, size: 48), child: Icon(Icons.album_outlined,
); color: TimbreColors.dimmed, size: 48),
);
} }
String _fmt(int? seconds) { String _fmt(int? seconds) {

View file

@ -1,14 +1,10 @@
import 'dart:io'; import 'package:flutter/widgets.dart' show NetworkImage;
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';
@ -65,16 +61,15 @@ class ConnectionState {
); );
} }
final credentialStoreProvider = Provider<CredentialStore>( final credentialStoreProvider =
(_) => CredentialStore(), Provider<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
/// between servers (one active at a time — TODO #2). /// between servers (one active at a time — TODO #2).
class ConnectionController extends StateNotifier<ConnectionState> { class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController(this._store) ConnectionController(this._store)
: super(const ConnectionState(status: ConnStatus.disconnected)) { : super(const ConnectionState(status: ConnStatus.disconnected)) {
_restore(); _restore();
} }
@ -211,10 +206,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
} }
} }
Future<void> _addOrUpdate( Future<void> _addOrUpdate(SubsonicCredentials creds,
SubsonicCredentials creds, { {required bool makeActive}) async {
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) {
@ -240,8 +233,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
final connectionProvider = final connectionProvider =
StateNotifierProvider<ConnectionController, ConnectionState>( StateNotifierProvider<ConnectionController, ConnectionState>(
(ref) => ConnectionController(ref.watch(credentialStoreProvider)), (ref) => ConnectionController(ref.watch(credentialStoreProvider)),
); );
/// The active client, or null when not connected. /// The active client, or null when not connected.
final subsonicClientProvider = Provider<SubsonicClient?>( final subsonicClientProvider = Provider<SubsonicClient?>(
@ -271,20 +264,9 @@ 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);
// Offline: synthesize the artist list from what's downloaded, so it if (client == null) return const [];
// 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;
}); });
@ -293,11 +275,7 @@ 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);
// Offline: synthesize the album grid from downloaded songs (watched if (client == null) return const [];
// 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;
@ -314,26 +292,23 @@ 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 = LibraryIndexController( final controller =
() => ref.read(subsonicClientProvider), LibraryIndexController(() => ref.read(subsonicClientProvider));
); ref.listen<ConnectionState>(connectionProvider, (_, _) {
ref.listen<ConnectionState>(connectionProvider, (_, _) { controller.onConnectionChanged();
controller.onConnectionChanged(); });
}); return controller;
return controller; });
});
// ---- 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 = StateProvider<BrowseFilter>( final albumFilterProvider =
(_) => const BrowseFilter(), StateProvider<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 = StateProvider<BrowseFilter>( final trackFilterProvider =
(_) => const BrowseFilter(), StateProvider<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) {
@ -357,36 +332,23 @@ 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. Falls back to the /// Distinct genres present across all indexed tracks.
/// 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 offline = ref.watch(subsonicClientProvider) == null; final songs = ref.watch(libraryIndexProvider).songs;
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 offline = ref.watch(subsonicClientProvider) == null; final songs = ref.watch(libraryIndexProvider).songs;
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 offline = ref.watch(subsonicClientProvider) == null; final songs = ref.watch(libraryIndexProvider).songs;
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);
@ -411,32 +373,18 @@ 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);
// Offline: rebuild the artist from downloaded songs. [id] is whatever if (client == null) throw StateError('Not connected');
// [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);
// Offline: rebuild the album from downloaded songs (see [artistProvider]). if (client == null) throw StateError('Not connected');
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 = FutureProvider.family<SearchResult3, String>(( final searchProvider =
ref, FutureProvider.family<SearchResult3, String>((ref, query) async {
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) {
@ -446,9 +394,7 @@ final searchProvider = FutureProvider.family<SearchResult3, String>((
// "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 return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result;
? 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]
@ -468,8 +414,8 @@ SearchResult3 filterSearchToStandard(SearchResult3 r, String query) {
final playHistoryProvider = final playHistoryProvider =
StateNotifierProvider<HistoryController, List<PlayRecord>>( StateNotifierProvider<HistoryController, List<PlayRecord>>(
(ref) => HistoryController(), (ref) => HistoryController(),
); );
final recentSongsProvider = Provider<List<PlayRecord>>( final recentSongsProvider = Provider<List<PlayRecord>>(
(ref) => recentSongs(ref.watch(playHistoryProvider)), (ref) => recentSongs(ref.watch(playHistoryProvider)),
@ -492,19 +438,18 @@ final rediscoverProvider = Provider<List<RediscoverArtist>>((ref) {
final favoritesProvider = final favoritesProvider =
StateNotifierProvider<FavoritesController, FavoritesState>((ref) { StateNotifierProvider<FavoritesController, FavoritesState>((ref) {
final controller = FavoritesController( final controller =
() => ref.read(subsonicClientProvider), FavoritesController(() => 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) { controller.hydrate();
controller.hydrate(); } else {
} else { controller.clear();
controller.clear(); }
} });
}); return controller;
return controller; });
});
/// Full starred set for the Favorites screen. /// Full starred set for the Favorites screen.
final starredProvider = FutureProvider<Starred2>((ref) async { final starredProvider = FutureProvider<Starred2>((ref) async {
@ -523,21 +468,21 @@ final starredProvider = FutureProvider<Starred2>((ref) async {
/// their status. Reloads its manifest whenever the server key changes. /// their status. Reloads its manifest whenever the server key changes.
final downloadManagerProvider = final downloadManagerProvider =
StateNotifierProvider<DownloadController, DownloadState>((ref) { StateNotifierProvider<DownloadController, DownloadState>((ref) {
final controller = DownloadController( final controller = DownloadController(
clientGetter: () => ref.read(subsonicClientProvider), clientGetter: () => ref.read(subsonicClientProvider),
settingsGetter: () => ref.read(settingsProvider), settingsGetter: () => ref.read(settingsProvider),
serverKeyGetter: () => ref.read(serverKeyProvider), serverKeyGetter: () => ref.read(serverKeyProvider),
); );
ref.listen<String?>(serverKeyProvider, (_, _) { ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer(); controller.reloadForServer();
}); });
// Raising the concurrency cap should launch queued downloads right away. // Raising the concurrency cap should launch queued downloads right away.
ref.listen<int>( ref.listen<int>(
settingsProvider.select((s) => s.maxConcurrentDownloads), settingsProvider.select((s) => s.maxConcurrentDownloads),
(_, _) => controller.onConcurrencyChanged(), (_, _) => controller.onConcurrencyChanged(),
); );
return controller; return controller;
}); });
// ---- Playlists ---------------------------------------------------------- // ---- Playlists ----------------------------------------------------------
@ -545,38 +490,34 @@ final downloadManagerProvider =
/// the server key changes (connect / disconnect / server switch). /// the server key changes (connect / disconnect / server switch).
final playlistsProvider = final playlistsProvider =
StateNotifierProvider<PlaylistsController, PlaylistsState>((ref) { StateNotifierProvider<PlaylistsController, PlaylistsState>((ref) {
final controller = PlaylistsController( final controller = PlaylistsController(
clientGetter: () => ref.read(subsonicClientProvider), clientGetter: () => ref.read(subsonicClientProvider),
serverKeyGetter: () => ref.read(serverKeyProvider), serverKeyGetter: () => ref.read(serverKeyProvider),
); );
ref.listen<String?>(serverKeyProvider, (_, _) { ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer(); controller.reloadForServer();
}); });
return controller; return controller;
}); });
/// 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>>( final realPlaylistsProvider = Provider<List<Playlist>>((ref) => ref
(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.
@ -597,53 +538,10 @@ 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 = StateNotifierProvider<PlaybackController, PlaybackState>(( final playbackProvider =
ref, StateNotifierProvider<PlaybackController, PlaybackState>((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) {
@ -651,23 +549,17 @@ final playbackProvider = StateNotifierProvider<PlaybackController, PlaybackState
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: rate, maxBitRate: ref.read(settingsProvider).streamMaxBitRate,
estimateContentLength: rate > 0,
); );
} }
// Prefer the local cached art file, then fall back to the server URL — this Uri? coverArtUriFor(Song s) {
// makes offline art work in Now Playing / the lock screen where a downloaded final client = ref.read(subsonicClientProvider);
// file exists. Null only when there's no id and no local/remote source. if (client == null || s.coverArt == null) return null;
Uri? coverArtUriFor(Song s) => return client.coverArtUri(s.coverArt!, size: 512);
resolveArtUri(ref, coverArt: s.coverArt, size: 512); }
final controller = PlaybackController( final controller = PlaybackController(
streamUriFor: streamUriFor, streamUriFor: streamUriFor,
@ -677,12 +569,7 @@ final playbackProvider = StateNotifierProvider<PlaybackController, PlaybackState
// 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;
// A resolved `file://` art URI (downloaded track) must load from disk, not final color = await extractAccent(NetworkImage(artUri.toString()));
// 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) {

View file

@ -302,25 +302,11 @@ 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.

View file

@ -1,95 +0,0 @@
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),
),
);
}

View file

@ -7,7 +7,6 @@ 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:
@ -45,18 +44,13 @@ class _CassetteViewState extends ConsumerState<CassetteView>
} }
void _onTick(Duration elapsed) { void _onTick(Duration elapsed) {
final dt = final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond;
(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( _model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress);
dt: dt,
playing: s.playing && s.supported,
progress: s.progress,
);
} }
@override @override
@ -124,7 +118,8 @@ class _CassetteViewState extends ConsumerState<CassetteView>
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _model, animation: _model,
child: cog, child: cog,
builder: (_, child) => Transform.rotate(angle: angle(), child: child), builder: (_, child) =>
Transform.rotate(angle: angle(), child: child),
), ),
); );
} }
@ -138,12 +133,15 @@ class _LabelArt extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Square art → wide label: crop the sides/top. A plain surface fill backs if (artUri == null) {
// the null/error cases (no album glyph here — the shell frames the label). return ColoredBox(color: TimbreColors.surface);
return ArtImage( }
artUri, return Image.network(
fit: BoxFit.cover, artUri!,
placeholder: ColoredBox(color: TimbreColors.surface), key: ValueKey(artUri),
fit: BoxFit.cover, // square art → wide label: crop the sides/top
gaplessPlayback: true,
errorBuilder: (_, _, _) => ColoredBox(color: TimbreColors.surface),
); );
} }
} }
@ -195,15 +193,9 @@ 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.leftReel, cfg.radius(model.progress, supply: true), tape);
cfg.radius(model.progress, supply: true),
tape,
);
canvas.drawCircle( canvas.drawCircle(
cfg.rightReel, cfg.rightReel, cfg.radius(model.progress, supply: false), tape);
cfg.radius(model.progress, supply: false),
tape,
);
canvas.restore(); canvas.restore();
} }
@ -253,8 +245,7 @@ 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 = final r2 = hubRadius * hubRadius +
hubRadius * hubRadius +
(fullRadius * fullRadius - hubRadius * hubRadius) * frac; (fullRadius * fullRadius - hubRadius * hubRadius) * frac;
return math.sqrt(r2); return math.sqrt(r2);
} }

View file

@ -3,7 +3,6 @@ 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.
@ -24,12 +23,11 @@ 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 = resolveArtUriW( final artUri = (client != null && current.coverArt != null)
ref, ? client.coverArtUri(current.coverArt!, size: 128).toString()
coverArt: current.coverArt, : null;
size: 128,
)?.toString();
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -47,16 +45,17 @@ class MiniPlayer extends ConsumerWidget {
SizedBox( SizedBox(
width: 40, width: 40,
height: 40, height: 40,
// Keep the mini player's darker `background` fill behind the child: ColoredBox(
// art (ArtImage's own fill is `surface`) by handing it a color: TimbreColors.background,
// background-tinted placeholder for the null/error cases. child: artUri != null
child: ArtImage( ? Image.network(
artUri, artUri,
fit: BoxFit.cover, key: ValueKey(artUri),
placeholder: ColoredBox( fit: BoxFit.cover,
color: TimbreColors.background, gaplessPlayback: true,
child: const _ArtFallback(), errorBuilder: (_, _, _) => const _ArtFallback(),
), )
: const _ArtFallback(),
), ),
), ),
const SizedBox(width: TimbreSpacing.md), const SizedBox(width: TimbreSpacing.md),
@ -84,11 +83,9 @@ class MiniPlayer extends ConsumerWidget {
), ),
), ),
_btn(Icons.skip_previous, controller.previous), _btn(Icons.skip_previous, controller.previous),
_btn( _btn(playing ? Icons.pause : Icons.play_arrow,
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),
], ],
), ),
@ -114,9 +111,7 @@ class _MiniProgress extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch( final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
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,
@ -134,6 +129,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, color: TimbreColors.dimmed, size: 22), child: Icon(Icons.album_outlined,
); color: TimbreColors.dimmed, size: 22),
);
} }

View file

@ -1,184 +0,0 @@
# 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.

View file

@ -1,173 +0,0 @@
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);
});
}