返回 JoyAI-Echo
CompactAudioPlayer.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / thread / CompactAudioPlayer.tsx
1 import { Pause, Play, Volume2, VolumeX } from "lucide-react";
2 import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
3
4 import styles from "./CompactAudioPlayer.module.css";
5
6 interface CompactAudioPlayerProps {
7 src: string;
8 label: string;
9 className?: string;
10 }
11
12 export function CompactAudioPlayer({
13 src,
14 label,
15 className,
16 }: CompactAudioPlayerProps) {
17 const audioRef = useRef<HTMLAudioElement>(null);
18 const rootRef = useRef<HTMLDivElement>(null);
19 const volumePanelId = useId();
20
21 const [playing, setPlaying] = useState(false);
22 const [progress, setProgress] = useState(0);
23 const [duration, setDuration] = useState(0);
24 const [volume, setVolume] = useState(1);
25 const [muted, setMuted] = useState(false);
26 const [volumeOpen, setVolumeOpen] = useState(false);
27
28 useEffect(() => {
29 const audio = audioRef.current;
30 if (!audio) return;
31
32 const onTimeUpdate = () => {
33 setProgress(audio.currentTime);
34 setDuration(audio.duration || 0);
35 };
36 const onLoaded = () => setDuration(audio.duration || 0);
37 const onPlay = () => setPlaying(true);
38 const onPause = () => setPlaying(false);
39 const onEnded = () => {
40 setPlaying(false);
41 setProgress(0);
42 };
43
44 audio.addEventListener("timeupdate", onTimeUpdate);
45 audio.addEventListener("loadedmetadata", onLoaded);
46 audio.addEventListener("durationchange", onLoaded);
47 audio.addEventListener("play", onPlay);
48 audio.addEventListener("pause", onPause);
49 audio.addEventListener("ended", onEnded);
50
51 return () => {
52 audio.removeEventListener("timeupdate", onTimeUpdate);
53 audio.removeEventListener("loadedmetadata", onLoaded);
54 audio.removeEventListener("durationchange", onLoaded);
55 audio.removeEventListener("play", onPlay);
56 audio.removeEventListener("pause", onPause);
57 audio.removeEventListener("ended", onEnded);
58 };
59 }, [src]);
60
61 useEffect(() => {
62 if (!volumeOpen) return;
63 const onPointerDown = (event: PointerEvent) => {
64 if (!rootRef.current?.contains(event.target as Node)) {
65 setVolumeOpen(false);
66 }
67 };
68 document.addEventListener("pointerdown", onPointerDown);
69 return () => document.removeEventListener("pointerdown", onPointerDown);
70 }, [volumeOpen]);
71
72 useEffect(() => {
73 const audio = audioRef.current;
74 if (!audio) return;
75 audio.volume = muted ? 0 : volume;
76 }, [muted, volume]);
77
78 const togglePlay = async () => {
79 const audio = audioRef.current;
80 if (!audio) return;
81 if (audio.paused) {
82 try {
83 await audio.play();
84 } catch {
85 /* autoplay / decode failures are ignored */
86 }
87 } else {
88 audio.pause();
89 }
90 };
91
92 const seek = (value: number) => {
93 const audio = audioRef.current;
94 if (!audio || !Number.isFinite(duration) || duration <= 0) return;
95 audio.currentTime = value;
96 setProgress(value);
97 };
98
99 const effectiveVolume = muted ? 0 : volume;
100 const progressPct =
101 duration > 0 ? Math.min(100, Math.max(0, (progress / duration) * 100)) : 0;
102
103 return (
104 <div
105 ref={rootRef}
106 className={[styles.player, className].filter(Boolean).join(" ")}
107 style={
108 {
109 "--progress": `${progressPct}%`,
110 "--volume": `${effectiveVolume * 100}%`,
111 } as CSSProperties
112 }
113 >
114 <audio ref={audioRef} preload="metadata" src={src} aria-label={label} />
115
116 <button
117 type="button"
118 onClick={togglePlay}
119 aria-label={playing ? "Pause" : "Play"}
120 className={styles.iconBtn}
121 >
122 {playing ? (
123 <Pause className="size-3.5 fill-current" aria-hidden />
124 ) : (
125 <Play className="size-3.5 fill-current" aria-hidden />
126 )}
127 </button>
128
129 <input
130 type="range"
131 min={0}
132 max={duration || 0}
133 step={0.01}
134 value={duration > 0 ? progress : 0}
135 disabled={!duration}
136 onChange={(e) => seek(Number(e.target.value))}
137 aria-label="Playback progress"
138 className={styles.progress}
139 />
140
141 <div className={styles.volumeWrap}>
142 {volumeOpen ? (
143 <div
144 id={volumePanelId}
145 role="dialog"
146 aria-label="Volume"
147 className={styles.volumePanel}
148 >
149 <input
150 type="range"
151 min={0}
152 max={1}
153 step={0.01}
154 value={effectiveVolume}
155 onChange={(e) => {
156 const next = Number(e.target.value);
157 setVolume(next);
158 setMuted(next === 0);
159 }}
160 aria-label="Adjust volume"
161 className={styles.volumeSlider}
162 />
163 </div>
164 ) : null}
165
166 <button
167 type="button"
168 aria-label="Volume"
169 aria-expanded={volumeOpen}
170 aria-controls={volumeOpen ? volumePanelId : undefined}
171 onClick={() => setVolumeOpen((open) => !open)}
172 className={styles.iconBtn}
173 >
174 {effectiveVolume === 0 ? (
175 <VolumeX className="size-3.5" aria-hidden />
176 ) : (
177 <Volume2 className="size-3.5" aria-hidden />
178 )}
179 </button>
180 </div>
181 </div>
182 );
183 }
184
184 lines Plain Text