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/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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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"
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue