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 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? 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 { RemoteControlController(this._ref) : super(RemoteControlState(supported: _supported())) { if (state.supported) { _ref.listen( connectionProvider, (_, next) => _onConnection(next), fireImmediately: true, ); } } final Ref _ref; final RemoteDiscovery _discovery = RemoteDiscovery(); RemoteHost? _host; RemoteSession? _session; RemotePlaybackProxy? _proxy; StreamSubscription? _snapSub; StreamSubscription? _statusSub; StreamSubscription>? _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 _onConnection(ConnectionState conn) async { if (conn.isOnline) { await _startHosting(conn); } else { await detach(); await _stopHosting(); } } Future _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 _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 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 stopBrowsing() async { await _discovery.stopBrowsing(); _update((s) => s.copyWith(browsing: false, devices: const [])); } // ---- Attach / detach as a remote -------------------------------------- Future 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 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( (ref) => RemoteControlController(ref), );