Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions ui/src/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export function VideoPlayer({
const audioFallbackAppliedRef = useRef(false);
const [faceOverlayEnabled, setFaceOverlayEnabled] = usePersistedFlag(FACE_OVERLAY_KEY, false);
const playbackTracker = useRef(createPlaybackTracker());
const sourceRef = useRef<HTMLSourceElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
const playTriggered = useRef(false);
const sourceRestoreRef = useRef<{ time: number; shouldPlay: boolean } | null>(null);
Expand Down Expand Up @@ -550,6 +551,7 @@ export function VideoPlayer({
const transcodeResolution = selectedQuality === SOURCE_TRANSCODE_QUALITY ? undefined : selectedQuality;
const effectiveStreamUrl = selectedQuality === "Direct" ? streamUrl : videos.transcodeUrl(videoId, transcodeResolution, transcodeStartSec > 0 ? transcodeStartSec : undefined);
const effectiveSourceType = selectedQuality === "Direct" ? getVideoSourceMimeType(format) : "video/mp4";
const effectiveSourceSignature = `${effectiveStreamUrl}|${effectiveSourceType ?? ""}`;

useEffect(() => {
const v = videoRef.current;
Expand Down Expand Up @@ -596,13 +598,12 @@ export function VideoPlayer({

pendingAutostartRef.current = true;
const video = videoRef.current;
const sourceSignature = `${effectiveStreamUrl}|${format || "mp4"}`;
if (!video || lastLoadedSourceRef.current !== sourceSignature) {
if (!video || lastLoadedSourceRef.current !== effectiveSourceSignature) {
return;
}

video.play().catch(() => {});
}, [autostart, autostartToken, effectiveStreamUrl, format]);
}, [autostart, autostartToken, effectiveSourceSignature]);

useEffect(() => {
const handler = () => setPip(document.pictureInPictureElement === videoRef.current);
Expand Down Expand Up @@ -1033,11 +1034,24 @@ export function VideoPlayer({
return;
}

const sourceSignature = `${effectiveStreamUrl}|${format || "mp4"}`;
if (lastLoadedSourceRef.current === sourceSignature) {
const source = sourceRef.current ?? video.querySelector("source");
const sourceSrcMatches = source?.getAttribute("src") === effectiveStreamUrl;
const sourceTypeMatches = effectiveSourceType
? source?.getAttribute("type") === effectiveSourceType
: !source?.hasAttribute("type");
if (lastLoadedSourceRef.current === effectiveSourceSignature && sourceSrcMatches && sourceTypeMatches) {
return;
}
lastLoadedSourceRef.current = sourceSignature;
lastLoadedSourceRef.current = effectiveSourceSignature;

if (source) {
source.setAttribute("src", effectiveStreamUrl);
if (effectiveSourceType) {
source.setAttribute("type", effectiveSourceType);
} else {
source.removeAttribute("type");
}
}

const pendingRestore = sourceRestoreRef.current;
sourceRestoreRef.current = null;
Expand Down Expand Up @@ -1073,7 +1087,7 @@ export function VideoPlayer({
return () => {
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
};
}, [clip, duration, effectiveResumeTime, effectiveStreamUrl, format, playerVideoStartMinDuration, playerVideoStartPercent, selectedQuality, transcodeStartSec]);
}, [clip, duration, effectiveResumeTime, effectiveSourceSignature, effectiveSourceType, effectiveStreamUrl, playerVideoStartMinDuration, playerVideoStartPercent, selectedQuality, transcodeStartSec]);

// Release the media element's network connection when the player unmounts. Without this, leaving a
// video (back to the list, or advancing to the next item in a queue when the player is keyed by id)
Expand All @@ -1093,6 +1107,7 @@ export function VideoPlayer({
video.querySelectorAll("source").forEach((s) => s.removeAttribute("src"));
video.removeAttribute("src");
video.load();
lastLoadedSourceRef.current = null;
} catch {
// Ignore — element may already be detached.
}
Expand Down Expand Up @@ -1258,7 +1273,7 @@ export function VideoPlayer({
onEndedProp?.();
}}
>
<source src={effectiveStreamUrl} type={effectiveSourceType} />
<source ref={sourceRef} src={effectiveStreamUrl} type={effectiveSourceType} />
{captions?.map((cap, idx) => (
<track
key={cap.id}
Expand Down
92 changes: 92 additions & 0 deletions ui/src/test/VideoPlayer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { VideoPlayer } from "../components/VideoPlayer";

vi.mock("../state/AppConfigContext", () => ({
useAppConfig: () => ({ config: { ui: {} } }),
}));

const playMock = vi.fn(() => Promise.resolve());
const pauseMock = vi.fn();
const loadMock = vi.fn();
const localStorageMock = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
};

class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}

beforeAll(() => {
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
vi.stubGlobal("localStorage", localStorageMock);

Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
writable: true,
value: playMock,
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true,
writable: true,
value: pauseMock,
});
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
writable: true,
value: loadMock,
});
});

describe("VideoPlayer source lifecycle", () => {
beforeEach(() => {
playMock.mockClear();
pauseMock.mockClear();
loadMock.mockClear();
});

it("restores the rendered source after StrictMode replays mount effects", async () => {
const { container } = render(
<React.StrictMode>
<VideoPlayer
streamUrl="/api/stream/video/1"
format="mp4"
duration={120}
videoId={1}
detections={[]}
trackingEnabled={false}
/>
</React.StrictMode>,
);

const source = container.querySelector("source");
expect(source).toBeInstanceOf(HTMLSourceElement);

await waitFor(() => {
expect(source).toHaveAttribute("src", "/api/stream/video/1");
expect(source).toHaveAttribute("type", "video/mp4");
});
});

it("does not declare a misleading MIME type for unknown direct video streams", () => {
const { container } = render(
<VideoPlayer
streamUrl="/api/stream/video/8912"
format="mpegts"
duration={120}
videoId={8912}
detections={[]}
trackingEnabled={false}
/>,
);

const source = container.querySelector("source");
expect(source).toBeInstanceOf(HTMLSourceElement);
expect(source).not.toHaveAttribute("type");
});
});
Loading