network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
|
|
@ -4,6 +4,10 @@
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
|
<!-- LAN cross-device control: mDNS discovery (nsd) benefits from holding a
|
||||||
|
Wi-Fi multicast lock for reliable service resolution. -->
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<application
|
<application
|
||||||
android:label="Timbre"
|
android:label="Timbre"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Timbre discovers and controls other Timbre devices on your Wi-Fi network so you can play from one device and control it from another.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_timbre._tcp</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Owns the just_audio player and translates Subsonic songs into a gapless
|
||||||
/// queue. Playback methods no-op where audio is unsupported, but queue/current
|
/// 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
|
/// state and album-art accent extraction still run so the UI is fully alive on
|
||||||
/// the Linux dev target.
|
/// the Linux dev target.
|
||||||
class PlaybackController extends StateNotifier<PlaybackState> {
|
class PlaybackController extends StateNotifier<PlaybackState>
|
||||||
|
implements PlaybackCommands {
|
||||||
PlaybackController({
|
PlaybackController({
|
||||||
required Uri? Function(Song) streamUriFor,
|
required Uri? Function(Song) streamUriFor,
|
||||||
required Uri? Function(Song) coverArtUriFor,
|
required Uri? Function(Song) coverArtUriFor,
|
||||||
|
|
@ -157,6 +182,11 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
static bool get _audioSupported =>
|
static bool get _audioSupported =>
|
||||||
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
!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() {
|
void _wireStreams() {
|
||||||
final player = _player!;
|
final player = _player!;
|
||||||
player.currentIndexStream.listen((i) {
|
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
|
/// into the player, so queue index == player source index 1:1. Every later
|
||||||
/// queue mutation (`playNext` / `addToQueue` / `removeAt`) relies on that
|
/// queue mutation (`playNext` / `addToQueue` / `removeAt`) relies on that
|
||||||
/// invariant to stay aligned.
|
/// invariant to stay aligned.
|
||||||
|
@override
|
||||||
Future<void> playSongs(List<Song> songs, {int startIndex = 0}) async {
|
Future<void> playSongs(List<Song> songs, {int startIndex = 0}) async {
|
||||||
if (songs.isEmpty) return;
|
if (songs.isEmpty) return;
|
||||||
|
|
||||||
|
|
@ -317,6 +348,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
/// Start playing the queue entry at [index] without rebuilding the queue —
|
/// 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
|
/// used by the queue list so tapping a row jumps to it and keeps the current
|
||||||
/// (possibly shuffled) order intact.
|
/// (possibly shuffled) order intact.
|
||||||
|
@override
|
||||||
Future<void> jumpTo(int index) async {
|
Future<void> jumpTo(int index) async {
|
||||||
final q = state.queue;
|
final q = state.queue;
|
||||||
if (index < 0 || index >= q.length) return;
|
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").
|
/// Insert [song] right after the current track (Timbre's "play next").
|
||||||
/// Falls back to [playSongs] when nothing is playing.
|
/// Falls back to [playSongs] when nothing is playing.
|
||||||
|
@override
|
||||||
Future<void> playNext(Song song) async {
|
Future<void> playNext(Song song) async {
|
||||||
if (_streamUriFor(song) == null) return;
|
if (_streamUriFor(song) == null) return;
|
||||||
final q = state.queue;
|
final q = state.queue;
|
||||||
|
|
@ -353,6 +386,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append [song] to the end of the queue.
|
/// Append [song] to the end of the queue.
|
||||||
|
@override
|
||||||
Future<void> addToQueue(Song song) async {
|
Future<void> addToQueue(Song song) async {
|
||||||
if (_streamUriFor(song) == null) return;
|
if (_streamUriFor(song) == null) return;
|
||||||
final q = state.queue;
|
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
|
/// playlist aligned. On mobile just_audio adjusts its own current index and
|
||||||
/// re-emits `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a
|
/// re-emits `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a
|
||||||
/// spurious re-scrobble when the playing track didn't actually change.
|
/// spurious re-scrobble when the playing track didn't actually change.
|
||||||
|
@override
|
||||||
Future<void> removeAt(int index) async {
|
Future<void> removeAt(int index) async {
|
||||||
final q = state.queue;
|
final q = state.queue;
|
||||||
if (index < 0 || index >= q.length) return;
|
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.
|
/// among the shuffled tail; hitting shuffle afterwards re-randomizes as usual.
|
||||||
/// A single targeted `moveAudioSource` keeps the current track playing without
|
/// A single targeted `moveAudioSource` keeps the current track playing without
|
||||||
/// the re-buffer a full source rebuild ([_applyOrder]) would cause.
|
/// the re-buffer a full source rebuild ([_applyOrder]) would cause.
|
||||||
|
@override
|
||||||
Future<void> reorderQueue(int oldIndex, int newIndex) async {
|
Future<void> reorderQueue(int oldIndex, int newIndex) async {
|
||||||
final q = state.queue;
|
final q = state.queue;
|
||||||
if (oldIndex < 0 || oldIndex >= q.length) return;
|
if (oldIndex < 0 || oldIndex >= q.length) return;
|
||||||
|
|
@ -452,6 +488,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
await player.moveAudioSource(oldIndex, newIndex);
|
await player.moveAudioSource(oldIndex, newIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> togglePlayPause() async {
|
Future<void> togglePlayPause() async {
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player == null) return;
|
if (player == null) return;
|
||||||
|
|
@ -462,10 +499,13 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> next() async => _player?.seekToNext();
|
Future<void> next() async => _player?.seekToNext();
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> previous() async => _player?.seekToPrevious();
|
Future<void> previous() async => _player?.seekToPrevious();
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> seek(Duration position) async => _player?.seek(position);
|
Future<void> seek(Duration position) async => _player?.seek(position);
|
||||||
|
|
||||||
/// Toggle shuffle by physically reordering the queue.
|
/// 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
|
/// (and already-played history) in place; disabling restores the pre-shuffle
|
||||||
/// order, dropping anything since removed and appending anything since added.
|
/// order, dropping anything since removed and appending anything since added.
|
||||||
/// The player's own shuffle mode is left off permanently.
|
/// The player's own shuffle mode is left off permanently.
|
||||||
|
@override
|
||||||
Future<void> toggleShuffle() async {
|
Future<void> toggleShuffle() async {
|
||||||
final enabled = !state.shuffle;
|
final enabled = !state.shuffle;
|
||||||
final q = state.queue;
|
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
|
/// Re-attempt the current queue after an [PlaybackError] (e.g. the network
|
||||||
/// came back). Rebuilds sources from the current queue — re-resolving each
|
/// came back). Rebuilds sources from the current queue — re-resolving each
|
||||||
/// URI, so any now-available local download is preferred — and resumes.
|
/// URI, so any now-available local download is preferred — and resumes.
|
||||||
|
@override
|
||||||
Future<void> retry() async {
|
Future<void> retry() async {
|
||||||
if (state.queue.isEmpty) return;
|
if (state.queue.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -615,6 +657,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
/// frozen (often at 0:00) when the app returns to the foreground. Called from
|
/// 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
|
/// the app-lifecycle `resumed` hook. Reads only — never rebuilds sources or
|
||||||
/// touches the queue order, so the queue-index==source-index invariant holds.
|
/// touches the queue order, so the queue-index==source-index invariant holds.
|
||||||
|
@override
|
||||||
void resyncFromPlayer() {
|
void resyncFromPlayer() {
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player == null) return;
|
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
|
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
|
||||||
/// single-track repeat).
|
/// single-track repeat).
|
||||||
|
@override
|
||||||
Future<void> cycleLoop() async {
|
Future<void> cycleLoop() async {
|
||||||
final nextMode = switch (state.loop) {
|
final nextMode = switch (state.loop) {
|
||||||
LoopMode.off => LoopMode.all,
|
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) {
|
Widget build(BuildContext context) {
|
||||||
final index = ref.watch(libraryIndexProvider);
|
final index = ref.watch(libraryIndexProvider);
|
||||||
final visible = ref.watch(visibleTracksProvider);
|
final visible = ref.watch(visibleTracksProvider);
|
||||||
final playback = ref.read(playbackProvider.notifier);
|
final playback = ref.read(playbackCommandsProvider);
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
|
|
||||||
final Widget body;
|
final Widget body;
|
||||||
|
|
@ -564,7 +564,7 @@ class AlbumScreen extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final album = ref.watch(albumProvider(id));
|
final album = ref.watch(albumProvider(id));
|
||||||
final downloads = ref.watch(downloadManagerProvider);
|
final downloads = ref.watch(downloadManagerProvider);
|
||||||
final playback = ref.read(playbackProvider.notifier);
|
final playback = ref.read(playbackCommandsProvider);
|
||||||
final songs = album.valueOrNull?.songs ?? const <Song>[];
|
final songs = album.valueOrNull?.songs ?? const <Song>[];
|
||||||
|
|
||||||
return _DetailScaffold(
|
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) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final downloads = ref.watch(downloadManagerProvider);
|
final downloads = ref.watch(downloadManagerProvider);
|
||||||
final controller = ref.read(downloadManagerProvider.notifier);
|
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 active = downloads.byId.values.where((d) => d.isActive).toList();
|
||||||
final completed = downloads.completed;
|
final completed = downloads.completed;
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,13 @@ class FavoritesScreen extends ConsumerWidget {
|
||||||
title: s.songs[i].title ?? 'Untitled',
|
title: s.songs[i].title ?? 'Untitled',
|
||||||
trailing: s.songs[i].artist,
|
trailing: s.songs[i].artist,
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackCommandsProvider)
|
||||||
.playSongs(s.songs, startIndex: i),
|
.playSongs(s.songs, startIndex: i),
|
||||||
onPlayNext: () => ref
|
onPlayNext: () => ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackCommandsProvider)
|
||||||
.playNext(s.songs[i]),
|
.playNext(s.songs[i]),
|
||||||
onAddToQueue: () => ref
|
onAddToQueue: () => ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackCommandsProvider)
|
||||||
.addToQueue(s.songs[i]),
|
.addToQueue(s.songs[i]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ class _HeroCard extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final client = ref.watch(subsonicClientProvider);
|
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.
|
// Fall back to the most recent track so the hero is useful before playback.
|
||||||
final recent = ref.watch(recentSongsProvider);
|
final recent = ref.watch(recentSongsProvider);
|
||||||
|
|
@ -136,7 +136,7 @@ class _HeroCard extends ConsumerWidget {
|
||||||
if (hasCurrent) {
|
if (hasCurrent) {
|
||||||
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
|
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
|
||||||
} else if (fallback != null) {
|
} else if (fallback != null) {
|
||||||
ref.read(playbackProvider.notifier).playSongs([fallback.toSong()]);
|
ref.read(playbackCommandsProvider).playSongs([fallback.toSong()]);
|
||||||
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
|
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -210,7 +210,7 @@ class _HeroProgress extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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);
|
return BlockProgressBar(progress: progress, cells: 32, height: 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ import '../layout/breakpoints.dart';
|
||||||
import '../playback/playback_engine.dart';
|
import '../playback/playback_engine.dart';
|
||||||
import '../settings/settings_store.dart';
|
import '../settings/settings_store.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
|
import '../state/remote_providers.dart';
|
||||||
import '../subsonic/models.dart';
|
import '../subsonic/models.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/block_progress_bar.dart';
|
import '../widgets/block_progress_bar.dart';
|
||||||
import '../widgets/cassette_view.dart';
|
import '../widgets/cassette_view.dart';
|
||||||
import '../widgets/hairline_panel.dart';
|
import '../widgets/hairline_panel.dart';
|
||||||
import 'add_to_playlist_sheet.dart';
|
import 'add_to_playlist_sheet.dart';
|
||||||
|
import 'devices_sheet.dart';
|
||||||
|
|
||||||
/// Now Playing tab — album art + info strip + transport, bound to the live
|
/// 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
|
/// playback engine. On compact screens the top region shows the full-size album
|
||||||
|
|
@ -44,18 +46,18 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||||
/// [FavoritesController.seedSong].
|
/// [FavoritesController.seedSong].
|
||||||
void _seedFavorites() {
|
void _seedFavorites() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final song = ref.read(playbackProvider).current;
|
final song = ref.read(activePlaybackProvider).current;
|
||||||
if (song != null) ref.read(favoritesProvider.notifier).seedSong(song);
|
if (song != null) ref.read(favoritesProvider.notifier).seedSong(song);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(playbackProvider);
|
final state = ref.watch(activePlaybackProvider);
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final current = state.current;
|
final current = state.current;
|
||||||
|
|
||||||
// Re-seed the favorites store whenever the track changes.
|
// 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());
|
(_, _) => _seedFavorites());
|
||||||
|
|
||||||
if (current == null) {
|
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
|
// Everything below the art region — shared by both layouts. The queue
|
||||||
// toggle is deliberately excluded: it belongs only to the compact layout
|
// toggle is deliberately excluded: it belongs only to the compact layout
|
||||||
// (where art and queue share one region), so it's appended separately.
|
// (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),
|
const SizedBox(width: TimbreSpacing.sm),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => ref.read(playbackProvider.notifier).retry(),
|
onPressed: () => ref.read(playbackCommandsProvider).retry(),
|
||||||
child: Text('Retry', style: TextStyle(color: accent)),
|
child: Text('Retry', style: TextStyle(color: accent)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -132,6 +139,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||||
const Expanded(child: _FittedArt()),
|
const Expanded(child: _FittedArt()),
|
||||||
const SizedBox(height: TimbreSpacing.lg),
|
const SizedBox(height: TimbreSpacing.lg),
|
||||||
...controls,
|
...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(
|
final queueToggle = _QueueToggle(
|
||||||
showQueue: _showQueue,
|
showQueue: _showQueue,
|
||||||
queueLength: state.queue.length,
|
queueLength: state.queue.length,
|
||||||
accent: accent,
|
accent: accent,
|
||||||
onTap: () => setState(() => _showQueue = !_showQueue),
|
onTap: () => setState(() => _showQueue = !_showQueue),
|
||||||
);
|
);
|
||||||
|
final bottomBar = remoteSupported
|
||||||
|
? Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
queueToggle,
|
||||||
|
const SizedBox(width: TimbreSpacing.sm),
|
||||||
|
const _RemoteButton(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: queueToggle;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
|
|
@ -161,7 +184,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||||
const SizedBox(height: TimbreSpacing.lg),
|
const SizedBox(height: TimbreSpacing.lg),
|
||||||
...controls,
|
...controls,
|
||||||
const SizedBox(height: TimbreSpacing.sm),
|
const SizedBox(height: TimbreSpacing.sm),
|
||||||
queueToggle,
|
bottomBar,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
// Art absorbs the leftover height, capped to a square, so the
|
// 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),
|
const SizedBox(height: TimbreSpacing.lg),
|
||||||
...controls,
|
...controls,
|
||||||
const SizedBox(height: TimbreSpacing.sm),
|
const SizedBox(height: TimbreSpacing.sm),
|
||||||
queueToggle,
|
bottomBar,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -275,12 +298,12 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(playbackProvider);
|
final state = ref.watch(activePlaybackProvider);
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
// Follow the playing track as it advances (or as shuffle reorders things).
|
// Follow the playing track as it advances (or as shuffle reorders things).
|
||||||
ref.listen<int?>(
|
ref.listen<int?>(
|
||||||
playbackProvider.select((s) => s.currentIndex),
|
activePlaybackProvider.select((s) => s.currentIndex),
|
||||||
(_, next) => _scrollToIndex(next),
|
(_, next) => _scrollToIndex(next),
|
||||||
);
|
);
|
||||||
// Jump to the current track the first time the queue is populated.
|
// 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
|
// index — exactly the convention reorderQueue (and just_audio's
|
||||||
// moveAudioSource) expects — so no off-by-one adjustment is needed.
|
// moveAudioSource) expects — so no off-by-one adjustment is needed.
|
||||||
onReorderItem: (oldIndex, newIndex) =>
|
onReorderItem: (oldIndex, newIndex) =>
|
||||||
ref.read(playbackProvider.notifier).reorderQueue(oldIndex, newIndex),
|
ref.read(playbackCommandsProvider).reorderQueue(oldIndex, newIndex),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final song = state.queue[i];
|
final song = state.queue[i];
|
||||||
final current = state.currentIndex;
|
final current = state.currentIndex;
|
||||||
|
|
@ -317,7 +340,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
|
||||||
// collide and ReorderableListView requires unique keys. These rows
|
// collide and ReorderableListView requires unique keys. These rows
|
||||||
// are stateless, so keying by index leaks no state.
|
// are stateless, so keying by index leaks no state.
|
||||||
key: ValueKey(i),
|
key: ValueKey(i),
|
||||||
onTap: () => ref.read(playbackProvider.notifier).jumpTo(i),
|
onTap: () => ref.read(playbackCommandsProvider).jumpTo(i),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: TimbreSpacing.lg,
|
horizontal: TimbreSpacing.lg,
|
||||||
|
|
@ -346,7 +369,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
|
||||||
style: const TextStyle(color: TimbreColors.dimmed)),
|
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
ref.read(playbackProvider.notifier).removeAt(i),
|
ref.read(playbackCommandsProvider).removeAt(i),
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: const SizedBox(
|
child: const SizedBox(
|
||||||
width: TimbreSpacing.minTouchTarget,
|
width: TimbreSpacing.minTouchTarget,
|
||||||
|
|
@ -403,7 +426,7 @@ class _AlbumArtPanel extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final coverArt =
|
final coverArt =
|
||||||
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
|
ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt));
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
final cassette =
|
final cassette =
|
||||||
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
|
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 {
|
class _InfoStrip extends StatelessWidget {
|
||||||
const _InfoStrip({required this.song, required this.accent});
|
const _InfoStrip({required this.song, required this.accent});
|
||||||
|
|
||||||
|
|
@ -562,10 +632,10 @@ class _NowPlayingProgress extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 =
|
final duration =
|
||||||
ref.watch(playbackProvider.select((s) => s.effectiveDuration));
|
ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration));
|
||||||
final progress = ref.watch(playbackProvider.select((s) => s.progress));
|
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_fmtDur(position),
|
Text(_fmtDur(position),
|
||||||
|
|
@ -592,7 +662,7 @@ class _Transport extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final controller = ref.read(playbackProvider.notifier);
|
final controller = ref.read(playbackCommandsProvider);
|
||||||
final loopIcon = switch (state.loop) {
|
final loopIcon = switch (state.loop) {
|
||||||
LoopMode.one => Icons.repeat_one,
|
LoopMode.one => Icons.repeat_one,
|
||||||
_ => Icons.repeat,
|
_ => Icons.repeat,
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,7 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
|
||||||
s.playlists.where((p) => p.id == widget.id).firstOrNull));
|
s.playlists.where((p) => p.id == widget.id).firstOrNull));
|
||||||
final connected = ref.watch(subsonicClientProvider) != null;
|
final connected = ref.watch(subsonicClientProvider) != null;
|
||||||
final me = ref.watch(currentUsernameProvider);
|
final me = ref.watch(currentUsernameProvider);
|
||||||
final playback = ref.read(playbackProvider.notifier);
|
final playback = ref.read(playbackCommandsProvider);
|
||||||
final songs = detail?.songs ?? const <Song>[];
|
final songs = detail?.songs ?? const <Song>[];
|
||||||
|
|
||||||
// Ownership drives which sharing affordance shows: owner → share toggle;
|
// Ownership drives which sharing affordance shows: owner → share toggle;
|
||||||
|
|
|
||||||
|
|
@ -102,13 +102,13 @@ class _Results extends ConsumerWidget {
|
||||||
title: r.songs[i].title ?? 'Untitled',
|
title: r.songs[i].title ?? 'Untitled',
|
||||||
trailing: r.songs[i].artist,
|
trailing: r.songs[i].artist,
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackCommandsProvider)
|
||||||
.playSongs(r.songs, startIndex: i),
|
.playSongs(r.songs, startIndex: i),
|
||||||
onPlayNext: () => ref
|
onPlayNext: () => ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackCommandsProvider)
|
||||||
.playNext(r.songs[i]),
|
.playNext(r.songs[i]),
|
||||||
onAddToQueue: () => ref
|
onAddToQueue: () => ref
|
||||||
.read(playbackProvider.notifier)
|
.read(playbackCommandsProvider)
|
||||||
.addToQueue(r.songs[i]),
|
.addToQueue(r.songs[i]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ class _AppShellState extends ConsumerState<AppShell>
|
||||||
// interruption and otherwise leaves the playhead frozen (see
|
// interruption and otherwise leaves the playhead frozen (see
|
||||||
// PlaybackController.resyncFromPlayer).
|
// PlaybackController.resyncFromPlayer).
|
||||||
if (state == AppLifecycleState.resumed) {
|
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.dart';
|
||||||
import '../theme/accent_extract.dart';
|
import '../theme/accent_extract.dart';
|
||||||
import 'favorites.dart';
|
import 'favorites.dart';
|
||||||
|
import 'remote_providers.dart';
|
||||||
|
|
||||||
// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be
|
// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be
|
||||||
// persisted) but historically lived here — re-export so existing importers of
|
// persisted) but historically lived here — re-export so existing importers of
|
||||||
|
|
@ -582,3 +583,26 @@ final playbackProvider =
|
||||||
controller.restoreForServer();
|
controller.restoreForServer();
|
||||||
return controller;
|
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;
|
if (dt <= 0) return;
|
||||||
// Read (not watch) inside the ticker: the model drives repaints itself, and
|
// Read (not watch) inside the ticker: the model drives repaints itself, and
|
||||||
// watching here would rebuild the whole widget every position tick.
|
// 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);
|
_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) {
|
if (ref.watch(selectedTabProvider) == nowPlayingTabIndex) {
|
||||||
return const SizedBox.shrink();
|
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();
|
if (current == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
final playing = ref.watch(playbackProvider.select((s) => s.playing));
|
final playing = ref.watch(activePlaybackProvider.select((s) => s.playing));
|
||||||
final controller = ref.read(playbackProvider.notifier);
|
final controller = ref.read(playbackCommandsProvider);
|
||||||
final client = ref.watch(subsonicClientProvider);
|
final client = ref.watch(subsonicClientProvider);
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final artUri = (client != null && current.coverArt != null)
|
final artUri = (client != null && current.coverArt != null)
|
||||||
|
|
@ -112,7 +112,7 @@ class _MiniProgress extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 2,
|
height: 2,
|
||||||
|
|
|
||||||
64
pubspec.lock
64
pubspec.lock
|
|
@ -472,6 +472,62 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
nsd:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: nsd
|
||||||
|
sha256: "2551fec29f6032459815a577f385f89e18099a3ca1c545b5dfb1e1a64b5705ec"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.1"
|
||||||
|
nsd_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nsd_android
|
||||||
|
sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
nsd_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nsd_ios
|
||||||
|
sha256: "57a4b8b218860346eba7e2d629c6fce1fa89a97645350130a15c45babb4b1324"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
nsd_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nsd_macos
|
||||||
|
sha256: cca373ee5f28ea22140d595b5cd06e83ebc2d6e4ee3e17d7fa1fd6564e4cb352
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
nsd_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nsd_platform_interface
|
||||||
|
sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
nsd_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nsd_windows
|
||||||
|
sha256: "68b4a256b0be258dbbad0ae789f2e8838d0935a353dac86b17c14c1a05df4ecd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -592,6 +648,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.2"
|
version: "6.5.2"
|
||||||
|
provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: provider
|
||||||
|
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5+1"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ dependencies:
|
||||||
just_audio_background: ^0.0.1-beta.17
|
just_audio_background: ^0.0.1-beta.17
|
||||||
palette_generator: ^0.3.3+7
|
palette_generator: ^0.3.3+7
|
||||||
flutter_svg: ^2.3.0
|
flutter_svg: ^2.3.0
|
||||||
|
nsd: ^5.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
173
test/remote_integration_test.dart
Normal file
173
test/remote_integration_test.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
@Timeout(Duration(seconds: 15))
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timbre/playback/playback_engine.dart';
|
||||||
|
import 'package:timbre/remote/discovery.dart';
|
||||||
|
import 'package:timbre/remote/host_server.dart';
|
||||||
|
import 'package:timbre/remote/messages.dart';
|
||||||
|
import 'package:timbre/remote/remote_session.dart';
|
||||||
|
import 'package:timbre/subsonic/models.dart';
|
||||||
|
|
||||||
|
/// End-to-end over a real loopback WebSocket: a [RemoteHost] driving a
|
||||||
|
/// (headless, audio-unsupported) [PlaybackController] and a [RemoteSession]
|
||||||
|
/// client in the same process. Exercises the handshake, HMAC auth, snapshot
|
||||||
|
/// broadcast, and command application — the whole stack minus the platform
|
||||||
|
/// audio backend and mDNS discovery.
|
||||||
|
void main() {
|
||||||
|
const serverKey = 'server-key';
|
||||||
|
const password = 'hunter2';
|
||||||
|
|
||||||
|
late PlaybackController controller;
|
||||||
|
late RemoteHost host;
|
||||||
|
final sessions = <RemoteSession>[];
|
||||||
|
|
||||||
|
PlaybackController buildController() => PlaybackController(
|
||||||
|
// Non-null so songs count as streamable; no real player exists on the
|
||||||
|
// test host (audio unsupported), so this URI is never opened.
|
||||||
|
streamUriFor: (s) => Uri.parse('http://host.local/stream/${s.id}'),
|
||||||
|
coverArtUriFor: (_) => null,
|
||||||
|
serverKeyGetter: () => serverKey,
|
||||||
|
onArt: (_) {},
|
||||||
|
onPlay: (_) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
RemoteSession newSession(
|
||||||
|
int port, {
|
||||||
|
String key = serverKey,
|
||||||
|
String pass = password,
|
||||||
|
}) {
|
||||||
|
final s = RemoteSession(
|
||||||
|
device: DiscoveredDevice(
|
||||||
|
name: 'Host',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: port,
|
||||||
|
serverKey: key,
|
||||||
|
version: kRemoteProtocolVersion,
|
||||||
|
),
|
||||||
|
serverKey: key,
|
||||||
|
password: pass,
|
||||||
|
deviceName: 'Remote',
|
||||||
|
);
|
||||||
|
sessions.add(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Song song(String id) => Song(id: id, title: 'Song $id');
|
||||||
|
|
||||||
|
// Subscribe to the "connected" event *before* dialing, so a fast handshake
|
||||||
|
// can't fire it before we're listening (broadcast streams drop unheard
|
||||||
|
// events).
|
||||||
|
Future<void> connectAuthed(RemoteSession s) async {
|
||||||
|
final connected =
|
||||||
|
s.status.firstWhere((x) => x == RemoteConnStatus.connected);
|
||||||
|
await s.connect();
|
||||||
|
await connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
controller = buildController();
|
||||||
|
host = RemoteHost(
|
||||||
|
controller: controller,
|
||||||
|
serverKey: serverKey,
|
||||||
|
password: password,
|
||||||
|
deviceName: 'Host',
|
||||||
|
);
|
||||||
|
await host.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
for (final s in sessions) {
|
||||||
|
await s.close();
|
||||||
|
}
|
||||||
|
sessions.clear();
|
||||||
|
await host.stop();
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid remote completes the handshake and receives a snapshot',
|
||||||
|
() async {
|
||||||
|
final session = newSession(host.port!);
|
||||||
|
final firstSnapshot = session.snapshots.first;
|
||||||
|
await connectAuthed(session);
|
||||||
|
expect(session.hostDevice, 'Host');
|
||||||
|
await firstSnapshot; // initial state pushed on connect
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host pushes a snapshot when local playback changes', () async {
|
||||||
|
final session = newSession(host.port!);
|
||||||
|
await connectAuthed(session);
|
||||||
|
|
||||||
|
final gotQueue =
|
||||||
|
session.snapshots.firstWhere((s) => s.queue.length == 2);
|
||||||
|
await controller.playSongs([song('a'), song('b')]);
|
||||||
|
|
||||||
|
final snap = await gotQueue;
|
||||||
|
expect(snap.queue.map((s) => s.id), ['a', 'b']);
|
||||||
|
expect(snap.currentIndex, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a command from the remote drives the host engine', () async {
|
||||||
|
await controller.playSongs([song('a'), song('b'), song('c')]);
|
||||||
|
|
||||||
|
final session = newSession(host.port!);
|
||||||
|
await connectAuthed(session);
|
||||||
|
|
||||||
|
// Jump to index 2 from the remote; the host engine should follow, and a
|
||||||
|
// fresh snapshot should reflect it.
|
||||||
|
final jumped =
|
||||||
|
session.snapshots.firstWhere((s) => s.currentIndex == 2);
|
||||||
|
session.send(const RemoteCommand.jumpTo(2));
|
||||||
|
|
||||||
|
final snap = await jumped;
|
||||||
|
expect(snap.currentIndex, 2);
|
||||||
|
expect(controller.currentState.currentIndex, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote can enqueue a track by sending its Song', () async {
|
||||||
|
await controller.playSongs([song('a')]);
|
||||||
|
|
||||||
|
final session = newSession(host.port!);
|
||||||
|
await connectAuthed(session);
|
||||||
|
|
||||||
|
final grew = session.snapshots.firstWhere((s) => s.queue.length == 2);
|
||||||
|
session.send(RemoteCommand.addToQueue(song('z')));
|
||||||
|
|
||||||
|
final snap = await grew;
|
||||||
|
expect(snap.queue.map((s) => s.id), ['a', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong password is denied', () async {
|
||||||
|
final session = newSession(host.port!, pass: 'wrong-password');
|
||||||
|
final denied =
|
||||||
|
session.status.firstWhere((s) => s == RemoteConnStatus.denied);
|
||||||
|
await session.connect();
|
||||||
|
await denied;
|
||||||
|
expect(host.clientCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different server key is denied', () async {
|
||||||
|
final session = newSession(host.port!, key: 'other-server');
|
||||||
|
final denied =
|
||||||
|
session.status.firstWhere((s) => s == RemoteConnStatus.denied);
|
||||||
|
await session.connect();
|
||||||
|
await denied;
|
||||||
|
expect(host.clientCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a host broadcast reaches every connected remote', () async {
|
||||||
|
final a = newSession(host.port!);
|
||||||
|
final b = newSession(host.port!);
|
||||||
|
await connectAuthed(a);
|
||||||
|
await connectAuthed(b);
|
||||||
|
expect(host.clientCount, 2);
|
||||||
|
|
||||||
|
// Subscribe both before the host changes, then drive from the host: both
|
||||||
|
// remotes must observe the new queue.
|
||||||
|
final aSees = a.snapshots.firstWhere((s) => s.queue.length == 1);
|
||||||
|
final bSees = b.snapshots.firstWhere((s) => s.queue.length == 1);
|
||||||
|
await controller.playSongs([song('a')]);
|
||||||
|
await aSees;
|
||||||
|
await bSees;
|
||||||
|
});
|
||||||
|
}
|
||||||
157
test/remote_protocol_test.dart
Normal file
157
test/remote_protocol_test.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timbre/remote/messages.dart';
|
||||||
|
import 'package:timbre/subsonic/models.dart';
|
||||||
|
|
||||||
|
Song _song(String id, {String? title}) => Song(id: id, title: title ?? id);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('RemoteAuth', () {
|
||||||
|
test('proof matches when the password matches', () {
|
||||||
|
const nonce = 'abc123';
|
||||||
|
final proof = RemoteAuth.proofFor(password: 'hunter2', nonce: nonce);
|
||||||
|
expect(
|
||||||
|
RemoteAuth.verify(password: 'hunter2', nonce: nonce, proof: proof),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proof fails on the wrong password', () {
|
||||||
|
const nonce = 'abc123';
|
||||||
|
final proof = RemoteAuth.proofFor(password: 'hunter2', nonce: nonce);
|
||||||
|
expect(
|
||||||
|
RemoteAuth.verify(password: 'wrong', nonce: nonce, proof: proof),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proof is nonce-bound (no replay across connections)', () {
|
||||||
|
final p1 = RemoteAuth.proofFor(password: 'pw', nonce: 'n1');
|
||||||
|
final p2 = RemoteAuth.proofFor(password: 'pw', nonce: 'n2');
|
||||||
|
expect(p1, isNot(p2));
|
||||||
|
expect(RemoteAuth.verify(password: 'pw', nonce: 'n2', proof: p1), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newNonce is fresh each call', () {
|
||||||
|
expect(RemoteAuth.newNonce(), isNot(RemoteAuth.newNonce()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('StateSnapshot codec', () {
|
||||||
|
test('round-trips through JSON', () {
|
||||||
|
final snap = StateSnapshot(
|
||||||
|
queue: [_song('1', title: 'One'), _song('2', title: 'Two')],
|
||||||
|
currentIndex: 1,
|
||||||
|
playing: true,
|
||||||
|
positionMs: 42000,
|
||||||
|
durationMs: 180000,
|
||||||
|
shuffle: true,
|
||||||
|
loop: 'all',
|
||||||
|
seq: 7,
|
||||||
|
);
|
||||||
|
final back = StateSnapshot.fromJson(
|
||||||
|
StateSnapshot.fromJson(snap.toJson()).toJson(),
|
||||||
|
);
|
||||||
|
expect(back.queue.map((s) => s.id), ['1', '2']);
|
||||||
|
expect(back.currentIndex, 1);
|
||||||
|
expect(back.playing, isTrue);
|
||||||
|
expect(back.positionMs, 42000);
|
||||||
|
expect(back.durationMs, 180000);
|
||||||
|
expect(back.shuffle, isTrue);
|
||||||
|
expect(back.loop, 'all');
|
||||||
|
expect(back.seq, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates a missing/empty queue', () {
|
||||||
|
final snap = StateSnapshot.fromJson({'playing': false});
|
||||||
|
expect(snap.queue, isEmpty);
|
||||||
|
expect(snap.currentIndex, isNull);
|
||||||
|
expect(snap.loop, 'off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('RemoteCommand codec', () {
|
||||||
|
test('payload-carrying ops round-trip', () {
|
||||||
|
final cmds = <RemoteCommand>[
|
||||||
|
const RemoteCommand.playPause(),
|
||||||
|
const RemoteCommand.seek(12345),
|
||||||
|
const RemoteCommand.jumpTo(3),
|
||||||
|
const RemoteCommand.removeAt(2),
|
||||||
|
const RemoteCommand.reorder(1, 4),
|
||||||
|
RemoteCommand.playSongs([_song('a'), _song('b')], startIndex: 1),
|
||||||
|
RemoteCommand.playNext(_song('c')),
|
||||||
|
RemoteCommand.addToQueue(_song('d')),
|
||||||
|
const RemoteCommand.toggleShuffle(),
|
||||||
|
const RemoteCommand.cycleLoop(),
|
||||||
|
];
|
||||||
|
for (final c in cmds) {
|
||||||
|
final back = RemoteCommand.fromJson(c.toJson());
|
||||||
|
expect(back.op, c.op);
|
||||||
|
expect(back.index, c.index);
|
||||||
|
expect(back.oldIndex, c.oldIndex);
|
||||||
|
expect(back.newIndex, c.newIndex);
|
||||||
|
expect(back.startIndex, c.startIndex);
|
||||||
|
expect(back.positionMs, c.positionMs);
|
||||||
|
expect(back.song?.id, c.song?.id);
|
||||||
|
expect(back.songs?.map((s) => s.id), c.songs?.map((s) => s.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown op throws', () {
|
||||||
|
expect(
|
||||||
|
() => RemoteCommand.fromJson({'op': 'nope'}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('RemoteMessage envelope', () {
|
||||||
|
test('encodes and decodes each message type', () {
|
||||||
|
final msgs = <RemoteMessage>[
|
||||||
|
const ChallengeMessage(
|
||||||
|
version: 1, serverKey: 'k', nonce: 'n', device: 'iPad'),
|
||||||
|
const AuthMessage(
|
||||||
|
version: 1, serverKey: 'k', proof: 'p', device: 'Phone'),
|
||||||
|
const WelcomeMessage(device: 'iPad'),
|
||||||
|
const DenyMessage(reason: 'bad auth'),
|
||||||
|
SnapshotMessage(StateSnapshot(
|
||||||
|
queue: [_song('1')],
|
||||||
|
currentIndex: 0,
|
||||||
|
playing: false,
|
||||||
|
positionMs: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
shuffle: false,
|
||||||
|
loop: 'off',
|
||||||
|
seq: 1,
|
||||||
|
)),
|
||||||
|
const CommandMessage(RemoteCommand.next()),
|
||||||
|
];
|
||||||
|
for (final m in msgs) {
|
||||||
|
final back = RemoteMessage.decode(m.encode());
|
||||||
|
expect(back.runtimeType, m.runtimeType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodes handshake fields', () {
|
||||||
|
final decoded = RemoteMessage.decode(const ChallengeMessage(
|
||||||
|
version: kRemoteProtocolVersion,
|
||||||
|
serverKey: 'server-key',
|
||||||
|
nonce: 'the-nonce',
|
||||||
|
device: 'Living Room iPad',
|
||||||
|
).encode());
|
||||||
|
expect(decoded, isA<ChallengeMessage>());
|
||||||
|
final c = decoded as ChallengeMessage;
|
||||||
|
expect(c.version, kRemoteProtocolVersion);
|
||||||
|
expect(c.serverKey, 'server-key');
|
||||||
|
expect(c.nonce, 'the-nonce');
|
||||||
|
expect(c.device, 'Living Room iPad');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a non-object frame', () {
|
||||||
|
expect(() => RemoteMessage.decode('42'), throwsFormatException);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an unknown message type', () {
|
||||||
|
expect(() => RemoteMessage.decode('{"t":"bogus"}'), throwsFormatException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ These are ranked in order of importance and should be tackled one element at a t
|
||||||
|
|
||||||
- [x] 1. Drag n Drop Queue Reorder
|
- [x] 1. Drag n Drop Queue Reorder
|
||||||
- [x] 2. Filtering and Sorting Options
|
- [x] 2. Filtering and Sorting Options
|
||||||
- [ ] 3. Network Connectivity
|
- [x] 3. Network Connectivity
|
||||||
- [x] 4. Simoultaneous Download Setting
|
- [x] 4. Simoultaneous Download Setting
|
||||||
- [x] 5. Playlist Sharing
|
- [x] 5. Playlist Sharing
|
||||||
- [ ] 6. In-App Purchase
|
- [ ] 6. In-App Purchase
|
||||||
|
|
@ -65,5 +65,33 @@ These are ranked in order of importance and should be tackled one element at a t
|
||||||
### 6. In-App "Buy the Dev a Coffee" ($5) — Medium, client + store config
|
### 6. In-App "Buy the Dev a Coffee" ($5) — Medium, client + store config
|
||||||
- **Resolved:** route through IAP (review-safe). Flutter `in_app_purchase`, modeled as a **consumable** $5 product configured in App Store Connect + Google Play Console. Apple requires digital tips to go through IAP (no external payment link on iOS). Optional button in `lib/screens/settings_screen.dart`.
|
- **Resolved:** route through IAP (review-safe). Flutter `in_app_purchase`, modeled as a **consumable** $5 product configured in App Store Connect + Google Play Console. Apple requires digital tips to go through IAP (no external payment link on iOS). Optional button in `lib/screens/settings_screen.dart`.
|
||||||
|
|
||||||
### 3. Network Connectivity / Cross-Device Control — Deferred deep-dive
|
### 3. Network Connectivity / Cross-Device Control — Large, LAN-only
|
||||||
Biggest lift. Client-only + Subsonic offers no shared-state or device-to-device channel, and the app has zero realtime code. Candidate directions to evaluate in a dedicated session: (a) Subsonic **jukebox mode** (server-side playback control, not another client — limited), (b) **LAN mDNS discovery + direct socket** (no server, same-Wi-Fi only), (c) relay service (would break client-only). No work until scoped separately.
|
|
||||||
|
**Scope (locked):** LAN-only · remote-control topology (one device plays audio, others act as remotes) · full remote (transport + live queue mirror + browse/enqueue/reorder). Both devices must be signed into the **same Subsonic server** — that constraint is what makes full remote cheap: the remote sends a track by `id` / `Song` JSON and the *host* resolves its own stream URI locally via the existing `streamUriFor`. No server changes; discovery + transport are pure LAN.
|
||||||
|
|
||||||
|
**Why not cross-network:** Subsonic/Navidrome exposes no realtime channel, pub/sub, or shared scratch space, so two instances can't coordinate over the internet without a relay (breaks client-only) or a hacky high-latency polling abuse of playlists. LAN-only is the robust client-only answer.
|
||||||
|
|
||||||
|
**Architecture**
|
||||||
|
- **Transport:** host runs an in-process WebSocket server (`dart:io` `HttpServer` + `WebSocketTransformer.upgrade` — no new dep); remote is a `WebSocket.connect` client.
|
||||||
|
- **Discovery:** mDNS/Bonjour via a new dep — `nsd` (register + browse; `bonsoir` is the alt). Service type `_timbre._tcp`; TXT record carries `serverKey`, protocol version, device name.
|
||||||
|
- **Auth (no manual pairing):** both devices already know the server password → derive an HMAC key from it; host sends a nonce, remote returns `HMAC-SHA256(nonce)`. Proves same-user without a PIN (`crypto` already a dep). Control gated to matching `serverKey` only.
|
||||||
|
- **Playback facade (load-bearing refactor):** `activePlaybackProvider` (state the UI renders) + `playbackCommandsProvider` (a `PlaybackCommands` interface the UI drives). `PlaybackController` (unchanged local engine) and a new `RemotePlaybackProxy` both satisfy the seam. Local mode delegates to the controller; remote mode renders the last WS snapshot and turns commands into outgoing messages (optimistic, reconciled on next snapshot).
|
||||||
|
- **New modules:** `lib/remote/messages.dart` (DTOs + codec + auth), `host_server.dart`, `discovery.dart`, `remote_session.dart`, `remote_playback.dart`, `lib/state/remote_providers.dart`, plus a "Devices" picker sheet + a "Playing on <device>" status chip.
|
||||||
|
|
||||||
|
**Build order**
|
||||||
|
1. **Protocol + codec + auth** — pure Dart, fully unit-testable (`Command` / `StateSnapshot` / `Hello` DTOs, versioning, HMAC challenge-response).
|
||||||
|
2. **Facade indirection** — add `activePlaybackProvider` + `playbackCommandsProvider`, migrate the ~11 UI files off `playbackProvider`. Ships as a no-op local-only refactor; de-risks everything downstream. **← land first.**
|
||||||
|
3. **Host server** — WS server broadcasts snapshots on `PlaybackController` change (reuse the throttle pattern), applies inbound commands.
|
||||||
|
4. **Discovery** — advertise when playable / browse for peers; platform config.
|
||||||
|
5. **Remote session + routing** — connect, mirror state, send commands, position interpolation, disconnect → fall back to local.
|
||||||
|
6. **UI** — device sheet, status chip, route browse/enqueue actions to the host.
|
||||||
|
7. **Auth + polish + tests** — loopback (host + client in one process) integration test.
|
||||||
|
|
||||||
|
**Risks / gotchas**
|
||||||
|
- **iOS 14+:** `NSLocalNetworkUsageDescription` + `NSBonjourServices=_timbre._tcp` in Info.plist or discovery silently fails. Android NSD needs a multicast lock (nsd handles).
|
||||||
|
- **iOS backgrounding:** host stays alive while *playing* (audio background mode via `just_audio_background`); a paused+backgrounded host may suspend its socket — remote reconnects on resume.
|
||||||
|
- **Position sync:** interpolate on the remote between periodic snapshots; reconcile on seek.
|
||||||
|
- **Offline host:** host resolves its own URIs; a track it can't stream reuses the existing `PlaybackError.offline` path.
|
||||||
|
- Multi-remote works free: host broadcasts to all clients.
|
||||||
|
|
||||||
|
**Estimate:** ~2–3× any other item on this list; step 2 (facade) is the reversible foundation to land first.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue