This commit is contained in:
Forrest 2026-08-06 21:32:28 -04:00
parent b6639fb1c9
commit 0a7af1d813
10 changed files with 743 additions and 35 deletions

245
eq-implementation-plan.md Normal file
View file

@ -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<EqBand> 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<EqBand> 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<void> setEnabled(bool)`, `Future<void> setCoeffs(List<BiquadCoeffs>, 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<bool>` 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.

View file

@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio_background/just_audio_background.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 'settings/settings_store.dart';
import 'shell/app_shell.dart'; import 'shell/app_shell.dart';
@ -15,6 +16,13 @@ import 'widgets/splash_screen.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); 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 // Lock-screen / notification transport is only available where audio_service
// has a backend — Android/iOS. Skipping init elsewhere keeps the Linux dev // has a backend — Android/iOS. Skipping init elsewhere keeps the Linux dev
// target (and tests) running. // target (and tests) running.

View file

@ -188,7 +188,12 @@ class PlaybackController extends StateNotifier<PlaybackState>
String? _restoredKey; String? _restoredKey;
static bool get _audioSupported => 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, /// 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). /// which reads it to send a newly-connected remote an immediate snapshot).

View file

@ -3,6 +3,8 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:bonsoir/bonsoir.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:nsd/nsd.dart'; import 'package:nsd/nsd.dart';
import 'messages.dart'; import 'messages.dart';
@ -33,42 +35,104 @@ class DiscoveredDevice {
String get id => name; String get id => name;
} }
/// Wraps `nsd` mDNS registration (advertise) + discovery (browse) behind a /// mDNS registration (advertise) + discovery (browse) behind a small,
/// small, app-shaped API. One instance owns at most one active registration /// app-shaped API. One instance owns at most one active registration and one
/// and one active browse at a time. /// active browse at a time.
/// ///
/// Advertising and browsing are independent: a device that is playing /// Advertising and browsing are independent: a device that is playing
/// advertises so others can find it, and any device can browse to control one. /// 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 /// TXT records carry the [serverKey] and protocol version so the browser can
/// filter to same-server, same-version peers before ever opening a socket. /// filter to same-server, same-version peers before ever opening a socket.
class RemoteDiscovery { ///
static const _kServerKey = 'sk'; /// There are two backends behind this interface, chosen by [RemoteDiscovery]'s
static const _kVersion = 'v'; /// 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; /// Latest discovered, filtered device list (also replayed as a stream).
Discovery? _discovery; List<DiscoveredDevice> get current;
ServiceListener? _listener; Stream<List<DiscoveredDevice>> get devices;
bool get isAdvertising;
bool get isBrowsing;
/// Publish this device as a controllable player on [port]. Replaces any
/// existing advertisement.
Future<void> advertise({
required String deviceName,
required int port,
required String serverKey,
});
Future<void> stopAdvertising();
/// Start browsing for players on [serverKey]'s network. Emits filtered
/// [DiscoveredDevice] lists on [devices]. Replaces any existing browse.
Future<void> startBrowsing(String serverKey);
Future<void> stopBrowsing();
Future<void> 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<List<DiscoveredDevice>> _devices =
StreamController<List<DiscoveredDevice>>.broadcast();
List<DiscoveredDevice> _current = const [];
/// The final (possibly conflict-renamed) name of our own advertisement, so /// The final (possibly conflict-renamed) name of our own advertisement, so
/// the browser can filter it out of its own results. /// the browser can filter it out of its own results.
String? _ownName; String? _ownName;
String? _browseServerKey; String? _browseServerKey;
final StreamController<List<DiscoveredDevice>> _devices =
StreamController<List<DiscoveredDevice>>.broadcast();
List<DiscoveredDevice> _current = const [];
/// Latest discovered, filtered device list (also replayed as a stream).
List<DiscoveredDevice> get current => _current; List<DiscoveredDevice> get current => _current;
Stream<List<DiscoveredDevice>> get devices => _devices.stream; Stream<List<DiscoveredDevice>> 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<DiscoveredDevice> all) {
_current = all.where(_accepts).toList();
if (!_devices.isClosed) _devices.add(_current);
}
Future<void> _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; bool get isAdvertising => _registration != null;
@override
bool get isBrowsing => _discovery != null; bool get isBrowsing => _discovery != null;
// ---- Advertise -------------------------------------------------------- // ---- Advertise --------------------------------------------------------
/// Publish this device as a controllable player on [port]. Replaces any @override
/// existing advertisement.
Future<void> advertise({ Future<void> advertise({
required String deviceName, required String deviceName,
required int port, required int port,
@ -88,6 +152,7 @@ class RemoteDiscovery {
_ownName = reg.service.name; _ownName = reg.service.name;
} }
@override
Future<void> stopAdvertising() async { Future<void> stopAdvertising() async {
final reg = _registration; final reg = _registration;
_registration = null; _registration = null;
@ -103,8 +168,7 @@ class RemoteDiscovery {
// ---- Browse ----------------------------------------------------------- // ---- Browse -----------------------------------------------------------
/// Start browsing for players on [serverKey]'s network. Emits filtered @override
/// [DiscoveredDevice] lists on [devices]. Replaces any existing browse.
Future<void> startBrowsing(String serverKey) async { Future<void> startBrowsing(String serverKey) async {
await stopBrowsing(); await stopBrowsing();
_browseServerKey = serverKey; _browseServerKey = serverKey;
@ -121,6 +185,7 @@ class RemoteDiscovery {
_rebuild(); _rebuild();
} }
@override
Future<void> stopBrowsing() async { Future<void> stopBrowsing() async {
final discovery = _discovery; final discovery = _discovery;
final listener = _listener; final listener = _listener;
@ -138,28 +203,17 @@ class RemoteDiscovery {
} }
} }
@override
Future<void> dispose() async { Future<void> dispose() async {
await stopAdvertising(); await stopAdvertising();
await stopBrowsing(); await stopBrowsing();
await _devices.close(); await _closeDevices();
} }
void _rebuild() { void _rebuild() {
final discovery = _discovery; final discovery = _discovery;
if (discovery == null) return; if (discovery == null) return;
final out = <DiscoveredDevice>[]; _emit(discovery.services.map(_toDevice).whereType<DiscoveredDevice>());
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);
} }
DiscoveredDevice? _toDevice(Service s) { DiscoveredDevice? _toDevice(Service s) {
@ -198,3 +252,165 @@ class RemoteDiscovery {
return utf8.decode(v, allowMalformed: true); 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<BonsoirBroadcastEvent>? _broadcastSub;
BonsoirDiscovery? _discovery;
StreamSubscription<BonsoirDiscoveryEvent>? _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<String, DiscoveredDevice> _found = {};
@override
bool get isAdvertising => _broadcast != null;
@override
bool get isBrowsing => _discovery != null;
// ---- Advertise --------------------------------------------------------
@override
Future<void> 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<void> 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<void> 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<void> 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<void> 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);
}
}

View file

@ -24,7 +24,7 @@ class RemoteControlState {
this.remoteState, 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; final bool supported;
/// This device is published as a controllable player. /// This device is published as a controllable player.
@ -114,7 +114,11 @@ class RemoteControlController extends StateNotifier<RemoteControlState> {
PlaybackCommands? get remoteCommands => _proxy; PlaybackCommands? get remoteCommands => _proxy;
static bool _supported() => 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 /// Mutate state only while still mounted. Async hosting/session callbacks can
/// resolve after the provider is disposed (e.g. app teardown); touching /// resolve after the provider is disposed (e.g. app teardown); touching
@ -274,6 +278,7 @@ class RemoteControlController extends StateNotifier<RemoteControlState> {
if (Platform.isIOS) return 'iOS device'; if (Platform.isIOS) return 'iOS device';
if (Platform.isAndroid) return 'Android device'; if (Platform.isAndroid) return 'Android device';
if (Platform.isMacOS) return 'Mac'; if (Platform.isMacOS) return 'Mac';
if (Platform.isLinux) return 'Linux';
return 'Timbre'; return 'Timbre';
} }

View file

@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); 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);
} }

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux flutter_secure_storage_linux
media_kit_libs_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

113
packaging/build_deb.sh Executable file
View file

@ -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" <<EOF
[Desktop Entry]
Type=Application
Name=Timbre
Comment=Subsonic music client
Exec=timbre
Icon=timbre
Terminal=false
Categories=AudioVideo;Audio;Player;
StartupWMClass=${APPID}
EOF
INSTALLED_KB="$(du -sk "$PKGDIR/opt" "$PKGDIR/usr" | awk '{s+=$1} END {print s}')"
cat > "$PKGDIR/DEBIAN/control" <<EOF
Package: timbre
Version: ${VERSION}
Section: sound
Priority: optional
Architecture: ${ARCH}
Maintainer: Forrest <lchurch@conversionpath.com>
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"

View file

@ -57,6 +57,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.4" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -129,6 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@ -384,6 +440,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.1-beta.17" 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: just_audio_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -456,6 +520,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" 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: meta:
dependency: transitive dependency: transitive
description: description:
@ -688,6 +768,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.28.0" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -805,6 +893,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: uuid:
dependency: transitive dependency: transitive
description: description:

View file

@ -43,9 +43,16 @@ dependencies:
collection: ^1.19.1 collection: ^1.19.1
just_audio: ^0.10.6 just_audio: ^0.10.6
just_audio_background: ^0.0.1-beta.17 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 palette_generator: ^0.3.3+7
flutter_svg: ^2.3.0 flutter_svg: ^2.3.0
nsd: ^5.0.1 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: dev_dependencies:
flutter_test: flutter_test: