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