Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions web/src/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { PauseIcon, PlayIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";

interface Props {
src: string;
className?: string;
}

const AudioPlayer = ({ src, className = "" }: Props) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const audio = audioRef.current;
if (!audio) return;

const handleLoadedMetadata = () => {
if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
setDuration(audio.duration);
}
setIsLoading(false);
};

const handleTimeUpdate = () => {
setCurrentTime(audio.currentTime);
if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
setDuration((prev) => (prev === 0 ? audio.duration : prev));
}
};

const handleEnded = () => {
setIsPlaying(false);
setCurrentTime(0);
};

const handleLoadedData = () => {
// For files without proper duration in metadata,
// try to get it after some data is loaded
if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
setDuration(audio.duration);
setIsLoading(false);
}
};

audio.addEventListener("loadedmetadata", handleLoadedMetadata);
audio.addEventListener("loadeddata", handleLoadedData);
audio.addEventListener("timeupdate", handleTimeUpdate);
audio.addEventListener("ended", handleEnded);

return () => {
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
audio.removeEventListener("loadeddata", handleLoadedData);
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.removeEventListener("ended", handleEnded);
};
}, []);
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect hook doesn't include src in its dependency array. When the audio source changes, the old event listeners remain attached to the audio element without proper cleanup. This can cause stale state updates and memory leaks. Add src to the dependency array or clean up and re-attach listeners when src changes.

Suggested change
}, []);
}, [src]);

Copilot uses AI. Check for mistakes.

const togglePlayPause = async () => {
const audio = audioRef.current;
if (!audio) return;

if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
try {
await audio.play();
setIsPlaying(true);
} catch (error) {
console.error("Failed to play audio:", error);
setIsPlaying(false);
}
}
};

const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;

const newTime = parseFloat(e.target.value);
audio.currentTime = newTime;
setCurrentTime(newTime);
};

const formatTime = (time: number): string => {
if (!isFinite(time) || isNaN(time)) return "0:00";

const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};

return (
<div className={`flex items-center gap-2 ${className}`}>
<audio ref={audioRef} src={src} preload="metadata" />

<div className="flex flex-row items-center px-2 py-1 rounded-md text-secondary-foreground gap-2">
<span className="font-mono text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<Button
variant="ghost"
size="sm"
onClick={togglePlayPause}
disabled={isLoading}
className="shrink-0 h-auto w-auto p-0.5 hover:bg-background/50"
>
{isPlaying ? <PauseIcon className="w-4 h-4" /> : <PlayIcon className="w-4 h-4" />}
</Button>
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleSeek}
disabled={isLoading || !duration}
className="flex-1 h-1 bg-muted hover:bg-background/50 rounded-lg appearance-none cursor-pointer disabled:opacity-50 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The range input has extremely long className with complex styling that makes it hard to read and maintain. Consider extracting this to a separate CSS class or using a more maintainable approach with CSS modules or styled components.

Copilot uses AI. Check for mistakes.
/>
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The audio player controls lack accessible labels. The play/pause button and seek slider need aria-label attributes for screen reader users. Add labels like "Play audio" / "Pause audio" for the button and "Seek audio position" for the range input.

Copilot uses AI. Check for mistakes.
</div>
</div>
);
};

export default AudioPlayer;
3 changes: 2 additions & 1 deletion web/src/components/MemoAttachment.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentUrl, isMidiFile } from "@/utils/attachment";
import AttachmentIcon from "./AttachmentIcon";
import AudioPlayer from "./AudioPlayer";

