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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user