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:
@@ -764,6 +764,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
selectedVersion: result.selectedVersion,
|
selectedVersion: result.selectedVersion,
|
||||||
timing: openTiming,
|
timing: openTiming,
|
||||||
headers: result.usesLocalMedia ? null : streamHeaders,
|
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,
|
play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup,
|
||||||
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
|
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
|
||||||
shouldContinue: isCurrentReload,
|
shouldContinue: isCurrentReload,
|
||||||
|
|||||||
@@ -128,7 +128,18 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
|||||||
_wasPlayingBeforeInactive = _wasPlayingBeforeInactive || wasActive;
|
_wasPlayingBeforeInactive = _wasPlayingBeforeInactive || wasActive;
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
try {
|
try {
|
||||||
|
// 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);
|
await _pauseWithPlaybackIntent(currentPlayer);
|
||||||
|
}
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Video paused due to app being hidden '
|
'Video paused due to app being hidden '
|
||||||
'(${isAutomotive
|
'(${isAutomotive
|
||||||
|
|||||||
@@ -366,6 +366,16 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
final trackManager = _trackManager;
|
final trackManager = _trackManager;
|
||||||
if (trackManager == null) return;
|
if (trackManager == null) return;
|
||||||
appLogger.d('Frame rate matching: resuming playback after $reason');
|
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;
|
_playbackIntentShouldPlay = true;
|
||||||
if (externalSubtitlePlan.requiresPostOpenAdd) {
|
if (externalSubtitlePlan.requiresPostOpenAdd) {
|
||||||
await trackManager.resumeAfterSubtitleLoad();
|
await trackManager.resumeAfterSubtitleLoad();
|
||||||
@@ -499,10 +509,14 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
waitUntilReady: externalSubtitlePlan.readyAfterOpen,
|
waitUntilReady: externalSubtitlePlan.readyAfterOpen,
|
||||||
);
|
);
|
||||||
} finally {
|
} 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;
|
_playbackIntentShouldPlay = true;
|
||||||
await trackManager.resumeAfterSubtitleLoad();
|
await trackManager.resumeAfterSubtitleLoad();
|
||||||
} else if (applySelectionWhenResumeSkipped) {
|
} else if (applySelectionWhenResumeSkipped || resumeWanted) {
|
||||||
trackManager.waitingForExternalSubsTrackSelection = false;
|
trackManager.waitingForExternalSubsTrackSelection = false;
|
||||||
trackManager.applyTrackSelectionWhenReady();
|
trackManager.applyTrackSelectionWhenReady();
|
||||||
}
|
}
|
||||||
@@ -628,7 +642,11 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
onOpening?.call();
|
onOpening?.call();
|
||||||
return player.open(
|
return player.open(
|
||||||
media,
|
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,
|
externalSubtitles: externalSubtitles,
|
||||||
timelineDuration: timing.timelineDuration,
|
timelineDuration: timing.timelineDuration,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import '../models/companion_remote/remote_command.dart';
|
|||||||
import '../providers/companion_remote_provider.dart';
|
import '../providers/companion_remote_provider.dart';
|
||||||
import '../services/companion_remote/companion_remote_receiver.dart';
|
import '../services/companion_remote/companion_remote_receiver.dart';
|
||||||
import '../services/fullscreen_state_manager.dart';
|
import '../services/fullscreen_state_manager.dart';
|
||||||
|
import '../services/car_ux_restrictions_service.dart';
|
||||||
import '../services/driver_distraction.dart';
|
import '../services/driver_distraction.dart';
|
||||||
import '../services/discord_rpc_service.dart';
|
import '../services/discord_rpc_service.dart';
|
||||||
import '../services/trackers/tracker_coordinator.dart';
|
import '../services/trackers/tracker_coordinator.dart';
|
||||||
@@ -943,6 +944,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
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();
|
_setupCompanionRemoteCallbacks();
|
||||||
_setupAppleTvRemotePlaybackActions();
|
_setupAppleTvRemotePlaybackActions();
|
||||||
@@ -951,8 +961,72 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (mounted) _showStillWatchingDialog();
|
if (mounted) _showStillWatchingDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (PlatformDetector.isAutomotive()) {
|
||||||
|
unawaited(_startPlayerInitializationOnceVehicleAnswers());
|
||||||
|
} else {
|
||||||
unawaited(_startPlayerInitialization(replaceCurrent: false));
|
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<void> _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<bool> _pauseWithoutDisturbingTheRoom(Player currentPlayer) async {
|
||||||
|
final syncOwnsIt = await (_watchTogetherProvider?.pauseLocallyForSystem() ?? Future.value(false));
|
||||||
|
if (syncOwnsIt) return true;
|
||||||
|
await _pauseWithPlaybackIntent(currentPlayer);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -1617,6 +1691,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_playerInitializationGeneration++;
|
_playerInitializationGeneration++;
|
||||||
_frameRate.dispose();
|
_frameRate.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
CarUxRestrictionsService.instance.listenable.removeListener(_handleCarRestrictionsChanged);
|
||||||
|
|
||||||
final transitionCompleter = _playbackTransitionIdleCompleter;
|
final transitionCompleter = _playbackTransitionIdleCompleter;
|
||||||
_playbackTransitionIdleCompleter = null;
|
_playbackTransitionIdleCompleter = null;
|
||||||
|
|||||||
@@ -618,6 +618,16 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
_controller?.detachPlayer(exiting: exiting);
|
_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.
|
/// Suppress sync heartbeats/corrections while the app is backgrounded.
|
||||||
void setBackgrounded(bool value) {
|
void setBackgrounded(bool value) {
|
||||||
_controller?.setBackgrounded(value);
|
_controller?.setBackgrounded(value);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../mpv/mpv.dart';
|
import '../../mpv/mpv.dart';
|
||||||
|
import '../../services/driver_distraction.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../primitives.dart';
|
import '../primitives.dart';
|
||||||
|
|
||||||
@@ -119,16 +120,34 @@ class AttachedPlayer {
|
|||||||
|
|
||||||
/// Start or resume playback. Records a ledger expectation so the resulting
|
/// Start or resume playback. Records a ledger expectation so the resulting
|
||||||
/// playing event is consumed as an ack.
|
/// 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() {
|
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));
|
final expectation = _expect(_Expectation.playing(true, _nowMs() + _expectationTtlMs));
|
||||||
return _guarded('play', (player) => player.play(), expectation);
|
return _guarded('play', (player) => player.play(), expectation);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> pause() {
|
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));
|
final expectation = _expect(_Expectation.playing(false, _nowMs() + _expectationTtlMs));
|
||||||
return _guarded('pause', (player) => player.pause(), expectation);
|
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) {
|
Future<bool> setRate(double rate) {
|
||||||
final expectation = _expect(_Expectation.rate(rate, _nowMs() + _expectationTtlMs));
|
final expectation = _expect(_Expectation.rate(rate, _nowMs() + _expectationTtlMs));
|
||||||
return _guarded('setRate', (player) => player.setRate(rate), expectation);
|
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
|
/// Seek issued by the sync layer. Routed through the screen's seek
|
||||||
/// delegate when provided (Plex transcode restarts need the full path),
|
/// delegate when provided (Plex transcode restarts need the full path),
|
||||||
/// falling back to a plain player seek.
|
/// 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;
|
final delegate = _remoteSeek;
|
||||||
if (delegate != null) {
|
if (delegate != null) {
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +173,13 @@ class AttachedPlayer {
|
|||||||
}
|
}
|
||||||
await player.seek(target);
|
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) {
|
_Expectation _expect(_Expectation expectation) {
|
||||||
@@ -157,6 +187,15 @@ class AttachedPlayer {
|
|||||||
return 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(
|
Future<bool> _guarded(
|
||||||
String actionName,
|
String actionName,
|
||||||
Future<void> Function(Player player) command, [
|
Future<void> Function(Player player) command, [
|
||||||
|
|||||||
@@ -650,10 +650,23 @@ class HostPlaybackCoordinator {
|
|||||||
_pendingStartPositionMs = null;
|
_pendingStartPositionMs = null;
|
||||||
final currentPlayer = _player;
|
final currentPlayer = _player;
|
||||||
if (currentPlayer == null || _phase != PlaybackPhase.playing) return;
|
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) {
|
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 {
|
} else {
|
||||||
unawaited(currentPlayer.play());
|
unawaited(currentPlayer.play().then(settle));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,28 @@ class WatchTogetherController {
|
|||||||
appLogger.d('WatchTogether: Player detached (exiting: $exiting)');
|
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
|
// Provider inputs
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'dart:async';
|
|||||||
import 'package:fake_async/fake_async.dart';
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.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 'package:plezy/watch_together/services/attached_player.dart';
|
||||||
|
|
||||||
import '../test_helpers/watch_together_fakes.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:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/playback_state.dart';
|
||||||
import 'package:plezy/watch_together/models/watch_session.dart';
|
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||||
import 'package:plezy/watch_together/services/attached_player.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:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/watch_together/models/playback_state.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