network addition

This commit is contained in:
Forrest 2026-08-04 16:39:18 -04:00
parent 3bd713d667
commit 2099d3d64d
27 changed files with 2237 additions and 40 deletions

View file

@ -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<void> playSongs(List<Song> songs, {int startIndex = 0});
Future<void> jumpTo(int index);
Future<void> playNext(Song song);
Future<void> addToQueue(Song song);
Future<void> removeAt(int index);
Future<void> reorderQueue(int oldIndex, int newIndex);
Future<void> togglePlayPause();
Future<void> next();
Future<void> previous();
Future<void> seek(Duration position);
Future<void> toggleShuffle();
Future<void> cycleLoop();
Future<void> 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<PlaybackState> {
class PlaybackController extends StateNotifier<PlaybackState>
implements PlaybackCommands {
PlaybackController({
required Uri? Function(Song) streamUriFor,
required Uri? Function(Song) coverArtUriFor,
@ -157,6 +182,11 @@ class PlaybackController extends StateNotifier<PlaybackState> {
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<PlaybackState> {
/// 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<void> playSongs(List<Song> songs, {int startIndex = 0}) async {
if (songs.isEmpty) return;
@ -317,6 +348,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// 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<void> jumpTo(int index) async {
final q = state.queue;
if (index < 0 || index >= q.length) return;
@ -339,6 +371,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// Insert [song] right after the current track (Timbre's "play next").
/// Falls back to [playSongs] when nothing is playing.
@override
Future<void> playNext(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
@ -353,6 +386,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
}
/// Append [song] to the end of the queue.
@override
Future<void> addToQueue(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
@ -368,6 +402,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// 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<void> removeAt(int index) async {
final q = state.queue;
if (index < 0 || index >= q.length) return;
@ -415,6 +450,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// 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<void> 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<PlaybackState> {
await player.moveAudioSource(oldIndex, newIndex);
}
@override
Future<void> togglePlayPause() async {
final player = _player;
if (player == null) return;
@ -462,10 +499,13 @@ class PlaybackController extends StateNotifier<PlaybackState> {
}
}
@override
Future<void> next() async => _player?.seekToNext();
@override
Future<void> previous() async => _player?.seekToPrevious();
@override
Future<void> seek(Duration position) async => _player?.seek(position);
/// Toggle shuffle by physically reordering the queue.
@ -478,6 +518,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// (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<void> toggleShuffle() async {
final enabled = !state.shuffle;
final q = state.queue;
@ -587,6 +628,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// 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<void> retry() async {
if (state.queue.isEmpty) return;
try {
@ -615,6 +657,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// 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<PlaybackState> {
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
/// single-track repeat).
@override
Future<void> cycleLoop() async {
final nextMode = switch (state.loop) {
LoopMode.off => LoopMode.all,

View file

@ -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<void> 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();
}
}

200
lib/remote/discovery.dart Normal file
View file

@ -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<List<DiscoveredDevice>> _devices =
StreamController<List<DiscoveredDevice>>.broadcast();
List<DiscoveredDevice> _current = const [];
/// Latest discovered, filtered device list (also replayed as a stream).
List<DiscoveredDevice> get current => _current;
Stream<List<DiscoveredDevice>> get devices => _devices.stream;
bool get isAdvertising => _registration != null;
bool get isBrowsing => _discovery != null;
// ---- Advertise --------------------------------------------------------
/// Publish this device as a controllable player on [port]. Replaces any
/// existing advertisement.
Future<void> advertise({
required String deviceName,
required int port,
required String serverKey,
}) 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<void> 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<void> 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<void> 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<void> dispose() async {
await stopAdvertising();
await stopBrowsing();
await _devices.close();
}
void _rebuild() {
final discovery = _discovery;
if (discovery == null) return;
final out = <DiscoveredDevice>[];
for (final s in discovery.services) {
final dev = _toDevice(s);
if (dev == null) continue;
if (_browseServerKey != null && dev.serverKey != _browseServerKey) {
continue; // different Subsonic server — its song ids won't resolve here
}
if (dev.version != kRemoteProtocolVersion) continue;
if (_ownName != null && s.name == _ownName) continue; // ourselves
out.add(dev);
}
_current = out;
if (!_devices.isClosed) _devices.add(out);
}
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 <String, Uint8List?>{};
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<String, Uint8List?> txt, String key) {
final v = txt[key];
if (v == null || v.isEmpty) return null;
return utf8.decode(v, allowMalformed: true);
}
}

273
lib/remote/host_server.dart Normal file
View file

@ -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<WebSocket> _clients = {};
final StreamController<int> _clientCount =
StreamController<int>.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<int> 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<int> 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<void> 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<void> 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.
}
}
}

362
lib/remote/messages.dart Normal file
View file

@ -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<int>.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<Song> queue;
final int? currentIndex;
final bool playing;
final int positionMs;
final int durationMs;
final bool shuffle;
final String loop;
final int seq;
Map<String, dynamic> 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<String, dynamic> j) => StateSnapshot(
queue: (j['queue'] as List? ?? const [])
.whereType<Map>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.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<Song> 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<Song>? songs;
final Song? song;
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>());
}
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>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.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<String, dynamic> 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<String, dynamic>();
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<String, dynamic>()),
);
case 'cmd':
return CommandMessage(
RemoteCommand.fromJson((m['cmd'] as Map).cast<String, dynamic>()),
);
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {'t': 'deny', 'reason': reason};
}
/// host → remote: a full playback snapshot.
class SnapshotMessage extends RemoteMessage {
const SnapshotMessage(this.snapshot);
final StateSnapshot snapshot;
@override
Map<String, dynamic> toJson() => {'t': 'state', 'snapshot': snapshot.toJson()};
}
/// remote → host: one control command.
class CommandMessage extends RemoteMessage {
const CommandMessage(this.command);
final RemoteCommand command;
@override
Map<String, dynamic> toJson() => {'t': 'cmd', 'cmd': command.toJson()};
}

