Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
152 changes: 152 additions & 0 deletions web/src/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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;

// Reset state when src changes
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);

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);
};
}, [src]);

useEffect(() => {
const handlePlayAudio = (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail !== audioRef.current && isPlaying) {
audioRef.current?.pause();
setIsPlaying(false);
}
};

document.addEventListener("play-audio", handlePlayAudio);
return () => {
document.removeEventListener("play-audio", handlePlayAudio);
};
}, [isPlaying]);

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

if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
try {
// Stop other audio players
const event = new CustomEvent("play-audio", { detail: audio });
document.dispatchEvent(event);

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" />
<Button
variant="ghost"
size="sm"
onClick={togglePlayPause}
disabled={isLoading}
className="shrink-0 p-0 h-5 w-5 hover:bg-transparent text-muted-foreground hover:text-foreground"
aria-label={isPlaying ? "Pause audio" : "Play audio"}
>
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
</Button>
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleSeek}
disabled={isLoading || !duration}
className="w-full min-w-[128px] h-1 rounded-md bg-secondary cursor-pointer appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full"
aria-label="Seek audio position"
/>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</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
150 changes: 115 additions & 35 deletions web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
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,
PauseIcon,
PlayIcon,
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 +26,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 +67,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 +128,107 @@ 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.message || "Failed to upload audio recording");
}
};

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"
aria-label={audioRecorder.isPaused ? "Resume recording" : "Pause recording"}
>
{audioRecorder.isPaused ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
</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 text-red-600 hover:text-red-700"
aria-label="Cancel recording"
>
<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