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
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/car_ux_restrictions_service.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/watch_together/services/attached_player.dart';
import '../test_helpers/watch_together_fakes.dart';
@@ -274,4 +276,123 @@ void main() {
});
});
});
group('driver distraction', () {
tearDown(() {
TvDetectionService.debugReset();
CarUxRestrictionsService.debugSetOverride(null);
});
test('a driving vehicle refuses a play the room asked for', () {
fakeAsync((async) {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
final (attached, player, _) = build(async);
attached.play();
async.flushMicrotasks();
expect(player.state.playing, isFalse, reason: 'DD-3: sync must not start video while driving');
attached.dispose();
});
});
test('a parked vehicle lets the room drive playback as usual', () {
fakeAsync((async) {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
final (attached, player, _) = build(async);
attached.play();
async.flushMicrotasks();
expect(player.state.playing, isTrue);
attached.dispose();
});
});
test('a pause the vehicle forces is an acknowledgement, not a room-wide intent', () {
fakeAsync((async) {
final (attached, player, _) = build(async, playing: true);
final intents = <bool>[];
attached.playingIntents.listen(intents.add);
// What the video screen issues when a car starts driving: the local player stops, but the
// room must not be told its user pressed pause.
attached.pause();
async.flushMicrotasks();
expect(player.state.playing, isFalse);
expect(intents, isEmpty, reason: 'one car driving must not pause everybody else');
attached.dispose();
});
});
test('pausing an already-paused player leaves no acknowledgement to swallow the next one', () {
fakeAsync((async) {
final (attached, player, _) = build(async);
final intents = <bool>[];
attached.playingIntents.listen(intents.add);
// The vehicle pauses a guest that is not playing — buffering, say — so nothing will report
// a transition, and no expectation may be left behind.
attached.pauseWithoutAck();
async.flushMicrotasks();
// The restriction lifts, the guest plays, and then the user pauses for real: that pause is
// theirs and the room has to hear about it.
player.emitPlaying(true);
async.flushMicrotasks();
player.emitPlaying(false);
async.flushMicrotasks();
expect(intents, contains(false), reason: 'a stale acknowledgement would have eaten this');
attached.dispose();
});
});
test('two pauses in flight leave only one acknowledgement behind', () {
fakeAsync((async) {
final (attached, player, _) = build(async, playing: true);
final intents = <bool>[];
attached.playingIntents.listen(intents.add);
// The car's direct restriction pause and its lifecycle pause both land before the player
// reports anything: two commands, one event.
attached.pause();
attached.pause();
async.flushMicrotasks();
expect(intents, isEmpty);
// The user's own pause afterwards is theirs, and the room has to hear it.
player.emitPlaying(true);
async.flushMicrotasks();
player.emitPlaying(false);
async.flushMicrotasks();
expect(intents, contains(false), reason: 'a surplus acknowledgement would have eaten this');
attached.dispose();
});
});
test('a sync seek while driving cannot leave the player running', () {
fakeAsync((async) {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
// End of file: mpv leaves the raw pause flag false, so seeking off it resumes without
// anyone calling play — the one way past the vehicle guard on play().
final (attached, player, _) = build(async, playing: true);
final intents = <bool>[];
attached.playingIntents.listen(intents.add);
attached.seek(const Duration(minutes: 3));
async.flushMicrotasks();
expect(player.state.playing, isFalse, reason: 'DD-3: a seek must not become playback while driving');
expect(intents, isEmpty, reason: 'and stopping it is this car\'s business, not the room\'s');
attached.dispose();
});
});
});
}
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/car_ux_restrictions_service.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/watch_together/models/playback_state.dart';
import 'package:plezy/watch_together/models/watch_session.dart';
import 'package:plezy/watch_together/services/attached_player.dart';
@@ -715,4 +717,32 @@ void main() {
});
});
});
group('driver distraction', () {
tearDown(() {
TvDetectionService.debugReset();
CarUxRestrictionsService.debugSetOverride(null);
});
test('a start the vehicle refuses puts the room back to paused', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachForMedia(async);
h.hostBecomesReady(async);
expect(h.last.phase, PlaybackPhase.playing);
// The car starts moving during the scheduled-start delay, so the play the
// host had already announced is refused.
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
final delay = h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds);
async.elapse(Duration(milliseconds: delay));
async.flushMicrotasks();
expect(h.player.state.playing, isFalse, reason: 'DD-3: driving must not start video');
expect(h.last.phase, PlaybackPhase.paused, reason: 'the room must not be told the host is playing');
h.dispose();
});
});
});
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/watch_together/models/playback_state.dart';
@@ -461,4 +463,48 @@ void main() {
});
});
});
group('a vehicle forcing a pause on one peer', () {
test('a guest stops locally and the room keeps playing', () {
fakeAsync((async) {
final room = _Room(async);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
async.elapse(const Duration(seconds: 2));
expect(room.guestPlayer.state.playing, isTrue);
bool? handled;
unawaited(room.guest.pauseLocallyForSystem().then((value) => handled = value));
async.flushMicrotasks();
expect(handled, isTrue);
expect(room.guestPlayer.state.playing, isFalse, reason: 'the car this guest is in must go quiet');
async.elapse(const Duration(seconds: 2));
expect(room.hostPlayer.state.playing, isTrue, reason: 'one guest driving must not stop the room');
expect(room.lastHostState().phase, PlaybackPhase.playing);
room.dispose();
});
});
test('a host is refused, because the room cannot outrun its own clock', () {
fakeAsync((async) {
final room = _Room(async);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
async.elapse(const Duration(seconds: 2));
bool? handled;
unawaited(room.host.pauseLocallyForSystem().then((value) => handled = value));
async.flushMicrotasks();
// Refused, so the caller pauses the ordinary way and the room follows: a host that keeps
// broadcasting a playing anchor from a frozen player would stall every guest.
expect(handled, isFalse);
expect(room.hostPlayer.state.playing, isTrue, reason: 'nothing local happened');
room.dispose();
});
});
});
}