View file

@ -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<void> playSongs(List<Song> songs, {int startIndex = 0}) async =>
_session.send(RemoteCommand.playSongs(songs, startIndex: startIndex));
@override
Future<void> jumpTo(int index) async =>
_session.send(RemoteCommand.jumpTo(index));
@override
Future<void> playNext(Song song) async =>
_session.send(RemoteCommand.playNext(song));
@override
Future<void> addToQueue(Song song) async =>
_session.send(RemoteCommand.addToQueue(song));
@override
Future<void> removeAt(int index) async =>
_session.send(RemoteCommand.removeAt(index));
@override
Future<void> reorderQueue(int oldIndex, int newIndex) async =>
_session.send(RemoteCommand.reorder(oldIndex, newIndex));
@override
Future<void> togglePlayPause() async =>
_session.send(const RemoteCommand.playPause());
@override
Future<void> next() async => _session.send(const RemoteCommand.next());
@override
Future<void> previous() async =>
_session.send(const RemoteCommand.previous());
@override
Future<void> seek(Duration position) async =>
_session.send(RemoteCommand.seek(position.inMilliseconds));
@override
Future<void> toggleShuffle() async =>
_session.send(const RemoteCommand.toggleShuffle());
@override
Future<void> cycleLoop() async =>
_session.send(const RemoteCommand.cycleLoop());
@override
Future<void> 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.
}
}

View file

@ -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<dynamic>? _sub;
final StreamController<StateSnapshot> _snapshots =
StreamController<StateSnapshot>.broadcast();
final StreamController<RemoteConnStatus> _status =
StreamController<RemoteConnStatus>.broadcast();
RemoteConnStatus _current = RemoteConnStatus.disconnected;
/// Host-reported friendly name, available after [RemoteConnStatus.connected].
String? hostDevice;
Stream<StateSnapshot> get snapshots => _snapshots.stream;
Stream<RemoteConnStatus> 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<void> 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<void> 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);
}
}

View file

@ -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 <Song>[];
return _DetailScaffold(

View file

@ -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<void> showDevicesSheet(BuildContext context) {
return showModalBottomSheet<void>(
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)),
),
],
),
);
}
}

View file

@ -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;

View file

@ -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]),
),
],

View file

@ -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);
}
}

View file

@ -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<NowPlayingScreen> {
/// [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<NowPlayingScreen> {
);
}
// 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<NowPlayingScreen> {
),
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<NowPlayingScreen> {
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<NowPlayingScreen> {
);
}
// 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<NowPlayingScreen> {
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<NowPlayingScreen> {
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<int?>(
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,

View file

@ -325,7 +325,7 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
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 <Song>[];
// Ownership drives which sharing affordance shows: owner → share toggle;

View file

@ -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]),
),
],

View file

@ -60,7 +60,7 @@ class _AppShellState extends ConsumerState<AppShell>
// interruption and otherwise leaves the playhead frozen (see
// PlaybackController.resyncFromPlayer).
if (state == AppLifecycleState.resumed) {
ref.read(playbackProvider.notifier).resyncFromPlayer();
ref.read(playbackCommandsProvider).resyncFromPlayer();
}
}

View file

@ -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<PlaybackState>((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<PlaybackCommands>((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);
});

View file

@ -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<DiscoveredDevice> 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<DiscoveredDevice>? 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<RemoteControlState> {
RemoteControlController(this._ref)
: super(RemoteControlState(supported: _supported())) {
if (state.supported) {
_ref.listen<ConnectionState>(
connectionProvider,
(_, next) => _onConnection(next),
fireImmediately: true,
);
}
}
final Ref _ref;
final RemoteDiscovery _discovery = RemoteDiscovery();
RemoteHost? _host;
RemoteSession? _session;
RemotePlaybackProxy? _proxy;
StreamSubscription<StateSnapshot>? _snapSub;
StreamSubscription<RemoteConnStatus>? _statusSub;
StreamSubscription<List<DiscoveredDevice>>? _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<void> _onConnection(ConnectionState conn) async {
if (conn.isOnline) {
await _startHosting(conn);
} else {
await detach();
await _stopHosting();
}
}
Future<void> _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<void> _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<void> 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<void> stopBrowsing() async {
await _discovery.stopBrowsing();
_update((s) => s.copyWith(browsing: false, devices: const []));
}
// ---- Attach / detach as a remote --------------------------------------
Future<void> 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<void> 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<RemoteControlController, RemoteControlState>(
(ref) => RemoteControlController(ref),
);

View file

@ -49,7 +49,7 @@ class _CassetteViewState extends ConsumerState<CassetteView>
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);
}

View file

@ -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,