network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
|
|
@ -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,
|
||||
|
|
|
|||
41
lib/remote/command_dispatch.dart
Normal file
41
lib/remote/command_dispatch.dart
Normal 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
200
lib/remote/discovery.dart
Normal 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
273
lib/remote/host_server.dart
Normal 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
362
lib/remote/messages.dart
Normal 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()};
|
||||
}
|
||||
92
lib/remote/remote_playback.dart
Normal file
92
lib/remote/remote_playback.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
135
lib/remote/remote_session.dart
Normal file
135
lib/remote/remote_session.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
228
lib/screens/devices_sheet.dart
Normal file
228
lib/screens/devices_sheet.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
295
lib/state/remote_providers.dart
Normal file
295
lib/state/remote_providers.dart
Normal 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),
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue