edits
This commit is contained in:
parent
b6639fb1c9
commit
0a7af1d813
10 changed files with 743 additions and 35 deletions
245
eq-implementation-plan.md
Normal file
245
eq-implementation-plan.md
Normal 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.
|
||||
|
|
@ -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<void> 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.
|
||||
|
|
|
|||
|
|
@ -188,7 +188,12 @@ class PlaybackController extends StateNotifier<PlaybackState>
|
|||
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).
|
||||
|
|
|
|||
|
|
@ -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<DiscoveredDevice> get current;
|
||||
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 browser can filter it out of its own results.
|
||||
String? _ownName;
|
||||
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;
|
||||
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;
|
||||
@override
|
||||
bool get isBrowsing => _discovery != null;
|
||||
|
||||
// ---- Advertise --------------------------------------------------------
|
||||
|
||||
/// Publish this device as a controllable player on [port]. Replaces any
|
||||
/// existing advertisement.
|
||||
@override
|
||||
Future<void> advertise({
|
||||
required String deviceName,
|
||||
required int port,
|
||||
|
|
@ -88,6 +152,7 @@ class RemoteDiscovery {
|
|||
_ownName = reg.service.name;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> startBrowsing(String serverKey) async {
|
||||
await stopBrowsing();
|
||||
_browseServerKey = serverKey;
|
||||
|
|
@ -121,6 +185,7 @@ class RemoteDiscovery {
|
|||
_rebuild();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopBrowsing() async {
|
||||
final discovery = _discovery;
|
||||
final listener = _listener;
|
||||
|
|
@ -138,28 +203,17 @@ class RemoteDiscovery {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await stopAdvertising();
|
||||
await stopBrowsing();
|
||||
await _devices.close();
|
||||
await _closeDevices();
|
||||
}
|
||||
|
||||
void _rebuild() {
|
||||
final discovery = _discovery;
|
||||
if (discovery == null) return;
|
||||
final out = <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);
|
||||
_emit(discovery.services.map(_toDevice).whereType<DiscoveredDevice>());
|
||||
}
|
||||
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RemoteControlState> {
|
|||
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<RemoteControlState> {
|
|||
if (Platform.isIOS) return 'iOS device';
|
||||
if (Platform.isAndroid) return 'Android device';
|
||||
if (Platform.isMacOS) return 'Mac';
|
||||
if (Platform.isLinux) return 'Linux';
|
||||
return 'Timbre';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@
|
|||
#include "generated_plugin_registrant.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) {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
media_kit_libs_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
113
packaging/build_deb.sh
Executable file
113
packaging/build_deb.sh
Executable 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"
|
||||
104
pubspec.lock
104
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue