Commit 6b0487dc authored by boojack's avatar boojack

fix: unify live photo previews around LIVE badge playback

parent 065e8174
import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react"; import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection"; import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentUrl } from "@/utils/attachment";
...@@ -49,12 +50,6 @@ const DocumentItem = ({ attachment }: { attachment: 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 = ({ const MotionItem = ({
item, item,
featured = false, featured = false,
...@@ -82,6 +77,16 @@ const MotionItem = ({ ...@@ -82,6 +77,16 @@ const MotionItem = ({
className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]" className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
preload="metadata" 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 <img
src={item.posterUrl} src={item.posterUrl}
...@@ -91,9 +96,8 @@ const MotionItem = ({ ...@@ -91,9 +96,8 @@ const MotionItem = ({
decoding="async" 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" /> <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 === "motion" && <MotionBadge />} {item.kind === "video" && (
{item.previewItem.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"> <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" /> <PlayIcon className="h-3.5 w-3.5 fill-current" />
</span> </span>
......
...@@ -37,7 +37,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P ...@@ -37,7 +37,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
sourceUrl: items, sourceUrl: items,
posterUrl: items, posterUrl: items,
filename: "Image", filename: "Image",
isMotion: false,
}, },
]; ];
} }
...@@ -49,7 +48,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P ...@@ -49,7 +48,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
sourceUrl: url, sourceUrl: url,
posterUrl: url, posterUrl: url,
filename: "Image", filename: "Image",
isMotion: false,
})); }));
} }
......
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;
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;
import { X } from "lucide-react"; import { X } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { PreviewMediaItem } from "@/utils/media-item"; import type { PreviewMediaItem } from "@/utils/media-item";
...@@ -15,8 +16,7 @@ interface Props { ...@@ -15,8 +16,7 @@ interface Props {
function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) { function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const previewItems = const previewItems =
items ?? items ?? imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image" }));
imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image", isMotion: false }));
// Update current index when initialIndex prop changes // Update current index when initialIndex prop changes
useEffect(() => { useEffect(() => {
...@@ -93,11 +93,16 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn ...@@ -93,11 +93,16 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain"
controls controls
autoPlay autoPlay
onLoadedMetadata={(event) => { />
if (currentItem.presentationTimestampUs && currentItem.presentationTimestampUs > 0n) { ) : currentItem.kind === "motion" ? (
event.currentTarget.currentTime = Number(currentItem.presentationTimestampUs) / 1_000_000; <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 <img
...@@ -113,7 +118,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn ...@@ -113,7 +118,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
{/* Screen reader description */} {/* Screen reader description */}
<div id="image-preview-description" className="sr-only"> <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> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
......
...@@ -11,16 +11,32 @@ import { ...@@ -11,16 +11,32 @@ import {
isMotionAttachment, isMotionAttachment,
} from "./attachment"; } from "./attachment";
export interface PreviewMediaItem { interface PreviewMediaItemBase {
id: string; id: string;
kind: "image" | "video"; filename: string;
}
export interface ImagePreviewMediaItem extends PreviewMediaItemBase {
kind: "image";
sourceUrl: string; sourceUrl: string;
posterUrl?: 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; presentationTimestampUs?: bigint;
} }
export type PreviewMediaItem = ImagePreviewMediaItem | VideoPreviewMediaItem | MotionPreviewMediaItem;
export interface AttachmentVisualItem { export interface AttachmentVisualItem {
id: string; id: string;
kind: "image" | "video" | "motion"; kind: "image" | "video" | "motion";
...@@ -115,7 +131,6 @@ function buildSingleAttachmentItem(attachment: Attachment): AttachmentVisualItem ...@@ -115,7 +131,6 @@ function buildSingleAttachmentItem(attachment: Attachment): AttachmentVisualItem
sourceUrl, sourceUrl,
posterUrl, posterUrl,
filename: attachment.filename, filename: attachment.filename,
isMotion: false,
}, },
mimeType: attachment.type, mimeType: attachment.type,
}; };
...@@ -135,11 +150,10 @@ function buildAppleMotionItem(still: Attachment, video: Attachment): AttachmentV ...@@ -135,11 +150,10 @@ function buildAppleMotionItem(still: Attachment, video: Attachment): AttachmentV
attachments: [still, video], attachments: [still, video],
previewItem: { previewItem: {
id: getAttachmentMotionGroupId(still) ?? still.name, id: getAttachmentMotionGroupId(still) ?? still.name,
kind: "video", kind: "motion",
sourceUrl,
posterUrl, posterUrl,
motionUrl: sourceUrl,
filename: still.filename, filename: still.filename,
isMotion: true,
}, },
mimeType: still.type, mimeType: still.type,
}; };
...@@ -156,11 +170,10 @@ function buildAndroidMotionItem(attachment: Attachment): AttachmentVisualItem { ...@@ -156,11 +170,10 @@ function buildAndroidMotionItem(attachment: Attachment): AttachmentVisualItem {
attachments: [attachment], attachments: [attachment],
previewItem: { previewItem: {
id: attachment.name, id: attachment.name,
kind: "video", kind: "motion",
sourceUrl: getAttachmentMotionClipUrl(attachment), motionUrl: getAttachmentMotionClipUrl(attachment),
posterUrl: getAttachmentThumbnailUrl(attachment), posterUrl: getAttachmentThumbnailUrl(attachment),
filename: attachment.filename, filename: attachment.filename,
isMotion: true,
presentationTimestampUs: attachment.motionMedia?.presentationTimestampUs, presentationTimestampUs: attachment.motionMedia?.presentationTimestampUs,
}, },
mimeType: attachment.type, mimeType: attachment.type,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment