From 4607d165fd64b4f7ebee3e3d800931ddaeab3e0d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:06:41 +0200 Subject: [PATCH] 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. --- .../parts/episode_navigation.dart | 2 + lib/screens/video_player/parts/lifecycle.dart | 13 +- .../video_player/parts/playback_open.dart | 24 +++- lib/screens/video_player_screen.dart | 77 ++++++++++- .../providers/watch_together_provider.dart | 10 ++ .../services/attached_player.dart | 43 ++++++- .../services/host_playback_coordinator.dart | 17 ++- .../services/watch_together_controller.dart | 22 ++++ test/watch_together/attached_player_test.dart | 121 ++++++++++++++++++ .../host_playback_coordinator_test.dart | 30 +++++ .../watch_together_controller_test.dart | 46 +++++++ 11 files changed, 396 insertions(+), 9 deletions(-) diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 3d00a9db..f8eb753d 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -764,6 +764,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { selectedVersion: result.selectedVersion, timing: openTiming, headers: result.usesLocalMedia ? null : streamHeaders, + // The vehicle is not consulted here: `_openMediaOnPlayer` reads it at the `player.open` + // itself, which is after this and its own awaited tuning work. play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup, externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: isCurrentReload, diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index 7061b4f4..399a1609 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -128,7 +128,18 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { _wasPlayingBeforeInactive = _wasPlayingBeforeInactive || wasActive; if (wasActive) { try { - await _pauseWithPlaybackIntent(currentPlayer); + // On a car this is the driving transition itself, on every head unit whose vehicle cannot + // report its restrictions. It is forced on this peer alone, so it must not travel to the + // rest of a Watch Together room; elsewhere backgrounding keeps its existing meaning. + if (isAutomotive) { + if (await _pauseWithoutDisturbingTheRoom(currentPlayer)) { + // The sync layer owns this pause and its resume. Drop the latch so the screen does not + // also restore playback on the way back and ask the room to play along with it. + _wasPlayingBeforeInactive = false; + } + } else { + await _pauseWithPlaybackIntent(currentPlayer); + } appLogger.d( 'Video paused due to app being hidden ' '(${isAutomotive diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 5b3a3207..59377ab8 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -366,6 +366,16 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { final trackManager = _trackManager; if (trackManager == null) return; appLogger.d('Frame rate matching: resuming playback after $reason'); + if (!automotivePlaybackAllowedNow()) { + // The vehicle outranks the startup gate: releasing the frame-rate gate is not permission to + // play. Subtitle selection still has to land, or the track stays stuck waiting for it. + _playbackIntentShouldPlay = false; + if (externalSubtitlePlan.requiresPostOpenAdd) { + trackManager.waitingForExternalSubsTrackSelection = false; + trackManager.applyTrackSelectionWhenReady(); + } + return; + } _playbackIntentShouldPlay = true; if (externalSubtitlePlan.requiresPostOpenAdd) { await trackManager.resumeAfterSubtitleLoad(); @@ -499,10 +509,14 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { waitUntilReady: externalSubtitlePlan.readyAfterOpen, ); } finally { - if (shouldResumeAfterSubtitleLoad()) { + // A car must not start playing just because subtitles finished loading: the vehicle's + // verdict outranks the caller's startup gate, and a skipped resume still has to release the + // subtitle-selection wait. + final resumeWanted = shouldResumeAfterSubtitleLoad(); + if (resumeWanted && automotivePlaybackAllowedNow()) { _playbackIntentShouldPlay = true; await trackManager.resumeAfterSubtitleLoad(); - } else if (applySelectionWhenResumeSkipped) { + } else if (applySelectionWhenResumeSkipped || resumeWanted) { trackManager.waitingForExternalSubsTrackSelection = false; trackManager.applyTrackSelectionWhenReady(); } @@ -628,7 +642,11 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { onOpening?.call(); return player.open( media, - play: shouldPlay, + // The last word on the vehicle, taken here because this is the only place media actually + // starts: callers decide `play` before awaiting resolve, tuning and track work, and a car + // that starts driving in between has already spent its restriction pausing the outgoing + // item. `DD-3` allows video no exemption, and the gated resume paths start it once parked. + play: shouldPlay && automotivePlaybackAllowedNow(), externalSubtitles: externalSubtitles, timelineDuration: timing.timelineDuration, ); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ae958c07..942a6a9b 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -40,6 +40,7 @@ import '../models/companion_remote/remote_command.dart'; import '../providers/companion_remote_provider.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; +import '../services/car_ux_restrictions_service.dart'; import '../services/driver_distraction.dart'; import '../services/discord_rpc_service.dart'; import '../services/trackers/tracker_coordinator.dart'; @@ -943,6 +944,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin } WidgetsBinding.instance.addObserver(this); + if (PlatformDetector.isAutomotive()) { + // Driving normally reaches this screen as a lifecycle event, because the + // system puts its blocking activity over a non-distraction-optimized app. + // Not always: restrictions are per display, so a session the driver is not + // looking at can be restricted while this activity stays resumed. `DD-3` + // gives video no exemption, so take the vehicle's word directly too. + CarUxRestrictionsService.instance.ensureStarted(); + CarUxRestrictionsService.instance.listenable.addListener(_handleCarRestrictionsChanged); + } _setupCompanionRemoteCallbacks(); _setupAppleTvRemotePlaybackActions(); @@ -951,7 +961,71 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (mounted) _showStillWatchingDialog(); }); - unawaited(_startPlayerInitialization(replaceCurrent: false)); + if (PlatformDetector.isAutomotive()) { + unawaited(_startPlayerInitializationOnceVehicleAnswers()); + } else { + unawaited(_startPlayerInitialization(replaceCurrent: false)); + } + } + + /// A car must not start video before the vehicle has spoken: `DD-3` gives video + /// no exemption while driving, so a cold start would otherwise play until the + /// verdict lands. The wait is bounded by the service, so a car that cannot + /// answer only delays this by that budget and then falls back to lifecycle + /// gating — which, for a screen the user just opened, permits playback. + Future _startPlayerInitializationOnceVehicleAnswers() async { + await CarUxRestrictionsService.instance.ensureResolved(); + if (!mounted) return; + await _startPlayerInitialization(replaceCurrent: false); + } + + /// The vehicle started requiring distraction optimization (`DD-3`). + /// + /// Pauses only. The backgrounding path is deliberately not reused: it hides the + /// render surface, suspends the live timeline and marks Watch Together + /// backgrounded, all of which `_handleAppResumed` undoes — and no resume event + /// is coming, because the activity never left the foreground. The playback gate + /// keeps this paused until the vehicle releases it. + void _handleCarRestrictionsChanged() { + if (!mounted || automotivePlaybackAllowedNow()) return; + _enqueueLifecycleTransition('restricted_automotive', _pauseForVehicleRestriction); + } + + /// Pauses for something the environment forced on this peer alone. + /// + /// A guest's pause goes to the Watch Together attachment, which records it as its own command so + /// the resulting event is consumed as an acknowledgement rather than a user intent that would + /// pause the whole room. A host is refused there and pauses the ordinary way: it is the room's + /// clock, and a room whose host cannot play has to pause with it. + /// + /// Returns whether the sync layer took ownership. When it did, it also owns the resume — through + /// the attachment, following the room — so the caller must not restore playback itself: doing so + /// would publish a play request and, in a room anyone can control, restart everybody. + Future _pauseWithoutDisturbingTheRoom(Player currentPlayer) async { + final syncOwnsIt = await (_watchTogetherProvider?.pauseLocallyForSystem() ?? Future.value(false)); + if (syncOwnsIt) return true; + await _pauseWithPlaybackIntent(currentPlayer); + return false; + } + + Future _pauseForVehicleRestriction() async { + final currentPlayer = player; + // Deliberately not gated on `state.isActive`: that is `playing && !completed`, which is false + // for the whole of a rebuffer while the native side still intends to play. Skipping here would + // leave the play intent standing, and playback would start the moment the buffer fills. + if (currentPlayer == null || !_isPlayerInitialized) return; + try { + await _pauseWithoutDisturbingTheRoom(currentPlayer); + } catch (e, stackTrace) { + appLogger.w('Failed to pause video for vehicle restrictions', error: e, stackTrace: stackTrace); + // Fail closed: `DD-3` is not satisfied by having tried, and the restriction has already + // fired, so nothing else is coming to stop this session. + try { + await currentPlayer.stop(); + } catch (e, stackTrace) { + appLogger.w('Failed to stop restricted video', error: e, stackTrace: stackTrace); + } + } } @override @@ -1617,6 +1691,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _playerInitializationGeneration++; _frameRate.dispose(); WidgetsBinding.instance.removeObserver(this); + CarUxRestrictionsService.instance.listenable.removeListener(_handleCarRestrictionsChanged); final transitionCompleter = _playbackTransitionIdleCompleter; _playbackTransitionIdleCompleter = null; diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index f39d8996..c35565f0 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -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 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); diff --git a/lib/watch_together/services/attached_player.dart b/lib/watch_together/services/attached_player.dart index eb113028..ea7bde2e 100644 --- a/lib/watch_together/services/attached_player.dart +++ b/lib/watch_together/services/attached_player.dart @@ -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 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 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 pauseWithoutAck() => _guarded('pause', (player) => player.pause()); + Future 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 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 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 _guarded( String actionName, Future Function(Player player) command, [ diff --git a/lib/watch_together/services/host_playback_coordinator.dart b/lib/watch_together/services/host_playback_coordinator.dart index 84213a78..a2a50a47 100644 --- a/lib/watch_together/services/host_playback_coordinator.dart +++ b/lib/watch_together/services/host_playback_coordinator.dart @@ -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)); } } diff --git a/lib/watch_together/services/watch_together_controller.dart b/lib/watch_together/services/watch_together_controller.dart index 66f538fc..ad4d6b16 100644 --- a/lib/watch_together/services/watch_together_controller.dart +++ b/lib/watch_together/services/watch_together_controller.dart @@ -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 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 // --------------------------------------------------------------------- diff --git a/test/watch_together/attached_player_test.dart b/test/watch_together/attached_player_test.dart index 1fc1bbd5..1cdaa7da 100644 --- a/test/watch_together/attached_player_test.dart +++ b/test/watch_together/attached_player_test.dart @@ -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 = []; + 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 = []; + 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 = []; + 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 = []; + 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(); + }); + }); + }); } diff --git a/test/watch_together/host_playback_coordinator_test.dart b/test/watch_together/host_playback_coordinator_test.dart index 65d08647..38a2a1d3 100644 --- a/test/watch_together/host_playback_coordinator_test.dart +++ b/test/watch_together/host_playback_coordinator_test.dart @@ -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(); + }); + }); + }); } diff --git a/test/watch_together/watch_together_controller_test.dart b/test/watch_together/watch_together_controller_test.dart index ec60b50a..18fbe693 100644 --- a/test/watch_together/watch_together_controller_test.dart +++ b/test/watch_together/watch_together_controller_test.dart @@ -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(); + }); + }); + }); }