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
37 changes: 36 additions & 1 deletion apps/web/src/components/media/MediaVideoPlayer.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import { act, render, stubGlobal, type Screen } from "../../../test/render";
import { MediaVideoPlayer } from "./MediaVideoPlayer";
Expand All @@ -10,6 +10,18 @@ class HiddenIntersectionObserver {
disconnect() {}
}

class VisibleIntersectionObserver {
constructor(private readonly callback: IntersectionObserverCallback) {}
observe(target: Element) {
this.callback(
[{ target, isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
unobserve() {}
disconnect() {}
}

function expectVideoSrc(screen: Screen, src: string) {
return expect.element(screen.getByLabelText("clip")).toHaveAttribute("src", src);
}
Expand Down Expand Up @@ -55,4 +67,27 @@ describe("MediaVideoPlayer", () => {
await screen.rerender(<MediaVideoPlayer src={null} label="clip" revision="mutation-2" />);
await expectVideoSrc(screen, "/asset/first/clip.mp4");
});

it("starts loading metadata once the player scrolls near the viewport", async () => {
using _observer = stubGlobal("IntersectionObserver", VisibleIntersectionObserver);
await using screen = await render(<MediaVideoPlayer src="/asset/clip.mp4" label="clip" />);

await expect.element(screen.getByLabelText("clip")).toHaveAttribute("preload", "metadata");
});

it("keeps a playing video playing when the player rerenders", async () => {
using _observer = stubGlobal("IntersectionObserver", HiddenIntersectionObserver);
await using screen = await render(
<MediaVideoPlayer src="/asset/clip.mp4" label="clip" className="w-40" />,
);
const video = screen.getByLabelText("clip").element() as HTMLVideoElement;
using pause = vi.spyOn(video, "pause");
await act(() => {
video.dispatchEvent(new Event("play"));
});

await screen.rerender(<MediaVideoPlayer src="/asset/clip.mp4" label="clip" className="w-48" />);

expect(pause).not.toHaveBeenCalled();
});
});
60 changes: 28 additions & 32 deletions apps/web/src/components/media/MediaVideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { settlePromise } from "@t3tools/client-runtime/state/runtime";
import { AsyncResult } from "effect/reactivity";
import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
import { useEffect, useState, type CSSProperties } from "react";
import { useState, type CSSProperties } from "react";

import { observeIntersection } from "../../lib/observeIntersection";
import { cn } from "../../lib/utils";
import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
import { Button } from "../ui/button";
Expand Down Expand Up @@ -44,13 +45,11 @@ export function MediaVideoPlayer({
onRetry,
actionsSource,
}: MediaVideoPlayerProps) {
// Use state so effects rebind when retry or failure remounts the video.
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
const [playbackSource, setPlaybackSource] = useState<{
src: string;
revision: string | null;
} | null>(null);
const [playingVideo, setPlayingVideo] = useState<HTMLVideoElement | null>(null);
const [playing, setPlaying] = useState(false);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
Expand All @@ -65,33 +64,15 @@ export function MediaVideoPlayer({
playbackSource !== null &&
playbackSource.revision !== revision &&
(latestSrc !== null || sourceFailed) &&
(video === null || playingVideo !== video)
!playing
) {
setPlaybackSource(
revision !== null && latestSrc !== null ? { src: latestSrc, revision } : null,
);
}

useEffect(() => {
if (!video || preload === "metadata" || preloaded) return;
let active = true;
const observer = new IntersectionObserver(
(entries) => {
if (!active || !entries.some((entry) => entry.isIntersecting)) return;
setPreloadedSrc(src);
observer.disconnect();
},
{ rootMargin: "200px" },
);
observer.observe(video);
return () => {
active = false;
observer.disconnect();
};
}, [video, src, preload, preloaded]);

useEffect(() => {
if (!video) return;
// Retry and failure remount the element, so its listeners live and die with it.
const attachVideo = (video: HTMLVideoElement) => {
const pauseWhenHidden = () => {
// Native fullscreen can hide the inline page while this video is still visible.
const fullscreen =
Expand All @@ -107,8 +88,22 @@ export function MediaVideoPlayer({
document.removeEventListener("fullscreenchange", pauseWhenHidden);
video.removeEventListener("webkitendfullscreen", pauseWhenHidden);
video.pause();
// A detached element's pause event never reaches onPause.
setPlaying(false);
};
}, [video]);
};

// On the wrapper, not the video: re-running the video's ref callback would pause playback.
const observePreload = (player: HTMLSpanElement) => {
if (src === null || failed || preload === "metadata" || preloaded) return;
return observeIntersection(
player,
(entry) => {
if (entry.isIntersecting) setPreloadedSrc(src);
},
{ rootMargin: "200px" },
);
};

const retry = async () => {
if (retrying) return;
Expand All @@ -128,6 +123,7 @@ export function MediaVideoPlayer({

const player = (
<span
ref={observePreload}
className={cn("relative inline-block align-middle", className)}
style={style}
data-markdown-copy={copyMarkdown}
Expand Down Expand Up @@ -162,7 +158,7 @@ export function MediaVideoPlayer({
) : src !== null && !failed ? (
<video
key={loadAttempt}
ref={setVideo}
ref={attachVideo}
src={src}
aria-label={label || "Video preview"}
aria-hidden={onOpen ? true : undefined}
Expand All @@ -181,14 +177,14 @@ export function MediaVideoPlayer({
prepareVideoFirstFrame(event.currentTarget);
if (sourceRevision !== null) setPlaybackSource({ src, revision: sourceRevision });
}}
onPlay={(event) => {
setPlayingVideo(event.currentTarget);
onPlay={() => {
setPlaying(true);
setPlaybackSource({ src, revision: sourceRevision });
}}
onPause={() => setPlayingVideo(null)}
onEnded={() => setPlayingVideo(null)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onError={() => {
setPlayingVideo(null);
setPlaying(false);
setPlaybackSource(null);
if (latestSrc === null || src === latestSrc) setFailedSrc(src);
}}
Expand Down
Loading