edits
This commit is contained in:
parent
b6639fb1c9
commit
0a7af1d813
10 changed files with 743 additions and 35 deletions
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue