mirror of https://github.com/usememos/memos
fix(web): render video attachment posters on mobile
parent
e8d32e87d1
commit
0e2a9a9c0c
@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VideoPosterProps {
|
||||
sourceUrl: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
posterUrl?: string;
|
||||
}
|
||||
|
||||
const MAX_POSTER_DIMENSION = 960;
|
||||
|
||||
const getFrameSourceUrl = (sourceUrl: string): string => {
|
||||
if (!sourceUrl || sourceUrl.startsWith("blob:") || sourceUrl.startsWith("data:") || sourceUrl.includes("#")) {
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
return `${sourceUrl}#t=0.001`;
|
||||
};
|
||||
|
||||
const getCanvasSize = (width: number, height: number) => {
|
||||
const scale = Math.min(1, MAX_POSTER_DIMENSION / Math.max(width, height));
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
};
|
||||
|
||||
const VideoPoster = ({ sourceUrl, alt, className, posterUrl }: VideoPosterProps) => {
|
||||
const usablePosterUrl = posterUrl && posterUrl !== sourceUrl ? posterUrl : undefined;
|
||||
const [capturedPosterUrl, setCapturedPosterUrl] = useState<string>();
|
||||
const frameSourceUrl = useMemo(() => getFrameSourceUrl(sourceUrl), [sourceUrl]);
|
||||
const posterImageUrl = usablePosterUrl ?? capturedPosterUrl;
|
||||
|
||||
useEffect(() => {
|
||||
setCapturedPosterUrl(undefined);
|
||||
}, [sourceUrl, usablePosterUrl]);
|
||||
|
||||
const captureFrame = useCallback(
|
||||
(video: HTMLVideoElement) => {
|
||||
if (usablePosterUrl || capturedPosterUrl || video.videoWidth <= 0 || video.videoHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { width, height } = getCanvasSize(video.videoWidth, video.videoHeight);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
setCapturedPosterUrl(canvas.toDataURL("image/jpeg", 0.86));
|
||||
} catch {
|
||||
// Cross-origin or codec-specific failures should keep the video fallback visible.
|
||||
}
|
||||
},
|
||||
[capturedPosterUrl, usablePosterUrl],
|
||||
);
|
||||
|
||||
if (posterImageUrl) {
|
||||
return <img src={posterImageUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
data-testid="video-poster-fallback"
|
||||
src={frameSourceUrl}
|
||||
className={cn("pointer-events-none", className)}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
onLoadedData={(event) => captureFrame(event.currentTarget)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPoster;
|
||||
@ -0,0 +1,47 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import VideoPoster from "@/components/VideoPoster";
|
||||
|
||||
describe("<VideoPoster>", () => {
|
||||
it("renders a mobile-friendly video fallback before a frame is captured", () => {
|
||||
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
|
||||
|
||||
const video = screen.getByTestId("video-poster-fallback");
|
||||
expect(video).toHaveAttribute("preload", "auto");
|
||||
expect(video).toHaveAttribute("playsinline");
|
||||
expect((video as HTMLVideoElement).muted).toBe(true);
|
||||
expect(video).toHaveClass("object-cover");
|
||||
});
|
||||
|
||||
it("renders a captured frame as an image poster", async () => {
|
||||
const drawImage = vi.fn();
|
||||
const toDataURL = vi.fn(() => "data:image/jpeg;base64,poster");
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string, options?: ElementCreationOptions) => {
|
||||
if (tagName === "canvas") {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => ({ drawImage })),
|
||||
toDataURL,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}
|
||||
|
||||
return originalCreateElement(tagName, options);
|
||||
});
|
||||
|
||||
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
|
||||
|
||||
const video = screen.getByTestId("video-poster-fallback") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "videoWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(video, "videoHeight", { configurable: true, value: 360 });
|
||||
|
||||
fireEvent.loadedData(video);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img", { name: "clip.mp4" })).toHaveAttribute("src", "data:image/jpeg;base64,poster");
|
||||
});
|
||||
expect(drawImage).toHaveBeenCalledWith(video, 0, 0, 640, 360);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue