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.
This commit is contained in:
edde746
2026-08-06 03:45:09 +02:00
parent 3a56218a12
commit 4607d165fd
11 changed files with 396 additions and 9 deletions
@@ -618,6 +618,16 @@ class WatchTogetherProvider with ChangeNotifier {
_controller?.detachPlayer(exiting: exiting);
}
/// Pause a guest's player without pausing the room — see
/// [WatchTogetherController.pauseLocallyForSystem]. Returns false when there is no attachment, or
/// when this peer is the host and must pause the room the ordinary way, so the caller falls back
/// to its own pause.
Future<bool> pauseLocallyForSystem() async {
final controller = _controller;
if (controller == null) return false;
return controller.pauseLocallyForSystem();
}
/// Suppress sync heartbeats/corrections while the app is backgrounded.
void setBackgrounded(bool value) {
_controller?.setBackgrounded(value);
@@ -3,6 +3,7 @@ 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';
@@ -119,16 +120,34 @@ class AttachedPlayer {
/// 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);
@@ -137,8 +156,12 @@ class AttachedPlayer {
/// 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.
Future<bool> seek(Duration target) {
return _guarded('seek', (player) async {
///
/// 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 {
@@ -150,6 +173,13 @@ class AttachedPlayer {
}
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) {
@@ -157,6 +187,15 @@ class AttachedPlayer {
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, [
@@ -650,10 +650,23 @@ class HostPlaybackCoordinator {
_pendingStartPositionMs = null;
final currentPlayer = _player;
if (currentPlayer == null || _phase != PlaybackPhase.playing) return;
// A vehicle that requires distraction optimization refuses the play. The room would
// otherwise sit in a playing phase the host is not honouring, with nothing to correct it, so
// put it back to paused: the host is the authority on what it is actually doing.
void settle(bool started) {
// Only this attachment's own refusal counts. A reload detaches mid-flight and its disposed
// player also answers false, but that pause belongs to the source switch, which deliberately
// holds the phase so the replacement can pick the room up again.
if (started || _player != currentPlayer || _phase != PlaybackPhase.playing) return;
_intendedPlaying = false;
_setPhase(PlaybackPhase.paused);
_broadcast();
}
if (startPos != null && (currentPlayer.position.inMilliseconds - startPos).abs() > 250) {
unawaited(currentPlayer.seek(Duration(milliseconds: startPos)).then((_) => currentPlayer.play()));
unawaited(currentPlayer.seek(Duration(milliseconds: startPos)).then((_) => currentPlayer.play()).then(settle));
} else {
unawaited(currentPlayer.play());
unawaited(currentPlayer.play().then(settle));
}
}
@@ -173,6 +173,28 @@ class WatchTogetherController {
appLogger.d('WatchTogether: Player detached (exiting: $exiting)');
}
/// Pause a guest's player without telling the room.
///
/// For a pause the environment forces on this peer alone — a vehicle that starts requiring
/// distraction optimization. Routing it through the attachment records the expectation, so the
/// resulting event is consumed as an acknowledgement instead of being published as a user intent
/// that would pause everybody.
///
/// A host is refused, and must pause the room the ordinary way. It is the room's clock: swallowing
/// its intent would leave the coordinator in a playing phase while its own player was frozen, and
/// every heartbeat would then publish that frozen position as the room's anchor — stalling or
/// rewinding the guests it was meant to protect. Returns false when there is nothing local to do.
Future<bool> pauseLocallyForSystem() async {
final attached = _attachedPlayer;
if (attached == null || _session.isHost) return false;
// Only a player that is actually playing will report the transition this acknowledgement is
// for. Recording one for a paused player — or one sitting at end of file, where mpv leaves the
// raw pause flag false but no further event is coming — would leave it in the ledger, where the
// user's next real pause would consume it and never reach the room.
if (!attached.playing || attached.completed) return attached.pauseWithoutAck();
return attached.pause();
}
// ---------------------------------------------------------------------
// Provider inputs
// ---------------------------------------------------------------------