mirror of https://github.com/usememos/memos.git
fix: unify live photo previews around LIVE badge playback
This commit is contained in:
parent
065e817470
commit
6b0487dcd8
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue