network addition

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

View file

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