From 63a17d897db39ac0713157cd10eec727ae346dec Mon Sep 17 00:00:00 2001 From: memoclaw <265580040+memoclaw@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:03:38 +0800 Subject: [PATCH] refactor: split audio attachment view into reusable components --- .../Attachment/AttachmentListView.tsx | 52 +---- .../Attachment/AudioAttachmentItem.tsx | 178 ++++++++++++++++++ .../Attachment/attachmentViewHelpers.ts | 64 +++++++ 3 files changed, 249 insertions(+), 45 deletions(-) create mode 100644 web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx create mode 100644 web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx index 1e4b3dd98..4ffaa3cef 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx @@ -1,42 +1,20 @@ -import { FileAudioIcon, FileIcon, PaperclipIcon } from "lucide-react"; +import { FileIcon, PaperclipIcon } from "lucide-react"; import { useMemo } from "react"; import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; -import { formatFileSize, getFileTypeLabel } from "@/utils/format"; +import { getAttachmentUrl } from "@/utils/attachment"; import SectionHeader from "../SectionHeader"; import AttachmentCard from "./AttachmentCard"; +import AudioAttachmentItem from "./AudioAttachmentItem"; +import { getAttachmentMetadata, isImageAttachment, separateAttachments } from "./attachmentViewHelpers"; interface AttachmentListViewProps { attachments: Attachment[]; onImagePreview?: (urls: string[], index: number) => void; } -const isImageAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "image/*"; -const isVideoAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "video/*"; -const isAudioAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "audio/*"; - -const separateAttachments = (attachments: Attachment[]) => { - const visual: Attachment[] = []; - const audio: Attachment[] = []; - const docs: Attachment[] = []; - - for (const attachment of attachments) { - if (isImageAttachment(attachment) || isVideoAttachment(attachment)) { - visual.push(attachment); - } else if (isAudioAttachment(attachment)) { - audio.push(attachment); - } else { - docs.push(attachment); - } - } - - return { visual, audio, docs }; -}; - const DocumentItem = ({ attachment }: { attachment: Attachment }) => { - const fileTypeLabel = getFileTypeLabel(attachment.type); - const fileSizeLabel = attachment.size ? formatFileSize(Number(attachment.size)) : undefined; + const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment); return (
@@ -62,22 +40,6 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => { ); }; -const AudioItem = ({ attachment }: { attachment: Attachment }) => { - const sourceUrl = getAttachmentUrl(attachment); - - return ( -
-
- - - {attachment.filename} - -
-
- ); -}; - interface VisualItemProps { attachment: Attachment; onImageClick?: (url: string) => void; @@ -114,9 +76,9 @@ const VisualGrid = ({ attachments, onImageClick }: { attachments: Attachment[]; ); const AudioList = ({ attachments }: { attachments: Attachment[] }) => ( -
+
{attachments.map((attachment) => ( - + ))}
); diff --git a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx new file mode 100644 index 000000000..4dc9f241e --- /dev/null +++ b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx @@ -0,0 +1,178 @@ +import { FileAudioIcon, PauseIcon, PlayIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { getAttachmentUrl } from "@/utils/attachment"; +import { formatAudioTime, getAttachmentMetadata } from "./attachmentViewHelpers"; + +const AUDIO_PLAYBACK_RATES = [1, 1.5, 2] as const; + +interface AudioProgressBarProps { + attachment: Attachment; + currentTime: number; + duration: number; + progressPercent: number; + onSeek: (value: string) => void; +} + +const AudioProgressBar = ({ attachment, currentTime, duration, progressPercent, onSeek }: AudioProgressBarProps) => ( +
+
+
+
+ onSeek(e.target.value)} + aria-label={`Seek ${attachment.filename}`} + className="relative z-10 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-default + [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full + [&::-webkit-slider-runnable-track]:bg-transparent + [&::-webkit-slider-thumb]:mt-[-3px] [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border/50 + [&::-webkit-slider-thumb]:bg-background/95 + [&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent + [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border + [&::-moz-range-thumb]:border-border/50 [&::-moz-range-thumb]:bg-background/95" + disabled={duration === 0} + /> +
+
+ {formatAudioTime(currentTime)} / {duration > 0 ? formatAudioTime(duration) : "--:--"} +
+
+); + +const AudioAttachmentItem = ({ attachment }: { attachment: Attachment }) => { + const sourceUrl = getAttachmentUrl(attachment); + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [playbackRate, setPlaybackRate] = useState<(typeof AUDIO_PLAYBACK_RATES)[number]>(1); + const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment); + const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + + useEffect(() => { + if (!audioRef.current) { + return; + } + + audioRef.current.playbackRate = playbackRate; + }, [playbackRate]); + + const togglePlayback = async () => { + const audio = audioRef.current; + + if (!audio) { + return; + } + + if (audio.paused) { + try { + await audio.play(); + } catch { + setIsPlaying(false); + } + return; + } + + audio.pause(); + }; + + const handleSeek = (value: string) => { + const audio = audioRef.current; + const nextTime = Number(value); + + if (!audio || Number.isNaN(nextTime)) { + return; + } + + audio.currentTime = nextTime; + setCurrentTime(nextTime); + }; + + const handlePlaybackRateChange = () => { + const currentRateIndex = AUDIO_PLAYBACK_RATES.findIndex((rate) => rate === playbackRate); + const nextRate = AUDIO_PLAYBACK_RATES[(currentRateIndex + 1) % AUDIO_PLAYBACK_RATES.length]; + setPlaybackRate(nextRate); + }; + + const handleDuration = (value: number) => { + setDuration(Number.isFinite(value) ? value : 0); + }; + + return ( +
+
+
+ +
+ +
+
+
+ {attachment.filename} +
+
+ {fileTypeLabel} + {fileSizeLabel && ( + <> + + {fileSizeLabel} + + )} +
+
+ +
+ + +
+
+
+ + + +
+ ); +}; + +export default AudioAttachmentItem; diff --git a/web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts b/web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts new file mode 100644 index 000000000..40c04d8ab --- /dev/null +++ b/web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts @@ -0,0 +1,64 @@ +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { getAttachmentType } from "@/utils/attachment"; +import { formatFileSize, getFileTypeLabel } from "@/utils/format"; + +export interface AttachmentGroups { + visual: Attachment[]; + audio: Attachment[]; + docs: Attachment[]; +} + +export interface AttachmentMetadata { + fileTypeLabel: string; + fileSizeLabel?: string; +} + +export const isImageAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "image/*"; +export const isVideoAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "video/*"; +export const isAudioAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "audio/*"; + +export const separateAttachments = (attachments: Attachment[]): AttachmentGroups => { + const groups: AttachmentGroups = { + visual: [], + audio: [], + docs: [], + }; + + for (const attachment of attachments) { + if (isImageAttachment(attachment) || isVideoAttachment(attachment)) { + groups.visual.push(attachment); + continue; + } + + if (isAudioAttachment(attachment)) { + groups.audio.push(attachment); + continue; + } + + groups.docs.push(attachment); + } + + return groups; +}; + +export const getAttachmentMetadata = (attachment: Attachment): AttachmentMetadata => ({ + fileTypeLabel: getFileTypeLabel(attachment.type), + fileSizeLabel: attachment.size ? formatFileSize(Number(attachment.size)) : undefined, +}); + +export const formatAudioTime = (seconds: number): string => { + if (!Number.isFinite(seconds) || seconds < 0) { + return "0:00"; + } + + const rounded = Math.floor(seconds); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const secs = rounded % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + } + + return `${minutes}:${secs.toString().padStart(2, "0")}`; +};