From bbc82f9fe52d8cb423bd1612b3d353cd11bfb95b Mon Sep 17 00:00:00 2001 From: TheNexter Date: Tue, 18 Nov 2025 18:32:27 +0100 Subject: [PATCH] Custom audio player that match design of memos --- web/src/components/AudioPlayer.tsx | 121 ++++++++++++++++++++++++++ web/src/components/MemoAttachment.tsx | 3 +- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 web/src/components/AudioPlayer.tsx diff --git a/web/src/components/AudioPlayer.tsx b/web/src/components/AudioPlayer.tsx new file mode 100644 index 000000000..59590fece --- /dev/null +++ b/web/src/components/AudioPlayer.tsx @@ -0,0 +1,121 @@ +import { PauseIcon, PlayIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +interface Props { + src: string; + className?: string; +} + +const AudioPlayer = ({ src, className = "" }: Props) => { + const audioRef = useRef(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 = () => { + setDuration(audio.duration); + setIsLoading(false); + }; + + const handleTimeUpdate = () => { + setCurrentTime(audio.currentTime); + }; + + 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) && audio.duration !== Infinity) { + 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); + }; + }, []); + + const togglePlayPause = () => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + } else { + audio.play(); + } + setIsPlaying(!isPlaying); + }; + + const handleSeek = (e: React.ChangeEvent) => { + 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 ( +
+
+ ); +}; + +export default AudioPlayer; diff --git a/web/src/components/MemoAttachment.tsx b/web/src/components/MemoAttachment.tsx index 2c6e318b5..1833614b3 100644 --- a/web/src/components/MemoAttachment.tsx +++ b/web/src/components/MemoAttachment.tsx @@ -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; @@ -20,7 +21,7 @@ const MemoAttachment: React.FC = (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) ? ( - + ) : ( <>