interface Props {
attachment: Attachment;
Expand All @@ -20,7 +21,7 @@ const MemoAttachment: React.FC<Props> = (props: Props) => {
className={`w-auto flex flex-row justify-start items-center text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors ${className}`}
>
{attachment.type.startsWith("audio") && !isMidiFile(attachment.type) ? (
<audio src={attachmentUrl} controls></audio>
<AudioPlayer src={attachmentUrl} />
) : (
<>
<AttachmentIcon className="w-4! h-4! mr-1" attachment={attachment} />
Expand Down
120 changes: 85 additions & 35 deletions web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MicIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
Expand All @@ -13,12 +14,14 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { attachmentStore } from "@/store";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { MemoEditorContext } from "../types";
import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog";
import { LocationDialog } from "./InsertMenu/LocationDialog";
import { useAudioRecorder } from "./InsertMenu/useAudioRecorder";
import { useFileUpload } from "./InsertMenu/useFileUpload";
import { useLinkMemo } from "./InsertMenu/useLinkMemo";
import { useLocation } from "./InsertMenu/useLocation";
Expand Down Expand Up @@ -52,6 +55,7 @@ const InsertMenu = observer((props: Props) => {
});

const location = useLocation(props.location);
const audioRecorder = useAudioRecorder();

Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the component unmounts or the recording UI is hidden while recording is in progress, the media stream and timer are not cleaned up. This will leave the microphone active and the timer running. Add cleanup logic in a useEffect cleanup function to call cancelRecording when the component unmounts.

Suggested change
// Cleanup media stream and timer when component unmounts
useEffect(() => {
return () => {
if (audioRecorder && typeof audioRecorder.cancelRecording === "function") {
audioRecorder.cancelRecording();
}
};
}, [audioRecorder]);

Copilot uses AI. Check for mistakes.
const isUploading = uploadingFlag || props.isUploading;

Expand Down Expand Up @@ -112,43 +116,89 @@ const InsertMenu = observer((props: Props) => {
});
};

const handleStopRecording = async () => {
try {
const blob = await audioRecorder.stopRecording();
const filename = `recording-${Date.now()}.webm`;
const file = new File([blob], filename, { type: "audio/webm" });
const { name, size, type } = file;
const buffer = new Uint8Array(await file.arrayBuffer());

const attachment = await attachmentStore.createAttachment({
attachment: Attachment.fromPartial({
filename: name,
size,
type,
content: buffer,
}),
attachmentId: "",
});
context.setAttachmentList([...context.attachmentList, attachment]);
} catch (error: any) {
console.error("Failed to upload audio recording:", error);
toast.error(error.details || "Failed to upload audio recording");
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message fallback uses error.details which is not a standard Error property in JavaScript. Standard Error objects have message property. Consider using error.message || "Failed to upload audio recording" for proper error handling.

Suggested change
toast.error(error.details || "Failed to upload audio recording");
toast.error(error.message || "Failed to upload audio recording");

Copilot uses AI. Check for mistakes.
}
};

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
{audioRecorder.isRecording ? (
<div className="flex flex-row items-center gap-2 mr-2">
<div className="flex flex-row items-center px-2 py-1 rounded-md bg-red-50 text-red-600 border border-red-200">
<div className={`w-2 h-2 rounded-full bg-red-500 mr-2 ${!audioRecorder.isPaused ? "animate-pulse" : ""}`} />
<span className="font-mono text-sm">{new Date(audioRecorder.recordingTime * 1000).toISOString().substring(14, 19)}</span>
</div>
<Button variant="outline" size="icon" onClick={audioRecorder.togglePause} className="shrink-0">
{audioRecorder.isPaused ? <MicIcon className="w-4 h-4" /> : <span className="font-bold text-xs">||</span>}
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pause button icon uses a hardcoded double pipe string "||" instead of a proper Pause icon component. This is inconsistent with the rest of the UI which uses Lucide icons. Consider using PauseIcon which is already imported.

Copilot uses AI. Check for mistakes.
</Button>
<Button variant="outline" size="icon" onClick={handleStopRecording} className="shrink-0 text-red-600 hover:text-red-700">
<div className="w-3 h-3 bg-current rounded-sm" />
</Button>
<Button variant="ghost" size="icon" onClick={audioRecorder.cancelRecording} className="shrink-0">
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The cancel recording button lacks clear visual indication of its destructive action. Consider adding text-red-600 or similar styling to match the stop button's indication that it's a cancel/delete action, improving user understanding of the button's purpose.

Suggested change
<Button variant="ghost" size="icon" onClick={audioRecorder.cancelRecording} className="shrink-0">
<Button variant="ghost" size="icon" onClick={audioRecorder.cancelRecording} className="shrink-0 text-red-600 hover:text-red-700">

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recording control buttons lack accessible labels. Screen reader users won't know what the pause, stop, and cancel buttons do. Add aria-label attributes to each button to describe their purpose (e.g., "Pause recording", "Stop and save recording", "Cancel recording").

Suggested change
<Button variant="outline" size="icon" onClick={audioRecorder.togglePause} className="shrink-0">
{audioRecorder.isPaused ? <MicIcon className="w-4 h-4" /> : <span className="font-bold text-xs">||</span>}
</Button>
<Button variant="outline" size="icon" onClick={handleStopRecording} className="shrink-0 text-red-600 hover:text-red-700">
<div className="w-3 h-3 bg-current rounded-sm" />
</Button>
<Button variant="ghost" size="icon" onClick={audioRecorder.cancelRecording} className="shrink-0">
<Button
variant="outline"
size="icon"
onClick={audioRecorder.togglePause}
className="shrink-0"
aria-label={audioRecorder.isPaused ? "Resume recording" : "Pause recording"}
>
{audioRecorder.isPaused ? <MicIcon className="w-4 h-4" /> : <span className="font-bold text-xs">||</span>}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleStopRecording}
className="shrink-0 text-red-600 hover:text-red-700"
aria-label="Stop and save recording"
>
<div className="w-3 h-3 bg-current rounded-sm" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={audioRecorder.cancelRecording}
className="shrink-0"
aria-label="Cancel recording"
>

Copilot uses AI. Check for mistakes.
<XIcon className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleUploadClick}>
<FileIcon className="w-4 h-4" />
{t("common.upload")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLinkDialogOpen(true)}>
<LinkIcon className="w-4 h-4" />
{t("tooltip.link-memo")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLocationClick}>
<MapPinIcon className="w-4 h-4" />
{t("tooltip.select-location")}
</DropdownMenuItem>
{/* View submenu with Focus Mode */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="w-4 h-4" />
{t("common.more")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={props.onToggleFocusMode}>
<Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")}
<span className="ml-auto text-xs text-muted-foreground opacity-60">⌘⇧F</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleUploadClick}>
<FileIcon className="w-4 h-4" />
{t("common.upload")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLinkDialogOpen(true)}>
<LinkIcon className="w-4 h-4" />
{t("tooltip.link-memo")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLocationClick}>
<MapPinIcon className="w-4 h-4" />
{t("tooltip.select-location")}
</DropdownMenuItem>
<DropdownMenuItem onClick={audioRecorder.startRecording}>
<MicIcon className="w-4 h-4" />
{t("tooltip.record-audio")}
</DropdownMenuItem>
{/* View submenu with Focus Mode */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="w-4 h-4" />
{t("common.more")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={props.onToggleFocusMode}>
<Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")}
<span className="ml-auto text-xs text-muted-foreground opacity-60">⌘⇧F</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)}

{/* Hidden file input */}
<input
Expand Down
Loading
Loading