309 lines
10 KiB
Dart
309 lines
10 KiB
Dart
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, Linux).
|
|
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 ||
|
|
Platform.isLinux);
|
|
|
|
/// 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));
|
|
// Only a terminal status drops us back to local playback. Transient
|
|
// drops (phone locked, Wi-Fi blip) are healed inside the session, which
|
|
// retries and re-emits `connecting` — we stay attached (bug-fixes #5).
|
|
// `denied` is a hard rejection; `error` here only means a device that
|
|
// never answered the initial dial.
|
|
if (st == RemoteConnStatus.denied || st == RemoteConnStatus.error) {
|
|
// 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();
|
|
}
|
|
|
|
/// Re-establish a dropped remote session immediately when the app returns to
|
|
/// the foreground. A controller is suspended while the phone is locked, so
|
|
/// its socket to the host is usually dead on resume; without this nudge the
|
|
/// user would wait out the reconnect backoff before control is restored
|
|
/// (bug-fixes #5). No-op when playing locally.
|
|
void onResume() => _session?.reconnectNow();
|
|
|
|
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';
|
|
if (Platform.isLinux) return 'Linux';
|
|
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),
|
|
);
|