From 2099d3d64d9d53c81bb5a3fa490cb95b32acf863 Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 4 Aug 2026 16:39:18 -0400 Subject: [PATCH] network addition --- android/app/src/main/AndroidManifest.xml | 4 + ios/Runner/Info.plist | 6 + lib/playback/playback_engine.dart | 46 ++- lib/remote/command_dispatch.dart | 41 +++ lib/remote/discovery.dart | 200 +++++++++++++ lib/remote/host_server.dart | 273 +++++++++++++++++ lib/remote/messages.dart | 362 +++++++++++++++++++++++ lib/remote/remote_playback.dart | 92 ++++++ lib/remote/remote_session.dart | 135 +++++++++ lib/screens/browser_screen.dart | 4 +- lib/screens/devices_sheet.dart | 228 ++++++++++++++ lib/screens/downloads_screen.dart | 2 +- lib/screens/favorites_screen.dart | 6 +- lib/screens/home_screen.dart | 6 +- lib/screens/now_playing_screen.dart | 104 +++++-- lib/screens/playlists_screen.dart | 2 +- lib/screens/search_screen.dart | 6 +- lib/shell/app_shell.dart | 2 +- lib/state/providers.dart | 24 ++ lib/state/remote_providers.dart | 295 ++++++++++++++++++ lib/widgets/cassette_view.dart | 2 +- lib/widgets/mini_player.dart | 8 +- pubspec.lock | 64 ++++ pubspec.yaml | 1 + test/remote_integration_test.dart | 173 +++++++++++ test/remote_protocol_test.dart | 157 ++++++++++ updates-features.md | 34 ++- 27 files changed, 2237 insertions(+), 40 deletions(-) create mode 100644 lib/remote/command_dispatch.dart create mode 100644 lib/remote/discovery.dart create mode 100644 lib/remote/host_server.dart create mode 100644 lib/remote/messages.dart create mode 100644 lib/remote/remote_playback.dart create mode 100644 lib/remote/remote_session.dart create mode 100644 lib/screens/devices_sheet.dart create mode 100644 lib/state/remote_providers.dart create mode 100644 test/remote_integration_test.dart create mode 100644 test/remote_protocol_test.dart 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() + .map((e) => Song.fromJson(e.cast())) + .toList(), + currentIndex: j['currentIndex'] as int?, + playing: j['playing'] == true, + positionMs: (j['positionMs'] as int?) ?? 0, + durationMs: (j['durationMs'] as int?) ?? 0, + shuffle: j['shuffle'] == true, + loop: (j['loop'] as String?) ?? 'off', + seq: (j['seq'] as int?) ?? 0, + ); +} + +// ---- Commands ----------------------------------------------------------- + +/// The remote-control verbs, 1:1 with the `PlaybackCommands` surface the UI +/// drives. `resyncFromPlayer` is intentionally absent — it is a local-engine +/// concern with no cross-device meaning (the remote gets state pushed to it). +enum RemoteOp { + playPause, + next, + previous, + seek, + jumpTo, + playSongs, + playNext, + addToQueue, + removeAt, + reorder, + toggleShuffle, + cycleLoop, + retry, +} + +/// One remote command plus whatever payload its [op] needs. A flat bag (rather +/// than a class-per-op hierarchy) keeps the codec and the call sites compact, +/// matching the model style elsewhere in the app. Use the named constructors so +/// each op only carries its own fields. +class RemoteCommand { + const RemoteCommand._( + this.op, { + this.index, + this.oldIndex, + this.newIndex, + this.startIndex, + this.positionMs, + this.songs, + this.song, + }); + + const RemoteCommand.playPause() : this._(RemoteOp.playPause); + const RemoteCommand.next() : this._(RemoteOp.next); + const RemoteCommand.previous() : this._(RemoteOp.previous); + const RemoteCommand.toggleShuffle() : this._(RemoteOp.toggleShuffle); + const RemoteCommand.cycleLoop() : this._(RemoteOp.cycleLoop); + const RemoteCommand.retry() : this._(RemoteOp.retry); + + const RemoteCommand.seek(int positionMs) + : this._(RemoteOp.seek, positionMs: positionMs); + const RemoteCommand.jumpTo(int index) : this._(RemoteOp.jumpTo, index: index); + const RemoteCommand.removeAt(int index) + : this._(RemoteOp.removeAt, index: index); + const RemoteCommand.reorder(int oldIndex, int newIndex) + : this._(RemoteOp.reorder, oldIndex: oldIndex, newIndex: newIndex); + + const RemoteCommand.playSongs(List songs, {int startIndex = 0}) + : this._(RemoteOp.playSongs, songs: songs, startIndex: startIndex); + const RemoteCommand.playNext(Song song) + : this._(RemoteOp.playNext, song: song); + const RemoteCommand.addToQueue(Song song) + : this._(RemoteOp.addToQueue, song: song); + + final RemoteOp op; + final int? index; + final int? oldIndex; + final int? newIndex; + final int? startIndex; + final int? positionMs; + final List? songs; + final Song? song; + + Map toJson() => { + 'op': op.name, + if (index != null) 'index': index, + if (oldIndex != null) 'oldIndex': oldIndex, + if (newIndex != null) 'newIndex': newIndex, + if (startIndex != null) 'startIndex': startIndex, + if (positionMs != null) 'positionMs': positionMs, + if (songs != null) 'songs': [for (final s in songs!) s.toJson()], + if (song != null) 'song': song!.toJson(), + }; + + factory RemoteCommand.fromJson(Map j) { + final op = RemoteOp.values.firstWhere( + (o) => o.name == j['op'], + orElse: () => throw const FormatException('unknown remote op'), + ); + Song? song; + if (j['song'] is Map) { + song = Song.fromJson((j['song'] as Map).cast()); + } + return RemoteCommand._( + op, + index: j['index'] as int?, + oldIndex: j['oldIndex'] as int?, + newIndex: j['newIndex'] as int?, + startIndex: j['startIndex'] as int?, + positionMs: j['positionMs'] as int?, + songs: j['songs'] is List + ? (j['songs'] as List) + .whereType() + .map((e) => Song.fromJson(e.cast())) + .toList() + : null, + song: song, + ); + } +} + +// ---- Envelope ----------------------------------------------------------- + +/// Every frame on the wire is one JSON object tagged by `t`. Handshake order: +/// host → [ChallengeMessage], remote → [AuthMessage], host → [WelcomeMessage] +/// (then a stream of [SnapshotMessage]) or [DenyMessage] + close. After the +/// handshake the remote sends [CommandMessage]s. +sealed class RemoteMessage { + const RemoteMessage(); + + Map toJson(); + + String encode() => jsonEncode(toJson()); + + /// Parse a wire frame. Throws [FormatException] on anything unrecognized so + /// the transport can drop a bad/hostile peer rather than mis-handle it. + static RemoteMessage decode(String raw) { + final j = jsonDecode(raw); + if (j is! Map) throw const FormatException('frame is not an object'); + final m = j.cast(); + switch (m['t']) { + case 'challenge': + return ChallengeMessage( + version: (m['v'] as int?) ?? 0, + serverKey: m['serverKey'] as String? ?? '', + nonce: m['nonce'] as String? ?? '', + device: m['device'] as String? ?? '', + ); + case 'auth': + return AuthMessage( + version: (m['v'] as int?) ?? 0, + serverKey: m['serverKey'] as String? ?? '', + proof: m['proof'] as String? ?? '', + device: m['device'] as String? ?? '', + ); + case 'welcome': + return WelcomeMessage(device: m['device'] as String? ?? ''); + case 'deny': + return DenyMessage(reason: m['reason'] as String? ?? ''); + case 'state': + return SnapshotMessage( + StateSnapshot.fromJson((m['snapshot'] as Map).cast()), + ); + case 'cmd': + return CommandMessage( + RemoteCommand.fromJson((m['cmd'] as Map).cast()), + ); + default: + throw FormatException('unknown message type: ${m['t']}'); + } + } +} + +/// host → remote: identifies the host and carries the auth nonce. +class ChallengeMessage extends RemoteMessage { + const ChallengeMessage({ + required this.version, + required this.serverKey, + required this.nonce, + required this.device, + }); + + final int version; + final String serverKey; + final String nonce; + final String device; + + @override + Map toJson() => { + 't': 'challenge', + 'v': version, + 'serverKey': serverKey, + 'nonce': nonce, + 'device': device, + }; +} + +/// remote → host: proves knowledge of the shared password and names itself. +class AuthMessage extends RemoteMessage { + const AuthMessage({ + required this.version, + required this.serverKey, + required this.proof, + required this.device, + }); + + final int version; + final String serverKey; + final String proof; + final String device; + + @override + Map toJson() => { + 't': 'auth', + 'v': version, + 'serverKey': serverKey, + 'proof': proof, + 'device': device, + }; +} + +/// host → remote: handshake accepted; snapshots follow. +class WelcomeMessage extends RemoteMessage { + const WelcomeMessage({required this.device}); + + final String device; + + @override + Map toJson() => {'t': 'welcome', 'device': device}; +} + +/// host → remote: handshake rejected (version / server / auth mismatch). +class DenyMessage extends RemoteMessage { + const DenyMessage({required this.reason}); + + final String reason; + + @override + Map toJson() => {'t': 'deny', 'reason': reason}; +} + +/// host → remote: a full playback snapshot. +class SnapshotMessage extends RemoteMessage { + const SnapshotMessage(this.snapshot); + + final StateSnapshot snapshot; + + @override + Map toJson() => {'t': 'state', 'snapshot': snapshot.toJson()}; +} + +/// remote → host: one control command. +class CommandMessage extends RemoteMessage { + const CommandMessage(this.command); + + final RemoteCommand command; + + @override + Map toJson() => {'t': 'cmd', 'cmd': command.toJson()}; +} diff --git a/lib/remote/remote_playback.dart b/lib/remote/remote_playback.dart new file mode 100644 index 0000000..081ebc4 --- /dev/null +++ b/lib/remote/remote_playback.dart @@ -0,0 +1,92 @@ +import 'package:just_audio/just_audio.dart' show LoopMode; + +import '../playback/playback_engine.dart'; +import '../subsonic/models.dart'; +import 'messages.dart'; +import 'remote_session.dart'; + +/// Map a wire [StateSnapshot] back into the app's [PlaybackState] so the UI can +/// render a remote device's playback exactly as it renders local playback. +PlaybackState playbackStateFromSnapshot(StateSnapshot s) => PlaybackState( + queue: s.queue, + currentIndex: s.currentIndex, + playing: s.playing, + position: Duration(milliseconds: s.positionMs), + duration: Duration(milliseconds: s.durationMs), + shuffle: s.shuffle, + loop: LoopMode.values.firstWhere( + (m) => m.name == s.loop, + orElse: () => LoopMode.off, + ), + supported: true, + ); + +/// The command half of the facade while attached to another device: every +/// [PlaybackCommands] call is serialized into a [RemoteCommand] and sent to the +/// host, which applies it to *its* engine. Playback state comes back the other +/// way as snapshots (see `remote_providers.dart`), so this proxy holds none. +/// +/// Commands are fire-and-forget: LAN round-trips are sub-frame, and the host's +/// next snapshot is the source of truth, so there is no optimistic local edit +/// to reconcile. +class RemotePlaybackProxy implements PlaybackCommands { + RemotePlaybackProxy(this._session); + + final RemoteSession _session; + + @override + Future playSongs(List songs, {int startIndex = 0}) async => + _session.send(RemoteCommand.playSongs(songs, startIndex: startIndex)); + + @override + Future jumpTo(int index) async => + _session.send(RemoteCommand.jumpTo(index)); + + @override + Future playNext(Song song) async => + _session.send(RemoteCommand.playNext(song)); + + @override + Future addToQueue(Song song) async => + _session.send(RemoteCommand.addToQueue(song)); + + @override + Future removeAt(int index) async => + _session.send(RemoteCommand.removeAt(index)); + + @override + Future reorderQueue(int oldIndex, int newIndex) async => + _session.send(RemoteCommand.reorder(oldIndex, newIndex)); + + @override + Future togglePlayPause() async => + _session.send(const RemoteCommand.playPause()); + + @override + Future next() async => _session.send(const RemoteCommand.next()); + + @override + Future previous() async => + _session.send(const RemoteCommand.previous()); + + @override + Future seek(Duration position) async => + _session.send(RemoteCommand.seek(position.inMilliseconds)); + + @override + Future toggleShuffle() async => + _session.send(const RemoteCommand.toggleShuffle()); + + @override + Future cycleLoop() async => + _session.send(const RemoteCommand.cycleLoop()); + + @override + Future retry() async => _session.send(const RemoteCommand.retry()); + + @override + void resyncFromPlayer() { + // No-op: the remote device owns the player; we receive its state pushed to + // us and have no local playhead to re-read. + } +} diff --git a/lib/remote/remote_session.dart b/lib/remote/remote_session.dart new file mode 100644 index 0000000..d00a2d4 --- /dev/null +++ b/lib/remote/remote_session.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:io'; + +import 'discovery.dart'; +import 'messages.dart'; + +/// Connection lifecycle of a remote-control session. +enum RemoteConnStatus { + /// Dialing / mid-handshake. + connecting, + + /// Handshake accepted; snapshots flowing. + connected, + + /// Host rejected the handshake (wrong server / version / auth). + denied, + + /// Cleanly closed (host went away or we detached). + disconnected, + + /// Transport failure (couldn't reach the host, socket dropped). + error, +} + +/// The controller side of a session: dials a [DiscoveredDevice], completes the +/// HMAC handshake, then surfaces the host's [StateSnapshot]s and forwards +/// [RemoteCommand]s back. Pure transport — it holds no playback state and knows +/// nothing about just_audio; `RemoteControlController` adapts it to the UI. +class RemoteSession { + RemoteSession({ + required this.device, + required this.serverKey, + required this.password, + required this.deviceName, + }); + + final DiscoveredDevice device; + final String serverKey; + final String password; + final String deviceName; + + WebSocket? _socket; + StreamSubscription? _sub; + + final StreamController _snapshots = + StreamController.broadcast(); + final StreamController _status = + StreamController.broadcast(); + RemoteConnStatus _current = RemoteConnStatus.disconnected; + + /// Host-reported friendly name, available after [RemoteConnStatus.connected]. + String? hostDevice; + + Stream get snapshots => _snapshots.stream; + Stream get status => _status.stream; + RemoteConnStatus get currentStatus => _current; + + /// Dial the host and start the handshake. Status transitions are emitted on + /// [status]; on success snapshots begin arriving on [snapshots]. + Future connect() async { + _set(RemoteConnStatus.connecting); + try { + final ws = await WebSocket.connect(device.wsUri.toString()) + .timeout(const Duration(seconds: 8)); + _socket = ws; + _sub = ws.listen( + _onData, + onDone: () => _set(RemoteConnStatus.disconnected), + onError: (_) => _set(RemoteConnStatus.error), + cancelOnError: true, + ); + } catch (_) { + _set(RemoteConnStatus.error); + } + } + + /// Send a control command to the host (no-op if not connected). + void send(RemoteCommand cmd) => _sendMessage(CommandMessage(cmd)); + + Future close() async { + await _sub?.cancel(); + _sub = null; + try { + await _socket?.close(); + } catch (_) { + // Already closed. + } + _socket = null; + _set(RemoteConnStatus.disconnected); + if (!_snapshots.isClosed) await _snapshots.close(); + if (!_status.isClosed) await _status.close(); + } + + void _onData(dynamic data) { + if (data is! String) return; + RemoteMessage msg; + try { + msg = RemoteMessage.decode(data); + } catch (_) { + return; // ignore a malformed frame + } + if (msg is ChallengeMessage) { + // Prove we know the shared password without ever sending it. + _sendMessage(AuthMessage( + version: kRemoteProtocolVersion, + serverKey: serverKey, + proof: RemoteAuth.proofFor(password: password, nonce: msg.nonce), + device: deviceName, + )); + } else if (msg is WelcomeMessage) { + hostDevice = msg.device; + _set(RemoteConnStatus.connected); + } else if (msg is DenyMessage) { + _set(RemoteConnStatus.denied); + _socket?.close().catchError((_) {}); + } else if (msg is SnapshotMessage) { + if (!_snapshots.isClosed) _snapshots.add(msg.snapshot); + } + // AuthMessage / CommandMessage are host-inbound only; ignore if echoed. + } + + void _sendMessage(RemoteMessage m) { + try { + _socket?.add(m.encode()); + } catch (_) { + // Socket gone; the listen callbacks will surface the disconnect. + } + } + + void _set(RemoteConnStatus s) { + if (_current == s) return; + _current = s; + if (!_status.isClosed) _status.add(s); + } +} diff --git a/lib/screens/browser_screen.dart b/lib/screens/browser_screen.dart index 81ddad0..fc1332d 100644 --- a/lib/screens/browser_screen.dart +++ b/lib/screens/browser_screen.dart @@ -339,7 +339,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { Widget build(BuildContext context) { final index = ref.watch(libraryIndexProvider); final visible = ref.watch(visibleTracksProvider); - final playback = ref.read(playbackProvider.notifier); + final playback = ref.read(playbackCommandsProvider); final client = ref.watch(subsonicClientProvider); final Widget body; @@ -564,7 +564,7 @@ class AlbumScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(albumProvider(id)); final downloads = ref.watch(downloadManagerProvider); - final playback = ref.read(playbackProvider.notifier); + final playback = ref.read(playbackCommandsProvider); final songs = album.valueOrNull?.songs ?? const []; return _DetailScaffold( diff --git a/lib/screens/devices_sheet.dart b/lib/screens/devices_sheet.dart new file mode 100644 index 0000000..9caf792 --- /dev/null +++ b/lib/screens/devices_sheet.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../remote/discovery.dart'; +import '../remote/remote_session.dart'; +import '../state/remote_providers.dart'; +import '../theme/tokens.dart'; + +/// Open the cross-device control sheet: pick a same-Wi-Fi Timbre device to +/// control, or detach back to local playback (updates-features.md #3). +Future showDevicesSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: TimbreColors.background, + isScrollControlled: true, + builder: (_) => const _DevicesSheet(), + ); +} + +class _DevicesSheet extends ConsumerStatefulWidget { + const _DevicesSheet(); + + @override + ConsumerState<_DevicesSheet> createState() => _DevicesSheetState(); +} + +class _DevicesSheetState extends ConsumerState<_DevicesSheet> { + @override + void initState() { + super.initState(); + // Browse only while the sheet is open — discovery is comparatively costly. + Future.microtask( + () => ref.read(remoteControlProvider.notifier).startBrowsing()); + } + + @override + void dispose() { + ref.read(remoteControlProvider.notifier).stopBrowsing(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final rc = ref.watch(remoteControlProvider); + final accent = Theme.of(context).colorScheme.primary; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl), + child: Text('Devices', + style: + TextStyle(color: accent, fontWeight: FontWeight.w700)), + ), + const SizedBox(height: TimbreSpacing.md), + if (!rc.supported) + const _Note( + "Cross-device control isn't available on this platform.") + else ...[ + // "Play here" row — active when currently controlling a remote. + _LocalRow( + active: !rc.isAttached, + onTap: rc.isAttached + ? () => ref.read(remoteControlProvider.notifier).detach() + : null, + ), + const _Divider(), + for (final d in rc.devices) + _DeviceRow( + device: d, + active: rc.attachedDevice?.id == d.id, + connecting: rc.attachedDevice?.id == d.id && + rc.status == RemoteConnStatus.connecting, + onTap: () => + ref.read(remoteControlProvider.notifier).attach(d), + ), + if (rc.devices.isEmpty) + const _Note('Searching for devices on your Wi-Fi…', + spinner: true), + if (rc.status == RemoteConnStatus.denied) + const _Note( + 'That device refused the connection (different account?).'), + ], + ], + ), + ), + ); + } +} + +/// The "play on this device" (local) row, shown selected when not attached. +class _LocalRow extends StatelessWidget { + const _LocalRow({required this.active, this.onTap}); + + final bool active; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + return InkWell( + onTap: onTap, + child: Container( + constraints: + const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), + padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl), + child: Row( + children: [ + Icon(Icons.smartphone, + size: 16, + color: active ? accent : TimbreColors.foreground), + const SizedBox(width: TimbreSpacing.md), + Expanded( + child: Text('This device', + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + )), + ), + if (active) + Text('●', style: TextStyle(color: accent)), + ], + ), + ), + ); + } +} + +class _DeviceRow extends StatelessWidget { + const _DeviceRow({ + required this.device, + required this.active, + required this.connecting, + required this.onTap, + }); + + final DiscoveredDevice device; + final bool active; + final bool connecting; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + return InkWell( + onTap: onTap, + child: Container( + constraints: + const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), + padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl), + child: Row( + children: [ + Icon(Icons.cast, + size: 16, + color: active ? accent : TimbreColors.foreground), + const SizedBox(width: TimbreSpacing.md), + Expanded( + child: Text( + device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: TimbreColors.foreground, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + if (connecting) + const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (active) + Text('●', style: TextStyle(color: accent)), + ], + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.symmetric( + horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.sm), + child: Divider(color: TimbreColors.border, height: 1), + ); +} + +class _Note extends StatelessWidget { + const _Note(this.text, {this.spinner = false}); + + final String text; + final bool spinner; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md), + child: Row( + children: [ + if (spinner) ...[ + const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: TimbreSpacing.md), + ], + Flexible( + child: Text(text, + style: const TextStyle(color: TimbreColors.dimmed)), + ), + ], + ), + ); + } +} diff --git a/lib/screens/downloads_screen.dart b/lib/screens/downloads_screen.dart index b3d56c6..246a667 100644 --- a/lib/screens/downloads_screen.dart +++ b/lib/screens/downloads_screen.dart @@ -15,7 +15,7 @@ class DownloadsScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final downloads = ref.watch(downloadManagerProvider); final controller = ref.read(downloadManagerProvider.notifier); - final playback = ref.read(playbackProvider.notifier); + final playback = ref.read(playbackCommandsProvider); final active = downloads.byId.values.where((d) => d.isActive).toList(); final completed = downloads.completed; diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index 62fc26e..e534822 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -46,13 +46,13 @@ class FavoritesScreen extends ConsumerWidget { title: s.songs[i].title ?? 'Untitled', trailing: s.songs[i].artist, onTap: () => ref - .read(playbackProvider.notifier) + .read(playbackCommandsProvider) .playSongs(s.songs, startIndex: i), onPlayNext: () => ref - .read(playbackProvider.notifier) + .read(playbackCommandsProvider) .playNext(s.songs[i]), onAddToQueue: () => ref - .read(playbackProvider.notifier) + .read(playbackCommandsProvider) .addToQueue(s.songs[i]), ), ], diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index afc793d..6b19140 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -97,7 +97,7 @@ class _HeroCard extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final accent = Theme.of(context).colorScheme.primary; final client = ref.watch(subsonicClientProvider); - final current = ref.watch(playbackProvider.select((s) => s.current)); + final current = ref.watch(activePlaybackProvider.select((s) => s.current)); // Fall back to the most recent track so the hero is useful before playback. final recent = ref.watch(recentSongsProvider); @@ -136,7 +136,7 @@ class _HeroCard extends ConsumerWidget { if (hasCurrent) { ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex; } else if (fallback != null) { - ref.read(playbackProvider.notifier).playSongs([fallback.toSong()]); + ref.read(playbackCommandsProvider).playSongs([fallback.toSong()]); ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex; } }, @@ -210,7 +210,7 @@ class _HeroProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch(playbackProvider.select((s) => s.progress)); + final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); return BlockProgressBar(progress: progress, cells: 32, height: 6); } } diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 6b6169a..b1dddbb 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -7,12 +7,14 @@ import '../layout/breakpoints.dart'; import '../playback/playback_engine.dart'; import '../settings/settings_store.dart'; import '../state/providers.dart'; +import '../state/remote_providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/block_progress_bar.dart'; import '../widgets/cassette_view.dart'; import '../widgets/hairline_panel.dart'; import 'add_to_playlist_sheet.dart'; +import 'devices_sheet.dart'; /// Now Playing tab — album art + info strip + transport, bound to the live /// playback engine. On compact screens the top region shows the full-size album @@ -44,18 +46,18 @@ class _NowPlayingScreenState extends ConsumerState { /// [FavoritesController.seedSong]. void _seedFavorites() { if (!mounted) return; - final song = ref.read(playbackProvider).current; + final song = ref.read(activePlaybackProvider).current; if (song != null) ref.read(favoritesProvider.notifier).seedSong(song); } @override Widget build(BuildContext context) { - final state = ref.watch(playbackProvider); + final state = ref.watch(activePlaybackProvider); final accent = Theme.of(context).colorScheme.primary; final current = state.current; // Re-seed the favorites store whenever the track changes. - ref.listen(playbackProvider.select((s) => s.current?.id), + ref.listen(activePlaybackProvider.select((s) => s.current?.id), (_, _) => _seedFavorites()); if (current == null) { @@ -65,6 +67,11 @@ class _NowPlayingScreenState extends ConsumerState { ); } + // Cross-device control is offered next to the queue toggle (compact) or + // beneath the transport (wide); hidden where the platform can't host/browse. + final remoteSupported = + ref.watch(remoteControlProvider.select((s) => s.supported)); + // Everything below the art region — shared by both layouts. The queue // toggle is deliberately excluded: it belongs only to the compact layout // (where art and queue share one region), so it's appended separately. @@ -98,7 +105,7 @@ class _NowPlayingScreenState extends ConsumerState { ), const SizedBox(width: TimbreSpacing.sm), TextButton( - onPressed: () => ref.read(playbackProvider.notifier).retry(), + onPressed: () => ref.read(playbackCommandsProvider).retry(), child: Text('Retry', style: TextStyle(color: accent)), ), ], @@ -132,6 +139,10 @@ class _NowPlayingScreenState extends ConsumerState { const Expanded(child: _FittedArt()), const SizedBox(height: TimbreSpacing.lg), ...controls, + if (remoteSupported) ...[ + const SizedBox(height: TimbreSpacing.sm), + const Center(child: _RemoteButton()), + ], ], ), ), @@ -142,13 +153,25 @@ class _NowPlayingScreenState extends ConsumerState { ); } - // Compact: art and queue share the top region, swapped by the toggle. + // Compact: art and queue share the top region, swapped by the toggle. The + // devices button sits beside the toggle so both bottom affordances share one + // centered row. final queueToggle = _QueueToggle( showQueue: _showQueue, queueLength: state.queue.length, accent: accent, onTap: () => setState(() => _showQueue = !_showQueue), ); + final bottomBar = remoteSupported + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + queueToggle, + const SizedBox(width: TimbreSpacing.sm), + const _RemoteButton(), + ], + ) + : queueToggle; return Padding( padding: padding, @@ -161,7 +184,7 @@ class _NowPlayingScreenState extends ConsumerState { const SizedBox(height: TimbreSpacing.lg), ...controls, const SizedBox(height: TimbreSpacing.sm), - queueToggle, + bottomBar, ], ) // Art absorbs the leftover height, capped to a square, so the @@ -174,7 +197,7 @@ class _NowPlayingScreenState extends ConsumerState { const SizedBox(height: TimbreSpacing.lg), ...controls, const SizedBox(height: TimbreSpacing.sm), - queueToggle, + bottomBar, ], ), ); @@ -275,12 +298,12 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { @override Widget build(BuildContext context) { - final state = ref.watch(playbackProvider); + final state = ref.watch(activePlaybackProvider); final accent = Theme.of(context).colorScheme.primary; // Follow the playing track as it advances (or as shuffle reorders things). ref.listen( - playbackProvider.select((s) => s.currentIndex), + activePlaybackProvider.select((s) => s.currentIndex), (_, next) => _scrollToIndex(next), ); // Jump to the current track the first time the queue is populated. @@ -302,7 +325,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { // index — exactly the convention reorderQueue (and just_audio's // moveAudioSource) expects — so no off-by-one adjustment is needed. onReorderItem: (oldIndex, newIndex) => - ref.read(playbackProvider.notifier).reorderQueue(oldIndex, newIndex), + ref.read(playbackCommandsProvider).reorderQueue(oldIndex, newIndex), itemBuilder: (context, i) { final song = state.queue[i]; final current = state.currentIndex; @@ -317,7 +340,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { // collide and ReorderableListView requires unique keys. These rows // are stateless, so keying by index leaks no state. key: ValueKey(i), - onTap: () => ref.read(playbackProvider.notifier).jumpTo(i), + onTap: () => ref.read(playbackCommandsProvider).jumpTo(i), child: Padding( padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.lg, @@ -346,7 +369,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> { style: const TextStyle(color: TimbreColors.dimmed)), InkWell( onTap: () => - ref.read(playbackProvider.notifier).removeAt(i), + ref.read(playbackCommandsProvider).removeAt(i), customBorder: const CircleBorder(), child: const SizedBox( width: TimbreSpacing.minTouchTarget, @@ -403,7 +426,7 @@ class _AlbumArtPanel extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final coverArt = - ref.watch(playbackProvider.select((s) => s.current?.coverArt)); + ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt)); final client = ref.watch(subsonicClientProvider); final cassette = ref.watch(settingsProvider.select((s) => s.nowPlayingCassette)); @@ -525,6 +548,53 @@ class _FavRating extends ConsumerWidget { } } +/// Cross-device control affordance sitting beside the queue toggle +/// (updates-features.md #3). Styled to match [_QueueToggle]: when controlling +/// another device it shows the device name in the accent colour; otherwise a +/// dim "Devices" entry point. Tapping opens the devices sheet (which also holds +/// the "This device" / detach action). +class _RemoteButton extends ConsumerWidget { + const _RemoteButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rc = ref.watch(remoteControlProvider); + final accent = Theme.of(context).colorScheme.primary; + final attached = rc.isAttached; + final label = attached ? (rc.attachedDevice?.name ?? 'Remote') : 'Devices'; + return InkWell( + onTap: () => showDevicesSheet(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: TimbreSpacing.lg, + vertical: TimbreSpacing.sm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + attached ? Icons.cast_connected : Icons.cast, + size: 16, + color: attached ? accent : TimbreColors.dimmed, + ), + const SizedBox(width: TimbreSpacing.sm), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: attached ? accent : TimbreColors.foreground, + ), + ), + ), + ], + ), + ), + ); + } +} + class _InfoStrip extends StatelessWidget { const _InfoStrip({required this.song, required this.accent}); @@ -562,10 +632,10 @@ class _NowPlayingProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final position = ref.watch(playbackProvider.select((s) => s.position)); + final position = ref.watch(activePlaybackProvider.select((s) => s.position)); final duration = - ref.watch(playbackProvider.select((s) => s.effectiveDuration)); - final progress = ref.watch(playbackProvider.select((s) => s.progress)); + ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration)); + final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); return Row( children: [ Text(_fmtDur(position), @@ -592,7 +662,7 @@ class _Transport extends StatelessWidget { @override Widget build(BuildContext context) { - final controller = ref.read(playbackProvider.notifier); + final controller = ref.read(playbackCommandsProvider); final loopIcon = switch (state.loop) { LoopMode.one => Icons.repeat_one, _ => Icons.repeat, diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index de4c932..b955503 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -325,7 +325,7 @@ class _PlaylistDetailScreenState extends ConsumerState { s.playlists.where((p) => p.id == widget.id).firstOrNull)); final connected = ref.watch(subsonicClientProvider) != null; final me = ref.watch(currentUsernameProvider); - final playback = ref.read(playbackProvider.notifier); + final playback = ref.read(playbackCommandsProvider); final songs = detail?.songs ?? const []; // Ownership drives which sharing affordance shows: owner → share toggle; diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 85e8b57..8408695 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -102,13 +102,13 @@ class _Results extends ConsumerWidget { title: r.songs[i].title ?? 'Untitled', trailing: r.songs[i].artist, onTap: () => ref - .read(playbackProvider.notifier) + .read(playbackCommandsProvider) .playSongs(r.songs, startIndex: i), onPlayNext: () => ref - .read(playbackProvider.notifier) + .read(playbackCommandsProvider) .playNext(r.songs[i]), onAddToQueue: () => ref - .read(playbackProvider.notifier) + .read(playbackCommandsProvider) .addToQueue(r.songs[i]), ), ], diff --git a/lib/shell/app_shell.dart b/lib/shell/app_shell.dart index 1c9c62b..0e9f02b 100644 --- a/lib/shell/app_shell.dart +++ b/lib/shell/app_shell.dart @@ -60,7 +60,7 @@ class _AppShellState extends ConsumerState // interruption and otherwise leaves the playhead frozen (see // PlaybackController.resyncFromPlayer). if (state == AppLifecycleState.resumed) { - ref.read(playbackProvider.notifier).resyncFromPlayer(); + ref.read(playbackCommandsProvider).resyncFromPlayer(); } } diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 1a60d45..b1f780a 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -14,6 +14,7 @@ import '../subsonic/subsonic_client.dart'; import '../theme/accent.dart'; import '../theme/accent_extract.dart'; import 'favorites.dart'; +import 'remote_providers.dart'; // `BrowseMode`/`SearchMode` are defined in the settings store (so they can be // persisted) but historically lived here — re-export so existing importers of @@ -582,3 +583,26 @@ final playbackProvider = controller.restoreForServer(); return controller; }); + +/// The playback state the UI should render: the mirrored state of the device +/// we're controlling as a remote when attached (updates-features.md #3), else +/// the local engine's state. Widgets watch this (with `.select`) instead of +/// [playbackProvider] so the local/remote source is a single swappable seam. +final activePlaybackProvider = Provider((ref) { + final remote = ref.watch( + remoteControlProvider.select((s) => s.isAttached ? s.remoteState : null), + ); + return remote ?? ref.watch(playbackProvider); +}); + +/// The command sink the UI should drive — a proxy that serializes commands over +/// the LAN to the attached device when acting as a remote, else the local +/// [PlaybackController]. Widgets read this instead of `playbackProvider.notifier`. +final playbackCommandsProvider = Provider((ref) { + final attached = ref.watch(remoteControlProvider.select((s) => s.isAttached)); + if (attached) { + final remote = ref.read(remoteControlProvider.notifier).remoteCommands; + if (remote != null) return remote; + } + return ref.watch(playbackProvider.notifier); +}); diff --git a/lib/state/remote_providers.dart b/lib/state/remote_providers.dart new file mode 100644 index 0000000..68259ea --- /dev/null +++ b/lib/state/remote_providers.dart @@ -0,0 +1,295 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../playback/playback_engine.dart'; +import '../remote/discovery.dart'; +import '../remote/host_server.dart'; +import '../remote/messages.dart'; +import '../remote/remote_playback.dart'; +import '../remote/remote_session.dart'; +import 'providers.dart'; + +/// Snapshot of cross-device control state for the UI (updates-features.md #3). +class RemoteControlState { + const RemoteControlState({ + this.supported = false, + this.advertising = false, + this.browsing = false, + this.devices = const [], + this.attachedDevice, + this.status = RemoteConnStatus.disconnected, + this.remoteState, + }); + + /// Whether this platform can host/discover at all (mobile + macOS). + final bool supported; + + /// This device is published as a controllable player. + final bool advertising; + + /// A discovery browse is running (device sheet open). + final bool browsing; + + /// Same-server players found on the LAN. + final List devices; + + /// The device we're controlling as a remote, or null when playing locally. + final DiscoveredDevice? attachedDevice; + + /// Connection status of the active remote session. + final RemoteConnStatus status; + + /// The controlled device's mirrored playback state (null until the first + /// snapshot arrives). The facade renders this in place of the local engine. + final PlaybackState? remoteState; + + /// True once we've committed to controlling a remote device (connecting or + /// connected). The facade routes commands to the remote while this holds. + bool get isAttached => + attachedDevice != null && + (status == RemoteConnStatus.connecting || + status == RemoteConnStatus.connected); + + RemoteControlState copyWith({ + bool? supported, + bool? advertising, + bool? browsing, + List? devices, + DiscoveredDevice? attachedDevice, + RemoteConnStatus? status, + PlaybackState? remoteState, + bool clearAttached = false, + bool clearRemoteState = false, + }) => + RemoteControlState( + supported: supported ?? this.supported, + advertising: advertising ?? this.advertising, + browsing: browsing ?? this.browsing, + devices: devices ?? this.devices, + attachedDevice: + clearAttached ? null : (attachedDevice ?? this.attachedDevice), + status: status ?? this.status, + remoteState: + clearRemoteState ? null : (remoteState ?? this.remoteState), + ); +} + +/// Owns both halves of cross-device control: +/// * **Host** — while online, runs a [RemoteHost] and advertises it over mDNS +/// so other devices can control this one. +/// * **Remote** — [attach]/[detach] to control another device, mirroring its +/// playback into [RemoteControlState.remoteState] (with smooth position +/// interpolation between the host's ~1 Hz snapshots). +/// +/// The facade providers (`activePlaybackProvider` / `playbackCommandsProvider` +/// in providers.dart) read this to decide whether the UI drives the local +/// engine or the attached remote. +class RemoteControlController extends StateNotifier { + RemoteControlController(this._ref) + : super(RemoteControlState(supported: _supported())) { + if (state.supported) { + _ref.listen( + connectionProvider, + (_, next) => _onConnection(next), + fireImmediately: true, + ); + } + } + + final Ref _ref; + final RemoteDiscovery _discovery = RemoteDiscovery(); + + RemoteHost? _host; + RemoteSession? _session; + RemotePlaybackProxy? _proxy; + StreamSubscription? _snapSub; + StreamSubscription? _statusSub; + StreamSubscription>? _devSub; + Timer? _interpolate; + + /// The command sink while attached, consumed by `playbackCommandsProvider`. + PlaybackCommands? get remoteCommands => _proxy; + + static bool _supported() => + !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); + + /// Mutate state only while still mounted. Async hosting/session callbacks can + /// resolve after the provider is disposed (e.g. app teardown); touching + /// `state` then throws, so every write funnels through here. + void _update(RemoteControlState Function(RemoteControlState) f) { + if (!mounted) return; + state = f(state); + } + + // ---- Hosting ---------------------------------------------------------- + + Future _onConnection(ConnectionState conn) async { + if (conn.isOnline) { + await _startHosting(conn); + } else { + await detach(); + await _stopHosting(); + } + } + + Future _startHosting(ConnectionState conn) async { + if (_host != null) return; // already hosting + final creds = conn.credentials; + if (creds == null) return; + final host = RemoteHost( + controller: _ref.read(playbackProvider.notifier), + serverKey: creds.id, + password: creds.password, + deviceName: _deviceName(), + ); + try { + final port = await host.start(); + _host = host; + await _discovery.advertise( + deviceName: _deviceName(), + port: port, + serverKey: creds.id, + ); + _update((s) => s.copyWith(advertising: true)); + } catch (_) { + await host.stop(); + _host = null; + } + } + + Future _stopHosting() async { + await _discovery.stopAdvertising(); + final host = _host; + _host = null; + if (host != null) await host.stop(); + _update((s) => s.copyWith(advertising: false)); + } + + // ---- Browsing (device sheet) ------------------------------------------ + + Future startBrowsing() async { + if (!state.supported) return; + final key = _ref.read(serverKeyProvider); + if (key == null) return; + _devSub ??= _discovery.devices.listen( + (list) => _update((s) => s.copyWith(devices: list)), + ); + await _discovery.startBrowsing(key); + _update((s) => s.copyWith(browsing: true, devices: _discovery.current)); + } + + Future stopBrowsing() async { + await _discovery.stopBrowsing(); + _update((s) => s.copyWith(browsing: false, devices: const [])); + } + + // ---- Attach / detach as a remote -------------------------------------- + + Future attach(DiscoveredDevice device) async { + await detach(); + final creds = _ref.read(connectionProvider).credentials; + if (creds == null) return; + + final session = RemoteSession( + device: device, + serverKey: creds.id, + password: creds.password, + deviceName: _deviceName(), + ); + _session = session; + _proxy = RemotePlaybackProxy(session); + _update((s) => s.copyWith( + attachedDevice: device, + status: RemoteConnStatus.connecting, + clearRemoteState: true, + )); + + _statusSub = session.status.listen((st) { + _update((s) => s.copyWith(status: st)); + // Any terminal status drops us back to local playback. + if (st == RemoteConnStatus.disconnected || + st == RemoteConnStatus.error || + st == RemoteConnStatus.denied) { + // Defer so we're not tearing the session down inside its own callback. + scheduleMicrotask(detach); + } + }); + _snapSub = session.snapshots.listen((snap) { + _update((s) => s.copyWith(remoteState: playbackStateFromSnapshot(snap))); + }); + + _startInterpolation(); + await session.connect(); + } + + Future detach() async { + await _snapSub?.cancel(); + _snapSub = null; + await _statusSub?.cancel(); + _statusSub = null; + _interpolate?.cancel(); + _interpolate = null; + final session = _session; + _session = null; + _proxy = null; + if (session != null) await session.close(); + if (!mounted) return; + if (state.attachedDevice != null || state.remoteState != null) { + state = state.copyWith( + clearAttached: true, + clearRemoteState: true, + status: RemoteConnStatus.disconnected, + ); + } + } + + /// Advance the mirrored position between snapshots so the progress bar moves + /// smoothly instead of stepping once per snapshot. Each authoritative + /// snapshot resets the baseline (see the `_snapSub` listener). + void _startInterpolation() { + _interpolate ??= Timer.periodic(const Duration(milliseconds: 500), (_) { + if (!mounted) return; + final rs = state.remoteState; + if (rs == null || !rs.playing) return; + final next = rs.position + const Duration(milliseconds: 500); + final dur = rs.effectiveDuration; + final capped = (dur > Duration.zero && next > dur) ? dur : next; + state = state.copyWith(remoteState: rs.copyWith(position: capped)); + }); + } + + String _deviceName() { + if (Platform.isIOS) return 'iOS device'; + if (Platform.isAndroid) return 'Android device'; + if (Platform.isMacOS) return 'Mac'; + return 'Timbre'; + } + + @override + void dispose() { + // Tear down resources directly (not via detach/_stopHosting, which write + // `state`) — those async paths would resolve after the notifier is + // unmounted and throw. + unawaited(_devSub?.cancel()); + unawaited(_snapSub?.cancel()); + unawaited(_statusSub?.cancel()); + _interpolate?.cancel(); + final session = _session; + _session = null; + _proxy = null; + if (session != null) unawaited(session.close()); + final host = _host; + _host = null; + if (host != null) unawaited(host.stop()); + unawaited(_discovery.dispose()); + super.dispose(); + } +} + +final remoteControlProvider = + StateNotifierProvider( + (ref) => RemoteControlController(ref), +); diff --git a/lib/widgets/cassette_view.dart b/lib/widgets/cassette_view.dart index b930875..c789aa1 100644 --- a/lib/widgets/cassette_view.dart +++ b/lib/widgets/cassette_view.dart @@ -49,7 +49,7 @@ class _CassetteViewState extends ConsumerState if (dt <= 0) return; // Read (not watch) inside the ticker: the model drives repaints itself, and // watching here would rebuild the whole widget every position tick. - final s = ref.read(playbackProvider); + final s = ref.read(activePlaybackProvider); _model.update(dt: dt, playing: s.playing && s.supported, progress: s.progress); } diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index e20c7e4..9844118 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -18,11 +18,11 @@ class MiniPlayer extends ConsumerWidget { if (ref.watch(selectedTabProvider) == nowPlayingTabIndex) { return const SizedBox.shrink(); } - final current = ref.watch(playbackProvider.select((s) => s.current)); + final current = ref.watch(activePlaybackProvider.select((s) => s.current)); if (current == null) return const SizedBox.shrink(); - final playing = ref.watch(playbackProvider.select((s) => s.playing)); - final controller = ref.read(playbackProvider.notifier); + final playing = ref.watch(activePlaybackProvider.select((s) => s.playing)); + final controller = ref.read(playbackCommandsProvider); final client = ref.watch(subsonicClientProvider); final accent = Theme.of(context).colorScheme.primary; final artUri = (client != null && current.coverArt != null) @@ -112,7 +112,7 @@ class _MiniProgress extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch(playbackProvider.select((s) => s.progress)); + final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); final accent = Theme.of(context).colorScheme.primary; return SizedBox( height: 2, diff --git a/pubspec.lock b/pubspec.lock index 2d29dcc..ea8722e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -472,6 +472,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nsd: + dependency: "direct main" + description: + name: nsd + sha256: "2551fec29f6032459815a577f385f89e18099a3ca1c545b5dfb1e1a64b5705ec" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + nsd_android: + dependency: transitive + description: + name: nsd_android + sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_ios: + dependency: transitive + description: + name: nsd_ios + sha256: "57a4b8b218860346eba7e2d629c6fce1fa89a97645350130a15c45babb4b1324" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + nsd_macos: + dependency: transitive + description: + name: nsd_macos + sha256: cca373ee5f28ea22140d595b5cd06e83ebc2d6e4ee3e17d7fa1fd6564e4cb352 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + nsd_platform_interface: + dependency: transitive + description: + name: nsd_platform_interface + sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_windows: + dependency: transitive + description: + name: nsd_windows + sha256: "68b4a256b0be258dbbad0ae789f2e8838d0935a353dac86b17c14c1a05df4ecd" + url: "https://pub.dev" + source: hosted + version: "3.0.1" objective_c: dependency: transitive description: @@ -592,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fdc77c2..fd936f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: just_audio_background: ^0.0.1-beta.17 palette_generator: ^0.3.3+7 flutter_svg: ^2.3.0 + nsd: ^5.0.1 dev_dependencies: flutter_test: diff --git a/test/remote_integration_test.dart b/test/remote_integration_test.dart new file mode 100644 index 0000000..3009a6a --- /dev/null +++ b/test/remote_integration_test.dart @@ -0,0 +1,173 @@ +@Timeout(Duration(seconds: 15)) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:timbre/playback/playback_engine.dart'; +import 'package:timbre/remote/discovery.dart'; +import 'package:timbre/remote/host_server.dart'; +import 'package:timbre/remote/messages.dart'; +import 'package:timbre/remote/remote_session.dart'; +import 'package:timbre/subsonic/models.dart'; + +/// End-to-end over a real loopback WebSocket: a [RemoteHost] driving a +/// (headless, audio-unsupported) [PlaybackController] and a [RemoteSession] +/// client in the same process. Exercises the handshake, HMAC auth, snapshot +/// broadcast, and command application — the whole stack minus the platform +/// audio backend and mDNS discovery. +void main() { + const serverKey = 'server-key'; + const password = 'hunter2'; + + late PlaybackController controller; + late RemoteHost host; + final sessions = []; + + PlaybackController buildController() => PlaybackController( + // Non-null so songs count as streamable; no real player exists on the + // test host (audio unsupported), so this URI is never opened. + streamUriFor: (s) => Uri.parse('http://host.local/stream/${s.id}'), + coverArtUriFor: (_) => null, + serverKeyGetter: () => serverKey, + onArt: (_) {}, + onPlay: (_) {}, + ); + + RemoteSession newSession( + int port, { + String key = serverKey, + String pass = password, + }) { + final s = RemoteSession( + device: DiscoveredDevice( + name: 'Host', + host: '127.0.0.1', + port: port, + serverKey: key, + version: kRemoteProtocolVersion, + ), + serverKey: key, + password: pass, + deviceName: 'Remote', + ); + sessions.add(s); + return s; + } + + Song song(String id) => Song(id: id, title: 'Song $id'); + + // Subscribe to the "connected" event *before* dialing, so a fast handshake + // can't fire it before we're listening (broadcast streams drop unheard + // events). + Future connectAuthed(RemoteSession s) async { + final connected = + s.status.firstWhere((x) => x == RemoteConnStatus.connected); + await s.connect(); + await connected; + } + + setUp(() async { + controller = buildController(); + host = RemoteHost( + controller: controller, + serverKey: serverKey, + password: password, + deviceName: 'Host', + ); + await host.start(); + }); + + tearDown(() async { + for (final s in sessions) { + await s.close(); + } + sessions.clear(); + await host.stop(); + controller.dispose(); + }); + + test('valid remote completes the handshake and receives a snapshot', + () async { + final session = newSession(host.port!); + final firstSnapshot = session.snapshots.first; + await connectAuthed(session); + expect(session.hostDevice, 'Host'); + await firstSnapshot; // initial state pushed on connect + }); + + test('host pushes a snapshot when local playback changes', () async { + final session = newSession(host.port!); + await connectAuthed(session); + + final gotQueue = + session.snapshots.firstWhere((s) => s.queue.length == 2); + await controller.playSongs([song('a'), song('b')]); + + final snap = await gotQueue; + expect(snap.queue.map((s) => s.id), ['a', 'b']); + expect(snap.currentIndex, 0); + }); + + test('a command from the remote drives the host engine', () async { + await controller.playSongs([song('a'), song('b'), song('c')]); + + final session = newSession(host.port!); + await connectAuthed(session); + + // Jump to index 2 from the remote; the host engine should follow, and a + // fresh snapshot should reflect it. + final jumped = + session.snapshots.firstWhere((s) => s.currentIndex == 2); + session.send(const RemoteCommand.jumpTo(2)); + + final snap = await jumped; + expect(snap.currentIndex, 2); + expect(controller.currentState.currentIndex, 2); + }); + + test('remote can enqueue a track by sending its Song', () async { + await controller.playSongs([song('a')]); + + final session = newSession(host.port!); + await connectAuthed(session); + + final grew = session.snapshots.firstWhere((s) => s.queue.length == 2); + session.send(RemoteCommand.addToQueue(song('z'))); + + final snap = await grew; + expect(snap.queue.map((s) => s.id), ['a', 'z']); + }); + + test('wrong password is denied', () async { + final session = newSession(host.port!, pass: 'wrong-password'); + final denied = + session.status.firstWhere((s) => s == RemoteConnStatus.denied); + await session.connect(); + await denied; + expect(host.clientCount, 0); + }); + + test('different server key is denied', () async { + final session = newSession(host.port!, key: 'other-server'); + final denied = + session.status.firstWhere((s) => s == RemoteConnStatus.denied); + await session.connect(); + await denied; + expect(host.clientCount, 0); + }); + + test('a host broadcast reaches every connected remote', () async { + final a = newSession(host.port!); + final b = newSession(host.port!); + await connectAuthed(a); + await connectAuthed(b); + expect(host.clientCount, 2); + + // Subscribe both before the host changes, then drive from the host: both + // remotes must observe the new queue. + final aSees = a.snapshots.firstWhere((s) => s.queue.length == 1); + final bSees = b.snapshots.firstWhere((s) => s.queue.length == 1); + await controller.playSongs([song('a')]); + await aSees; + await bSees; + }); +} diff --git a/test/remote_protocol_test.dart b/test/remote_protocol_test.dart new file mode 100644 index 0000000..9b292d5 --- /dev/null +++ b/test/remote_protocol_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:timbre/remote/messages.dart'; +import 'package:timbre/subsonic/models.dart'; + +Song _song(String id, {String? title}) => Song(id: id, title: title ?? id); + +void main() { + group('RemoteAuth', () { + test('proof matches when the password matches', () { + const nonce = 'abc123'; + final proof = RemoteAuth.proofFor(password: 'hunter2', nonce: nonce); + expect( + RemoteAuth.verify(password: 'hunter2', nonce: nonce, proof: proof), + isTrue, + ); + }); + + test('proof fails on the wrong password', () { + const nonce = 'abc123'; + final proof = RemoteAuth.proofFor(password: 'hunter2', nonce: nonce); + expect( + RemoteAuth.verify(password: 'wrong', nonce: nonce, proof: proof), + isFalse, + ); + }); + + test('proof is nonce-bound (no replay across connections)', () { + final p1 = RemoteAuth.proofFor(password: 'pw', nonce: 'n1'); + final p2 = RemoteAuth.proofFor(password: 'pw', nonce: 'n2'); + expect(p1, isNot(p2)); + expect(RemoteAuth.verify(password: 'pw', nonce: 'n2', proof: p1), isFalse); + }); + + test('newNonce is fresh each call', () { + expect(RemoteAuth.newNonce(), isNot(RemoteAuth.newNonce())); + }); + }); + + group('StateSnapshot codec', () { + test('round-trips through JSON', () { + final snap = StateSnapshot( + queue: [_song('1', title: 'One'), _song('2', title: 'Two')], + currentIndex: 1, + playing: true, + positionMs: 42000, + durationMs: 180000, + shuffle: true, + loop: 'all', + seq: 7, + ); + final back = StateSnapshot.fromJson( + StateSnapshot.fromJson(snap.toJson()).toJson(), + ); + expect(back.queue.map((s) => s.id), ['1', '2']); + expect(back.currentIndex, 1); + expect(back.playing, isTrue); + expect(back.positionMs, 42000); + expect(back.durationMs, 180000); + expect(back.shuffle, isTrue); + expect(back.loop, 'all'); + expect(back.seq, 7); + }); + + test('tolerates a missing/empty queue', () { + final snap = StateSnapshot.fromJson({'playing': false}); + expect(snap.queue, isEmpty); + expect(snap.currentIndex, isNull); + expect(snap.loop, 'off'); + }); + }); + + group('RemoteCommand codec', () { + test('payload-carrying ops round-trip', () { + final cmds = [ + const RemoteCommand.playPause(), + const RemoteCommand.seek(12345), + const RemoteCommand.jumpTo(3), + const RemoteCommand.removeAt(2), + const RemoteCommand.reorder(1, 4), + RemoteCommand.playSongs([_song('a'), _song('b')], startIndex: 1), + RemoteCommand.playNext(_song('c')), + RemoteCommand.addToQueue(_song('d')), + const RemoteCommand.toggleShuffle(), + const RemoteCommand.cycleLoop(), + ]; + for (final c in cmds) { + final back = RemoteCommand.fromJson(c.toJson()); + expect(back.op, c.op); + expect(back.index, c.index); + expect(back.oldIndex, c.oldIndex); + expect(back.newIndex, c.newIndex); + expect(back.startIndex, c.startIndex); + expect(back.positionMs, c.positionMs); + expect(back.song?.id, c.song?.id); + expect(back.songs?.map((s) => s.id), c.songs?.map((s) => s.id)); + } + }); + + test('unknown op throws', () { + expect( + () => RemoteCommand.fromJson({'op': 'nope'}), + throwsFormatException, + ); + }); + }); + + group('RemoteMessage envelope', () { + test('encodes and decodes each message type', () { + final msgs = [ + const ChallengeMessage( + version: 1, serverKey: 'k', nonce: 'n', device: 'iPad'), + const AuthMessage( + version: 1, serverKey: 'k', proof: 'p', device: 'Phone'), + const WelcomeMessage(device: 'iPad'), + const DenyMessage(reason: 'bad auth'), + SnapshotMessage(StateSnapshot( + queue: [_song('1')], + currentIndex: 0, + playing: false, + positionMs: 0, + durationMs: 0, + shuffle: false, + loop: 'off', + seq: 1, + )), + const CommandMessage(RemoteCommand.next()), + ]; + for (final m in msgs) { + final back = RemoteMessage.decode(m.encode()); + expect(back.runtimeType, m.runtimeType); + } + }); + + test('decodes handshake fields', () { + final decoded = RemoteMessage.decode(const ChallengeMessage( + version: kRemoteProtocolVersion, + serverKey: 'server-key', + nonce: 'the-nonce', + device: 'Living Room iPad', + ).encode()); + expect(decoded, isA()); + final c = decoded as ChallengeMessage; + expect(c.version, kRemoteProtocolVersion); + expect(c.serverKey, 'server-key'); + expect(c.nonce, 'the-nonce'); + expect(c.device, 'Living Room iPad'); + }); + + test('rejects a non-object frame', () { + expect(() => RemoteMessage.decode('42'), throwsFormatException); + }); + + test('rejects an unknown message type', () { + expect(() => RemoteMessage.decode('{"t":"bogus"}'), throwsFormatException); + }); + }); +} diff --git a/updates-features.md b/updates-features.md index 059b563..4b623d6 100644 --- a/updates-features.md +++ b/updates-features.md @@ -6,7 +6,7 @@ These are ranked in order of importance and should be tackled one element at a t - [x] 1. Drag n Drop Queue Reorder - [x] 2. Filtering and Sorting Options -- [ ] 3. Network Connectivity +- [x] 3. Network Connectivity - [x] 4. Simoultaneous Download Setting - [x] 5. Playlist Sharing - [ ] 6. In-App Purchase @@ -65,5 +65,33 @@ These are ranked in order of importance and should be tackled one element at a t ### 6. In-App "Buy the Dev a Coffee" ($5) — Medium, client + store config - **Resolved:** route through IAP (review-safe). Flutter `in_app_purchase`, modeled as a **consumable** $5 product configured in App Store Connect + Google Play Console. Apple requires digital tips to go through IAP (no external payment link on iOS). Optional button in `lib/screens/settings_screen.dart`. -### 3. Network Connectivity / Cross-Device Control — Deferred deep-dive -Biggest lift. Client-only + Subsonic offers no shared-state or device-to-device channel, and the app has zero realtime code. Candidate directions to evaluate in a dedicated session: (a) Subsonic **jukebox mode** (server-side playback control, not another client — limited), (b) **LAN mDNS discovery + direct socket** (no server, same-Wi-Fi only), (c) relay service (would break client-only). No work until scoped separately. +### 3. Network Connectivity / Cross-Device Control — Large, LAN-only + +**Scope (locked):** LAN-only · remote-control topology (one device plays audio, others act as remotes) · full remote (transport + live queue mirror + browse/enqueue/reorder). Both devices must be signed into the **same Subsonic server** — that constraint is what makes full remote cheap: the remote sends a track by `id` / `Song` JSON and the *host* resolves its own stream URI locally via the existing `streamUriFor`. No server changes; discovery + transport are pure LAN. + +**Why not cross-network:** Subsonic/Navidrome exposes no realtime channel, pub/sub, or shared scratch space, so two instances can't coordinate over the internet without a relay (breaks client-only) or a hacky high-latency polling abuse of playlists. LAN-only is the robust client-only answer. + +**Architecture** +- **Transport:** host runs an in-process WebSocket server (`dart:io` `HttpServer` + `WebSocketTransformer.upgrade` — no new dep); remote is a `WebSocket.connect` client. +- **Discovery:** mDNS/Bonjour via a new dep — `nsd` (register + browse; `bonsoir` is the alt). Service type `_timbre._tcp`; TXT record carries `serverKey`, protocol version, device name. +- **Auth (no manual pairing):** both devices already know the server password → derive an HMAC key from it; host sends a nonce, remote returns `HMAC-SHA256(nonce)`. Proves same-user without a PIN (`crypto` already a dep). Control gated to matching `serverKey` only. +- **Playback facade (load-bearing refactor):** `activePlaybackProvider` (state the UI renders) + `playbackCommandsProvider` (a `PlaybackCommands` interface the UI drives). `PlaybackController` (unchanged local engine) and a new `RemotePlaybackProxy` both satisfy the seam. Local mode delegates to the controller; remote mode renders the last WS snapshot and turns commands into outgoing messages (optimistic, reconciled on next snapshot). +- **New modules:** `lib/remote/messages.dart` (DTOs + codec + auth), `host_server.dart`, `discovery.dart`, `remote_session.dart`, `remote_playback.dart`, `lib/state/remote_providers.dart`, plus a "Devices" picker sheet + a "Playing on " status chip. + +**Build order** +1. **Protocol + codec + auth** — pure Dart, fully unit-testable (`Command` / `StateSnapshot` / `Hello` DTOs, versioning, HMAC challenge-response). +2. **Facade indirection** — add `activePlaybackProvider` + `playbackCommandsProvider`, migrate the ~11 UI files off `playbackProvider`. Ships as a no-op local-only refactor; de-risks everything downstream. **← land first.** +3. **Host server** — WS server broadcasts snapshots on `PlaybackController` change (reuse the throttle pattern), applies inbound commands. +4. **Discovery** — advertise when playable / browse for peers; platform config. +5. **Remote session + routing** — connect, mirror state, send commands, position interpolation, disconnect → fall back to local. +6. **UI** — device sheet, status chip, route browse/enqueue actions to the host. +7. **Auth + polish + tests** — loopback (host + client in one process) integration test. + +**Risks / gotchas** +- **iOS 14+:** `NSLocalNetworkUsageDescription` + `NSBonjourServices=_timbre._tcp` in Info.plist or discovery silently fails. Android NSD needs a multicast lock (nsd handles). +- **iOS backgrounding:** host stays alive while *playing* (audio background mode via `just_audio_background`); a paused+backgrounded host may suspend its socket — remote reconnects on resume. +- **Position sync:** interpolate on the remote between periodic snapshots; reconcile on seek. +- **Offline host:** host resolves its own URIs; a track it can't stream reuses the existing `PlaybackError.offline` path. +- Multi-remote works free: host broadcasts to all clients. + +**Estimate:** ~2–3× any other item on this list; step 2 (facade) is the reversible foundation to land first.