diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5c70417..c798b7a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,10 @@
+
+
+
+ NSLocalNetworkUsageDescription
+ Timbre discovers and controls other Timbre devices on your Wi-Fi network so you can play from one device and control it from another.
+ NSBonjourServices
+
+ _timbre._tcp
+
UIBackgroundModes
audio
diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart
index e8147f8..03e1d5f 100644
--- a/lib/playback/playback_engine.dart
+++ b/lib/playback/playback_engine.dart
@@ -105,11 +105,36 @@ class PlaybackState {
}
}
+/// The command surface the UI drives, independent of *where* playback happens.
+///
+/// Implemented today only by [PlaybackController] (the local engine). Once
+/// cross-device control lands (updates-features.md #3), a `RemotePlaybackProxy`
+/// will also implement it, serializing each call into a LAN message to the
+/// device that is actually playing. The UI binds to this interface via
+/// `playbackCommandsProvider` so neither implementation leaks into widgets.
+abstract interface class PlaybackCommands {
+ Future playSongs(List songs, {int startIndex = 0});
+ Future jumpTo(int index);
+ Future playNext(Song song);
+ Future addToQueue(Song song);
+ Future removeAt(int index);
+ Future reorderQueue(int oldIndex, int newIndex);
+ Future togglePlayPause();
+ Future next();
+ Future previous();
+ Future seek(Duration position);
+ Future toggleShuffle();
+ Future cycleLoop();
+ Future retry();
+ void resyncFromPlayer();
+}
+
/// Owns the just_audio player and translates Subsonic songs into a gapless
/// queue. Playback methods no-op where audio is unsupported, but queue/current
/// state and album-art accent extraction still run so the UI is fully alive on
/// the Linux dev target.
-class PlaybackController extends StateNotifier {
+class PlaybackController extends StateNotifier
+ implements PlaybackCommands {
PlaybackController({
required Uri? Function(Song) streamUriFor,
required Uri? Function(Song) coverArtUriFor,
@@ -157,6 +182,11 @@ class PlaybackController extends StateNotifier {
static bool get _audioSupported =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
+ /// 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).
+ /// Widgets should watch `activePlaybackProvider` instead.
+ PlaybackState get currentState => state;
+
void _wireStreams() {
final player = _player!;
player.currentIndexStream.listen((i) {
@@ -246,6 +276,7 @@ class PlaybackController extends StateNotifier {
/// into the player, so queue index == player source index 1:1. Every later
/// queue mutation (`playNext` / `addToQueue` / `removeAt`) relies on that
/// invariant to stay aligned.
+ @override
Future playSongs(List songs, {int startIndex = 0}) async {
if (songs.isEmpty) return;
@@ -317,6 +348,7 @@ class PlaybackController extends StateNotifier {
/// Start playing the queue entry at [index] without rebuilding the queue —
/// used by the queue list so tapping a row jumps to it and keeps the current
/// (possibly shuffled) order intact.
+ @override
Future jumpTo(int index) async {
final q = state.queue;
if (index < 0 || index >= q.length) return;
@@ -339,6 +371,7 @@ class PlaybackController extends StateNotifier {
/// Insert [song] right after the current track (Timbre's "play next").
/// Falls back to [playSongs] when nothing is playing.
+ @override
Future playNext(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
@@ -353,6 +386,7 @@ class PlaybackController extends StateNotifier {
}
/// Append [song] to the end of the queue.
+ @override
Future addToQueue(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
@@ -368,6 +402,7 @@ class PlaybackController extends StateNotifier {
/// playlist aligned. On mobile just_audio adjusts its own current index and
/// re-emits `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a
/// spurious re-scrobble when the playing track didn't actually change.
+ @override
Future removeAt(int index) async {
final q = state.queue;
if (index < 0 || index >= q.length) return;
@@ -415,6 +450,7 @@ class PlaybackController extends StateNotifier {
/// among the shuffled tail; hitting shuffle afterwards re-randomizes as usual.
/// A single targeted `moveAudioSource` keeps the current track playing without
/// the re-buffer a full source rebuild ([_applyOrder]) would cause.
+ @override
Future reorderQueue(int oldIndex, int newIndex) async {
final q = state.queue;
if (oldIndex < 0 || oldIndex >= q.length) return;
@@ -452,6 +488,7 @@ class PlaybackController extends StateNotifier {
await player.moveAudioSource(oldIndex, newIndex);
}
+ @override
Future togglePlayPause() async {
final player = _player;
if (player == null) return;
@@ -462,10 +499,13 @@ class PlaybackController extends StateNotifier {
}
}
+ @override
Future next() async => _player?.seekToNext();
+ @override
Future previous() async => _player?.seekToPrevious();
+ @override
Future seek(Duration position) async => _player?.seek(position);
/// Toggle shuffle by physically reordering the queue.
@@ -478,6 +518,7 @@ class PlaybackController extends StateNotifier {
/// (and already-played history) in place; disabling restores the pre-shuffle
/// order, dropping anything since removed and appending anything since added.
/// The player's own shuffle mode is left off permanently.
+ @override
Future toggleShuffle() async {
final enabled = !state.shuffle;
final q = state.queue;
@@ -587,6 +628,7 @@ class PlaybackController extends StateNotifier {
/// Re-attempt the current queue after an [PlaybackError] (e.g. the network
/// came back). Rebuilds sources from the current queue — re-resolving each
/// URI, so any now-available local download is preferred — and resumes.
+ @override
Future retry() async {
if (state.queue.isEmpty) return;
try {
@@ -615,6 +657,7 @@ class PlaybackController extends StateNotifier {
/// frozen (often at 0:00) when the app returns to the foreground. Called from
/// the app-lifecycle `resumed` hook. Reads only — never rebuilds sources or
/// touches the queue order, so the queue-index==source-index invariant holds.
+ @override
void resyncFromPlayer() {
final player = _player;
if (player == null) return;
@@ -639,6 +682,7 @@ class PlaybackController extends StateNotifier {
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
/// single-track repeat).
+ @override
Future cycleLoop() async {
final nextMode = switch (state.loop) {
LoopMode.off => LoopMode.all,
diff --git a/lib/remote/command_dispatch.dart b/lib/remote/command_dispatch.dart
new file mode 100644
index 0000000..d96d67e
--- /dev/null
+++ b/lib/remote/command_dispatch.dart
@@ -0,0 +1,41 @@
+import '../playback/playback_engine.dart';
+import 'messages.dart';
+
+/// Apply a wire [RemoteCommand] to a [PlaybackCommands] target — the single
+/// place the protocol's verbs map onto the playback surface. The host uses it
+/// to drive its local engine from a remote's commands; keeping it transport-
+/// free (no sockets) lets the loopback test exercise the mapping directly.
+Future applyRemoteCommand(
+ PlaybackCommands target,
+ RemoteCommand cmd,
+) async {
+ switch (cmd.op) {
+ case RemoteOp.playPause:
+ await target.togglePlayPause();
+ case RemoteOp.next:
+ await target.next();
+ case RemoteOp.previous:
+ await target.previous();
+ case RemoteOp.seek:
+ await target.seek(Duration(milliseconds: cmd.positionMs ?? 0));
+ case RemoteOp.jumpTo:
+ await target.jumpTo(cmd.index ?? 0);
+ case RemoteOp.playSongs:
+ await target.playSongs(cmd.songs ?? const [],
+ startIndex: cmd.startIndex ?? 0);
+ case RemoteOp.playNext:
+ if (cmd.song != null) await target.playNext(cmd.song!);
+ case RemoteOp.addToQueue:
+ if (cmd.song != null) await target.addToQueue(cmd.song!);
+ case RemoteOp.removeAt:
+ await target.removeAt(cmd.index ?? 0);
+ case RemoteOp.reorder:
+ await target.reorderQueue(cmd.oldIndex ?? 0, cmd.newIndex ?? 0);
+ case RemoteOp.toggleShuffle:
+ await target.toggleShuffle();
+ case RemoteOp.cycleLoop:
+ await target.cycleLoop();
+ case RemoteOp.retry:
+ await target.retry();
+ }
+}
diff --git a/lib/remote/discovery.dart b/lib/remote/discovery.dart
new file mode 100644
index 0000000..827ce4c
--- /dev/null
+++ b/lib/remote/discovery.dart
@@ -0,0 +1,200 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:nsd/nsd.dart';
+
+import 'messages.dart';
+
+/// A host advertising itself on the LAN, resolved enough to connect to.
+class DiscoveredDevice {
+ const DiscoveredDevice({
+ required this.name,
+ required this.host,
+ required this.port,
+ required this.serverKey,
+ required this.version,
+ });
+
+ final String name;
+ final String host;
+ final int port;
+
+ /// The host's Subsonic [serverKey]; only matches are shown/controllable.
+ final String serverKey;
+ final int version;
+
+ /// `ws://host:port` — the WebSocket endpoint `remote_session.dart` dials.
+ Uri get wsUri => Uri(scheme: 'ws', host: host, port: port);
+
+ /// Stable identity for list diffing / selection (mDNS instance names are
+ /// unique per network).
+ 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.
+///
+/// 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';
+
+ Registration? _registration;
+ Discovery? _discovery;
+ ServiceListener? _listener;
+
+ /// The final (possibly conflict-renamed) name of our own advertisement, so
+ /// the browser can filter it out of its own results.
+ String? _ownName;
+ String? _browseServerKey;
+
+ final StreamController> _devices =
+ StreamController>.broadcast();
+ List _current = const [];
+
+ /// Latest discovered, filtered device list (also replayed as a stream).
+ List get current => _current;
+ Stream> get devices => _devices.stream;
+
+ bool get isAdvertising => _registration != null;
+ bool get isBrowsing => _discovery != null;
+
+ // ---- Advertise --------------------------------------------------------
+
+ /// Publish this device as a controllable player on [port]. Replaces any
+ /// existing advertisement.
+ Future advertise({
+ required String deviceName,
+ required int port,
+ required String serverKey,
+ }) async {
+ await stopAdvertising();
+ final reg = await register(Service(
+ name: deviceName,
+ type: kRemoteServiceType,
+ port: port,
+ txt: {
+ _kServerKey: _bytes(serverKey),
+ _kVersion: _bytes('$kRemoteProtocolVersion'),
+ },
+ ));
+ _registration = reg;
+ _ownName = reg.service.name;
+ }
+
+ Future stopAdvertising() async {
+ final reg = _registration;
+ _registration = null;
+ _ownName = null;
+ if (reg != null) {
+ try {
+ await unregister(reg);
+ } catch (_) {
+ // Already gone / platform hiccup — nothing to recover.
+ }
+ }
+ }
+
+ // ---- Browse -----------------------------------------------------------
+
+ /// Start browsing for players on [serverKey]'s network. Emits filtered
+ /// [DiscoveredDevice] lists on [devices]. Replaces any existing browse.
+ Future startBrowsing(String serverKey) async {
+ await stopBrowsing();
+ _browseServerKey = serverKey;
+ // v4 lookup so we get a routable address to dial rather than a bare
+ // `.local` hostname the socket layer may not resolve.
+ final discovery = await startDiscovery(
+ kRemoteServiceType,
+ ipLookupType: IpLookupType.v4,
+ );
+ _discovery = discovery;
+ void listener(Service service, ServiceStatus status) => _rebuild();
+ _listener = listener;
+ discovery.addServiceListener(listener);
+ _rebuild();
+ }
+
+ Future stopBrowsing() async {
+ final discovery = _discovery;
+ final listener = _listener;
+ _discovery = null;
+ _listener = null;
+ _browseServerKey = null;
+ _current = const [];
+ if (discovery != null) {
+ if (listener != null) discovery.removeServiceListener(listener);
+ try {
+ await stopDiscovery(discovery);
+ } catch (_) {
+ // Already stopped.
+ }
+ }
+ }
+
+ Future dispose() async {
+ await stopAdvertising();
+ await stopBrowsing();
+ await _devices.close();
+ }
+
+ void _rebuild() {
+ final discovery = _discovery;
+ if (discovery == null) return;
+ final out = [];
+ for (final s in discovery.services) {
+ final dev = _toDevice(s);
+ if (dev == null) continue;
+ if (_browseServerKey != null && dev.serverKey != _browseServerKey) {
+ continue; // different Subsonic server — its song ids won't resolve here
+ }
+ if (dev.version != kRemoteProtocolVersion) continue;
+ if (_ownName != null && s.name == _ownName) continue; // ourselves
+ out.add(dev);
+ }
+ _current = out;
+ if (!_devices.isClosed) _devices.add(out);
+ }
+
+ DiscoveredDevice? _toDevice(Service s) {
+ final port = s.port;
+ if (port == null) return null;
+ final host = _hostOf(s);
+ if (host == null) return null;
+ final txt = s.txt ?? const {};
+ return DiscoveredDevice(
+ name: s.name ?? host,
+ host: host,
+ port: port,
+ serverKey: _text(txt, _kServerKey) ?? '',
+ version: int.tryParse(_text(txt, _kVersion) ?? '') ?? 0,
+ );
+ }
+
+ /// Prefer a resolved IPv4 address; fall back to the reported hostname.
+ static String? _hostOf(Service s) {
+ final addrs = s.addresses;
+ if (addrs != null && addrs.isNotEmpty) {
+ final v4 = addrs.firstWhere(
+ (a) => a.type == InternetAddressType.IPv4,
+ orElse: () => addrs.first,
+ );
+ return v4.address;
+ }
+ return s.host;
+ }
+
+ static Uint8List _bytes(String s) => Uint8List.fromList(utf8.encode(s));
+
+ static String? _text(Map txt, String key) {
+ final v = txt[key];
+ if (v == null || v.isEmpty) return null;
+ return utf8.decode(v, allowMalformed: true);
+ }
+}
diff --git a/lib/remote/host_server.dart b/lib/remote/host_server.dart
new file mode 100644
index 0000000..be8d6a3
--- /dev/null
+++ b/lib/remote/host_server.dart
@@ -0,0 +1,273 @@
+// `controller` can't be a private-named parameter, so an initializing formal
+// isn't possible for `_controller` (same reason as playback_engine.dart).
+// ignore_for_file: prefer_initializing_formals
+
+import 'dart:async';
+import 'dart:io';
+
+import '../playback/playback_engine.dart';
+import 'command_dispatch.dart';
+import 'messages.dart';
+
+/// Turns the local device into a controllable player: runs an in-process
+/// WebSocket server, authenticates remotes against the shared server password,
+/// streams playback snapshots to them, and applies the commands they send to
+/// the local [PlaybackController].
+///
+/// Lifecycle is owned by `remote_providers.dart`, which starts the host while
+/// this device is playable and advertises its [port] over mDNS
+/// (`discovery.dart`). The server binds an ephemeral port on all IPv4
+/// interfaces; discovery carries the port to remotes.
+class RemoteHost {
+ RemoteHost({
+ required PlaybackController controller,
+ required this.serverKey,
+ required this.password,
+ required this.deviceName,
+ }) : _controller = controller;
+
+ final PlaybackController _controller;
+
+ /// Only remotes reporting this same [serverKey] (i.e. signed into the same
+ /// Subsonic server) are allowed to connect — song ids must resolve on both.
+ final String serverKey;
+
+ /// Shared secret for the HMAC handshake — never sent, only proven.
+ final String password;
+
+ /// Friendly name shown to remotes ("Living Room iPad").
+ final String deviceName;
+
+ HttpServer? _server;
+ void Function()? _removeListener;
+
+ final Set _clients = {};
+ final StreamController _clientCount =
+ StreamController.broadcast();
+
+ /// Monotonic snapshot sequence so a remote can drop stale/out-of-order state.
+ int _seq = 0;
+
+ /// The last state actually put on the wire, for cheap change detection.
+ PlaybackState? _sentState;
+ Timer? _throttle;
+ PlaybackState? _pending;
+
+ /// Bound port once [start] has run, else null.
+ int? get port => _server?.port;
+
+ /// Emits the number of connected remotes (drives the "N remotes" UI).
+ Stream get clientCountStream => _clientCount.stream;
+
+ int get clientCount => _clients.length;
+
+ /// Bind and begin serving. Returns the chosen ephemeral port. Idempotent —
+ /// a second call returns the already-bound port.
+ Future start() async {
+ final existing = _server;
+ if (existing != null) return existing.port;
+
+ final server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
+ _server = server;
+ server.listen((req) async {
+ if (!WebSocketTransformer.isUpgradeRequest(req)) {
+ req.response
+ ..statusCode = HttpStatus.forbidden
+ ..close();
+ return;
+ }
+ try {
+ final ws = await WebSocketTransformer.upgrade(req);
+ _handleSocket(ws);
+ } catch (_) {
+ // Failed upgrade — nothing to clean up.
+ }
+ });
+
+ // fireImmediately:false — the per-client welcome snapshot covers the
+ // initial state; we only want deltas here.
+ _removeListener =
+ _controller.addListener(_onStateChanged, fireImmediately: false);
+ return server.port;
+ }
+
+ /// Stop serving and drop every remote. Safe to call when not started.
+ Future stop() async {
+ _removeListener?.call();
+ _removeListener = null;
+ _throttle?.cancel();
+ _throttle = null;
+ _pending = null;
+ _sentState = null;
+ for (final c in _clients.toList()) {
+ await c.close().catchError((_) {});
+ }
+ _clients.clear();
+ if (!_clientCount.isClosed) _clientCount.add(0);
+ await _server?.close(force: true);
+ _server = null;
+ }
+
+ Future dispose() async {
+ await stop();
+ await _clientCount.close();
+ }
+
+ // ---- Handshake + per-client loop --------------------------------------
+
+ void _handleSocket(WebSocket socket) {
+ final nonce = RemoteAuth.newNonce();
+ var authed = false;
+
+ // Drop a peer that connects but never authenticates.
+ final authTimeout = Timer(const Duration(seconds: 10), () {
+ if (!authed) socket.close().catchError((_) {});
+ });
+
+ _send(
+ socket,
+ ChallengeMessage(
+ version: kRemoteProtocolVersion,
+ serverKey: serverKey,
+ nonce: nonce,
+ device: deviceName,
+ ),
+ );
+
+ socket.listen(
+ (data) async {
+ RemoteMessage msg;
+ try {
+ msg = RemoteMessage.decode(data as String);
+ } catch (_) {
+ return; // ignore a malformed frame
+ }
+
+ if (!authed) {
+ final denyReason = _rejectReason(msg, nonce);
+ if (denyReason != null) {
+ _send(socket, DenyMessage(reason: denyReason));
+ await socket.close().catchError((_) {});
+ return;
+ }
+ authed = true;
+ authTimeout.cancel();
+ _send(socket, WelcomeMessage(device: deviceName));
+ _clients.add(socket);
+ if (!_clientCount.isClosed) _clientCount.add(_clients.length);
+ // Immediate full snapshot so the remote paints without waiting for
+ // the next state change.
+ _send(socket, SnapshotMessage(_snapshotOf(_controller.currentState)));
+ return;
+ }
+
+ if (msg is CommandMessage) {
+ try {
+ await applyRemoteCommand(_controller, msg.command);
+ } catch (_) {
+ // A command that fails locally (e.g. offline host) surfaces via the
+ // next snapshot's error field — no need to tear down the client.
+ }
+ }
+ },
+ onDone: () {
+ authTimeout.cancel();
+ _removeClient(socket);
+ },
+ onError: (_) {
+ authTimeout.cancel();
+ _removeClient(socket);
+ },
+ cancelOnError: true,
+ );
+ }
+
+ /// Null if the auth message is acceptable, else a human-readable deny reason.
+ String? _rejectReason(RemoteMessage msg, String nonce) {
+ if (msg is! AuthMessage) return 'expected auth';
+ if (msg.version != kRemoteProtocolVersion) return 'protocol version';
+ if (msg.serverKey != serverKey) return 'different server';
+ if (!RemoteAuth.verify(
+ password: password,
+ nonce: nonce,
+ proof: msg.proof,
+ )) {
+ return 'authentication failed';
+ }
+ return null;
+ }
+
+ void _removeClient(WebSocket socket) {
+ if (_clients.remove(socket) && !_clientCount.isClosed) {
+ _clientCount.add(_clients.length);
+ }
+ }
+
+ // ---- Broadcast --------------------------------------------------------
+
+ /// Send discrete changes (play/pause, track, queue, shuffle/loop, error)
+ /// immediately; coalesce position-only ticks to at most one per second (the
+ /// remote interpolates position between snapshots).
+ void _onStateChanged(PlaybackState s) {
+ if (_clients.isEmpty) {
+ _sentState = s;
+ return;
+ }
+ final prev = _sentState;
+ if (prev == null || _discreteChanged(prev, s)) {
+ _flush(s);
+ } else {
+ _pending = s;
+ _throttle ??= Timer(const Duration(seconds: 1), () {
+ _throttle = null;
+ final p = _pending;
+ _pending = null;
+ if (p != null) _flush(p);
+ });
+ }
+ }
+
+ void _flush(PlaybackState s) {
+ _throttle?.cancel();
+ _throttle = null;
+ _pending = null;
+ _sentState = s;
+ final frame = SnapshotMessage(_snapshotOf(s)).encode();
+ for (final c in _clients.toList()) {
+ try {
+ c.add(frame);
+ } catch (_) {
+ _removeClient(c);
+ }
+ }
+ }
+
+ static bool _discreteChanged(PlaybackState a, PlaybackState b) =>
+ a.playing != b.playing ||
+ a.currentIndex != b.currentIndex ||
+ a.shuffle != b.shuffle ||
+ a.loop != b.loop ||
+ a.error != b.error ||
+ // copyWith reuses the same queue list unless it actually changed, so an
+ // identity check cheaply distinguishes a queue edit from a position tick.
+ !identical(a.queue, b.queue);
+
+ StateSnapshot _snapshotOf(PlaybackState s) => StateSnapshot(
+ queue: s.queue,
+ currentIndex: s.currentIndex,
+ playing: s.playing,
+ positionMs: s.position.inMilliseconds,
+ durationMs: s.effectiveDuration.inMilliseconds,
+ shuffle: s.shuffle,
+ loop: s.loop.name,
+ seq: ++_seq,
+ );
+
+ void _send(WebSocket socket, RemoteMessage msg) {
+ try {
+ socket.add(msg.encode());
+ } catch (_) {
+ // Socket already gone; the listen callbacks will clean it up.
+ }
+ }
+}
diff --git a/lib/remote/messages.dart b/lib/remote/messages.dart
new file mode 100644
index 0000000..d2d22c1
--- /dev/null
+++ b/lib/remote/messages.dart
@@ -0,0 +1,362 @@
+import 'dart:convert';
+import 'dart:math' show Random;
+
+import 'package:crypto/crypto.dart';
+
+import '../subsonic/models.dart';
+
+/// Wire protocol for LAN cross-device control (updates-features.md #3).
+///
+/// Deliberately dependency-light — only `dart:convert`, `crypto`, and the
+/// [Song] model — so the whole protocol is unit-testable without Flutter
+/// bindings or a real socket. Higher layers (`host_server.dart` /
+/// `remote_session.dart`) own the transport and the just_audio-facing
+/// [PlaybackState] <-> [StateSnapshot] conversion; nothing here imports
+/// just_audio or riverpod.
+///
+/// Bump [kRemoteProtocolVersion] on any breaking envelope/field change; both
+/// ends refuse to talk across a mismatch (see `host_server.dart`).
+const int kRemoteProtocolVersion = 1;
+
+/// mDNS/Bonjour service type advertised + browsed by [discovery]. iOS requires
+/// this exact string listed under `NSBonjourServices` in Info.plist.
+const String kRemoteServiceType = '_timbre._tcp';
+
+// ---- Auth ---------------------------------------------------------------
+
+/// Challenge-response auth keyed off the shared Subsonic password. Both devices
+/// are signed into the same server, so both know the password; proving it
+/// (without ever sending it) gates control to the same user without a manual
+/// PIN. The host issues a per-connection [newNonce]; the remote returns
+/// [proofFor]; the host [verify]s. A fresh nonce per connection stops replay.
+class RemoteAuth {
+ RemoteAuth._();
+
+ static final Random _rng = Random.secure();
+
+ /// A fresh 128-bit base64url nonce for one connection's challenge.
+ static String newNonce() {
+ final bytes = List.generate(16, (_) => _rng.nextInt(256));
+ return base64Url.encode(bytes);
+ }
+
+ /// `HMAC-SHA256(key = password, msg = nonce)`, hex-encoded.
+ static String proofFor({required String password, required String nonce}) {
+ final mac = Hmac(sha256, utf8.encode(password));
+ return mac.convert(utf8.encode(nonce)).toString();
+ }
+
+ /// Constant-time compare of a received [proof] against the expected one.
+ static bool verify({
+ required String password,
+ required String nonce,
+ required String proof,
+ }) {
+ final expected = proofFor(password: password, nonce: nonce);
+ if (expected.length != proof.length) return false;
+ var diff = 0;
+ for (var i = 0; i < expected.length; i++) {
+ diff |= expected.codeUnitAt(i) ^ proof.codeUnitAt(i);
+ }
+ return diff == 0;
+ }
+}
+
+// ---- Playback snapshot --------------------------------------------------
+
+/// A transport-friendly mirror of the host's `PlaybackState`. `loop` is the
+/// enum *name* (just_audio's `LoopMode`) so this file stays free of just_audio;
+/// the remote proxy maps it back. [seq] is a monotonic counter so a remote can
+/// drop a stale/out-of-order snapshot.
+class StateSnapshot {
+ const StateSnapshot({
+ required this.queue,
+ required this.currentIndex,
+ required this.playing,
+ required this.positionMs,
+ required this.durationMs,
+ required this.shuffle,
+ required this.loop,
+ required this.seq,
+ });
+
+ final List queue;
+ final int? currentIndex;
+ final bool playing;
+ final int positionMs;
+ final int durationMs;
+ final bool shuffle;
+ final String loop;
+ final int seq;
+
+ Map toJson() => {
+ 'queue': [for (final s in queue) s.toJson()],
+ 'currentIndex': currentIndex,
+ 'playing': playing,
+ 'positionMs': positionMs,
+ 'durationMs': durationMs,
+ 'shuffle': shuffle,
+ 'loop': loop,
+ 'seq': seq,
+ };
+
+ factory StateSnapshot.fromJson(Map j) => StateSnapshot(
+ queue: (j['queue'] as List? ?? const [])
+ .whereType