feat: support mobile video previews

This commit is contained in:
TerryM
2026-06-01 16:35:40 +08:00
parent c53032155b
commit a968f47640
16 changed files with 275 additions and 47 deletions

View File

@@ -9,6 +9,7 @@ import {
} from "react";
import type { Attachment } from "../../types/post";
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
import { useVideoPreviewSource } from "./hooks/useVideoPreviewSource";
import { useVideoPlayer } from "./overlays/VideoPlayer";
function pad2(n: number): string {
@@ -127,6 +128,7 @@ export function MessageInlineVideo({
const [snapProgress, setSnapProgress] = useState(false);
const t = TOKENS[size];
const videoSrc = useVideoPreviewSource(attachment);
useEffect(() => {
const v = videoRef.current;
@@ -270,7 +272,7 @@ export function MessageInlineVideo({
<>
<video
ref={videoRef}
src={attachment.url}
src={videoSrc}
poster={attachment.posterUrl}
playsInline
autoPlay={autoPlay}

View File

@@ -6,12 +6,20 @@ import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { MessageInlineVideo } from "../MessageInlineVideo";
import {
useShouldUseMobilePreview,
useVideoPreviewSource,
} from "../hooks/useVideoPreviewSource";
import { useVideoPlayer } from "../overlays/VideoPlayer";
import { autolink } from "../utils/autolink";
import { CollapsibleText } from "../CollapsibleText";
import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
import {
videoMetadataPreviewSource,
videoPreviewSource,
} from "../utils/videoPreviewSource";
import { useToast } from "../../Toast";
const MAX_VISIBLE = 4;
@@ -59,9 +67,8 @@ function VideoAttachmentCard({
const [playing, setPlaying] = useState(false);
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
const duration = formatDuration(attachment.durationSec);
const previewVideoUrl = attachment.url.includes("#")
? attachment.url
: `${attachment.url}#t=0.1`;
const videoSrc = useVideoPreviewSource(attachment);
const previewVideoUrl = videoMetadataPreviewSource(videoSrc);
return (
<div
@@ -208,6 +215,8 @@ function VideoListDialog({
onClose: () => void;
onPick: (attachment: Attachment) => void;
}) {
const useMobilePreview = useShouldUseMobilePreview();
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
@@ -242,9 +251,9 @@ function VideoListDialog({
<div className="max-h-[70vh] overflow-y-auto p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{videos.map((video, index) => {
const thumb = video.posterUrl ?? video.thumbnailUrl;
const previewVideoUrl = video.url.includes("#")
? video.url
: `${video.url}#t=0.1`;
const previewVideoUrl = videoMetadataPreviewSource(
videoPreviewSource(video, useMobilePreview),
);
const duration = formatDuration(video.durationSec);
return (
<div

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import type { Attachment } from "../../../types/post";
import {
mobilePreviewMediaQuery,
videoPreviewSource,
} from "../utils/videoPreviewSource";
function getMatchesMobilePreview(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(mobilePreviewMediaQuery).matches;
}
export function useShouldUseMobilePreview(): boolean {
const [useMobilePreview, setUseMobilePreview] = useState(
getMatchesMobilePreview,
);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const media = window.matchMedia(mobilePreviewMediaQuery);
const update = () => setUseMobilePreview(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return useMobilePreview;
}
export function useVideoPreviewSource(attachment: Attachment): string {
return videoPreviewSource(attachment, useShouldUseMobilePreview());
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { Attachment } from "../../../types/post";
import {
videoMetadataPreviewSource,
videoPreviewSource,
} from "./videoPreviewSource";
const attachment: Attachment = {
id: "att-1",
kind: "video",
url: "/uploads/desktop-preview.mp4",
mobilePreviewUrl: "/uploads/mobile-540p-preview.mp4",
mime: "video/mp4",
filename: "original.mp4",
sizeBytes: 1024,
};
describe("videoPreviewSource", () => {
it("uses the desktop preview by default", () => {
expect(videoPreviewSource(attachment, false)).toBe(
"/uploads/desktop-preview.mp4",
);
});
it("uses mobilePreviewUrl only when mobile preview is active", () => {
expect(videoPreviewSource(attachment, true)).toBe(
"/uploads/mobile-540p-preview.mp4",
);
});
it("falls back to the desktop preview when mobilePreviewUrl is absent", () => {
expect(
videoPreviewSource({ ...attachment, mobilePreviewUrl: undefined }, true),
).toBe("/uploads/desktop-preview.mp4");
});
it("adds a metadata seek fragment only when the URL has no fragment", () => {
expect(videoMetadataPreviewSource("/uploads/video.mp4")).toBe(
"/uploads/video.mp4#t=0.1",
);
expect(videoMetadataPreviewSource("/uploads/video.mp4#t=2")).toBe(
"/uploads/video.mp4#t=2",
);
});
});

View File

@@ -0,0 +1,17 @@
import type { Attachment } from "../../../types/post";
export const mobilePreviewMediaQuery = "(max-width: 760px)";
export function videoPreviewSource(
attachment: Attachment,
useMobilePreview: boolean,
): string {
if (useMobilePreview && attachment.mobilePreviewUrl) {
return attachment.mobilePreviewUrl;
}
return attachment.url;
}
export function videoMetadataPreviewSource(url: string): string {
return url.includes("#") ? url : `${url}#t=0.1`;
}