From 0a7af1d8136cfda93f909ace3a8a1341974a2418 Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 6 Aug 2026 21:32:28 -0400 Subject: [PATCH 1/4] edits --- eq-implementation-plan.md | 245 ++++++++++++++++ lib/main.dart | 8 + lib/playback/playback_engine.dart | 7 +- lib/remote/discovery.dart | 280 ++++++++++++++++--- lib/state/remote_providers.dart | 9 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + packaging/build_deb.sh | 113 ++++++++ pubspec.lock | 104 +++++++ pubspec.yaml | 7 + 10 files changed, 743 insertions(+), 35 deletions(-) create mode 100644 eq-implementation-plan.md create mode 100755 packaging/build_deb.sh diff --git a/eq-implementation-plan.md b/eq-implementation-plan.md new file mode 100644 index 0000000..7bc911a --- /dev/null +++ b/eq-implementation-plan.md @@ -0,0 +1,245 @@ +# Parametric EQ — Implementation Plan (native DSP + vendored just_audio) + +## Context + +Timbre has no built-in EQ; users listening on headphones without a hardware EQ box +have no way to shape tone or apply AutoEq headphone-correction profiles. We want a +**true parametric EQ** (per-band adjustable frequency / Q / gain, i.e. the AutoEq +`PK Fc/Gain/Q` model) that works on **both iOS and Android**, while **keeping the +existing lock-screen / notification / Control Center transport** that +`just_audio_background` provides today. + +### Why this architecture + +- `just_audio` 0.10.6 ships **no parametric EQ**. Its only effect classes are + `AndroidEqualizer` (a graphic, gain-only, Android-only system EQ) and + `AndroidLoudnessEnhancer`; `DarwinAudioEffect` is an empty marker mixin + (`just_audio.dart:4392`) with no iOS implementation. Custom `AudioEffect` + subclasses aren't possible from outside the package (the wiring is package-private). +- `flutter_soloud` *does* offer a cross-platform parametric EQ, but it is a bare + audio engine with **no media-session integration** — adopting it means rewriting + the playback engine **and** rebuilding lock-screen/notification controls via a + hand-written `audio_service` bridge. Rejected: we must keep background controls. +- Therefore: keep `just_audio` + `just_audio_background` untouched at the app level, + and inject a custom biquad DSP into `just_audio`'s native audio pipeline on each + platform. This preserves the entire existing engine (queue, 100-track windowing, + streaming, error recovery in `lib/playback/playback_engine.dart`). + +### Core design decision + +Compute **RBJ-cookbook biquad coefficients once in Dart** and push them to a "dumb" +native biquad-cascade processor on each platform. This keeps all filter math in one +place (Dart, unit-testable) and makes the native code identical in concept across +platforms — it just runs `y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2` per section, per +channel. The EQ lives at the **audio-sink / output stage**, so it is independent of +source windowing and survives track/window swaps automatically. + +`just_audio` exposes **no hook** to inject a processor, so we must **vendor `just_audio` +0.10.6 as a path (or git-fork) dependency** and patch its native source. The patch is +small and localized on each platform (see below). + +--- + +## Confirmed native injection points + +**Android** (`android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java`): +- `ensurePlayerInitialized()` builds ExoPlayer via a custom `RenderersFactory` lambda + at **lines 779–786**, wrapping `new DefaultRenderersFactory(context)`. +- Patch: replace that with a `DefaultRenderersFactory` subclass overriding + `buildAudioSink(...)` to return + `new DefaultAudioSink.Builder(context).setAudioProcessors(new AudioProcessor[]{ biquadProcessor }).build()`. +- `biquadProcessor` implements `androidx.media3.common.audio.AudioProcessor`, running + the cascaded biquads on the PCM buffer. Coefficients + enabled flag are set on it + live via a new method-channel handler in `MainMethodCallHandler.java` / + `JustAudioPlugin.java`. + +**iOS / macOS** (`darwin/just_audio/Sources/just_audio/`): +- Playback is AVPlayer-based; player items are created/inserted in `AudioPlayer.m` + (~lines 513–570) and modeled by `IndexedPlayerItem.m`. +- Patch: attach an `AVMutableAudioMix` carrying an `MTAudioProcessingTap` to each + `IndexedPlayerItem`'s audio track. The tap's `process` callback runs the same + biquad cascade on the PCM. Coefficients pushed via the plugin's method channel and + read by the tap (double-buffered / lock-free swap). +- **KNOWN RISK — must de-risk first (Phase 0):** `MTAudioProcessingTap` does **not** + fire for HLS remote streams. Subsonic uses progressive HTTP (not HLS), so the tap + *should* fire, but this is the single biggest unknown. Fallback if it doesn't: + rewrite the darwin backend to `AVAudioEngine` + `AVAudioUnitEQ` (which has native + `.parametric` bands) — substantially larger, so we validate before committing. + +**Unsupported targets:** gate exactly like the engine's existing +`_audioSupported` (`playback_engine.dart:190-191`, Android/iOS/macOS). On Linux/tests +the EQ is a no-op; the UI still renders and persists settings. + +--- + +## Phase 0 — De-risk (do this before anything else) + +Small throwaway spikes against the vendored fork: + +1. **iOS tap spike:** vendor `just_audio`, add a trivial pass-through (or fixed + −6 dB gain) `MTAudioProcessingTap` to player items, play a real Subsonic + **stream** URL (not a local file), and confirm the callback fires and audio is + audibly altered. Test both original and transcoded (mp3/opus) streams. This + validates or kills the primary architecture. +2. **Android sink spike:** vendor `just_audio`, add a pass-through `AudioProcessor` + via the `buildAudioSink` override, confirm audio plays and the processor receives + buffers. Confirm `just_audio_background` still shows notification controls. +3. **Fork maintainability:** confirm the vendored package builds cleanly for both + platforms in CI (`codemagic.yaml`) and document the pin (exact 0.10.6 base + + our patch) so future `just_audio` upgrades are a deliberate re-patch. + +Exit criteria: EQ demonstrably alters audio on a real device on both platforms while +lock-screen controls still work. If the iOS tap fails on streams, revisit the +AVAudioEngine fallback (or reconsider `flutter_soloud`) before proceeding. + +--- + +## Phase 1 — DSP core in Dart (pure, unit-tested) + +New file `lib/eq/biquad.dart`: +- `EqBandType { peaking, lowShelf, highShelf }`. +- `EqBand { EqBandType type; double freqHz; double q; double gainDb; }` (immutable). +- `BiquadCoeffs { double b0,b1,b2,a1,a2; }` (a0-normalized). +- `BiquadCoeffs coeffsFor(EqBand band, int sampleRate)` — RBJ Audio-EQ-Cookbook + formulas for peaking/low-shelf/high-shelf. +- Master **preamp** (dB → linear gain) applied as a final scalar. + +New file `lib/eq/autoeq.dart`: +- `List parseAutoEqProfile(String text)` — parses AutoEq `ParametricEQ.txt`: + `Preamp: -6.0 dB` and `Filter 1: ON PK Fc 105 Hz Gain -2.0 dB Q 0.70` lines + (PK→peaking, LSC→lowShelf, HSC→highShelf). Ignore `OFF` filters. + +Unit tests in `test/eq_biquad_test.dart`, `test/eq_autoeq_test.dart` (mirrors the +existing `test/` style): verify coefficients against known reference values (e.g. a +0 dB peaking filter → identity `b0=1,b1=0,b2=0,a1=0,a2=0` after normalization; a known +peaking case against hand-computed values) and AutoEq parsing of a real profile. + +--- + +## Phase 2 — Persistence (extend the existing settings store) + +Extend `AppSettings` in `lib/settings/settings_store.dart` following its exact +conventions (immutable + `copyWith` with the `_unset` sentinel + `toJson`/`fromJson`, +enums by `.name`): +- `bool eqEnabled` (default `false`) +- `double eqPreampDb` (default `0`) +- `List eqBands` (default a sensible starter set, e.g. 5–10 peaking bands at + ISO centers 31/62/125/250/500/1k/2k/4k/8k/16k with 0 dB gain) +- JSON: bands serialize as a list of `{type, freqHz, q, gainDb}` maps. + +Add `SettingsController` setters mirroring the existing ones (each does +`state = state.copyWith(...); _persist();`): +`setEqEnabled`, `setEqPreampDb`, `setEqBand(int index, EqBand)`, `setEqBands`, +`resetEq`. Static helpers for default band lists + labels follow the file's +`bitrateChoices`/`bitrateLabel` convention. + +--- + +## Phase 3 — Dart↔native bridge + engine wiring + +New file `lib/eq/eq_bridge.dart`: +- A thin `MethodChannel('com.laforrestchurch.timbre/eq')` wrapper: + `Future setEnabled(bool)`, `Future setCoeffs(List, double preampLinear)`. +- Recompute coefficients whenever bands/preamp change and push them; the native side + swaps them atomically. +- Platform-gated: no-op where `!_audioSupported`. + +Wire into playback in `lib/state/providers.dart` (mirrors how `streamMaxBitRate` is +read and how concurrency changes are reacted to at lines 480–483): +- In `playbackProvider`, after constructing the controller, `ref.listen` on + `settingsProvider.select((s) => (s.eqEnabled, s.eqBands, s.eqPreampDb))` and push + updated coefficients through `eq_bridge`. +- Sample rate: the biquad cache assumes a nominal rate (44.1/48 kHz); the native + processor recomputes/accepts coefficients per its actual output rate — pass the + rate up from native on format change, or compute for the common rate and accept the + minor center-frequency drift on hi-res (document the choice). +- Note the **remote-playback scope**: EQ applies on the device actually outputting + audio (the local engine), consistent with `activePlaybackProvider` / + `playbackCommandsProvider`. No change needed for the remote path. + +Native method-channel handlers added to the vendored plugin (Android +`MainMethodCallHandler.java`; iOS `JustAudioPlugin.m`), each forwarding +enabled/coeffs to the biquad processor/tap. + +--- + +## Phase 4 — UI (matches the app's design language) + +New file `lib/screens/equalizer_screen.dart`, pushed as a full sub-screen via +`Navigator.of(context).push(MaterialPageRoute(...))` (same as `SettingsScreen` from +`lib/shell/app_shell.dart:287`): +- Reuse `HairlinePanel` (`lib/widgets/hairline_panel.dart`), `TimbreColors`, + `TimbreSpacing`, JetBrains Mono. +- An **enable toggle** modeled as the existing `_ChoiceChips` On/Off pattern + (`settings_screen.dart:141`). +- A **live EQ curve** painted with a `CustomPainter` (sum of band magnitude responses + in dB across log-frequency), styled with `TimbreColors.accent`/`border` — visually + a sibling of `block_progress_bar.dart`. +- **Per-band controls**: a new custom control (there is no Material `Slider` in the + app). Cheapest on-brand option = reuse `_Stepper` semantics for discrete dB/freq/Q + steps; nicer option = a custom vertical gain slider built from the token + primitives (`InkWell`+`Container`, `minTouchTarget`). Start with steppers, upgrade + later if desired. +- **Master preamp** stepper. +- **Import AutoEq profile** affordance (paste text / file pick) → `parseAutoEqProfile` + → `setEqBands` + `setEqPreampDb`. **Reset** button → `resetEq`. + +Entry points: +- **Settings**: a new `HairlinePanel(title: 'Equalizer')` row in + `lib/screens/settings_screen.dart` (place after "Downloads", ~line 95) whose tap + pushes `EqualizerScreen` — model the tappable row on the existing "Add server" + `InkWell` (`settings_screen.dart:225`). +- **Now Playing**: an EQ button in the bottom-bar row (`now_playing_screen.dart` + ~159–174), styled exactly like `_RemoteButton` (`now_playing_screen.dart:583`), + opening the same screen. + +--- + +## Files to create / modify + +**Create:** `lib/eq/biquad.dart`, `lib/eq/autoeq.dart`, `lib/eq/eq_bridge.dart`, +`lib/screens/equalizer_screen.dart`, `test/eq_biquad_test.dart`, +`test/eq_autoeq_test.dart`. + +**Modify (app):** `lib/settings/settings_store.dart` (EQ fields + setters), +`lib/state/providers.dart` (listen + push coeffs), `lib/screens/settings_screen.dart` +(entry row), `lib/screens/now_playing_screen.dart` (EQ button), `pubspec.yaml` +(point `just_audio` at the vendored fork). + +**Modify (vendored `just_audio` fork):** +- Android: `AudioPlayer.java` (~779–786, sink override), new + `BiquadAudioProcessor.java`, `MainMethodCallHandler.java` (channel). +- iOS/macOS: `AudioPlayer.m` / `IndexedPlayerItem.m` (audio mix + tap), new + `BiquadTap.m/.h`, `JustAudioPlugin.m` (channel). + +**Build config:** Android minSdk stays at current (biquad AudioProcessor needs no new +API); iOS deployment target 15.0 is fine (`MTAudioProcessingTap` since iOS 6). +`codemagic.yaml` must build the vendored fork; pin/document the fork revision. + +--- + +## Verification + +- **Unit:** `flutter test` — coefficient math (identity + known cases) and AutoEq + parsing. +- **Manual (both platforms, real device):** + 1. Play a Subsonic **stream**; toggle EQ on/off — audible change, no dropouts. + 2. Set a strong low-shelf boost / narrow peaking cut and confirm by ear +, if + possible, a spectrum-analyzer app on a sine sweep. + 3. Import a real AutoEq `ParametricEQ.txt` and confirm the curve + preamp apply. + 4. Confirm lock-screen / notification / Control Center controls still work with EQ + active, and that EQ persists across app restart and survives track changes and + 100-track window slides. +- **Regression:** existing `test/` suite still green; playback error-recovery and + gapless behavior unchanged. + +## Biggest risks (watch these) + +1. **iOS `MTAudioProcessingTap` on remote streams** — validated in Phase 0; AVAudioEngine + fallback if it fails. +2. **Vendoring `just_audio`** — future upstream upgrades require re-applying the patch; + pin the base version and keep the diff minimal/documented. +3. **Sample-rate handling** for hi-res (up to 192 kHz) — decide coefficient recompute + vs. fixed-rate approximation and document it. +4. **CPU cost** of many biquads per channel on low-end devices — keep default band + count modest (≤10) and profile. diff --git a/lib/main.dart b/lib/main.dart index 83f5eef..0e0f186 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio_background/just_audio_background.dart'; +import 'package:just_audio_media_kit/just_audio_media_kit.dart'; import 'settings/settings_store.dart'; import 'shell/app_shell.dart'; @@ -15,6 +16,13 @@ import 'widgets/splash_screen.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // Desktop (Linux/Windows) has no native just_audio backend; route playback + // through libmpv via media_kit. Audio then lands on the system mixer + // (PipeWire/PulseAudio), where a system EQ (e.g. EasyEffects) can shape it. + // macOS/iOS/Android keep just_audio's own native backends. + if (!kIsWeb && (Platform.isLinux || Platform.isWindows)) { + JustAudioMediaKit.ensureInitialized(linux: true, windows: true); + } // Lock-screen / notification transport is only available where audio_service // has a backend — Android/iOS. Skipping init elsewhere keeps the Linux dev // target (and tests) running. diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index 4cf7c6f..a9d88e5 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -188,7 +188,12 @@ class PlaybackController extends StateNotifier String? _restoredKey; static bool get _audioSupported => - !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); + !kIsWeb && + (Platform.isAndroid || + Platform.isIOS || + Platform.isMacOS || + Platform.isLinux || + Platform.isWindows); /// The current state, for out-of-widget consumers (e.g. the remote host, /// which reads it to send a newly-connected remote an immediate snapshot). diff --git a/lib/remote/discovery.dart b/lib/remote/discovery.dart index 827ce4c..886e291 100644 --- a/lib/remote/discovery.dart +++ b/lib/remote/discovery.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:nsd/nsd.dart'; import 'messages.dart'; @@ -33,42 +35,104 @@ class DiscoveredDevice { String get id => name; } -/// Wraps `nsd` mDNS registration (advertise) + discovery (browse) behind a -/// small, app-shaped API. One instance owns at most one active registration -/// and one active browse at a time. +/// mDNS registration (advertise) + discovery (browse) behind a small, +/// app-shaped API. One instance owns at most one active registration and one +/// active browse at a time. /// /// Advertising and browsing are independent: a device that is playing /// advertises so others can find it, and any device can browse to control one. /// TXT records carry the [serverKey] and protocol version so the browser can /// filter to same-server, same-version peers before ever opening a socket. -class RemoteDiscovery { - static const _kServerKey = 'sk'; - static const _kVersion = 'v'; +/// +/// There are two backends behind this interface, chosen by [RemoteDiscovery]'s +/// factory: `nsd` (Bonjour/NSD) on Android/iOS/macOS/Windows, and `bonsoir` +/// (Avahi over D-Bus) on Linux — `nsd` has no Linux implementation. Both speak +/// the same DNS-SD wire format, so a Linux host and a phone controller +/// interoperate. +abstract class RemoteDiscovery { + factory RemoteDiscovery() { + if (!kIsWeb && Platform.isLinux) return _BonsoirDiscovery(); + return _NsdDiscovery(); + } - Registration? _registration; - Discovery? _discovery; - ServiceListener? _listener; + /// Latest discovered, filtered device list (also replayed as a stream). + List get current; + Stream> get devices; + + bool get isAdvertising; + bool get isBrowsing; + + /// Publish this device as a controllable player on [port]. Replaces any + /// existing advertisement. + Future advertise({ + required String deviceName, + required int port, + required String serverKey, + }); + + Future stopAdvertising(); + + /// Start browsing for players on [serverKey]'s network. Emits filtered + /// [DiscoveredDevice] lists on [devices]. Replaces any existing browse. + Future startBrowsing(String serverKey); + + Future stopBrowsing(); + + Future dispose(); +} + +/// TXT-record keys shared by both backends' wire format. +const _kServerKey = 'sk'; +const _kVersion = 'v'; + +/// Stream plumbing + peer filtering shared by both backends. Keeps the +/// same-server / same-version / not-ourselves rules in one place so the two +/// implementations only differ in how they talk to their platform. +mixin _DiscoveryState { + final StreamController> _devices = + StreamController>.broadcast(); + List _current = const []; /// The final (possibly conflict-renamed) name of our own advertisement, so /// the browser can filter it out of its own results. String? _ownName; String? _browseServerKey; - final StreamController> _devices = - StreamController>.broadcast(); - List _current = const []; - - /// Latest discovered, filtered device list (also replayed as a stream). List get current => _current; Stream> get devices => _devices.stream; + bool _accepts(DiscoveredDevice d) { + if (_browseServerKey != null && d.serverKey != _browseServerKey) { + return false; // different Subsonic server — its song ids won't resolve here + } + if (d.version != kRemoteProtocolVersion) return false; + if (_ownName != null && d.name == _ownName) return false; // ourselves + return true; + } + + /// Filter [all] to controllable peers and publish the result. + void _emit(Iterable all) { + _current = all.where(_accepts).toList(); + if (!_devices.isClosed) _devices.add(_current); + } + + Future _closeDevices() => _devices.close(); +} + +/// `nsd`-backed discovery for Android/iOS/macOS/Windows. +class _NsdDiscovery with _DiscoveryState implements RemoteDiscovery { + Registration? _registration; + Discovery? _discovery; + ServiceListener? _listener; + + @override bool get isAdvertising => _registration != null; + @override bool get isBrowsing => _discovery != null; // ---- Advertise -------------------------------------------------------- - /// Publish this device as a controllable player on [port]. Replaces any - /// existing advertisement. + @override Future advertise({ required String deviceName, required int port, @@ -88,6 +152,7 @@ class RemoteDiscovery { _ownName = reg.service.name; } + @override Future stopAdvertising() async { final reg = _registration; _registration = null; @@ -103,8 +168,7 @@ class RemoteDiscovery { // ---- Browse ----------------------------------------------------------- - /// Start browsing for players on [serverKey]'s network. Emits filtered - /// [DiscoveredDevice] lists on [devices]. Replaces any existing browse. + @override Future startBrowsing(String serverKey) async { await stopBrowsing(); _browseServerKey = serverKey; @@ -121,6 +185,7 @@ class RemoteDiscovery { _rebuild(); } + @override Future stopBrowsing() async { final discovery = _discovery; final listener = _listener; @@ -138,28 +203,17 @@ class RemoteDiscovery { } } + @override Future dispose() async { await stopAdvertising(); await stopBrowsing(); - await _devices.close(); + await _closeDevices(); } void _rebuild() { final discovery = _discovery; if (discovery == null) return; - final out = []; - for (final s in discovery.services) { - final dev = _toDevice(s); - if (dev == null) continue; - if (_browseServerKey != null && dev.serverKey != _browseServerKey) { - continue; // different Subsonic server — its song ids won't resolve here - } - if (dev.version != kRemoteProtocolVersion) continue; - if (_ownName != null && s.name == _ownName) continue; // ourselves - out.add(dev); - } - _current = out; - if (!_devices.isClosed) _devices.add(out); + _emit(discovery.services.map(_toDevice).whereType()); } DiscoveredDevice? _toDevice(Service s) { @@ -198,3 +252,165 @@ class RemoteDiscovery { return utf8.decode(v, allowMalformed: true); } } + +/// `bonsoir`-backed discovery for Linux (Avahi over D-Bus). `nsd` ships no +/// Linux implementation, so this backend fills the gap while speaking the same +/// DNS-SD service type and TXT keys as [_NsdDiscovery]. +/// +/// Requires the Avahi daemon to be running; if it isn't, advertise/browse +/// simply surface no peers rather than crashing. +class _BonsoirDiscovery with _DiscoveryState implements RemoteDiscovery { + BonsoirBroadcast? _broadcast; + StreamSubscription? _broadcastSub; + + BonsoirDiscovery? _discovery; + StreamSubscription? _discoverySub; + + /// Resolved peers keyed by mDNS instance name; browsing emits found events + /// (no address yet) followed by resolved/updated events that fill in the IP. + final Map _found = {}; + + @override + bool get isAdvertising => _broadcast != null; + @override + bool get isBrowsing => _discovery != null; + + // ---- Advertise -------------------------------------------------------- + + @override + Future advertise({ + required String deviceName, + required int port, + required String serverKey, + }) async { + await stopAdvertising(); + final service = BonsoirService( + name: deviceName, + type: kRemoteServiceType, + port: port, + attributes: { + _kServerKey: serverKey, + _kVersion: '$kRemoteProtocolVersion', + }, + ); + final broadcast = BonsoirBroadcast(service: service); + await broadcast.initialize(); + // Avahi renames on conflict; track the name it actually published so the + // browser can filter our own advertisement out. + _ownName = service.name; + _broadcastSub = broadcast.eventStream?.listen((event) { + if (event is BonsoirBroadcastStartedEvent) { + _ownName = event.service.name; + } + }); + await broadcast.start(); + _broadcast = broadcast; + } + + @override + Future stopAdvertising() async { + await _broadcastSub?.cancel(); + _broadcastSub = null; + final broadcast = _broadcast; + _broadcast = null; + _ownName = null; + if (broadcast != null) { + try { + await broadcast.stop(); + } catch (_) { + // Already gone / Avahi hiccup — nothing to recover. + } + } + } + + // ---- Browse ----------------------------------------------------------- + + @override + Future startBrowsing(String serverKey) async { + await stopBrowsing(); + _browseServerKey = serverKey; + _found.clear(); + final BonsoirDiscovery discovery; + try { + discovery = BonsoirDiscovery(type: kRemoteServiceType); + await discovery.initialize(); + } catch (_) { + // Avahi unavailable (daemon not running / no D-Bus) — degrade to an + // empty device list rather than surfacing an uncaught async error. + _browseServerKey = null; + return; + } + _discovery = discovery; + _discoverySub = discovery.eventStream?.listen((event) { + switch (event) { + case BonsoirDiscoveryServiceFoundEvent(): + // Found but not yet resolved — ask for its address/TXT records. + event.service.resolve(discovery.serviceResolver); + case BonsoirDiscoveryServiceResolvedEvent(:final service) || + BonsoirDiscoveryServiceUpdatedEvent(:final service): + final dev = _toDevice(service); + if (dev != null) { + _found[service.name] = dev; + _emit(_found.values); + } + case BonsoirDiscoveryServiceLostEvent(): + _found.remove(event.service.name); + _emit(_found.values); + default: + break; + } + }); + try { + await discovery.start(); + } catch (_) { + await stopBrowsing(); // tear down the half-started browse cleanly + return; + } + _emit(_found.values); + } + + @override + Future stopBrowsing() async { + await _discoverySub?.cancel(); + _discoverySub = null; + _browseServerKey = null; + _found.clear(); + _current = const []; + final discovery = _discovery; + _discovery = null; + if (discovery != null) { + try { + await discovery.stop(); + } catch (_) { + // Already stopped. + } + } + } + + @override + Future dispose() async { + await stopAdvertising(); + await stopBrowsing(); + await _closeDevices(); + } + + DiscoveredDevice? _toDevice(BonsoirService s) { + final host = _hostOf(s); + if (host == null) return null; // unresolved (no address yet) + return DiscoveredDevice( + name: s.name, + host: host, + port: s.port, + serverKey: s.attributes[_kServerKey] ?? '', + version: int.tryParse(s.attributes[_kVersion] ?? '') ?? 0, + ); + } + + /// Prefer a routable IPv4 address; Avahi may also report IPv6 (which contain + /// colons) or link-local addresses we can't dial cleanly. + static String? _hostOf(BonsoirService s) { + final addrs = s.hostAddresses; + if (addrs.isEmpty) return null; + return addrs.firstWhere((a) => !a.contains(':'), orElse: () => addrs.first); + } +} diff --git a/lib/state/remote_providers.dart b/lib/state/remote_providers.dart index 074cfc0..f215504 100644 --- a/lib/state/remote_providers.dart +++ b/lib/state/remote_providers.dart @@ -24,7 +24,7 @@ class RemoteControlState { this.remoteState, }); - /// Whether this platform can host/discover at all (mobile + macOS). + /// Whether this platform can host/discover at all (mobile, macOS, Linux). final bool supported; /// This device is published as a controllable player. @@ -114,7 +114,11 @@ class RemoteControlController extends StateNotifier { PlaybackCommands? get remoteCommands => _proxy; static bool _supported() => - !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); + !kIsWeb && + (Platform.isAndroid || + Platform.isIOS || + Platform.isMacOS || + Platform.isLinux); /// Mutate state only while still mounted. Async hosting/session callbacks can /// resolve after the provider is disposed (e.g. app teardown); touching @@ -274,6 +278,7 @@ class RemoteControlController extends StateNotifier { if (Platform.isIOS) return 'iOS device'; if (Platform.isAndroid) return 'Android device'; if (Platform.isMacOS) return 'Mac'; + if (Platform.isLinux) return 'Linux'; return 'Timbre'; } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..1c10bb6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index ce58916..9fc3985 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + media_kit_libs_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packaging/build_deb.sh b/packaging/build_deb.sh new file mode 100755 index 0000000..7ebc9a8 --- /dev/null +++ b/packaging/build_deb.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Build a Debian/Ubuntu .deb for Timbre from the Flutter Linux release bundle. +# +# Works even on non-Debian hosts (e.g. Arch): a .deb is an `ar` archive of +# `debian-binary` + `control.tar.gz` + `data.tar.gz`, so we assemble it with +# `ar` + `tar` and never need `dpkg-deb`. +# +# Runtime deps are declared in the control file below. On Linux, media_kit +# loads the *system* libmpv at runtime (it is NOT bundled), so libmpv is a hard +# dependency. libsecret backs flutter_secure_storage; GTK backs the embedder. +# +# Usage: packaging/build_deb.sh [version] [arch] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +VERSION="${1:-$(grep -m1 '^version:' pubspec.yaml | sed -E 's/version:\s*([0-9.]+).*/\1/')}" +ARCH="${2:-amd64}" +APPID="com.laforrestchurch.timbre" +BUNDLE="build/linux/x64/release/bundle" +OUT="build/deb" +PKGDIR="$OUT/timbre_${VERSION}_${ARCH}" + +if [[ ! -x "$BUNDLE/timbre" ]]; then + echo "Release bundle not found at $BUNDLE — run: flutter build linux --release" >&2 + exit 1 +fi + +echo "==> Staging package tree ($VERSION / $ARCH)" +rm -rf "$PKGDIR" +mkdir -p "$PKGDIR/opt/timbre" \ + "$PKGDIR/usr/bin" \ + "$PKGDIR/usr/share/applications" \ + "$PKGDIR/usr/share/pixmaps" \ + "$PKGDIR/usr/share/icons/hicolor/512x512/apps" \ + "$PKGDIR/DEBIAN" + +# App payload → /opt/timbre; /usr/bin/timbre is a symlink (Flutter resolves +# lib/ and data/ relative to the resolved executable path via /proc/self/exe). +cp -r "$BUNDLE/." "$PKGDIR/opt/timbre/" +ln -sf /opt/timbre/timbre "$PKGDIR/usr/bin/timbre" + +# Icon (into pixmaps for universal fallback + hicolor for modern launchers). +cp assets/icon/icon.png "$PKGDIR/usr/share/pixmaps/timbre.png" +cp assets/icon/icon.png "$PKGDIR/usr/share/icons/hicolor/512x512/apps/timbre.png" + +cat > "$PKGDIR/usr/share/applications/${APPID}.desktop" < "$PKGDIR/DEBIAN/control" < +Installed-Size: ${INSTALLED_KB} +Depends: libgtk-3-0 | libgtk-3-0t64, libmpv2 | libmpv1, libsecret-1-0 +Recommends: avahi-daemon +Description: Timbre — a Subsonic music client + A Subsonic client for streaming your music library. Audio is played through + libmpv, so it lands on the system mixer (PipeWire/PulseAudio) where a system + equalizer such as EasyEffects can shape it. +EOF + +# postinst/postrm: refresh desktop + icon caches when the tools exist (no-op +# otherwise). Non-fatal so install never breaks on a minimal system. +cat > "$PKGDIR/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q /usr/share/icons/hicolor || true +fi +EOF +cp "$PKGDIR/DEBIAN/postinst" "$PKGDIR/DEBIAN/postrm" +chmod 0755 "$PKGDIR/DEBIAN/postinst" "$PKGDIR/DEBIAN/postrm" + +# md5sums for every payload file (relative paths, no ./ prefix — dpkg style). +( cd "$PKGDIR" && find opt usr -type f -print0 | xargs -0 md5sum > DEBIAN/md5sums ) + +echo "==> Assembling .deb" +BUILD="$OUT/_assemble" +rm -rf "$BUILD"; mkdir -p "$BUILD" +echo "2.0" > "$BUILD/debian-binary" +# control.tar.gz from DEBIAN/ contents; data.tar.gz from the filesystem tree. +tar --owner=0 --group=0 --numeric-owner -czf "$BUILD/control.tar.gz" -C "$PKGDIR/DEBIAN" . +tar --owner=0 --group=0 --numeric-owner -czf "$BUILD/data.tar.gz" \ + -C "$PKGDIR" --exclude=./DEBIAN . + +DEB="$OUT/timbre_${VERSION}_${ARCH}.deb" +rm -f "$DEB" +# Member order is mandated by the .deb format: debian-binary, control, data. +( cd "$BUILD" && ar rc "$OLDPWD/$DEB" debian-binary control.tar.gz data.tar.gz ) +rm -rf "$BUILD" + +echo "==> Built: $DEB" +ls -lh "$DEB" +echo "==> Contents (ar members):" && ar t "$DEB" diff --git a/pubspec.lock b/pubspec.lock index ea8722e..95d6a96 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.4" + bonsoir: + dependency: "direct main" + description: + name: bonsoir + sha256: "86a8f55539d29c17ac2cb8a16698978aaa9e7c5d14139fb2234dad33b6ecfc33" + url: "https://pub.dev" + source: hosted + version: "7.1.4" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: c6413e82150074d56e71ffe2e54bb1dfe66a59310affc4486d33e9eee6b362e6 + url: "https://pub.dev" + source: hosted + version: "7.1.2" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "04425a8657e3131683c7966689d4e65e114cb5181de94aac89d2fba84cfaaca4" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: "9afae7bb9509c5bd20a0ea2ca02a0f2b1ef09cca1ceb2ea288ed58a0f03d1e11" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "6c55c786b01ad279f675ae72cea3a885aa746e416d2e8c1f0c46e9d2c06420b0" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: aaabbb652fe68d3eb26a9b2cddefaae26cd9ca6ebe737d476ab9ed535c7c2516 + url: "https://pub.dev" + source: hosted + version: "7.3.0" boolean_selector: dependency: transitive description: @@ -129,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" dio: dependency: "direct main" description: @@ -384,6 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.1-beta.17" + just_audio_media_kit: + dependency: "direct main" + description: + name: just_audio_media_kit + sha256: f3cf04c3a50339709e87e90b4e841eef4364ab4be2bdbac0c54cc48679f84d23 + url: "https://pub.dev" + source: hosted + version: "2.1.0" just_audio_platform_interface: dependency: transitive description: @@ -456,6 +520,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + media_kit: + dependency: transitive + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_linux: + dependency: "direct main" + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" meta: dependency: transitive description: @@ -688,6 +768,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" sky_engine: dependency: transitive description: flutter @@ -805,6 +893,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e643068..7220331 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,9 +43,16 @@ dependencies: collection: ^1.19.1 just_audio: ^0.10.6 just_audio_background: ^0.0.1-beta.17 + # Linux/Windows playback backend for just_audio (libmpv via media_kit). + # Android/iOS/macOS use just_audio's own native backends. + just_audio_media_kit: ^2.1.0 + media_kit_libs_linux: any palette_generator: ^0.3.3+7 flutter_svg: ^2.3.0 nsd: ^5.0.1 + # mDNS on Linux: `nsd` has no Linux backend, so cross-device control there + # goes through bonsoir (Avahi over D-Bus). Other platforms keep using nsd. + bonsoir: ^7.1.4 dev_dependencies: flutter_test: From db33f0764b5c85969a731aca22303c9a4dfe8d91 Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 13 Aug 2026 11:29:11 -0400 Subject: [PATCH 2/4] streaming edits --- lib/playback/playback_engine.dart | 53 +++++++++++++++++++++++-------- lib/state/providers.dart | 8 ++++- lib/subsonic/subsonic_client.dart | 16 +++++++++- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index a9d88e5..b2c8216 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -32,6 +32,7 @@ class PlaybackState { this.queue = const [], this.currentIndex, this.playing = false, + this.buffering = false, this.position = Duration.zero, this.duration = Duration.zero, this.shuffle = false, @@ -43,6 +44,13 @@ class PlaybackState { final List queue; final int? currentIndex; 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 duration; final bool shuffle; @@ -83,6 +91,7 @@ class PlaybackState { List? queue, int? currentIndex, bool? playing, + bool? buffering, Duration? position, Duration? duration, bool? shuffle, @@ -95,6 +104,7 @@ class PlaybackState { queue: queue ?? this.queue, currentIndex: currentIndex ?? this.currentIndex, playing: playing ?? this.playing, + buffering: buffering ?? this.buffering, position: position ?? this.position, duration: duration ?? this.duration, shuffle: shuffle ?? this.shuffle, @@ -215,7 +225,12 @@ class PlaybackController extends StateNotifier _maybeSlideWindow(); }); player.playerStateStream.listen((s) { - state = state.copyWith(playing: s.playing); + final ps = s.processingState; + state = state.copyWith( + playing: s.playing, + buffering: ps == ProcessingState.loading || + ps == ProcessingState.buffering, + ); }); player.positionStream.listen((p) { state = state.copyWith(position: p); @@ -306,20 +321,32 @@ class PlaybackController extends StateNotifier AudioSource _sourceFor(Song song) { final uri = _streamUriFor(song)!; - if (!uri.isScheme('file')) _remoteSourceIds.add(song.id); + final isRemote = !uri.isScheme('file'); + if (isRemote) _remoteSourceIds.add(song.id); final art = _coverArtUriFor(song); - return AudioSource.uri( - uri, - tag: MediaItem( - id: '${song.id}#${_tagSeq++}', - title: song.title ?? 'Unknown', - album: song.album, - artist: song.artist, - duration: - song.duration != null ? Duration(seconds: song.duration!) : null, - artUri: art, - ), + final tag = MediaItem( + id: '${song.id}#${_tagSeq++}', + title: song.title ?? 'Unknown', + album: song.album, + artist: song.artist, + duration: + song.duration != null ? Duration(seconds: song.duration!) : null, + artUri: art, ); + // Wrap remote streams in a caching source on mobile: it fetches bytes to an + // OS-evictable temp file, giving the native player a genuinely seekable + // source with a known length — robust against chunked transcodes that omit + // Content-Length and against brief network drops. Downloads (file://) are + // already seekable, and the media_kit desktop backend uses just_audio's + // localhost proxy path we don't rely on, so both stay on the plain source. + if (isRemote && (Platform.isAndroid || Platform.isIOS)) { + // LockCachingAudioSource is marked experimental in just_audio but is + // stable in practice; the streaming reliability it provides is the whole + // point of this path. + // ignore: experimental_member_use + return LockCachingAudioSource(uri, tag: tag); + } + return AudioSource.uri(uri, tag: tag); } /// Replace the queue with [songs] and start at [startIndex]. diff --git a/lib/state/providers.dart b/lib/state/providers.dart index bd1fb69..da8a25b 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -549,9 +549,15 @@ final playbackProvider = if (local != null) return Uri.file(local); final client = ref.read(subsonicClientProvider); 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( s.id, - maxBitRate: ref.read(settingsProvider).streamMaxBitRate, + maxBitRate: rate, + estimateContentLength: rate > 0, ); } diff --git a/lib/subsonic/subsonic_client.dart b/lib/subsonic/subsonic_client.dart index bdcb218..d4906df 100644 --- a/lib/subsonic/subsonic_client.dart +++ b/lib/subsonic/subsonic_client.dart @@ -302,11 +302,25 @@ class SubsonicClient { /// manager, which fetches these bytes to disk). `maxBitRate == 0` means /// original / no transcode; [format] requests a specific transcode container /// (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', { 'id': id, if (maxBitRate > 0) 'maxBitRate': '$maxBitRate', if (format != null && format.isNotEmpty) 'format': format, + if (estimateContentLength) 'estimateContentLength': 'true', }); /// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range. From 7a199fe4df99174d5c40e8d954673bd69fa9fb9a Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 13 Aug 2026 13:40:42 -0400 Subject: [PATCH 3/4] stream edits --- lib/playback/playback_engine.dart | 90 +++++++++++---- offline-parity-plan.md | 184 ++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 23 deletions(-) create mode 100644 offline-parity-plan.md diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index b2c8216..1c6418b 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -193,6 +193,24 @@ class PlaybackController extends StateNotifier /// one disk write per window. 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 /// the empty launch state can't clobber a snapshot before restore runs. String? _restoredKey; @@ -232,20 +250,43 @@ class PlaybackController extends StateNotifier ps == ProcessingState.buffering, ); }); - player.positionStream.listen((p) { - state = state.copyWith(position: p); - }); player.durationStream.listen((d) { if (d != null) state = state.copyWith(duration: d); }); - // just_audio surfaces load/decode failures (e.g. an unreachable remote - // source after the network drops) as errors on the event stream. Without a - // handler the platform player runs its own recovery — restarting the item - // at 0 or auto-advancing — which is the reported "scrub back / skip" bug. + // The event stream carries the raw, unclamped `updatePosition`; anchor on it + // (and re-anchor on every seek / pause / track change) and reflect it + // immediately so paused/seeked positions are exact. Steady-state advancing + // is done by the ticker below. We also handle load/decode failures here: + // without a handler the platform player runs its own recovery — restarting + // the item at 0 or auto-advancing — the reported "scrub back / skip" bug. player.playbackEventStream.listen( - (_) {}, + (event) { + _posAnchor = event.updatePosition; + _posAnchorAt = event.updateTime; + state = state.copyWith(position: event.updatePosition); + }, 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; + final elapsed = DateTime.now().difference(_posAnchorAt); + if (elapsed.isNegative) return; + var pos = _posAnchor + elapsed; + final total = state.effectiveDuration; + if (total > Duration.zero && pos > total) pos = total; + state = state.copyWith(position: pos); } /// Id of the song we last ran play side effects for. Queue edits shift @@ -333,19 +374,15 @@ class PlaybackController extends StateNotifier song.duration != null ? Duration(seconds: song.duration!) : null, artUri: art, ); - // Wrap remote streams in a caching source on mobile: it fetches bytes to an - // OS-evictable temp file, giving the native player a genuinely seekable - // source with a known length — robust against chunked transcodes that omit - // Content-Length and against brief network drops. Downloads (file://) are - // already seekable, and the media_kit desktop backend uses just_audio's - // localhost proxy path we don't rely on, so both stay on the plain source. - if (isRemote && (Platform.isAndroid || Platform.isIOS)) { - // LockCachingAudioSource is marked experimental in just_audio but is - // stable in practice; the streaming reliability it provides is the whole - // point of this path. - // ignore: experimental_member_use - return LockCachingAudioSource(uri, tag: tag); - } + // 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); } @@ -894,9 +931,15 @@ class PlaybackController extends StateNotifier void resyncFromPlayer() { final player = _player; if (player == null) return; - final pos = player.position; - final dur = player.duration ?? state.duration; final playing = player.playing; + // `player.position` is clamped to the reported duration, which is + // `Duration.zero` for unknown-length streams and would snap the playhead to + // 0 while playing. Use our unclamped anchor when playing; the raw getter is + // correct when paused. + final pos = playing + ? _posAnchor + DateTime.now().difference(_posAnchorAt) + : player.position; + final dur = player.duration ?? state.duration; // player.currentIndex is a window-relative index; map it back to logical. final idx = player.currentIndex != null ? _windowStart + player.currentIndex! @@ -1062,6 +1105,7 @@ class PlaybackController extends StateNotifier @override void dispose() { _saveTimer?.cancel(); + _positionTicker?.cancel(); _player?.dispose(); super.dispose(); } diff --git a/offline-parity-plan.md b/offline-parity-plan.md new file mode 100644 index 0000000..8e719bd --- /dev/null +++ b/offline-parity-plan.md @@ -0,0 +1,184 @@ +# Offline Parity: Browse, Artwork & Downloads Queue + +## Context + +When the device is offline, the app is far less usable than when streaming, even +though downloaded content exists on disk: + +- **Albums / Artists / Tracks load blank.** `browser_screen.dart` hard-gates the + whole Browse area behind a live `SubsonicClient`, and the backing providers + (`artistsProvider`, `albumsProvider`) return `const []` while + `LibraryIndexController.ensureBuilt()` bails before touching its cache when + `client == null`. So the only way to reach downloaded music offline is the + Downloads tab. +- **The Downloads tab plays one song at a time.** Tapping a saved track calls + `playback.playSongs([d.song])` — a single-element queue — and the screen has no + shuffle / play-all / play-next / add-to-queue controls that every streaming + screen has. +- **No offline artwork.** Downloads save only the audio file and the `coverArt` + *id*; the image itself is never cached, and all art URIs are built inline via + `client.coverArtUri(...)` guarded by `client != null`, so offline every cover + goes blank. + +**Decisions made with the user:** +1. Offline browse views show **downloaded content only** (reconstructed from the + downloads manifest) — everything shown is guaranteed playable. Online browse is + unchanged (server-backed). +2. Downloading a track **also caches its cover art** to disk so offline browse and + Now Playing show real artwork. + +**Intended outcome:** offline, the Albums / Artists / Tracks tabs, their detail +screens, artwork, and the Downloads tab all behave like a first-class local +library with full queue controls — parity with the streaming experience, scoped to +what's been downloaded. + +The key enabler already exists: the downloads manifest persists the **full +`Song`** per completed track (`DownloadInfo.song` → `song.toJson()`), carrying +`albumId`, `artistId`, `album`, `artist`, `coverArt`, `year`, `genre`, `track`, +`discNumber`. That is enough to reconstruct Albums/Artists/Tracks. The +`playlists.dart` controller is an in-repo precedent for the offline-mirror pattern. + +--- + +## Part A — Reconstruct the offline library from downloads + +### A1. New pure module: `lib/library/offline_library.dart` +Pure, I/O-free grouping functions over `List` (mirrors the style of +`library/browse_query.dart`): +- `List albumsFromSongs(List 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 artistsFromSongs(List 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//art/.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 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. From 66633302603f0ac04bd291275207f28dd2ba72ad Mon Sep 17 00:00:00 2001 From: Forrest Date: Sun, 16 Aug 2026 12:46:30 -0400 Subject: [PATCH 4/4] offline updates and playhead fix --- lib/debug/log_store.dart | 64 +++++++ lib/downloads/download_manager.dart | 255 +++++++++++++++++++----- lib/library/offline_library.dart | 153 +++++++++++++++ lib/playback/playback_engine.dart | 26 ++- lib/screens/browser_screen.dart | 287 +++++++++++++++------------- lib/screens/debug_screen.dart | 190 ++++++++++++++++++ lib/screens/downloads_screen.dart | 199 ++++++++++++++----- lib/screens/home_screen.dart | 232 +++++++++++----------- lib/screens/now_playing_screen.dart | 182 +++++++++++------- lib/state/providers.dart | 273 ++++++++++++++++++-------- lib/widgets/art_image.dart | 95 +++++++++ lib/widgets/cassette_view.dart | 41 ++-- lib/widgets/mini_player.dart | 48 ++--- test/offline_library_test.dart | 173 +++++++++++++++++ 14 files changed, 1673 insertions(+), 545 deletions(-) create mode 100644 lib/debug/log_store.dart create mode 100644 lib/library/offline_library.dart create mode 100644 lib/screens/debug_screen.dart create mode 100644 lib/widgets/art_image.dart create mode 100644 test/offline_library_test.dart diff --git a/lib/debug/log_store.dart b/lib/debug/log_store.dart new file mode 100644 index 0000000..5b48412 --- /dev/null +++ b/lib/debug/log_store.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; + +/// Severity of a captured log line, used only to tint it in the Debug tab. +enum LogLevel { info, error } + +/// A single captured console line, stamped with the wall-clock time it arrived. +@immutable +class LogEntry { + const LogEntry(this.time, this.text, this.level); + + final DateTime time; + final String text; + final LogLevel level; + + /// `HH:MM:SS.mmm` — enough resolution to correlate bursts while streaming. + String get timeLabel { + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(time.hour)}:${two(time.minute)}:${two(time.second)}' + '.${time.millisecond.toString().padLeft(3, '0')}'; + } +} + +/// In-memory ring buffer of console/system output, surfaced in the Debug tab so +/// the app's logs can be read (and copied) on-device during beta testing — +/// there's no attached debugger on a TestFlight/sideloaded build. +/// +/// A process-wide singleton because the capture hooks (the zone `print` +/// override and `FlutterError.onError`) are installed in `main()`, outside the +/// widget/provider tree. The UI listens via [ListenableBuilder]. +class LogStore extends ChangeNotifier { + LogStore._(); + static final LogStore instance = LogStore._(); + + /// Keep the tail bounded so a chatty session can't grow memory without limit. + static const int _maxEntries = 3000; + + final List _entries = []; + + /// Newest-last, read-only view for the UI. + List 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'); +} diff --git a/lib/downloads/download_manager.dart b/lib/downloads/download_manager.dart index 6111b07..ad27921 100644 --- a/lib/downloads/download_manager.dart +++ b/lib/downloads/download_manager.dart @@ -55,28 +55,27 @@ class DownloadInfo { int? sizeBytes, double? progress, String? error, - }) => - DownloadInfo( - song: song, - status: status ?? this.status, - path: path ?? this.path, - bitRate: bitRate ?? this.bitRate, - format: format ?? this.format, - sizeBytes: sizeBytes ?? this.sizeBytes, - progress: progress ?? this.progress, - error: error, - ); + }) => DownloadInfo( + song: song, + status: status ?? this.status, + path: path ?? this.path, + bitRate: bitRate ?? this.bitRate, + format: format ?? this.format, + sizeBytes: sizeBytes ?? this.sizeBytes, + progress: progress ?? this.progress, + error: error, + ); /// Serialize a completed record. The path is written as *relative* to the /// app-support directory by [DownloadController._persist] (key `relPath`) — /// absolute paths embed the iOS app-container UUID, which changes across app /// updates and would orphan every download. See [DownloadController]. Map toJson() => { - 'song': song.toJson(), - if (bitRate != null) 'bitRate': bitRate, - if (format != null) 'format': format, - if (sizeBytes != null) 'sizeBytes': sizeBytes, - }; + 'song': song.toJson(), + if (bitRate != null) 'bitRate': bitRate, + if (format != null) 'format': format, + if (sizeBytes != null) 'sizeBytes': sizeBytes, + }; /// Rebuild a completed record from the manifest. [path] is the absolute path /// resolved by the controller from the stored relative (or legacy absolute) @@ -95,10 +94,16 @@ class DownloadInfo { /// Snapshot of all known downloads for the active server, keyed by song id. class DownloadState { - const DownloadState({this.byId = const {}}); + const DownloadState({this.byId = const {}, this.artById = const {}}); final Map 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 artById; + DownloadInfo? operator [](String id) => byId[id]; bool isDownloaded(String id) => byId[id]?.isDone ?? false; @@ -106,10 +111,16 @@ class DownloadState { List get completed => 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)); - DownloadState copyWith({Map? byId}) => - DownloadState(byId: byId ?? this.byId); + DownloadState copyWith({ + Map? byId, + Map? artById, + }) => + DownloadState(byId: byId ?? this.byId, artById: artById ?? this.artById); } /// Downloads tracks to disk for offline playback. Files live under @@ -122,10 +133,10 @@ class DownloadController extends StateNotifier { required SubsonicClient? Function() clientGetter, required AppSettings Function() settingsGetter, required String? Function() serverKeyGetter, - }) : _clientGetter = clientGetter, - _settingsGetter = settingsGetter, - _serverKeyGetter = serverKeyGetter, - super(const DownloadState()) { + }) : _clientGetter = clientGetter, + _settingsGetter = settingsGetter, + _serverKeyGetter = serverKeyGetter, + super(const DownloadState()) { reloadForServer(); } @@ -133,10 +144,12 @@ class DownloadController extends StateNotifier { final AppSettings Function() _settingsGetter; final String? Function() _serverKeyGetter; - final Dio _dio = Dio(BaseOptions( - receiveTimeout: const Duration(minutes: 5), - headers: {'User-Agent': 'timbre'}, - )); + final Dio _dio = Dio( + BaseOptions( + receiveTimeout: const Duration(minutes: 5), + headers: {'User-Agent': 'timbre'}, + ), + ); int _generation = 0; String? _loadedKey; @@ -186,6 +199,15 @@ class DownloadController extends StateNotifier { 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); // ---- Server switching / manifest load ---------------------------------- @@ -197,6 +219,25 @@ class DownloadController extends StateNotifier { return d; } + /// Directory holding cached cover art for [key], created on demand. Sits + /// beside the audio files at `/art`. Art filenames are derived + /// from the cover-art id via [_sanitizeArtId] so tracks sharing an id share a + /// single file (dedup). + Future _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 _manifestFile(String key) async { final dir = await getApplicationSupportDirectory(); return File('${dir.path}/downloads_$key.json'); @@ -238,8 +279,20 @@ class DownloadController extends StateNotifier { 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 = {}; + 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) { - state = DownloadState(byId: byId); + state = DownloadState(byId: byId, artById: artById); // Self-migrate the manifest to relative paths. if (migrated) await _persist(); } @@ -277,8 +330,9 @@ class DownloadController extends StateNotifier { // Re-read the concurrency cap each pump so a settings change takes effect // mid-session: raising it starts more downloads immediately; lowering it // stops launching new ones while in-flight downloads drain naturally. - final maxConcurrent = - AppSettings.clampConcurrentDownloads(_settingsGetter().maxConcurrentDownloads); + final maxConcurrent = AppSettings.clampConcurrentDownloads( + _settingsGetter().maxConcurrentDownloads, + ); while (_active < maxConcurrent && _queue.isNotEmpty) { final id = _queue.removeAt(0); final info = state.byId[id]; @@ -296,8 +350,12 @@ class DownloadController extends StateNotifier { final client = _clientGetter(); final key = _serverKeyGetter(); if (client == null || key == null) { - _put(state.byId[song.id]! - .copyWith(status: DownloadStatus.failed, error: 'Not connected')); + _put( + state.byId[song.id]!.copyWith( + status: DownloadStatus.failed, + error: 'Not connected', + ), + ); return; } final settings = _settingsGetter(); @@ -309,8 +367,12 @@ class DownloadController extends StateNotifier { final ext = format ?? (rate > 0 ? 'mp3' : (song.suffix ?? 'mp3')); try { - _put(state.byId[song.id]! - .copyWith(status: DownloadStatus.downloading, progress: 0)); + _put( + state.byId[song.id]!.copyWith( + status: DownloadStatus.downloading, + progress: 0, + ), + ); final dir = await _downloadsDir(key); final finalPath = '${dir.path}/${song.id}.$ext'; @@ -346,16 +408,24 @@ class DownloadController extends StateNotifier { await tmp.rename(finalPath); final size = await File(finalPath).length(); - _put(DownloadInfo( - song: song, - status: DownloadStatus.done, - path: finalPath, - bitRate: rate == 0 ? null : rate, - format: format, - sizeBytes: size, - progress: 1, - )); + _put( + DownloadInfo( + song: song, + status: DownloadStatus.done, + path: finalPath, + bitRate: rate == 0 ? null : rate, + format: format, + sizeBytes: size, + progress: 1, + ), + ); 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) { if (gen != _generation) return; final cur = state.byId[song.id]; @@ -365,6 +435,56 @@ class DownloadController extends StateNotifier { } } + /// Best-effort fetch of [song]'s cover art to `/.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 _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 ------------------------------------------------------------- /// Delete a single download (file + manifest entry). @@ -379,7 +499,28 @@ class DownloadController extends StateNotifier { } catch (_) {} } final next = Map.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.from(state.artById)..remove(coverArt); + } + } + + state = state.copyWith(byId: next, artById: nextArt); await _persist(); } @@ -395,6 +536,14 @@ class DownloadController extends StateNotifier { } 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(); if (key != null) { try { @@ -407,9 +556,7 @@ class DownloadController extends StateNotifier { // ---- Internals ---------------------------------------------------------- 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). @@ -421,12 +568,14 @@ class DownloadController extends StateNotifier { final file = await _manifestFile(key); final tmp = File('${file.path}.tmp'); await tmp.writeAsString( - jsonEncode(state.completed.map((d) { - final j = d.toJson(); - final rel = _relativize(d.path); - if (rel != null) j['relPath'] = rel; - return j; - }).toList()), + jsonEncode( + state.completed.map((d) { + final j = d.toJson(); + final rel = _relativize(d.path); + if (rel != null) j['relPath'] = rel; + return j; + }).toList(), + ), ); await tmp.rename(file.path); } catch (_) {} diff --git a/lib/library/offline_library.dart b/lib/library/offline_library.dart new file mode 100644 index 0000000..f5e4b35 --- /dev/null +++ b/lib/library/offline_library.dart @@ -0,0 +1,153 @@ +import '../subsonic/models.dart'; + +/// Reconstructs [Album]s and [Artist]s from a flat list of downloaded [Song]s. +/// The offline library only ever has tracks (that's all that's cached on disk), +/// so album/artist entities are synthesized on demand. Pure — no I/O. + +/// Compare two strings case-insensitively, treating null as empty (sorts first). +int _byString(String? a, String? b) => + (a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase()); + +/// Compare where a null [a]/[b] always sorts *last*. Takes bare [Comparable] so +/// `int` (`Comparable`) 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 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 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 albumsFromSongs(List 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 = >{}; + for (final s in songs) { + final key = _albumKey(s); + if (key == null) continue; // no album identity → skip + groups.putIfAbsent(key, () => []).add(s); + } + + final out = []; + 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 artistsFromSongs(List songs) { + final groups = >{}; + for (final s in songs) { + final key = _artistKey(s); + if (key == null) continue; // no artist identity → skip + groups.putIfAbsent(key, () => []).add(s); + } + + final out = []; + 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 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 songs, String id) { + for (final a in artistsFromSongs(songs)) { + if (a.id == id) return a; + } + return null; +} diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index 1c6418b..128ce9b 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -263,7 +263,17 @@ class PlaybackController extends StateNotifier (event) { _posAnchor = event.updatePosition; _posAnchorAt = event.updateTime; - state = state.copyWith(position: event.updatePosition); + // `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), ); @@ -281,12 +291,20 @@ class PlaybackController extends StateNotifier /// 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); - if (elapsed.isNegative) return; - var pos = _posAnchor + elapsed; + var pos = elapsed.isNegative ? _posAnchor : _posAnchor + elapsed; final total = state.effectiveDuration; if (total > Duration.zero && pos > total) pos = total; - state = state.copyWith(position: pos); + return pos; } /// Id of the song we last ran play side effects for. Queue edits shift diff --git a/lib/screens/browser_screen.dart b/lib/screens/browser_screen.dart index 27e14d4..79042e2 100644 --- a/lib/screens/browser_screen.dart +++ b/lib/screens/browser_screen.dart @@ -5,6 +5,7 @@ import '../downloads/download_manager.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; +import '../widgets/art_image.dart'; import '../widgets/hairline_panel.dart'; import '../widgets/toast.dart'; import 'add_tag_sheet.dart'; @@ -24,8 +25,9 @@ class BrowserScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(subsonicClientProvider); final mode = ref.watch(browseModeProvider); + final offline = ref.watch(subsonicClientProvider) == null; + final hasDownloads = ref.watch(downloadedSongsProvider).isNotEmpty; return Padding( padding: const EdgeInsets.fromLTRB( @@ -44,9 +46,9 @@ class BrowserScreen extends ConsumerWidget { _Action( icon: Icons.search, label: 'Search', - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SearchScreen()), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SearchScreen())), ), _Action( icon: Icons.favorite_border, @@ -65,9 +67,9 @@ class BrowserScreen extends ConsumerWidget { _Action( icon: Icons.label_outline, label: 'Tags', - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const TagsScreen()), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const TagsScreen())), ), _Action( icon: Icons.download, @@ -82,7 +84,7 @@ class BrowserScreen extends ConsumerWidget { _ModeSelector(mode: mode), const SizedBox(height: TimbreSpacing.lg), Expanded( - child: client == null + child: offline && !hasDownloads ? const HairlinePanel( title: 'Browse', active: true, @@ -115,8 +117,9 @@ class _ModeSelector extends ConsumerWidget { return InkWell( onTap: () => ref.read(browseModeProvider.notifier).state = m, child: Container( - constraints: - const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), + constraints: const BoxConstraints( + minHeight: TimbreSpacing.minTouchTarget, + ), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), alignment: Alignment.center, child: Text( @@ -124,8 +127,9 @@ class _ModeSelector extends ConsumerWidget { style: TextStyle( color: active ? TimbreColors.foreground : TimbreColors.dimmed, fontWeight: active ? FontWeight.w700 : FontWeight.w400, - decoration: - active ? TextDecoration.underline : TextDecoration.none, + decoration: active + ? TextDecoration.underline + : TextDecoration.none, decorationColor: accent, decorationThickness: 2, ), @@ -191,7 +195,6 @@ class _AlbumsPanel extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final albums = ref.watch(visibleAlbumsProvider); final filter = ref.watch(albumFilterProvider); - final client = ref.watch(subsonicClientProvider); return HairlinePanel( title: 'Albums', active: true, @@ -215,18 +218,20 @@ class _AlbumsPanel extends ConsumerWidget { Expanded( child: list.isEmpty ? _Centered( - child: _ErrorText(filter.isActive - ? 'No albums match these filters.' - : 'No albums on this server.'), + child: _ErrorText( + filter.isActive + ? 'No albums match these filters.' + : 'No albums on this server.', + ), ) : LayoutBuilder( builder: (context, constraints) { - final cols = - (constraints.maxWidth / 180).floor().clamp(2, 6); + final cols = (constraints.maxWidth / 180) + .floor() + .clamp(2, 6); return GridView.builder( padding: EdgeInsets.zero, - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: cols, mainAxisSpacing: TimbreSpacing.md, crossAxisSpacing: TimbreSpacing.md, @@ -237,13 +242,11 @@ class _AlbumsPanel extends ConsumerWidget { itemCount: list.length, itemBuilder: (context, i) => _AlbumTile( album: list[i], - artUri: - (client != null && list[i].coverArt != null) - ? client - .coverArtUri(list[i].coverArt!, - size: 300) - .toString() - : null, + artUri: resolveArtUriW( + ref, + coverArt: list[i].coverArt, + size: 300, + )?.toString(), ), ); }, @@ -266,25 +269,18 @@ class _AlbumTile extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id))), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AspectRatio( aspectRatio: 1, - child: ColoredBox( - color: TimbreColors.surface, - child: artUri != null - ? Image.network( - artUri!, - key: ValueKey(artUri), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => const _AlbumArtFallback(), - ) - : const _AlbumArtFallback(), + child: ArtImage( + artUri, + fit: BoxFit.cover, + placeholder: const _AlbumArtFallback(), ), ), const SizedBox(height: TimbreSpacing.xs), @@ -299,8 +295,7 @@ class _AlbumTile extends StatelessWidget { album.artist!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: TimbreColors.dimmed, fontSize: 12), + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), ), ], ), @@ -312,9 +307,8 @@ class _AlbumArtFallback extends StatelessWidget { const _AlbumArtFallback(); @override 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 @@ -331,7 +325,11 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(libraryIndexProvider.notifier).ensureBuilt(); + // Offline the tracks come from the provider fallback (downloaded songs); + // only crawl the live library when we actually have a server connection. + if (ref.read(subsonicClientProvider) != null) { + ref.read(libraryIndexProvider.notifier).ensureBuilt(); + } }); } @@ -340,10 +338,12 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { final index = ref.watch(libraryIndexProvider); final visible = ref.watch(visibleTracksProvider); final playback = ref.read(playbackCommandsProvider); - final client = ref.watch(subsonicClientProvider); + final offline = ref.watch(subsonicClientProvider) == null; final Widget body; - if (index.building) { + // Offline the crawled index is empty; `visible` is backed by the downloaded + // songs instead, so skip the online-only indexing / empty-index branches. + if (!offline && index.building) { final total = index.total; final label = total > 0 ? 'Indexing ${index.done}/$total albums…' @@ -358,11 +358,13 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { ], ), ); - } else if (index.songs.isEmpty) { + } else if (!offline && index.songs.isEmpty) { body = _Centered( - child: _ErrorText(index.error != null - ? 'Could not build the track index.' - : 'No tracks indexed yet.'), + child: _ErrorText( + index.error != null + ? 'Could not build the track index.' + : 'No tracks indexed yet.', + ), ); } else { final downloads = ref.watch(downloadManagerProvider); @@ -373,24 +375,24 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { Expanded( child: visible.isEmpty ? const _Centered( - child: _ErrorText('No tracks match these filters.')) + child: _ErrorText('No tracks match these filters.'), + ) : ListView.builder( padding: EdgeInsets.zero, itemCount: visible.length, itemBuilder: (context, i) { final song = visible[i]; - final artUri = (client != null && song.coverArt != null) - ? client - .coverArtUri(song.coverArt!, size: 128) - .toString() - : null; + final artUri = resolveArtUriW( + ref, + coverArt: song.coverArt, + size: 128, + )?.toString(); return BrowseRow( title: song.title ?? 'Untitled', subtitle: song.artist, artUri: artUri, downloadStatus: downloads.byId[song.id]?.status, - onTap: () => - playback.playSongs(visible, startIndex: i), + onTap: () => playback.playSongs(visible, startIndex: i), onPlayNext: () => playback.playNext(song), onAddToQueue: () => playback.addToQueue(song), onAddToPlaylist: () => @@ -414,7 +416,9 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { return HairlinePanel( title: 'Tracks', active: true, - trailing: index.songs.isNotEmpty ? '(${visible.length})' : null, + trailing: index.songs.isNotEmpty || visible.isNotEmpty + ? '(${visible.length})' + : null, padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), action: Row( mainAxisSize: MainAxisSize.min, @@ -425,8 +429,10 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { : () => ref.read(libraryIndexProvider.notifier).refresh(), child: Padding( padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs), - child: Text('↻ refresh', - style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)), + child: Text( + '↻ refresh', + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), + ), ), ), if (visible.isNotEmpty) @@ -445,19 +451,27 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.download, - size: 16, color: TimbreColors.foreground), + Icon( + Icons.download, + size: 16, + color: TimbreColors.foreground, + ), SizedBox(width: TimbreSpacing.sm), - Text('Download all', - style: TextStyle(color: TimbreColors.foreground)), + Text( + 'Download all', + style: TextStyle(color: TimbreColors.foreground), + ), ], ), ), ], child: Padding( padding: const EdgeInsets.all(TimbreSpacing.xs), - child: Icon(Icons.more_vert, - size: 18, color: TimbreColors.dimmed), + child: Icon( + Icons.more_vert, + size: 18, + color: TimbreColors.dimmed, + ), ), ), ], @@ -470,22 +484,27 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { /// library, so it's gated behind a dialog unlike per-album download-all. /// [songs] is the currently-visible (filtered/sorted) set. Future _confirmDownloadAll( - BuildContext context, List songs) async { + BuildContext context, + List songs, + ) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: TimbreColors.surface, title: const Text('Download these tracks?'), content: Text( - 'This queues all ${songs.length} listed tracks for offline ' - 'download. It may use significant storage and data.'), + 'This queues all ${songs.length} listed tracks for offline ' + 'download. It may use significant storage and data.', + ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel')), + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Download all')), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Download all'), + ), ], ), ); @@ -543,9 +562,7 @@ class ArtistScreen extends ConsumerWidget { title: album.name ?? 'Unknown album', trailing: album.year?.toString(), onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => AlbumScreen(id: album.id), - ), + MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)), ), ); }, @@ -574,16 +591,13 @@ class AlbumScreen extends ConsumerWidget { : [ IconButton( tooltip: 'Add to playlist', - onPressed: () => - showAddToPlaylistSheet(context, songs: songs), + onPressed: () => showAddToPlaylistSheet(context, songs: songs), icon: const Icon(Icons.playlist_add), ), IconButton( tooltip: 'Download album', onPressed: () { - ref - .read(downloadManagerProvider.notifier) - .downloadAll(songs); + ref.read(downloadManagerProvider.notifier).downloadAll(songs); showToast(context, 'Downloading album…'); }, icon: const Icon(Icons.download), @@ -678,39 +692,36 @@ class BrowseRow extends StatelessWidget { Widget build(BuildContext context) { final accent = Theme.of(context).colorScheme.primary; final isDone = downloadStatus == DownloadStatus.done; - final isActive = downloadStatus == DownloadStatus.queued || + final isActive = + downloadStatus == DownloadStatus.queued || downloadStatus == DownloadStatus.downloading; return InkWell( onTap: onTap, child: Container( - constraints: - const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), + constraints: const BoxConstraints( + minHeight: TimbreSpacing.minTouchTarget, + ), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ if (artUri != null) ...[ - SizedBox( + ArtImage( + artUri, width: 40, height: 40, - child: ColoredBox( - color: TimbreColors.surface, - child: Image.network( - artUri!, - key: ValueKey(artUri), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => const _AlbumArtFallback(), - ), - ), + fit: BoxFit.cover, + placeholder: const _AlbumArtFallback(), ), const SizedBox(width: TimbreSpacing.md), ], if (leading != null) SizedBox( width: 28, - child: Text(leading!, - style: TextStyle(color: TimbreColors.dimmed)), + child: Text( + leading!, + style: TextStyle(color: TimbreColors.dimmed), + ), ), Expanded( child: Column( @@ -729,7 +740,9 @@ class BrowseRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: TimbreColors.dimmed, fontSize: 12), + color: TimbreColors.dimmed, + fontSize: 12, + ), ), ], ), @@ -737,8 +750,7 @@ class BrowseRow extends StatelessWidget { if (isDone) Padding( 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) const Padding( @@ -751,8 +763,7 @@ class BrowseRow extends StatelessWidget { ), if (trailing != null) ...[ const SizedBox(width: TimbreSpacing.md), - Text(trailing!, - style: TextStyle(color: TimbreColors.dimmed)), + Text(trailing!, style: TextStyle(color: TimbreColors.dimmed)), ], if (onPlayNext != null) _RowIcon( @@ -812,8 +823,7 @@ class _RowMenu extends StatelessWidget { icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), color: TimbreColors.surface, padding: EdgeInsets.zero, - constraints: - const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget), + constraints: const BoxConstraints(minWidth: TimbreSpacing.minTouchTarget), onSelected: (v) { switch (v) { case 'playlist': @@ -829,17 +839,22 @@ class _RowMenu extends StatelessWidget { itemBuilder: (_) => [ if (onAddToPlaylist != null) const PopupMenuItem( - value: 'playlist', child: Text('Add to playlist')), + value: 'playlist', + child: Text('Add to playlist'), + ), if (onAddToTag != null) const PopupMenuItem(value: 'tag', child: Text('Add tag…')), if (isDownloaded && onRemoveDownload != null) const PopupMenuItem( - value: 'remove_download', child: Text('Remove download')) + value: 'remove_download', + child: Text('Remove download'), + ) else if (onDownload != null) PopupMenuItem( - value: 'download', - enabled: !isDownloading, - child: Text(isDownloading ? 'Downloading…' : 'Download')), + value: 'download', + enabled: !isDownloading, + child: Text(isDownloading ? 'Downloading…' : 'Download'), + ), ], ); } @@ -881,10 +896,12 @@ class _DetailScaffold extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w700)), + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), actions: actions, ), body: SafeArea(child: child), @@ -901,12 +918,16 @@ class _NotConnected extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('Not connected.', - style: TextStyle(color: TimbreColors.foreground)), + Text( + "You're offline.", + style: TextStyle(color: TimbreColors.foreground), + ), SizedBox(height: TimbreSpacing.sm), - Text('Tap the status bar to add a Subsonic server.', - textAlign: TextAlign.center, - style: TextStyle(color: TimbreColors.dimmed)), + Text( + 'Download music to browse it here.', + textAlign: TextAlign.center, + style: TextStyle(color: TimbreColors.dimmed), + ), ], ), ); @@ -918,19 +939,19 @@ class _Centered extends StatelessWidget { final Widget child; @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.all(TimbreSpacing.xl), - child: Center(child: child), - ); + padding: const EdgeInsets.all(TimbreSpacing.xl), + child: Center(child: child), + ); } class _Loading extends StatelessWidget { const _Loading(); @override Widget build(BuildContext context) => const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ); + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); } class _ErrorText extends StatelessWidget { @@ -938,10 +959,10 @@ class _ErrorText extends StatelessWidget { final String message; @override Widget build(BuildContext context) => Text( - message, - textAlign: TextAlign.center, - style: TextStyle(color: TimbreColors.dimmed), - ); + message, + textAlign: TextAlign.center, + style: TextStyle(color: TimbreColors.dimmed), + ); } String? _fmtDuration(int? seconds) { diff --git a/lib/screens/debug_screen.dart b/lib/screens/debug_screen.dart new file mode 100644 index 0000000..4f19c94 --- /dev/null +++ b/lib/screens/debug_screen.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../debug/log_store.dart'; +import '../theme/tokens.dart'; + +/// Beta-testing console: shows the app's captured `print`/`debugPrint` output +/// and uncaught errors (see [LogStore], wired up in `main`). Read-only view +/// with copy-all / clear / follow controls — no debugger needed on-device. +class DebugScreen extends StatefulWidget { + const DebugScreen({super.key}); + + @override + State createState() => _DebugScreenState(); +} + +class _DebugScreenState extends State { + 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 _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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/downloads_screen.dart b/lib/screens/downloads_screen.dart index 7b2a81d..4cc762d 100644 --- a/lib/screens/downloads_screen.dart +++ b/lib/screens/downloads_screen.dart @@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../downloads/download_manager.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; +import '../widgets/art_image.dart'; import '../widgets/hairline_panel.dart'; +import '../widgets/toast.dart'; /// Manage offline downloads: what's saved, how much space it uses, and any /// in-flight transfers. Tapping a completed track plays it. @@ -19,25 +21,33 @@ class DownloadsScreen extends ConsumerWidget { final active = downloads.byId.values.where((d) => d.isActive).toList(); final 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( appBar: AppBar( - title: const Text('Downloads', - style: TextStyle(fontWeight: FontWeight.w700)), + title: const Text( + 'Downloads', + style: TextStyle(fontWeight: FontWeight.w700), + ), actions: [ if (completed.isNotEmpty) TextButton( onPressed: () => _confirmClear(context, controller), - child: Text('Clear all', - style: TextStyle(color: TimbreColors.dimmed)), + child: Text( + 'Clear all', + style: TextStyle(color: TimbreColors.dimmed), + ), ), ], ), body: SafeArea( child: (active.isEmpty && completed.isEmpty) ? Center( - child: Text('No downloads yet.', - style: TextStyle(color: TimbreColors.dimmed)), + child: Text( + 'No downloads yet.', + style: TextStyle(color: TimbreColors.dimmed), + ), ) : ListView( padding: const EdgeInsets.all(TimbreSpacing.lg), @@ -47,11 +57,10 @@ class DownloadsScreen extends ConsumerWidget { title: 'Downloading', trailing: '(${active.length})', padding: const EdgeInsets.symmetric( - vertical: TimbreSpacing.md), + vertical: TimbreSpacing.md, + ), 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), @@ -62,21 +71,68 @@ class DownloadsScreen extends ConsumerWidget { trailing: completed.isEmpty ? null : '${completed.length} · ${_fmtBytes(downloads.totalBytes)}', - padding: - const EdgeInsets.symmetric(vertical: TimbreSpacing.md), + action: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: savedSongs.isEmpty + ? null + : () => playback.playSongs(savedSongs), + child: Padding( + padding: const EdgeInsets.all(TimbreSpacing.xs), + child: Icon( + Icons.play_arrow, + size: 18, + color: TimbreColors.dimmed, + ), + ), + ), + InkWell( + onTap: savedSongs.isEmpty + ? null + : () { + playback.toggleShuffle(); + playback.playSongs(savedSongs); + }, + child: Padding( + padding: const EdgeInsets.all(TimbreSpacing.xs), + child: Icon( + Icons.shuffle, + size: 18, + color: TimbreColors.dimmed, + ), + ), + ), + ], + ), + padding: const EdgeInsets.symmetric( + vertical: TimbreSpacing.md, + ), child: completed.isEmpty ? Padding( padding: EdgeInsets.all(TimbreSpacing.lg), - child: Text('Nothing saved for offline yet.', - style: TextStyle(color: TimbreColors.dimmed)), + child: Text( + 'Nothing saved for offline yet.', + style: TextStyle(color: TimbreColors.dimmed), + ), ) : Column( children: [ - for (final d in completed) + for (final (i, d) in completed.indexed) _SavedRow( info: d, - onPlay: () => - playback.playSongs([d.song]), + artUri: resolveArtUriW( + ref, + coverArt: d.song.coverArt, + size: 128, + )?.toString(), + onPlay: () => playback.playSongs( + savedSongs, + startIndex: i, + ), + onPlayNext: () => playback.playNext(d.song), + onAddToQueue: () => + playback.addToQueue(d.song), onRemove: () => controller.remove(d.song.id), ), ], @@ -89,21 +145,26 @@ class DownloadsScreen extends ConsumerWidget { } Future _confirmClear( - BuildContext context, DownloadController controller) async { + BuildContext context, + DownloadController controller, + ) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: TimbreColors.surface, title: const Text('Remove all downloads?'), 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: [ TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel')), + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Remove all')), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Remove all'), + ), ], ), ); @@ -121,21 +182,27 @@ class _ActiveRow extends StatelessWidget { final failed = info.status == DownloadStatus.failed; return Padding( padding: const EdgeInsets.symmetric( - horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.xs), + horizontal: TimbreSpacing.lg, + vertical: TimbreSpacing.xs, + ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(info.song.title ?? 'Untitled', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.foreground)), + Text( + info.song.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.foreground), + ), const SizedBox(height: TimbreSpacing.xs), if (failed) - const Text('Failed', - style: TextStyle(color: Color(0xFFE06C75), fontSize: 12)) + const Text( + 'Failed', + style: TextStyle(color: Color(0xFFE06C75), fontSize: 12), + ) else LinearProgressIndicator( value: info.progress > 0 ? info.progress : null, @@ -162,35 +229,55 @@ class _ActiveRow extends StatelessWidget { class _SavedRow extends StatelessWidget { const _SavedRow({ required this.info, + required this.artUri, required this.onPlay, + required this.onPlayNext, + required this.onAddToQueue, required this.onRemove, }); final DownloadInfo info; + + /// Resolved cover-art URI (downloaded art is local, so it shows offline). + final String? artUri; final VoidCallback onPlay; + final VoidCallback onPlayNext; + final VoidCallback onAddToQueue; final VoidCallback onRemove; @override Widget build(BuildContext context) { - final quality = info.format ?? + final quality = + info.format ?? (info.bitRate != null ? '${info.bitRate} kbps' : 'Original'); return InkWell( onTap: onPlay, child: Container( - constraints: - const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), + constraints: const BoxConstraints( + minHeight: TimbreSpacing.minTouchTarget, + ), padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ + ArtImage( + artUri, + width: 40, + height: 40, + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(width: TimbreSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(info.song.title ?? 'Untitled', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.foreground)), + Text( + info.song.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.foreground), + ), Text( [ info.song.artist, @@ -198,21 +285,37 @@ class _SavedRow extends StatelessWidget { ].where((e) => e != null && e.isNotEmpty).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: - TextStyle(color: TimbreColors.dimmed, fontSize: 12), + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), ), ], ), ), - InkWell( - onTap: onRemove, - customBorder: const CircleBorder(), - child: SizedBox( - width: TimbreSpacing.minTouchTarget, - height: TimbreSpacing.minTouchTarget, - child: Icon(Icons.delete_outline, - size: 20, color: TimbreColors.dimmed), - ), + PopupMenuButton( + icon: Icon(Icons.more_vert, size: 20, color: TimbreColors.dimmed), + color: TimbreColors.surface, + onSelected: (v) { + switch (v) { + case 'next': + onPlayNext(); + showToast(context, 'Playing next', icon: Icons.check); + case 'queue': + onAddToQueue(); + showToast(context, 'Added to queue', icon: Icons.check); + case 'remove': + onRemove(); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'next', child: Text('Play next')), + const PopupMenuItem( + value: 'queue', + child: Text('Add to queue'), + ), + const PopupMenuItem( + value: 'remove', + child: Text('Remove download'), + ), + ], ), ], ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a0aabbd..bd21f52 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -5,6 +5,7 @@ import '../history/play_history.dart'; import '../subsonic/models.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; +import '../widgets/art_image.dart'; import '../widgets/block_progress_bar.dart'; import 'browser_screen.dart'; @@ -22,9 +23,7 @@ class HomeScreen extends ConsumerWidget { final random = ref.watch(randomAlbumsProvider); String? artFor(String? coverArt, {int size = 300}) => - (client != null && coverArt != null) - ? client.coverArtUri(coverArt, size: size).toString() - : null; + resolveArtUriW(ref, coverArt: coverArt, size: size)?.toString(); return ListView( padding: const EdgeInsets.fromLTRB( @@ -55,11 +54,7 @@ class HomeScreen extends ConsumerWidget { ), // 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. _RandomAlbum( @@ -81,9 +76,9 @@ class HomeScreen extends ConsumerWidget { } static void _pushAlbum(BuildContext context, String albumId) { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId))); } } @@ -96,7 +91,6 @@ class _HeroCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accent = Theme.of(context).colorScheme.primary; - final client = ref.watch(subsonicClientProvider); final current = ref.watch(activePlaybackProvider.select((s) => s.current)); // Fall back to the most recent track so the hero is useful before playback. @@ -104,9 +98,11 @@ class _HeroCard extends ConsumerWidget { final PlayRecord? fallback = recent.isEmpty ? null : recent.first; final String? coverArt = current?.coverArt ?? fallback?.coverArt; - final artUri = (client != null && coverArt != null) - ? client.coverArtUri(coverArt, size: 240).toString() - : null; + final artUri = resolveArtUriW( + ref, + coverArt: coverArt, + size: 240, + )?.toString(); final title = current?.title ?? fallback?.title; final subtitle = current?.artist ?? fallback?.artist; @@ -118,12 +114,17 @@ class _HeroCard extends ConsumerWidget { onTap: () => ref.read(selectedTabProvider.notifier).state = 1, child: Row( children: [ - Icon(Icons.library_music_outlined, - color: TimbreColors.dimmed, size: 40), + Icon( + Icons.library_music_outlined, + color: TimbreColors.dimmed, + size: 40, + ), const SizedBox(width: TimbreSpacing.lg), Expanded( - child: Text('Browse your library to start listening', - style: TextStyle(color: TimbreColors.foreground)), + child: Text( + 'Browse your library to start listening', + style: TextStyle(color: TimbreColors.foreground), + ), ), ], ), @@ -145,18 +146,7 @@ class _HeroCard extends ConsumerWidget { SizedBox( width: 64, height: 64, - 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), - ), + child: ArtImage(artUri, fit: BoxFit.cover), ), const SizedBox(width: TimbreSpacing.lg), Expanded( @@ -166,29 +156,40 @@ class _HeroCard extends ConsumerWidget { children: [ Row( children: [ - Icon(hasCurrent ? Icons.play_arrow : Icons.history, - size: 14, color: accent), + Icon( + hasCurrent ? Icons.play_arrow : Icons.history, + size: 14, + color: accent, + ), const SizedBox(width: TimbreSpacing.xs), - Text(hasCurrent ? 'NOW PLAYING' : 'RESUME', - style: TextStyle( - color: accent, - fontSize: 11, - letterSpacing: 1, - fontWeight: FontWeight.w700)), + Text( + hasCurrent ? 'NOW PLAYING' : 'RESUME', + style: TextStyle( + color: accent, + fontSize: 11, + letterSpacing: 1, + fontWeight: FontWeight.w700, + ), + ), ], ), const SizedBox(height: TimbreSpacing.xs), - Text(title, + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) + Text( + subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: TimbreColors.foreground, - fontWeight: FontWeight.w700)), - if (subtitle != null) - Text(subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed)), + style: TextStyle(color: TimbreColors.dimmed), + ), if (hasCurrent) ...[ const SizedBox(height: TimbreSpacing.md), const _HeroProgress(), @@ -210,14 +211,19 @@ class _HeroProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); + final progress = ref.watch( + activePlaybackProvider.select((s) => s.progress), + ); return BlockProgressBar(progress: progress, cells: 32, height: 6); } } class _HeroShell extends StatelessWidget { - const _HeroShell( - {required this.child, required this.accent, required this.onTap}); + const _HeroShell({ + required this.child, + required this.accent, + required this.onTap, + }); final Widget child; final Color accent; @@ -282,11 +288,14 @@ class _ShelfHeader extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - Text(title, - style: TextStyle( - color: TimbreColors.foreground, - fontWeight: FontWeight.w700, - letterSpacing: 0.5)), + Text( + title, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), const Spacer(), if (onShuffle != null) InkWell( @@ -321,7 +330,11 @@ class _AlbumShelf extends StatelessWidget { return albums.when( loading: () => _Shelf( title: title, - cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()], + cards: const [ + _ArtCardSkeleton(), + _ArtCardSkeleton(), + _ArtCardSkeleton(), + ], ), error: (_, _) => const SizedBox.shrink(), data: (list) => _Shelf( @@ -332,9 +345,9 @@ class _AlbumShelf extends StatelessWidget { artUri: artFor(a.coverArt), title: a.name ?? 'Unknown album', subtitle: a.artist, - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))), ), ], ), @@ -378,9 +391,9 @@ class _RandomAlbum extends StatelessWidget { const _RandomSkeleton() else InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id))), child: _RandomBody(album: a, artUri: artFor(a.coverArt)), ), ], @@ -402,20 +415,11 @@ class _RandomBody extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: SizedBox( + child: ArtImage( + artUri, + fit: BoxFit.cover, width: _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), @@ -424,28 +428,35 @@ class _RandomBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(album.name ?? 'Unknown album', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: TimbreColors.foreground, - fontWeight: FontWeight.w700)), + Text( + album.name ?? 'Unknown album', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: FontWeight.w700, + ), + ), const SizedBox(height: TimbreSpacing.xs), if (album.artist != null) - Text(album.artist!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.dimmed)), + Text( + album.artist!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.dimmed), + ), if (album.year != null) - Text('${album.year}', - style: TextStyle( - color: TimbreColors.dimmed, fontSize: 12)), + Text( + '${album.year}', + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), + ), if (album.genre != null) - Text(album.genre!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: TimbreColors.dimmed, fontSize: 12)), + Text( + album.genre!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), + ), ], ), ), @@ -497,34 +508,27 @@ class _ArtCard extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: SizedBox( + child: ArtImage( + artUri, + fit: BoxFit.cover, width: _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), - Text(title, + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: TimbreColors.foreground), + ), + if (subtitle != null) + Text( + subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: TimbreColors.foreground)), - if (subtitle != null) - Text(subtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - TextStyle(color: TimbreColors.dimmed, fontSize: 12)), + style: TextStyle(color: TimbreColors.dimmed, fontSize: 12), + ), ], ), ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 68ee04b..3706351 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -10,6 +10,7 @@ import '../state/providers.dart'; import '../state/remote_providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; +import '../widgets/art_image.dart'; import '../widgets/block_progress_bar.dart'; import '../widgets/cassette_view.dart'; import '../widgets/hairline_panel.dart'; @@ -57,20 +58,25 @@ class _NowPlayingScreenState extends ConsumerState { final current = state.current; // Re-seed the favorites store whenever the track changes. - ref.listen(activePlaybackProvider.select((s) => s.current?.id), - (_, _) => _seedFavorites()); + ref.listen( + activePlaybackProvider.select((s) => s.current?.id), + (_, _) => _seedFavorites(), + ); if (current == null) { return Center( - child: Text('Nothing playing.', - style: TextStyle(color: TimbreColors.dimmed)), + child: Text( + 'Nothing playing.', + style: TextStyle(color: TimbreColors.dimmed), + ), ); } // Cross-device control is offered next to the queue toggle (compact) or // beneath the transport (wide); hidden where the platform can't host/browse. - final remoteSupported = - ref.watch(remoteControlProvider.select((s) => s.supported)); + final remoteSupported = ref.watch( + remoteControlProvider.select((s) => s.supported), + ); // Everything below the art region — shared by both layouts. The queue // toggle is deliberately excluded: it belongs only to the compact layout @@ -288,8 +294,10 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { if (index == null || index < 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!_controller.hasClients) return; - final target = - (index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent); + final target = (index * _rowExtent).clamp( + 0.0, + _controller.position.maxScrollExtent, + ); _controller.animateTo( target, duration: const Duration(milliseconds: 300), @@ -375,8 +383,9 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { overflow: TextOverflow.ellipsis, style: TextStyle( color: titleColor, - fontWeight: - isCurrent ? FontWeight.w700 : FontWeight.w400, + fontWeight: isCurrent + ? FontWeight.w700 + : FontWeight.w400, ), ), if (song.artist != null) @@ -392,17 +401,21 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { ], ), ), - Text(_fmt(song.duration), - style: TextStyle(color: TimbreColors.dimmed)), + Text( + _fmt(song.duration), + style: TextStyle(color: TimbreColors.dimmed), + ), InkWell( - onTap: () => - ref.read(playbackCommandsProvider).removeAt(i), + onTap: () => ref.read(playbackCommandsProvider).removeAt(i), customBorder: const CircleBorder(), child: SizedBox( width: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget, - child: Icon(Icons.close, - size: 18, color: TimbreColors.dimmed), + child: Icon( + Icons.close, + size: 18, + color: TimbreColors.dimmed, + ), ), ), ], @@ -430,8 +443,9 @@ class _FittedArt extends StatelessWidget { alignment: Alignment.topCenter, child: LayoutBuilder( builder: (context, c) { - final side = - c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth; + final side = c.maxHeight.isFinite + ? c.maxHeight.clamp(0.0, c.maxWidth) + : c.maxWidth; return SizedBox( width: side, height: side, @@ -452,14 +466,17 @@ class _AlbumArtPanel extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final coverArt = - ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt)); - final client = ref.watch(subsonicClientProvider); - final cassette = - ref.watch(settingsProvider.select((s) => s.nowPlayingCassette)); - final artUri = (client != null && coverArt != null) - ? client.coverArtUri(coverArt, size: 512).toString() - : null; + final coverArt = ref.watch( + activePlaybackProvider.select((s) => s.current?.coverArt), + ); + final cassette = ref.watch( + settingsProvider.select((s) => s.nowPlayingCassette), + ); + final artUri = resolveArtUriW( + ref, + coverArt: coverArt, + size: 512, + )?.toString(); return HairlinePanel( title: cassette ? 'Cassette' : 'Album Art', @@ -468,17 +485,10 @@ class _AlbumArtPanel extends ConsumerWidget { ? Center(child: CassetteView(artUri: artUri)) : AspectRatio( aspectRatio: 1, - child: ColoredBox( - color: TimbreColors.surface, - child: artUri != null - ? Image.network( - artUri, - key: ValueKey(artUri), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => const _ArtFallback(), - ) - : const _ArtFallback(), + child: ArtImage( + artUri, + fit: BoxFit.cover, + placeholder: const _ArtFallback(), ), ), ); @@ -497,13 +507,16 @@ class _FavRating extends ConsumerWidget { final fav = ref.watch(favoritesProvider); final accent = Theme.of(context).colorScheme.primary; final starred = fav.isSongStarred(song.id); - final rating = - fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0); + final rating = fav.ratingFor(song.id) != 0 + ? fav.ratingFor(song.id) + : (song.userRating ?? 0); - final downloadStatus = - ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status)); + final downloadStatus = ref.watch( + downloadManagerProvider.select((s) => s.byId[song.id]?.status), + ); final isDownloaded = downloadStatus == DownloadStatus.done; - final isDownloading = downloadStatus == DownloadStatus.queued || + final isDownloading = + downloadStatus == DownloadStatus.queued || downloadStatus == DownloadStatus.downloading; return Row( @@ -541,8 +554,11 @@ class _FavRating extends ConsumerWidget { customBorder: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(TimbreSpacing.sm), - child: Icon(Icons.playlist_add, - size: 22, color: TimbreColors.dimmed), + child: Icon( + Icons.playlist_add, + size: 22, + color: TimbreColors.dimmed, + ), ), ), InkWell( @@ -637,12 +653,16 @@ class _InfoStrip extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(song.title ?? 'Untitled', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: accent, fontWeight: FontWeight.w700)), - Text(song.artist ?? 'Unknown artist', - style: TextStyle(color: TimbreColors.foreground)), + Text( + song.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: accent, fontWeight: FontWeight.w700), + ), + Text( + song.artist ?? 'Unknown artist', + style: TextStyle(color: TimbreColors.foreground), + ), if (album.isNotEmpty) Text(album, style: TextStyle(color: TimbreColors.dimmed)), ], @@ -659,29 +679,35 @@ class _NowPlayingProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final position = ref.watch(activePlaybackProvider.select((s) => s.position)); - final duration = - ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration)); - final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); + final position = ref.watch( + activePlaybackProvider.select((s) => s.position), + ); + final duration = ref.watch( + activePlaybackProvider.select((s) => s.effectiveDuration), + ); + final progress = ref.watch( + activePlaybackProvider.select((s) => s.progress), + ); return Row( children: [ - Text(_fmtDur(position), - style: TextStyle(color: TimbreColors.dimmed)), + Text(_fmtDur(position), style: TextStyle(color: TimbreColors.dimmed)), const SizedBox(width: TimbreSpacing.md), Expanded( child: BlockProgressBar(progress: progress, cells: 28, height: 18), ), 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 { - const _Transport( - {required this.state, required this.ref, required this.accent}); + const _Transport({ + required this.state, + required this.ref, + required this.accent, + }); final PlaybackState state; final WidgetRef ref; @@ -697,21 +723,34 @@ class _Transport extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _btn(Icons.shuffle, controller.toggleShuffle, - color: state.shuffle ? accent : TimbreColors.dimmed), + _btn( + Icons.shuffle, + controller.toggleShuffle, + color: state.shuffle ? accent : TimbreColors.dimmed, + ), _btn(Icons.skip_previous, controller.previous), - _btn(state.playing ? Icons.pause : Icons.play_arrow, - controller.togglePlayPause, - color: accent, size: 40), + _btn( + state.playing ? Icons.pause : Icons.play_arrow, + controller.togglePlayPause, + color: accent, + size: 40, + ), _btn(Icons.skip_next, controller.next), - _btn(loopIcon, controller.cycleLoop, - color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed), + _btn( + loopIcon, + controller.cycleLoop, + color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed, + ), ], ); } - Widget _btn(IconData icon, VoidCallback onTap, - {Color? color, double size = 28}) { + Widget _btn( + IconData icon, + VoidCallback onTap, { + Color? color, + double size = 28, + }) { return IconButton( onPressed: onTap, icon: Icon(icon, color: color ?? TimbreColors.foreground, size: size), @@ -723,9 +762,8 @@ class _ArtFallback extends StatelessWidget { const _ArtFallback(); @override 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) { diff --git a/lib/state/providers.dart b/lib/state/providers.dart index da8a25b..d89f7f9 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -1,10 +1,14 @@ -import 'package:flutter/widgets.dart' show NetworkImage; +import 'dart:io'; + +import 'package:flutter/widgets.dart' + show FileImage, ImageProvider, NetworkImage; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../downloads/download_manager.dart'; import '../history/play_history.dart'; import '../library/browse_query.dart'; import '../library/library_index.dart'; +import '../library/offline_library.dart'; import '../playback/playback_engine.dart'; import '../playlists/playlists.dart'; import '../settings/settings_store.dart'; @@ -61,15 +65,16 @@ class ConnectionState { ); } -final credentialStoreProvider = - Provider((_) => CredentialStore()); +final credentialStoreProvider = Provider( + (_) => CredentialStore(), +); /// Owns the active server connection and the list of saved servers: builds the /// client, pings, persists credentials, auto-restores on launch, and switches /// between servers (one active at a time — TODO #2). class ConnectionController extends StateNotifier { ConnectionController(this._store) - : super(const ConnectionState(status: ConnStatus.disconnected)) { + : super(const ConnectionState(status: ConnStatus.disconnected)) { _restore(); } @@ -206,8 +211,10 @@ class ConnectionController extends StateNotifier { } } - Future _addOrUpdate(SubsonicCredentials creds, - {required bool makeActive}) async { + Future _addOrUpdate( + SubsonicCredentials creds, { + required bool makeActive, + }) async { final idx = _servers.indexWhere((s) => s.id == creds.id); final next = [..._servers]; if (idx >= 0) { @@ -233,8 +240,8 @@ class ConnectionController extends StateNotifier { final connectionProvider = StateNotifierProvider( - (ref) => ConnectionController(ref.watch(credentialStoreProvider)), -); + (ref) => ConnectionController(ref.watch(credentialStoreProvider)), + ); /// The active client, or null when not connected. final subsonicClientProvider = Provider( @@ -264,9 +271,20 @@ final browseModeProvider = StateProvider((_) => BrowseMode.artists); // ---- 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>( + (ref) => + ref.watch(downloadManagerProvider).completed.map((d) => d.song).toList(), +); + final artistsProvider = FutureProvider>((ref) async { final client = ref.watch(subsonicClientProvider); - if (client == null) return const []; + // Offline: synthesize the artist list from what's downloaded, so it + // repopulates as downloads complete and recomputes on connect/disconnect. + if (client == null) { + return artistsFromSongs(ref.watch(downloadedSongsProvider)); + } final result = await client.getArtists(); return result.all; }); @@ -275,7 +293,11 @@ final artistsProvider = FutureProvider>((ref) async { /// truncated. Backs the Albums cover-art grid. final albumsProvider = FutureProvider>((ref) async { final client = ref.watch(subsonicClientProvider); - if (client == null) return const []; + // Offline: synthesize the album grid from downloaded songs (watched + // synchronously up-front, before any await, so it recomputes as they land). + if (client == null) { + return albumsFromSongs(ref.watch(downloadedSongsProvider)); + } const pageSize = 500; final all = []; var offset = 0; @@ -292,23 +314,26 @@ final albumsProvider = FutureProvider>((ref) async { /// (and any in-flight build cancelled) whenever the server changes. final libraryIndexProvider = StateNotifierProvider((ref) { - final controller = - LibraryIndexController(() => ref.read(subsonicClientProvider)); - ref.listen(connectionProvider, (_, _) { - controller.onConnectionChanged(); - }); - return controller; -}); + final controller = LibraryIndexController( + () => ref.read(subsonicClientProvider), + ); + ref.listen(connectionProvider, (_, _) { + controller.onConnectionChanged(); + }); + return controller; + }); // ---- Browse filtering / sorting ----------------------------------------- /// Session-only genre/year filter for the Albums grid (resets on restart). -final albumFilterProvider = - StateProvider((_) => const BrowseFilter()); +final albumFilterProvider = StateProvider( + (_) => const BrowseFilter(), +); /// Session-only genre/year filter for the Tracks list (resets on restart). -final trackFilterProvider = - StateProvider((_) => const BrowseFilter()); +final trackFilterProvider = StateProvider( + (_) => const BrowseFilter(), +); /// Distinct genres present across all albums, for the album genre picker. final albumGenresProvider = Provider>((ref) { @@ -332,23 +357,36 @@ final visibleAlbumsProvider = Provider>>((ref) { .whenData((albums) => applyAlbumQuery(albums, filter, sort)); }); -/// Distinct genres present across all indexed tracks. +/// Distinct genres present across all indexed tracks. Falls back to the +/// downloaded songs when offline (the crawled index is wiped without a server). final trackGenresProvider = Provider>((ref) { - final songs = ref.watch(libraryIndexProvider).songs; + final offline = ref.watch(subsonicClientProvider) == null; + final songs = offline + ? ref.watch(downloadedSongsProvider) + : ref.watch(libraryIndexProvider).songs; return distinctGenres(songs.map((s) => s.genre)); }); /// Distinct release years present across all indexed tracks, newest first. +/// Falls back to the downloaded songs when offline. final trackYearsProvider = Provider>((ref) { - final songs = ref.watch(libraryIndexProvider).songs; + final offline = ref.watch(subsonicClientProvider) == null; + final songs = offline + ? ref.watch(downloadedSongsProvider) + : ref.watch(libraryIndexProvider).songs; return distinctYears(songs.map((s) => s.year)); }); /// 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>((ref) { final filter = ref.watch(trackFilterProvider); final sort = ref.watch(settingsProvider.select((s) => s.trackSort)); - final songs = ref.watch(libraryIndexProvider).songs; + final offline = ref.watch(subsonicClientProvider) == null; + final songs = offline + ? ref.watch(downloadedSongsProvider) + : ref.watch(libraryIndexProvider).songs; // Live ratings so the Rating sort/filter reacts to star changes immediately. final ratings = ref.watch(favoritesProvider).ratings; return applyTrackQuery(songs, filter, sort, ratings: ratings); @@ -373,18 +411,32 @@ final randomAlbumsProvider = FutureProvider>((ref) async { final artistProvider = FutureProvider.family((ref, id) async { final client = ref.watch(subsonicClientProvider); - if (client == null) throw StateError('Not connected'); + // Offline: rebuild the artist from downloaded songs. [id] is whatever + // [artistsFromSongs] produced (real id or name), so it's passed straight + // through. Keep throwing when absent so the FutureProvider error state works. + if (client == null) { + final artist = artistFromSongs(ref.watch(downloadedSongsProvider), id); + if (artist == null) throw StateError('Not found offline'); + return artist; + } return client.getArtist(id); }); final albumProvider = FutureProvider.family((ref, id) async { final client = ref.watch(subsonicClientProvider); - if (client == null) throw StateError('Not connected'); + // Offline: rebuild the album from downloaded songs (see [artistProvider]). + if (client == null) { + final album = albumFromSongs(ref.watch(downloadedSongsProvider), id); + if (album == null) throw StateError('Not found offline'); + return album; + } return client.getAlbum(id); }); -final searchProvider = - FutureProvider.family((ref, query) async { +final searchProvider = FutureProvider.family(( + ref, + query, +) async { final client = ref.watch(subsonicClientProvider); final q = query.trim(); if (client == null || q.isEmpty) { @@ -394,7 +446,9 @@ final searchProvider = // "Standard" trims the server's broad matches down to name/title hits; // "Discovery" (default) returns the server result unchanged. final mode = ref.watch(settingsProvider).searchMode; - return mode == SearchMode.standard ? filterSearchToStandard(result, q) : result; + return mode == SearchMode.standard + ? filterSearchToStandard(result, q) + : result; }); /// Narrows a [SearchResult3] to items whose *own* name/title contains [query] @@ -414,8 +468,8 @@ SearchResult3 filterSearchToStandard(SearchResult3 r, String query) { final playHistoryProvider = StateNotifierProvider>( - (ref) => HistoryController(), -); + (ref) => HistoryController(), + ); final recentSongsProvider = Provider>( (ref) => recentSongs(ref.watch(playHistoryProvider)), @@ -438,18 +492,19 @@ final rediscoverProvider = Provider>((ref) { final favoritesProvider = StateNotifierProvider((ref) { - final controller = - FavoritesController(() => ref.read(subsonicClientProvider)); - // Re-hydrate on connect, clear on disconnect. - ref.listen(connectionProvider, (prev, next) { - if (next.isOnline) { - controller.hydrate(); - } else { - controller.clear(); - } - }); - return controller; -}); + final controller = FavoritesController( + () => ref.read(subsonicClientProvider), + ); + // Re-hydrate on connect, clear on disconnect. + ref.listen(connectionProvider, (prev, next) { + if (next.isOnline) { + controller.hydrate(); + } else { + controller.clear(); + } + }); + return controller; + }); /// Full starred set for the Favorites screen. final starredProvider = FutureProvider((ref) async { @@ -468,21 +523,21 @@ final starredProvider = FutureProvider((ref) async { /// their status. Reloads its manifest whenever the server key changes. final downloadManagerProvider = StateNotifierProvider((ref) { - final controller = DownloadController( - clientGetter: () => ref.read(subsonicClientProvider), - settingsGetter: () => ref.read(settingsProvider), - serverKeyGetter: () => ref.read(serverKeyProvider), - ); - ref.listen(serverKeyProvider, (_, _) { - controller.reloadForServer(); - }); - // Raising the concurrency cap should launch queued downloads right away. - ref.listen( - settingsProvider.select((s) => s.maxConcurrentDownloads), - (_, _) => controller.onConcurrencyChanged(), - ); - return controller; -}); + final controller = DownloadController( + clientGetter: () => ref.read(subsonicClientProvider), + settingsGetter: () => ref.read(settingsProvider), + serverKeyGetter: () => ref.read(serverKeyProvider), + ); + ref.listen(serverKeyProvider, (_, _) { + controller.reloadForServer(); + }); + // Raising the concurrency cap should launch queued downloads right away. + ref.listen( + settingsProvider.select((s) => s.maxConcurrentDownloads), + (_, _) => controller.onConcurrencyChanged(), + ); + return controller; + }); // ---- Playlists ---------------------------------------------------------- @@ -490,34 +545,38 @@ final downloadManagerProvider = /// the server key changes (connect / disconnect / server switch). final playlistsProvider = StateNotifierProvider((ref) { - final controller = PlaylistsController( - clientGetter: () => ref.read(subsonicClientProvider), - serverKeyGetter: () => ref.read(serverKeyProvider), - ); - ref.listen(serverKeyProvider, (_, _) { - controller.reloadForServer(); - }); - return controller; -}); + final controller = PlaylistsController( + clientGetter: () => ref.read(subsonicClientProvider), + serverKeyGetter: () => ref.read(serverKeyProvider), + ); + ref.listen(serverKeyProvider, (_, _) { + controller.reloadForServer(); + }); + return controller; + }); /// User-facing playlists — everything *not* marked as a Timbre tag. Backs the /// Playlists screen and the "add to playlist" sheet. -final realPlaylistsProvider = Provider>((ref) => ref - .watch(playlistsProvider) - .playlists - .where((p) => !isTagPlaylist(p)) - .toList()); +final realPlaylistsProvider = Provider>( + (ref) => ref + .watch(playlistsProvider) + .playlists + .where((p) => !isTagPlaylist(p)) + .toList(), +); /// Tags — playlists carrying the tag comment marker. Backs the Tags screen and /// the "add tag" sheet. Same underlying store as [playlistsProvider]; only the /// partition differs. final tagsProvider = Provider>( - (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. /// Used to split owned playlists from ones shared by other users. final currentUsernameProvider = Provider( - (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 "add to playlist" sheet — you can only add tracks to your own playlists. @@ -538,10 +597,53 @@ final sharedPlaylistsProvider = Provider>((ref) { .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 ----------------------------------------------------------- -final playbackProvider = - StateNotifierProvider((ref) { +final playbackProvider = StateNotifierProvider(( + ref, +) { // Prefer a local downloaded file when one exists (works offline / survives // service interruptions); otherwise stream at the configured bitrate. Uri? streamUriFor(Song s) { @@ -561,11 +663,11 @@ final playbackProvider = ); } - Uri? coverArtUriFor(Song s) { - final client = ref.read(subsonicClientProvider); - if (client == null || s.coverArt == null) return null; - return client.coverArtUri(s.coverArt!, size: 512); - } + // Prefer the local cached art file, then fall back to the server URL — this + // makes offline art work in Now Playing / the lock screen where a downloaded + // file exists. Null only when there's no id and no local/remote source. + Uri? coverArtUriFor(Song s) => + resolveArtUri(ref, coverArt: s.coverArt, size: 512); final controller = PlaybackController( streamUriFor: streamUriFor, @@ -575,7 +677,12 @@ final playbackProvider = // Skip extraction entirely when the accent is pinned (static accent, or a // theme that locks its accent like Lavender). if (ref.read(settingsProvider).accentIsFixed) return; - final color = await extractAccent(NetworkImage(artUri.toString())); + // A resolved `file://` art URI (downloaded track) must load from disk, not + // the network — extract from a FileImage in that case, else a NetworkImage. + final ImageProvider image = artUri.isScheme('file') + ? FileImage(File(artUri.toFilePath())) + : NetworkImage(artUri.toString()); + final color = await extractAccent(image); if (color != null) ref.read(accentProvider.notifier).set(color); }, onPlay: (song) { diff --git a/lib/widgets/art_image.dart b/lib/widgets/art_image.dart new file mode 100644 index 0000000..13d405c --- /dev/null +++ b/lib/widgets/art_image.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../theme/tokens.dart'; + +/// Cover art that renders from EITHER a local file (`file://` URI) or a network +/// URL, picking [FileImage] vs [NetworkImage] purely by the URI scheme. This is +/// the single place all art-rendering call sites go through, so a downloaded +/// track's art shows offline (the caller hands us the already-built URI string; +/// resolving download-vs-network happens upstream in `providers.dart`). +/// +/// Behaviour preserved from the old scattered `Image.network(...)` call sites: +/// * `fit: BoxFit.cover`, `gaplessPlayback: true`, and a `ValueKey(uri)` so +/// switching tracks keeps the previous frame until the new art decodes (no +/// flash) and rebuilds cleanly. +/// * a surface-filled [placeholder] with a muted album icon for the null, +/// loading-error, and missing-file cases (via `errorBuilder`). +/// +/// Sizing is never hardcoded — [width]/[height]/[fit] are respected as passed. +/// Optional [borderRadius] clips the art with a [ClipRRect]; callers that pass +/// it should drop their own outer `ClipRRect` so we don't double-clip. +class ArtImage extends StatelessWidget { + const ArtImage( + this.uri, { + super.key, + this.fit = BoxFit.cover, + this.width, + this.height, + this.placeholder, + this.borderRadius, + }); + + /// The already-built art URI. A `file://` URI loads from disk; anything else + /// (http/https) loads over the network. `null` → [placeholder]. + final String? uri; + + final BoxFit fit; + final double? width; + final double? height; + + /// Shown for null/error/missing art. Defaults to [_ArtPlaceholder]. + final Widget? placeholder; + + /// If set, the art (and placeholder) are clipped to these rounded corners. + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final fallback = placeholder ?? const _ArtPlaceholder(); + + Widget child; + if (uri == null) { + child = fallback; + } else { + final parsed = Uri.tryParse(uri!); + final ImageProvider provider = (parsed != null && parsed.scheme == 'file') + ? FileImage(File(parsed.toFilePath())) + : NetworkImage(uri!); + child = Image( + image: provider, + key: ValueKey(uri), + fit: fit, + gaplessPlayback: true, + errorBuilder: (_, _, _) => fallback, + ); + } + + // Back the art with the surface fill so transparent/loading gaps read as + // a panel rather than the bare canvas (matches the old ColoredBox wrap). + child = ColoredBox(color: TimbreColors.surface, child: child); + + if (width != null || height != null) { + child = SizedBox(width: width, height: height, child: child); + } + if (borderRadius != null) { + child = ClipRRect(borderRadius: borderRadius!, child: child); + } + return child; + } +} + +/// The default art placeholder: a muted album glyph on the surface fill. Used +/// for null art and as the loading/error fallback. +class _ArtPlaceholder extends StatelessWidget { + const _ArtPlaceholder(); + + @override + Widget build(BuildContext context) => ColoredBox( + color: TimbreColors.surface, + child: Center( + child: Icon(Icons.album_outlined, color: TimbreColors.dimmed), + ), + ); +} diff --git a/lib/widgets/cassette_view.dart b/lib/widgets/cassette_view.dart index 3039892..1c408a0 100644 --- a/lib/widgets/cassette_view.dart +++ b/lib/widgets/cassette_view.dart @@ -7,6 +7,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; +import 'art_image.dart'; /// Animated cassette for the Now Playing screen. Composites, in the shell's /// `469×298` coordinate space, from back to front: @@ -44,13 +45,18 @@ class _CassetteViewState extends ConsumerState } void _onTick(Duration elapsed) { - final dt = (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond; + final dt = + (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond; _last = elapsed; if (dt <= 0) return; // Read (not watch) inside the ticker: the model drives repaints itself, and // watching here would rebuild the whole widget every position tick. final s = ref.read(activePlaybackProvider); - _model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress); + _model.update( + dt: dt, + playing: s.playing && s.supported, + progress: s.progress, + ); } @override @@ -118,8 +124,7 @@ class _CassetteViewState extends ConsumerState child: AnimatedBuilder( animation: _model, child: cog, - builder: (_, child) => - Transform.rotate(angle: angle(), child: child), + builder: (_, child) => Transform.rotate(angle: angle(), child: child), ), ); } @@ -133,15 +138,12 @@ class _LabelArt extends StatelessWidget { @override Widget build(BuildContext context) { - if (artUri == null) { - return ColoredBox(color: TimbreColors.surface); - } - return Image.network( - artUri!, - key: ValueKey(artUri), - fit: BoxFit.cover, // square art → wide label: crop the sides/top - gaplessPlayback: true, - errorBuilder: (_, _, _) => ColoredBox(color: TimbreColors.surface), + // Square art → wide label: crop the sides/top. A plain surface fill backs + // the null/error cases (no album glyph here — the shell frames the label). + return ArtImage( + artUri, + fit: BoxFit.cover, + placeholder: ColoredBox(color: TimbreColors.surface), ); } } @@ -193,9 +195,15 @@ class _TapePainter extends CustomPainter { canvas.drawRect(cfg.windowRect, Paint()..color = cfg.padColor); final tape = Paint()..color = cfg.tapeColor; canvas.drawCircle( - cfg.leftReel, cfg.radius(model.progress, supply: true), tape); + cfg.leftReel, + cfg.radius(model.progress, supply: true), + tape, + ); canvas.drawCircle( - cfg.rightReel, cfg.radius(model.progress, supply: false), tape); + cfg.rightReel, + cfg.radius(model.progress, supply: false), + tape, + ); canvas.restore(); } @@ -245,7 +253,8 @@ class _CassetteConfig { /// reel is full at p=0 and empty at p=1; the take-up reel is the reverse. double radius(double progress, {required bool supply}) { final frac = (supply ? 1 - progress : progress).clamp(0.0, 1.0); - final r2 = hubRadius * hubRadius + + final r2 = + hubRadius * hubRadius + (fullRadius * fullRadius - hubRadius * hubRadius) * frac; return math.sqrt(r2); } diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 11289eb..ac2f313 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; +import 'art_image.dart'; /// Persistent mini-player pinned above the tab bar. Visible only while a track /// is loaded; tapping the body jumps to the Now Playing tab. @@ -23,11 +24,12 @@ class MiniPlayer extends ConsumerWidget { final playing = ref.watch(activePlaybackProvider.select((s) => s.playing)); final controller = ref.read(playbackCommandsProvider); - final client = ref.watch(subsonicClientProvider); final accent = Theme.of(context).colorScheme.primary; - final artUri = (client != null && current.coverArt != null) - ? client.coverArtUri(current.coverArt!, size: 128).toString() - : null; + final artUri = resolveArtUriW( + ref, + coverArt: current.coverArt, + size: 128, + )?.toString(); return Column( mainAxisSize: MainAxisSize.min, @@ -45,17 +47,16 @@ class MiniPlayer extends ConsumerWidget { SizedBox( width: 40, height: 40, - child: ColoredBox( - color: TimbreColors.background, - child: artUri != null - ? Image.network( - artUri, - key: ValueKey(artUri), - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => const _ArtFallback(), - ) - : const _ArtFallback(), + // Keep the mini player's darker `background` fill behind the + // art (ArtImage's own fill is `surface`) by handing it a + // background-tinted placeholder for the null/error cases. + child: ArtImage( + artUri, + fit: BoxFit.cover, + placeholder: ColoredBox( + color: TimbreColors.background, + child: const _ArtFallback(), + ), ), ), const SizedBox(width: TimbreSpacing.md), @@ -83,9 +84,11 @@ class MiniPlayer extends ConsumerWidget { ), ), _btn(Icons.skip_previous, controller.previous), - _btn(playing ? Icons.pause : Icons.play_arrow, - controller.togglePlayPause, - color: accent), + _btn( + playing ? Icons.pause : Icons.play_arrow, + controller.togglePlayPause, + color: accent, + ), _btn(Icons.skip_next, controller.next), ], ), @@ -111,7 +114,9 @@ class _MiniProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); + final progress = ref.watch( + activePlaybackProvider.select((s) => s.progress), + ); final accent = Theme.of(context).colorScheme.primary; return SizedBox( height: 2, @@ -129,7 +134,6 @@ class _ArtFallback extends StatelessWidget { const _ArtFallback(); @override 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), + ); } diff --git a/test/offline_library_test.dart b/test/offline_library_test.dart new file mode 100644 index 0000000..7512444 --- /dev/null +++ b/test/offline_library_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:timbre/library/offline_library.dart'; +import 'package:timbre/subsonic/models.dart'; + +/// Builds a downloaded-track [Song] with just the fields the offline +/// reconstruction reads. Everything else defaults to null. +Song _song( + String id, { + String? title, + String? album, + String? albumId, + String? artist, + String? artistId, + String? coverArt, + int? track, + int? discNumber, + int? year, + String? genre, +}) => + Song( + id: id, + title: title, + album: album, + albumId: albumId, + artist: artist, + artistId: artistId, + coverArt: coverArt, + track: track, + discNumber: discNumber, + year: year, + genre: genre, + ); + +void main() { + group('offline_library — album reconstruction', () { + test('groups songs by albumId and synthesizes album metadata', () { + final songs = [ + _song('1', + title: 'A', + album: 'Rumours', + albumId: 'alb1', + artist: 'Fleetwood Mac', + artistId: 'art1', + coverArt: 'cov1', + track: 2, + year: 1977, + genre: 'Rock'), + _song('2', + title: 'B', + album: 'Rumours', + albumId: 'alb1', + artist: 'Fleetwood Mac', + artistId: 'art1', + coverArt: 'cov1', + track: 1, + year: 1977, + genre: 'Rock'), + ]; + final albums = albumsFromSongs(songs); + expect(albums, hasLength(1)); + final a = albums.single; + expect(a.id, 'alb1'); + expect(a.name, 'Rumours'); + expect(a.artist, 'Fleetwood Mac'); + expect(a.artistId, 'art1'); + expect(a.coverArt, 'cov1'); + expect(a.year, 1977); + expect(a.genre, 'Rock'); + expect(a.songCount, 2); + }); + + test('orders an album by disc then track, nulls last', () { + final songs = [ + _song('1', album: 'X', albumId: 'x', title: 'no-track'), + _song('2', album: 'X', albumId: 'x', title: 'd1t2', discNumber: 1, track: 2), + _song('3', album: 'X', albumId: 'x', title: 'd2t1', discNumber: 2, track: 1), + _song('4', album: 'X', albumId: 'x', title: 'd1t1', discNumber: 1, track: 1), + ]; + final ids = albumsFromSongs(songs).single.songs.map((s) => s.title).toList(); + expect(ids, ['d1t1', 'd1t2', 'd2t1', 'no-track']); + }); + + test('falls back to album name as key and id when albumId is missing', () { + final songs = [ + _song('1', album: 'Untitled Sessions', title: 'A'), + _song('2', album: 'Untitled Sessions', title: 'B'), + ]; + final albums = albumsFromSongs(songs); + expect(albums, hasLength(1)); + expect(albums.single.id, 'Untitled Sessions'); + expect(albums.single.songCount, 2); + }); + + test('skips songs with no album identity', () { + final songs = [ + _song('1', title: 'orphan'), + _song('2', album: 'Real', albumId: 'r', title: 'kept'), + ]; + final albums = albumsFromSongs(songs); + expect(albums, hasLength(1)); + expect(albums.single.id, 'r'); + }); + + test('sorts albums by name case-insensitively', () { + final songs = [ + _song('1', album: 'zebra', albumId: 'z'), + _song('2', album: 'Apple', albumId: 'a'), + _song('3', album: 'mango', albumId: 'm'), + ]; + final names = albumsFromSongs(songs).map((a) => a.name).toList(); + expect(names, ['Apple', 'mango', 'zebra']); + }); + }); + + group('offline_library — artist reconstruction', () { + test('groups by artistId and nests albums', () { + final songs = [ + _song('1', + album: 'One', + albumId: 'a1', + artist: 'Radiohead', + artistId: 'r', + coverArt: 'c1'), + _song('2', + album: 'Two', + albumId: 'a2', + artist: 'Radiohead', + artistId: 'r', + coverArt: 'c2'), + ]; + final artists = artistsFromSongs(songs); + expect(artists, hasLength(1)); + final a = artists.single; + expect(a.id, 'r'); + expect(a.name, 'Radiohead'); + expect(a.albumCount, 2); + expect(a.albums.map((al) => al.id), containsAll(['a1', 'a2'])); + expect(a.coverArt, isNotNull); + }); + + test('falls back to artist name as key and id', () { + final songs = [_song('1', album: 'X', albumId: 'x', artist: 'Nameless Band')]; + final artists = artistsFromSongs(songs); + expect(artists.single.id, 'Nameless Band'); + }); + }); + + group('offline_library — keyed lookups', () { + final songs = [ + _song('1', album: 'One', albumId: 'a1', artist: 'Band', artistId: 'b1'), + _song('2', album: 'Two', albumId: 'a2', artist: 'Band', artistId: 'b1'), + ]; + + test('albumFromSongs returns the matching album or null', () { + expect(albumFromSongs(songs, 'a2')?.name, 'Two'); + expect(albumFromSongs(songs, 'nope'), isNull); + }); + + test('artistFromSongs returns the matching artist or null', () { + final artist = artistFromSongs(songs, 'b1'); + expect(artist?.name, 'Band'); + expect(artist?.albumCount, 2); + expect(artistFromSongs(songs, 'nope'), isNull); + }); + }); + + test('empty input yields empty output', () { + expect(albumsFromSongs(const []), isEmpty); + expect(artistsFromSongs(const []), isEmpty); + expect(albumFromSongs(const [], 'x'), isNull); + expect(artistFromSongs(const [], 'x'), isNull); + }); +}