diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx index 944cf877a..37f96e009 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx @@ -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 = () => ( - - LIVE - -); - 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" ? ( + ) : ( )} -
- {item.kind === "motion" && } - {item.previewItem.kind === "video" && ( +
+ {item.kind === "video" && ( diff --git a/web/src/components/MemoView/hooks/useImagePreview.ts b/web/src/components/MemoView/hooks/useImagePreview.ts index 75fcb84fe..49fd9de38 100644 --- a/web/src/components/MemoView/hooks/useImagePreview.ts +++ b/web/src/components/MemoView/hooks/useImagePreview.ts @@ -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, })); } diff --git a/web/src/components/MotionPhotoPlayer.tsx b/web/src/components/MotionPhotoPlayer.tsx new file mode 100644 index 000000000..d1c416688 --- /dev/null +++ b/web/src/components/MotionPhotoPlayer.tsx @@ -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(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 ( +
+ {alt} +
+ ); +}; + +export default MotionPhotoPlayer; diff --git a/web/src/components/MotionPhotoPreview.tsx b/web/src/components/MotionPhotoPreview.tsx new file mode 100644 index 000000000..f3627b8ca --- /dev/null +++ b/web/src/components/MotionPhotoPreview.tsx @@ -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 ( +
+ + +
+ ); +}; + +export default MotionPhotoPreview; diff --git a/web/src/components/PreviewImageDialog.tsx b/web/src/components/PreviewImageDialog.tsx index f34169da3..3e6c8b459 100644 --- a/web/src/components/PreviewImageDialog.tsx +++ b/web/src/components/PreviewImageDialog.tsx @@ -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" ? ( + ) : ( - Image preview dialog. Press Escape to close or click outside the image. + Attachment preview dialog. Press Escape to close or click outside the media.
diff --git a/web/src/utils/media-item.ts b/web/src/utils/media-item.ts index bf744c00b..e0487fb1f 100644 --- a/web/src/utils/media-item.ts +++ b/web/src/utils/media-item.ts @@ -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,