feat: Automatically recover from frozen live streams (#2569)

- Closes: #2099
This commit is contained in:
Dermot Duffy
2026-07-07 21:37:24 -07:00
committed by GitHub
parent 9f956fe89f
commit fb1bbc739e
73 changed files with 3321 additions and 364 deletions
+185 -1
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
import { VideoMediaPlayerController } from '../../../src/components-lib/media-player/video';
import {
hideMediaControlsTemporarily,
@@ -16,6 +17,48 @@ class NotAllowedError extends Error {
name = 'NotAllowedError';
}
const STALL_MS = FRAME_STALL_SECONDS * 1000;
// A real jsdom <video> (so `'requestVideoFrameCallback' in video` is genuinely
// false unless we add it) with controllable properties and a driveable frame
// callback.
const createVideo = (options?: {
readyState?: number;
paused?: boolean;
seeking?: boolean;
ended?: boolean;
rvfc?: boolean;
}): {
video: HTMLVideoElement;
deliverFrame: () => void;
cancel: ReturnType<typeof vi.fn>;
} => {
const video = document.createElement('video');
const define = (prop: string, value: unknown): void => {
Object.defineProperty(video, prop, { value, configurable: true });
};
define('readyState', options?.readyState ?? HTMLMediaElement.HAVE_CURRENT_DATA);
define('paused', options?.paused ?? false);
define('seeking', options?.seeking ?? false);
define('ended', options?.ended ?? false);
let frameCallback: (() => void) | null = null;
const cancel = vi.fn();
if (options?.rvfc !== false) {
let handle = 0;
define(
'requestVideoFrameCallback',
vi.fn((cb: () => void) => {
frameCallback = cb;
return ++handle;
}),
);
define('cancelVideoFrameCallback', cancel);
}
return { video, deliverFrame: () => frameCallback?.(), cancel };
};
// @vitest-environment jsdom
describe('VideoMediaPlayerController', () => {
beforeEach(() => {
@@ -287,4 +330,145 @@ describe('VideoMediaPlayerController', () => {
expect(controller.getPIPElement()).toBeNull();
});
});
describe('subscribeLiveness', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should report a stall when frames stop arriving', () => {
const { video, deliverFrame } = createVideo();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
deliverFrame();
vi.advanceTimersByTime(STALL_MS);
// The first frame confirms live, then no further frame arrives -> stall.
expect(callback).toHaveBeenNthCalledWith(1, true);
expect(callback).toHaveBeenNthCalledWith(2, false);
});
it('should not report a stall while a paused video still holds a frame', () => {
// A genuine user pause: paused with a current frame is idle, never
// reported -- not even after the stall window.
const { video } = createVideo({ paused: true });
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should report a video stuck paused with no current frame', () => {
// The observed go2rtc failure: its internal reconnect leaves the <video>
// paused at readyState 0 (spinning). That is not a genuine pause -- there
// is no frame to pause on -- so playback is still expected and a missing
// frame is a stall.
const { video } = createVideo({
paused: true,
readyState: HTMLMediaElement.HAVE_NOTHING,
});
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(false);
});
it('should report no stall when requestVideoFrameCallback is unavailable', () => {
const { video } = createVideo({ rvfc: false });
expect('requestVideoFrameCallback' in video).toBe(false);
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should stop watching and cancel the frame callback on unsubscribe', () => {
const { video, cancel } = createVideo();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
const unsubscribe = controller.subscribeLiveness(callback);
unsubscribe();
vi.advanceTimersByTime(STALL_MS);
expect(cancel).toHaveBeenCalled();
expect(callback).not.toHaveBeenCalled();
});
it('should not re-arm when the last subscriber unsubscribes during recovery', () => {
const { video, deliverFrame, cancel } = createVideo();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
let unsubscribe: () => void = () => {};
const callback = vi.fn((isLive: boolean) => {
if (isLive) {
unsubscribe();
}
});
unsubscribe = controller.subscribeLiveness(callback);
// Stall -> callback(false)
vi.advanceTimersByTime(STALL_MS);
// Recovery -> callback(true) -> unsubscribe -> source stopped
deliverFrame();
expect(cancel).toHaveBeenCalled();
});
it('should skip re-arming and report no stall when the video is gone between frames', () => {
const { video, deliverFrame } = createVideo();
let currentVideo: HTMLVideoElement | null = video;
const controller = new VideoMediaPlayerController(
createLitElement(),
() => currentVideo,
);
const callback = vi.fn();
controller.subscribeLiveness(callback);
deliverFrame();
// Remove video
currentVideo = null;
// Re-arm skipped: no video
deliverFrame();
// The first frame reported live; the stall timer then fires with no
// video, so no stall is ever reported.
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalledWith(false);
});
it('should cancel nothing on unsubscribe when the video is already gone', () => {
const { video, cancel } = createVideo();
let currentVideo: HTMLVideoElement | null = video;
const controller = new VideoMediaPlayerController(
createLitElement(),
() => currentVideo,
);
const unsubscribe = controller.subscribeLiveness(vi.fn());
currentVideo = null;
unsubscribe();
expect(cancel).not.toHaveBeenCalled();
});
});
});