Files
edde746 4607d165fd fix(automotive): keep video from starting while a car is driving
DD-3 gives video no exemption: a restricted vehicle must not play it at all. The
gate is read at the single point where media actually opens, so every path that
can start a picture - an explicit play, a gapless arm, a track or channel switch,
a frame-rate-match resume, a reload, and the queue navigation commands of the OS
media session - is covered by one check rather than by a guard at each call site.
A seek can also start playback with no play call, because mpv resumes when it
seeks off the end of a file, so a restricted seek is followed by a pause.

Watch Together needed the pause to be local. A vehicle stopping one peer is not a
room-wide intent: a guest's forced pause is swallowed by the attachment's ledger
rather than published, while a host's still pauses the room, because a host that
kept broadcasting a frozen anchor would stall or rewind every guest it was meant
to protect. The layer that owns a pause owns the resume for it, and one
acknowledgement is recorded per event, so a surplus cannot eat the user's next
real pause.
2026-08-06 03:45:09 +02:00

298 lines
11 KiB
Dart

import 'dart:async';
import 'package:flutter/services.dart';
import '../../mpv/mpv.dart';
import '../../services/driver_distraction.dart';
import '../../utils/app_logger.dart';
import '../primitives.dart';
enum _ExpectationKind { playing, rate }
class _Expectation {
final _ExpectationKind kind;
final bool? playingValue;
final double? rateValue;
final int deadlineMs;
_Expectation.playing(bool value, this.deadlineMs)
: kind = _ExpectationKind.playing,
playingValue = value,
rateValue = null;
_Expectation.rate(double value, this.deadlineMs)
: kind = _ExpectationKind.rate,
playingValue = null,
rateValue = value;
}
/// One player attachment to a Watch Together session.
///
/// Wraps the screen's [Player] with:
/// - **Guarded commands** that survive player teardown races: recoverable
/// failures ([StateError], `COMMAND_FAILED`/`NOT_INITIALIZED`
/// [PlatformException]s) report `false` and fire [AttachedPlayer.new]'s
/// `onLost` once instead of throwing.
/// - An **expected-state ledger** separating command acks from user intents
/// on the playing/rate streams. Property events arrive *after* the command
/// future resolves, so a boolean "remote action in progress" flag misses
/// them; the ledger matches observed transitions against outstanding
/// expectations instead.
/// - Fresh snapshot reads for sync math ([position] uses
/// [Player.currentPosition], not the throttled state).
///
/// The session controller creates one instance per attachment and disposes
/// it on detach — instance lifecycle *is* the staleness guard.
class AttachedPlayer {
AttachedPlayer({required Player player, required this._onLost, this._remoteSeek, int Function()? nowMs})
: _player = player,
_nowMs = nowMs ?? watchTogetherSystemNowMs {
_lastPlaying = player.state.playing;
_lastBuffering = player.state.buffering;
_lastRate = player.state.rate;
_subscriptions.add(player.streams.playing.listen(_onPlayingEvent));
_subscriptions.add(player.streams.buffering.listen(_onBufferingEvent));
_subscriptions.add(player.streams.rate.listen(_onRateEvent));
_subscriptions.add(
player.streams.playbackRestart.listen((_) {
if (!_disposed) _loadedSignalsController.add(null);
}),
);
}
/// How long an issued command may wait for its property event before the
/// expectation is considered dead (covers silently-swallowed commands).
static const int _expectationTtlMs = 3000;
final Player _player;
final void Function() _onLost;
final Future<void> Function(Duration target)? _remoteSeek;
final int Function() _nowMs;
final List<StreamSubscription<dynamic>> _subscriptions = [];
final List<_Expectation> _expectations = [];
final _playingIntentsController = StreamController<bool>.broadcast();
final _rateIntentsController = StreamController<double>.broadcast();
final _bufferingChangesController = StreamController<bool>.broadcast();
final _loadedSignalsController = StreamController<void>.broadcast();
late bool _lastPlaying;
late bool _lastBuffering;
late double _lastRate;
bool _disposed = false;
bool _lostFired = false;
/// User-initiated play/pause transitions (command acks are filtered out).
Stream<bool> get playingIntents => _playingIntentsController.stream;
/// User-initiated rate changes (command acks are filtered out).
Stream<double> get rateIntents => _rateIntentsController.stream;
/// Raw buffering transitions (`paused-for-cache`).
Stream<bool> get bufferingChanges => _bufferingChangesController.stream;
/// `playback-restart` events: first frame rendered after load and after
/// every seek.
Stream<void> get loadedSignals => _loadedSignalsController.stream;
bool get usable => !_disposed && !_player.disposed;
// Fresh snapshots.
Duration get position => _player.currentPosition;
bool get playing => _player.state.playing;
bool get buffering => _player.state.buffering;
bool get completed => _player.state.completed;
bool get seekable => _player.state.seekable;
Duration get duration => _player.state.duration;
double get rate => _player.state.rate;
bool get passthroughActive => _player.audioPassthroughActive;
/// Demuxer cache ahead of the playhead, or null when the backend hasn't
/// reported a cache position.
Duration? get bufferAhead {
final buffer = _player.state.buffer;
if (buffer == Duration.zero) return null;
final ahead = buffer - position;
return ahead.isNegative ? Duration.zero : ahead;
}
/// Start or resume playback. Records a ledger expectation so the resulting
/// playing event is consumed as an ack.
///
/// Refused while a vehicle requires distraction optimization: the sync layer
/// mirrors whatever the room is doing, and a host that keeps playing must not
/// restart video in a car that is driving (`DD-3`). The room's state is left
/// alone, so the guest catches up once the car is parked.
Future<bool> play() {
if (!automotivePlaybackAllowedNow()) {
appLogger.d('Watch Together play refused: the vehicle requires distraction optimization');
return Future.value(false);
}
final expectation = _expect(_Expectation.playing(true, _nowMs() + _expectationTtlMs));
return _guarded('play', (player) => player.play(), expectation);
}
Future<bool> pause() {
// One acknowledgement per event: a pause issued while another is still unacknowledged would
// leave the surplus in the ledger, and the user's next real pause would be consumed as its ack.
if (_awaitingPlaying(false)) return pauseWithoutAck();
final expectation = _expect(_Expectation.playing(false, _nowMs() + _expectationTtlMs));
return _guarded('pause', (player) => player.pause(), expectation);
}
/// Pauses without recording an expectation, for a player that is not playing right now — a
/// buffering one still intends to, so the command matters, but no `playing(false)` event is
/// coming to acknowledge. Recording one anyway would leave it in the ledger for its whole
/// lifetime and let it swallow the user's next real pause.
Future<bool> pauseWithoutAck() => _guarded('pause', (player) => player.pause());
Future<bool> setRate(double rate) {
final expectation = _expect(_Expectation.rate(rate, _nowMs() + _expectationTtlMs));
return _guarded('setRate', (player) => player.setRate(rate), expectation);
}
/// Seek issued by the sync layer. Routed through the screen's seek
/// delegate when provided (Plex transcode restarts need the full path),
/// falling back to a plain player seek.
///
/// A seek can start playback without anyone calling [play] — mpv leaves `pause=false` at end of
/// file, so seeking off it resumes — which would walk straight past the vehicle guard on [play].
/// While the vehicle requires distraction optimization the seek is therefore followed by a pause.
Future<bool> seek(Duration target) async {
final seeked = await _guarded('seek', (player) async {
final delegate = _remoteSeek;
if (delegate != null) {
try {
await delegate(target);
return;
} catch (e) {
appLogger.w('AttachedPlayer: seek delegate failed, falling back to player.seek', error: e);
}
}
await player.seek(target);
});
if (seeked && !automotivePlaybackAllowedNow()) {
// Decided after the seek, because that is when a player resumed by it reports itself playing
// — and only then is there a transition to acknowledge. Acknowledging it keeps this peer's
// enforced pause off the room, exactly like the one the restriction listener issues.
await (playing ? pause() : pauseWithoutAck());
}
return seeked;
}
_Expectation _expect(_Expectation expectation) {
_expectations.add(expectation);
return expectation;
}
/// Whether an unconsumed acknowledgement for this playing value is already outstanding.
///
/// Two commands in the same direction produce one event, so a second expectation would outlive
/// it and consume the user's next real transition instead.
bool _awaitingPlaying(bool value) {
_pruneExpired();
return _expectations.any((e) => e.kind == _ExpectationKind.playing && e.playingValue == value);
}
Future<bool> _guarded(
String actionName,
Future<void> Function(Player player) command, [
_Expectation? expectation,
]) async {
if (!usable) {
_expectations.remove(expectation);
_handleLost(actionName, StateError('Player became unavailable'));
return false;
}
try {
await command(_player);
} on StateError catch (e) {
_expectations.remove(expectation);
_handleLost(actionName, e);
return false;
} on PlatformException catch (e) {
_expectations.remove(expectation);
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
_handleLost(actionName, e);
return false;
}
rethrow;
}
if (!usable) {
_expectations.remove(expectation);
if (!_disposed) _handleLost(actionName, StateError('Player became unavailable'));
return false;
}
return true;
}
void _handleLost(String actionName, Object error) {
if (_disposed || _lostFired) return;
_lostFired = true;
appLogger.w('AttachedPlayer: $actionName failed because the player became unavailable', error: error);
_onLost();
}
void _pruneExpired() {
final now = _nowMs();
_expectations.removeWhere((e) => now > e.deadlineMs);
}
bool _consumePlayingExpectation(bool value) {
_pruneExpired();
final index = _expectations.indexWhere((e) => e.kind == _ExpectationKind.playing && e.playingValue == value);
if (index < 0) return false;
_expectations.removeAt(index);
return true;
}
bool _consumeRateExpectation(double value) {
_pruneExpired();
final index = _expectations.indexWhere(
(e) => e.kind == _ExpectationKind.rate && (e.rateValue! - value).abs() < 0.001,
);
if (index < 0) return false;
_expectations.removeAt(index);
return true;
}
void _onPlayingEvent(bool value) {
if (_disposed || value == _lastPlaying) return;
_lastPlaying = value;
if (_consumePlayingExpectation(value)) return;
_playingIntentsController.add(value);
}
void _onRateEvent(double value) {
if (_disposed || value == _lastRate) return;
_lastRate = value;
if (_consumeRateExpectation(value)) return;
_rateIntentsController.add(value);
}
void _onBufferingEvent(bool value) {
if (_disposed || value == _lastBuffering) return;
_lastBuffering = value;
_bufferingChangesController.add(value);
}
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
_expectations.clear();
final subscriptions = List<StreamSubscription<dynamic>>.of(_subscriptions);
_subscriptions.clear();
for (final subscription in subscriptions) {
unawaited(subscription.cancel());
}
await _playingIntentsController.close();
await _rateIntentsController.close();
await _bufferingChangesController.close();
await _loadedSignalsController.close();
}
}