feat: support mobile video previews
This commit is contained in:
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
||||
import type { Resource } from "../api";
|
||||
import { CategoryIcon } from "./CategoryIcon";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useLocalizedPath } from "../useLocalizedPath";
|
||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
|
||||
@@ -16,10 +17,11 @@ export function LatestUpdateRow({
|
||||
iconKey: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const lp = useLocalizedPath();
|
||||
const dateStr = formatDateYmd(r.updatedAt);
|
||||
|
||||
return (
|
||||
<Link to={`/resource/${r.id}`} className={LATEST_CARD_CLASS}>
|
||||
<Link to={lp(`/resource/${r.id}`)} className={LATEST_CARD_CLASS}>
|
||||
<div className="flex shrink-0 items-center justify-center pt-0.5">
|
||||
<CategoryIcon
|
||||
iconKey={iconKey}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { assetUrl, type Category } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useLocalizedPath } from "../useLocalizedPath";
|
||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
@@ -91,6 +92,7 @@ function PopularRankRow({
|
||||
}) {
|
||||
const { t, lang } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const lp = useLocalizedPath();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [coverFailed, setCoverFailed] = useState(false);
|
||||
@@ -120,7 +122,9 @@ function PopularRankRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`)
|
||||
navigate(
|
||||
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`),
|
||||
)
|
||||
}
|
||||
aria-label={r.title}
|
||||
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
||||
import type { Resource } from "../api";
|
||||
import { assetUrl } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useLocalizedPath } from "../useLocalizedPath";
|
||||
import { useMemo, useState } from "react";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
|
||||
@@ -49,6 +50,7 @@ export function RecommendedCard({
|
||||
layout?: "carousel" | "grid";
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const lp = useLocalizedPath();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const figmaCover =
|
||||
@@ -105,7 +107,7 @@ export function RecommendedCard({
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
to={`/resource/${r.id}`}
|
||||
to={lp(`/resource/${r.id}`)}
|
||||
aria-label={displayTitle}
|
||||
className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Link } from "react-router-dom";
|
||||
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||
import { langQuery, type Lang } from "../i18n";
|
||||
import { useLocalizedPath } from "../useLocalizedPath";
|
||||
import type { Post, PostListResponse } from "../types/post";
|
||||
import { MessageBubble } from "./messageStream/MessageBubble";
|
||||
import { postDisplayText, postTitleText } from "./messageStream/utils/postText";
|
||||
@@ -126,6 +127,7 @@ export function SearchPanel({
|
||||
onResultClick,
|
||||
}: SearchPanelProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const lp = useLocalizedPath();
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
const [selectedTag, setSelectedTag] = useState("");
|
||||
const [tagPosts, setTagPosts] = useState<Post[]>([]);
|
||||
@@ -334,7 +336,7 @@ export function SearchPanel({
|
||||
return (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/browse?post=${encodeURIComponent(post.id)}`}
|
||||
to={lp(`/browse?post=${encodeURIComponent(post.id)}`)}
|
||||
onClick={onResultClick}
|
||||
className="block rounded-2xl border border-white/10 bg-[#191921] px-4 py-3 transition hover:border-ark-gold/60 hover:bg-[#22232D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
34
src/components/messageStream/hooks/useVideoPreviewSource.ts
Normal file
34
src/components/messageStream/hooks/useVideoPreviewSource.ts
Normal 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());
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
17
src/components/messageStream/utils/videoPreviewSource.ts
Normal file
17
src/components/messageStream/utils/videoPreviewSource.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user