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: