fix: unify live photo previews around LIVE badge playback

This commit is contained in:
boojack 2026-04-06 11:56:58 +08:00
parent 065e817470
commit 6b0487dcd8
6 changed files with 240 additions and 30 deletions

View File

@ -1,6 +1,7 @@
import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react";
import { useMemo } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment";
@ -49,12 +50,6 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
);
};
const MotionBadge = () => (
<span className="pointer-events-none absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white backdrop-blur-sm">
LIVE
</span>
);
const MotionItem = ({
item,
featured = false,
@ -82,6 +77,16 @@ const MotionItem = ({
className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
preload="metadata"
/>
) : item.kind === "motion" ? (
<MotionPhotoPreview
posterUrl={item.posterUrl}
motionUrl={item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl}
alt={item.filename}
presentationTimestampUs={item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined}
containerClassName="h-full w-full"
badgeClassName="left-2 top-2 px-2 py-0.5 text-[10px]"
mediaClassName="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
) : (
<img
src={item.posterUrl}
@ -91,9 +96,8 @@ const MotionItem = ({
decoding="async"
/>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
{item.kind === "motion" && <MotionBadge />}
{item.previewItem.kind === "video" && (
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
{item.kind === "video" && (
<span className="pointer-events-none absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>

View File

@ -37,7 +37,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
sourceUrl: items,
posterUrl: items,
filename: "Image",
isMotion: false,
},
];
}
@ -49,7 +48,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
sourceUrl: url,
posterUrl: url,
filename: "Image",
isMotion: false,
}));
}

View File

@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface MotionPhotoPlayerProps {
posterUrl: string;
motionUrl: string;
alt: string;
presentationTimestampUs?: bigint;
containerClassName?: string;
mediaClassName?: string;
active?: boolean;
loop?: boolean;
}
const MotionPhotoPlayer = ({
posterUrl,
motionUrl,
alt,
presentationTimestampUs,
containerClassName,
mediaClassName,
active,
loop = false,
}: MotionPhotoPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const resetPlaybackPosition = useCallback(
(video: HTMLVideoElement) => {
const startTime = presentationTimestampUs && presentationTimestampUs > 0n ? Number(presentationTimestampUs) / 1_000_000 : 0;
video.currentTime = startTime;
},
[presentationTimestampUs],
);
const stopPlayback = useCallback(
(resetPosition = true) => {
const video = videoRef.current;
if (!video) {
return;
}
video.pause();
if (resetPosition && video.readyState >= 1) {
resetPlaybackPosition(video);
}
setIsPlaying(false);
},
[resetPlaybackPosition],
);
const startPlayback = useCallback(
async (loop: boolean) => {
const video = videoRef.current;
if (!video) {
return;
}
video.loop = loop;
if (video.readyState >= 1) {
resetPlaybackPosition(video);
}
try {
await video.play();
setIsPlaying(true);
} catch {
setIsPlaying(false);
}
},
[resetPlaybackPosition],
);
useEffect(() => stopPlayback, [stopPlayback]);
useEffect(() => {
if (!active) {
stopPlayback();
return;
}
void startPlayback(loop);
}, [active, loop, startPlayback, stopPlayback]);
return (
<div className={cn("relative overflow-hidden", containerClassName)}>
<img
src={posterUrl}
alt={alt}
className={cn("block max-h-full max-w-full select-none object-cover", mediaClassName)}
draggable={false}
loading="lazy"
decoding="async"
/>
<video
ref={videoRef}
src={motionUrl}
poster={posterUrl}
className={cn(
"pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200",
isPlaying ? "opacity-100" : "opacity-0",
mediaClassName,
)}
muted
playsInline
preload="metadata"
disableRemotePlayback
onLoadedMetadata={(event) => resetPlaybackPosition(event.currentTarget)}
onEnded={() => stopPlayback()}
/>
</div>
);
};
export default MotionPhotoPlayer;

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import MotionPhotoPlayer from "@/components/MotionPhotoPlayer";
import { cn } from "@/lib/utils";
interface MotionPhotoPreviewProps {
posterUrl: string;
motionUrl: string;
alt: string;
presentationTimestampUs?: bigint;
containerClassName?: string;
mediaClassName?: string;
badgeClassName?: string;
loop?: boolean;
}
const MotionPhotoPreview = ({
posterUrl,
motionUrl,
alt,
presentationTimestampUs,
containerClassName,
mediaClassName,
badgeClassName,
loop = false,
}: MotionPhotoPreviewProps) => {
const [motionActive, setMotionActive] = useState(false);
useEffect(() => {
setMotionActive(false);
}, [motionUrl, posterUrl]);
return (
<div className={cn("relative max-w-full max-h-full", containerClassName)}>
<MotionPhotoPlayer
posterUrl={posterUrl}
motionUrl={motionUrl}
alt={alt}
presentationTimestampUs={presentationTimestampUs}
active={motionActive}
loop={loop}
containerClassName={cn("max-w-full max-h-full", containerClassName)}
mediaClassName={mediaClassName}
/>
<button
type="button"
className={cn(
"absolute rounded-full border border-border/45 bg-background/65 px-2.5 py-1 text-xs font-semibold tracking-wide text-foreground backdrop-blur-sm transition-colors hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
badgeClassName,
)}
onMouseEnter={() => setMotionActive(true)}
onMouseLeave={() => setMotionActive(false)}
onFocus={() => setMotionActive(true)}
onBlur={() => setMotionActive(false)}
onPointerDown={(event) => {
event.stopPropagation();
if (event.pointerType !== "mouse") {
setMotionActive(true);
}
}}
onPointerUp={(event) => {
event.stopPropagation();
if (event.pointerType !== "mouse") {
setMotionActive(false);
}
}}
onPointerCancel={() => setMotionActive(false)}
aria-label="Hover or press to play live photo"
>
LIVE
</button>
</div>
);
};
export default MotionPhotoPreview;

View File

@ -1,5 +1,6 @@
import { X } from "lucide-react";
import React, { useEffect, useState } from "react";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { PreviewMediaItem } from "@/utils/media-item";
@ -15,8 +16,7 @@ interface Props {
function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const previewItems =
items ??
imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image", isMotion: false }));
items ?? imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image" }));
// Update current index when initialIndex prop changes
useEffect(() => {
@ -93,11 +93,16 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
className="max-w-full max-h-full object-contain"
controls
autoPlay
onLoadedMetadata={(event) => {
if (currentItem.presentationTimestampUs && currentItem.presentationTimestampUs > 0n) {
event.currentTarget.currentTime = Number(currentItem.presentationTimestampUs) / 1_000_000;
}
}}
/>
) : currentItem.kind === "motion" ? (
<MotionPhotoPreview
key={currentItem.id}
posterUrl={currentItem.posterUrl}
motionUrl={currentItem.motionUrl}
alt={`Preview live photo ${safeIndex + 1} of ${previewItems.length}`}
presentationTimestampUs={currentItem.presentationTimestampUs}
badgeClassName="left-4 top-4"
mediaClassName="max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] object-contain sm:max-h-[calc(100vh-4rem)] sm:max-w-[calc(100vw-4rem)]"
/>
) : (
<img
@ -113,7 +118,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
{/* Screen reader description */}
<div id="image-preview-description" className="sr-only">
Image preview dialog. Press Escape to close or click outside the image.
Attachment preview dialog. Press Escape to close or click outside the media.
</div>
</DialogContent>
</Dialog>

View File

@ -11,16 +11,32 @@ import {
isMotionAttachment,
} from "./attachment";
export interface PreviewMediaItem {
interface PreviewMediaItemBase {
id: string;
kind: "image" | "video";
filename: string;
}
export interface ImagePreviewMediaItem extends PreviewMediaItemBase {
kind: "image";
sourceUrl: string;
posterUrl?: string;
filename: string;
isMotion: boolean;
}
export interface VideoPreviewMediaItem extends PreviewMediaItemBase {
kind: "video";
sourceUrl: string;
posterUrl?: string;
}
export interface MotionPreviewMediaItem extends PreviewMediaItemBase {
kind: "motion";
posterUrl: string;
motionUrl: string;
presentationTimestampUs?: bigint;
}
export type PreviewMediaItem = ImagePreviewMediaItem | VideoPreviewMediaItem | MotionPreviewMediaItem;
export interface AttachmentVisualItem {
id: string;
kind: "image" | "video" | "motion";
@ -115,7 +131,6 @@ function buildSingleAttachmentItem(attachment: Attachment): AttachmentVisualItem
sourceUrl,
posterUrl,
filename: attachment.filename,
isMotion: false,
},
mimeType: attachment.type,
};
@ -135,11 +150,10 @@ function buildAppleMotionItem(still: Attachment, video: Attachment): AttachmentV
attachments: [still, video],
previewItem: {
id: getAttachmentMotionGroupId(still) ?? still.name,
kind: "video",
sourceUrl,
kind: "motion",
posterUrl,
motionUrl: sourceUrl,
filename: still.filename,
isMotion: true,
},
mimeType: still.type,
};
@ -156,11 +170,10 @@ function buildAndroidMotionItem(attachment: Attachment): AttachmentVisualItem {
attachments: [attachment],
previewItem: {
id: attachment.name,
kind: "video",
sourceUrl: getAttachmentMotionClipUrl(attachment),
kind: "motion",
motionUrl: getAttachmentMotionClipUrl(attachment),
posterUrl: getAttachmentThumbnailUrl(attachment),
filename: attachment.filename,
isMotion: true,
presentationTimestampUs: attachment.motionMedia?.presentationTimestampUs,
},
mimeType: attachment.type,