diff --git a/lib/watch_together/services/host_playback_coordinator.dart b/lib/watch_together/services/host_playback_coordinator.dart index adb459d19..1331b3279 100644 --- a/lib/watch_together/services/host_playback_coordinator.dart +++ b/lib/watch_together/services/host_playback_coordinator.dart @@ -57,11 +57,23 @@ class HostPlaybackCoordinator { /// After a self stall, the room resumes only once the host has buffered /// this many times the stall's length (bounded by - /// [selfRecoveryMaxHoldMs]). Resuming with a fixed second of data on a - /// link that just starved for four is a guaranteed second stall, and every - /// stall is a pause plus a group restart for every guest. + /// [selfRecoveryMaxHeadroomMs], and by the media left to buffer). Resuming + /// with a fixed second of data on a link that just starved for four is a + /// guaranteed second stall, and every stall is a pause plus a group restart + /// for every guest. static const int selfRecoveryHeadroomFactor = 3; - static const int selfRecoveryMaxHoldMs = 15000; + static const int selfRecoveryMaxHeadroomMs = 15000; + + /// Longest the room waits for that headroom, measured from the stall's + /// end. The cache is an external quantity — a link that cannot refill in + /// this long will stall again whatever we do, and a room held paused with + /// no feedback is the worse failure. Also the bound when the backend + /// reports no cache position at all. + static const int selfRecoveryMaxWaitMs = 15000; + + /// Slack for a cache that ends a few frames shy of the reported duration + /// once everything left has been demuxed. + static const int selfRecoveryEofSlackMs = 1000; /// mpv's own `cache-pause-wait` while hosting: how much it refills before /// leaving `paused-for-cache` (default 1 s). Raised so a starved host does @@ -652,34 +664,38 @@ class HostPlaybackCoordinator { // After our own stall, require cache headroom before resuming so we // don't immediately drag the room back into a stall. The requirement - // scales with the stall we just had: a link that starved for four - // seconds needs far more than a fixed two seconds of data. + // scales with the stall we just had, capped by the media left to buffer + // — near the end nothing more can arrive — and the wait itself is + // bounded, since the cache may simply never grow. final player = _player; if (_recoveringFromSelfStall && player != null) { if (player.buffering) return; // A new stall event will re-drive us. - final neededMs = _selfRecoveryHeadroomMs(); + final waitedMs = _nowMs() - _selfStallEndedMs; + final neededMs = _selfRecoveryHeadroomMs(player); final ahead = player.bufferAhead; - if (ahead != null) { - if (ahead.inMilliseconds < neededMs) { - _scheduleAllReadyCheck(500); - return; - } - } else { - // No cache position from this backend: hold for the time the cache - // would take to fill instead, measured from the stall's end. - final remainingMs = _selfStallEndedMs + neededMs - _nowMs(); - if (remainingMs > 0) { - _scheduleAllReadyCheck(remainingMs); - return; - } + // No cache position from this backend: hold for the time the cache + // would take to fill instead. + final satisfied = ahead != null ? ahead.inMilliseconds >= neededMs : waitedMs >= neededMs; + if (!satisfied && waitedMs < selfRecoveryMaxWaitMs) { + final untilDeadlineMs = selfRecoveryMaxWaitMs - waitedMs; + _scheduleAllReadyCheck(ahead != null ? min(500, untilDeadlineMs) : min(neededMs - waitedMs, untilDeadlineMs)); + return; } } _recoveringFromSelfStall = false; _resolveAllReady(); } - int _selfRecoveryHeadroomMs() => - (_lastSelfStallMs * selfRecoveryHeadroomFactor).clamp(selfRecoveryMinBufferAheadMs, selfRecoveryMaxHoldMs); + int _selfRecoveryHeadroomMs(AttachedPlayer player) { + final scaled = (_lastSelfStallMs * selfRecoveryHeadroomFactor).clamp( + selfRecoveryMinBufferAheadMs, + selfRecoveryMaxHeadroomMs, + ); + final durationMs = player.duration.inMilliseconds; + if (durationMs <= 0) return scaled; // Live or unknown length: nothing to cap against. + final remainingMs = durationMs - player.position.inMilliseconds - selfRecoveryEofSlackMs; + return remainingMs < scaled ? max(0, remainingMs) : scaled; + } void _resolveAllReady() { _cancelSafety(); diff --git a/test/watch_together/host_playback_coordinator_test.dart b/test/watch_together/host_playback_coordinator_test.dart index 29097f8db..d35b8f4de 100644 --- a/test/watch_together/host_playback_coordinator_test.dart +++ b/test/watch_together/host_playback_coordinator_test.dart @@ -286,6 +286,45 @@ void main() { }); }); + test('host stall near the end: headroom is capped by the media left, not held for what cannot arrive', () { + fakeAsync((async) { + final h = _Harness(async, duration: const Duration(seconds: 310)); + h.coordinator.onPeerJoined('guest', compatible: true); + h.attachForMedia(async); + h.guestReports(async); + h.hostBecomesReady(async); + async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds))); + h.player.setPosition(const Duration(seconds: 300)); + + h.player.emitBuffering(true); + async.elapse(const Duration(seconds: 4)); // 4s stall → 12s wanted, but only 10s of media remain. + h.player.setBuffer(const Duration(seconds: 310)); // Everything left is buffered. + h.player.emitBuffering(false); + async.elapse(const Duration(milliseconds: 1500)); + + expect(h.last.phase, PlaybackPhase.playing); + h.dispose(); + }); + }); + + test('host stall: a cache that never grows releases the room at the wait deadline', () { + fakeAsync((async) { + final h = playingRoom(async); + h.player.setPosition(const Duration(minutes: 5)); + + h.player.emitBuffering(true); + async.elapse(const Duration(seconds: 4)); + h.player.setBuffer(const Duration(minutes: 5, seconds: 3)); // 3s ahead, 12s wanted, and it stays there. + h.player.emitBuffering(false); + async.elapse(Duration(milliseconds: HostPlaybackCoordinator.selfRecoveryMaxWaitMs - 500)); + expect(h.last.phase, PlaybackPhase.waitingForPeers); + + async.elapse(const Duration(seconds: 1)); + expect(h.last.phase, PlaybackPhase.playing); + h.dispose(); + }); + }); + test('host stall: a heartbeat during a sub-grace blip keeps extrapolating the last anchor', () { fakeAsync((async) { final h = playingRoom(async);