fix(web): render video attachment posters on mobile

pull/6010/head
boojack 1 month ago
parent e8d32e87d1
commit 0e2a9a9c0c

@ -1,5 +1,6 @@
import { FileAudioIcon, FileIcon, PlayIcon } from "lucide-react";
import AudioAttachmentItem from "@/components/MemoMetadata/Attachment/AudioAttachmentItem";
import VideoPoster from "@/components/VideoPoster";
import type { AttachmentLibraryListItem } from "@/hooks/useAttachmentLibrary";
import { cn } from "@/lib/utils";
import { getAttachmentThumbnailUrl, getAttachmentType, isMotionAttachment } from "@/utils/attachment";
@ -26,7 +27,7 @@ const AttachmentThumb = ({ item, className }: { item: AttachmentLibraryListItem;
if (type === "video/*") {
return (
<div className={cn("relative overflow-hidden rounded-xl bg-muted/35", className)}>
<video src={item.sourceUrl} className="h-full w-full object-cover" preload="metadata" />
<VideoPoster sourceUrl={item.sourceUrl} alt={item.attachment.filename} className="h-full w-full object-cover" />
<span className="absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>

@ -1,6 +1,7 @@
import { PlayIcon } from "lucide-react";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { Badge } from "@/components/ui/badge";
import VideoPoster from "@/components/VideoPoster";
import type { AttachmentLibraryMediaItem, AttachmentLibraryMonthGroup } from "@/hooks/useAttachmentLibrary";
import { useTranslate } from "@/utils/i18n";
import { AttachmentMetadataLine, AttachmentOpenButton } from "./AttachmentLibraryPrimitives";
@ -19,7 +20,12 @@ const AttachmentMediaCard = ({ item, onPreview }: { item: AttachmentLibraryMedia
<div className="relative aspect-[5/4] overflow-hidden bg-muted/40">
{item.kind === "video" ? (
<>
<video src={item.sourceUrl} poster={item.posterUrl} className="h-full w-full object-cover" preload="metadata" />
<VideoPoster
sourceUrl={item.sourceUrl}
posterUrl={item.posterUrl}
alt={item.filename}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-linear-to-t from-black/35 via-black/5 to-transparent" />
<span className="absolute bottom-2.5 right-2.5 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm backdrop-blur-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />

@ -3,6 +3,7 @@ import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import VideoPoster from "@/components/VideoPoster";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment";
@ -115,7 +116,7 @@ const CollageVisualItem = ({
<VisualTile className={cn("block h-full w-full", className)} onPreview={onPreview} overlayLabel={overlayLabel}>
{item.kind === "video" ? (
<>
<video src={item.sourceUrl} className={COVER_MEDIA_CLASS} preload="metadata" />
<VideoPoster sourceUrl={item.sourceUrl} posterUrl={item.posterUrl} alt={item.filename} className={COVER_MEDIA_CLASS} />
{!overlayLabel && (
<VideoPlayBadge className={COLLAGE_VIDEO_PLAY_BADGE_CLASS}>
<PlayIcon className="h-3.5 w-3.5 fill-current" />
@ -170,7 +171,7 @@ const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: (
return (
<VisualTile className={cn("block", SINGLE_VIDEO_CARD_WIDTH_CLASS)} onPreview={onPreview}>
<div className="relative aspect-video bg-black/5">
<video src={item.sourceUrl} poster={item.posterUrl} className={COVER_MEDIA_CLASS} preload="metadata" />
<VideoPoster sourceUrl={item.sourceUrl} posterUrl={item.posterUrl} alt={item.filename} className={COVER_MEDIA_CLASS} />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/35 via-black/5 to-transparent" />
<VideoPlayBadge className="bottom-3 right-3 h-9 w-9">
<PlayIcon className="h-4 w-4 fill-current" />

@ -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…
Cancel
Save