network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
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),
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue