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 (
+
+

+
+ );
+};
+
+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,