@arronqzy/react-view 1.0.22 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,334 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ } from "react";
7
+ import { useI18n } from "@arronqzy/i18n/react";
8
+ import {
9
+ Checkbox,
10
+ Input,
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "@arronqzy/ui";
17
+ import type { PanelElement } from "../../types";
18
+ import { ConfigHintIcon } from "../ConfigHintIcon";
19
+ import { readFileAsDataUrl, runBusyTask } from "../../utils/async-work";
20
+ import { getPanelMessages } from "../../constants/messages";
21
+ import { type ConfigSectionHelpers, type UpdateElement } from "./helpers";
22
+
23
+ export function PanelConfigAudioSection({
24
+ element,
25
+ helpers,
26
+ updateElement,
27
+ }: {
28
+ element: PanelElement;
29
+ helpers: ConfigSectionHelpers;
30
+ updateElement: UpdateElement;
31
+ }) {
32
+ const { t } = useI18n();
33
+ const messages = React.useMemo(() => getPanelMessages(t), [t]);
34
+ const { renderSection, renderFieldGroup, renderColorField } = helpers;
35
+ const selectedElement = element;
36
+ const [audioStatus, setAudioStatus] = useState("");
37
+ const [isRecordingAudio, setIsRecordingAudio] = useState(false);
38
+ const recorderRef = useRef<MediaRecorder | null>(null);
39
+ const recordStreamRef = useRef<MediaStream | null>(null);
40
+ const audioChunksRef = useRef<BlobPart[]>([]);
41
+
42
+ useEffect(
43
+ () => () => {
44
+ recordStreamRef.current?.getTracks().forEach((track) => track.stop());
45
+ },
46
+ []
47
+ );
48
+
49
+ const updateSelectedAudio = useCallback(
50
+ (patch: Partial<PanelElement>) => {
51
+ if (selectedElement.materialType !== "audio") return;
52
+ updateElement(selectedElement.id, patch);
53
+ },
54
+ [selectedElement, updateElement]
55
+ );
56
+ const handleUploadAudioFile = useCallback(
57
+ async (file: File) => {
58
+ if (selectedElement.materialType !== "audio") return;
59
+ try {
60
+ await runBusyTask(t("common.uploadingFile"), async () => {
61
+ const base64 = await readFileAsDataUrl(file, messages.readAudioFailed, "audio");
62
+ updateSelectedAudio({ audioSrc: base64 });
63
+ setAudioStatus(messages.audioLocalSaved);
64
+ try {
65
+ const form = new FormData();
66
+ form.append("file", file);
67
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
68
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
69
+ const data = (await resp.json()) as { url?: string };
70
+ if (data.url) {
71
+ updateSelectedAudio({ audioRemoteUrl: data.url });
72
+ setAudioStatus(messages.audioRemoteUploaded);
73
+ }
74
+ } catch {
75
+ setAudioStatus(messages.audioServerUploadFailed);
76
+ }
77
+ });
78
+ } catch (error) {
79
+ setAudioStatus(
80
+ error instanceof Error ? error.message : messages.readAudioFailed
81
+ );
82
+ }
83
+ },
84
+ [messages, selectedElement, t, updateSelectedAudio]
85
+ );
86
+ const handleUploadAudioPoster = useCallback(
87
+ async (file: File) => {
88
+ if (selectedElement.materialType !== "audio") return;
89
+ try {
90
+ const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
91
+ updateSelectedAudio({ audioPosterImage: base64 });
92
+ setAudioStatus(messages.audioPosterSet);
93
+ } catch (error) {
94
+ setAudioStatus(
95
+ error instanceof Error ? error.message : messages.readImageFailed
96
+ );
97
+ }
98
+ },
99
+ [messages, selectedElement, updateSelectedAudio]
100
+ );
101
+ const stopRecordingAudio = useCallback(() => {
102
+ recorderRef.current?.stop();
103
+ }, []);
104
+ const startRecordingAudio = useCallback(async () => {
105
+ if (selectedElement.materialType !== "audio") return;
106
+ if (!navigator.mediaDevices?.getUserMedia) {
107
+ setAudioStatus(messages.audioRecordUnsupported);
108
+ return;
109
+ }
110
+ try {
111
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
112
+ recordStreamRef.current = stream;
113
+ const recorder = new MediaRecorder(stream);
114
+ recorderRef.current = recorder;
115
+ audioChunksRef.current = [];
116
+ recorder.ondataavailable = (event) => {
117
+ if (event.data.size > 0) audioChunksRef.current.push(event.data);
118
+ };
119
+ recorder.onstop = async () => {
120
+ const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
121
+ try {
122
+ const dataUrl = await readFileAsDataUrl(blob, messages.readRecordAudioFailed, "audio");
123
+ updateSelectedAudio({ audioSrc: dataUrl });
124
+ setAudioStatus(messages.audioRecordSaved);
125
+ } catch (error) {
126
+ setAudioStatus(
127
+ error instanceof Error ? error.message : messages.readRecordAudioFailed
128
+ );
129
+ }
130
+ recordStreamRef.current?.getTracks().forEach((track) => track.stop());
131
+ recordStreamRef.current = null;
132
+ recorderRef.current = null;
133
+ setIsRecordingAudio(false);
134
+ };
135
+ recorder.start();
136
+ setIsRecordingAudio(true);
137
+ setAudioStatus(messages.audioRecording);
138
+ } catch {
139
+ setAudioStatus(messages.audioRecordStartFailed);
140
+ setIsRecordingAudio(false);
141
+ }
142
+ }, [messages, selectedElement, updateSelectedAudio]);
143
+
144
+ return (
145
+ <>
146
+ {
147
+ renderSection(
148
+ "audioConfig",
149
+ t("panel.config.sectionAudio"),
150
+ <>
151
+ {renderFieldGroup(
152
+ t("panel.config.groupAudioSource"),
153
+ <>
154
+ <label className="block space-y-1">
155
+ <div>{t("panel.config.audioUrl")}</div>
156
+ <Input
157
+ value={selectedElement.audioRemoteUrl ?? ""}
158
+ onChange={(e) =>
159
+ updateSelectedAudio({
160
+ audioRemoteUrl: e.target.value || undefined,
161
+ audioSrc: e.target.value || selectedElement.audioSrc,
162
+ })
163
+ }
164
+ placeholder="https://example.com/audio.mp3"
165
+ className="h-7"
166
+ />
167
+ </label>
168
+ <div className="flex items-center gap-2">
169
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
170
+ {t("panel.config.uploadAudio")}
171
+ <Input
172
+ type="file"
173
+ accept="audio/*"
174
+ className="hidden"
175
+ onChange={async (e) => {
176
+ const file = e.target.files?.[0];
177
+ e.currentTarget.value = "";
178
+ if (!file) return;
179
+ await handleUploadAudioFile(file);
180
+ }}
181
+ />
182
+ </label>
183
+ <button
184
+ type="button"
185
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent disabled:opacity-50"
186
+ onClick={isRecordingAudio ? stopRecordingAudio : startRecordingAudio}
187
+ >
188
+ {isRecordingAudio ? t("panel.config.stopRecord") : t("panel.config.startRecord")}
189
+ </button>
190
+ </div>
191
+ {audioStatus ? (
192
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
193
+ {audioStatus}
194
+ </div>
195
+ ) : null}
196
+ <audio
197
+ controls
198
+ className="h-8 w-full"
199
+ src={selectedElement.audioSrc || selectedElement.audioRemoteUrl || ""}
200
+ />
201
+ </>
202
+ )}
203
+ {renderFieldGroup(
204
+ t("panel.config.groupDisplayStyle"),
205
+ <>
206
+ <label className="flex items-center gap-2">
207
+ <Checkbox
208
+ checked={selectedElement.mediaAutoPauseOnEdit !== false}
209
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
210
+ onCheckedChange={(checked) =>
211
+ updateSelectedAudio({
212
+ mediaAutoPauseOnEdit: checked !== false,
213
+ })
214
+ }
215
+ />
216
+ <span>{t("panel.config.autoPauseMedia")}</span>
217
+ </label>
218
+ <label className="block space-y-1">
219
+ <div>{t("panel.config.audioIconPreset")}</div>
220
+ <Select
221
+ value={selectedElement.audioIconPreset ?? "__none__"}
222
+ onValueChange={(value) =>
223
+ updateSelectedAudio({
224
+ audioIconPreset:
225
+ value === "__none__"
226
+ ? undefined
227
+ : (value as "speaker" | "music" | "headphone" | "wave"),
228
+ })
229
+ }
230
+ >
231
+ <SelectTrigger className="h-7">
232
+ <SelectValue placeholder={t("panel.config.selectIcon")} />
233
+ </SelectTrigger>
234
+ <SelectContent>
235
+ <SelectItem value="__none__">{t("panel.config.iconDefaultProgress")}</SelectItem>
236
+ <SelectItem value="speaker">{t("panel.config.iconSpeaker")}</SelectItem>
237
+ <SelectItem value="music">{t("panel.config.iconMusic")}</SelectItem>
238
+ <SelectItem value="headphone">{t("panel.config.iconHeadphone")}</SelectItem>
239
+ <SelectItem value="wave">{t("panel.config.iconWave")}</SelectItem>
240
+ </SelectContent>
241
+ </Select>
242
+ </label>
243
+ <label className="block space-y-1">
244
+ <div>{t("panel.config.visualEffect")}</div>
245
+ <Select
246
+ value={selectedElement.audioVisualEffect ?? "pulse"}
247
+ onValueChange={(value) =>
248
+ updateSelectedAudio({
249
+ audioVisualEffect: value as "none" | "pulse" | "ripple",
250
+ })
251
+ }
252
+ >
253
+ <SelectTrigger className="h-7">
254
+ <SelectValue placeholder={t("panel.config.selectEffect")} />
255
+ </SelectTrigger>
256
+ <SelectContent>
257
+ <SelectItem value="none">{t("panel.config.effectNone")}</SelectItem>
258
+ <SelectItem value="pulse">{t("panel.config.effectPulse")}</SelectItem>
259
+ <SelectItem value="ripple">{t("panel.config.effectRipple")}</SelectItem>
260
+ </SelectContent>
261
+ </Select>
262
+ </label>
263
+ <label className="block space-y-1">
264
+ <div>{t("panel.config.effectSpeed")}</div>
265
+ <Select
266
+ value={selectedElement.audioVisualSpeed ?? "normal"}
267
+ onValueChange={(value) =>
268
+ updateSelectedAudio({
269
+ audioVisualSpeed: value as "slow" | "normal" | "fast",
270
+ })
271
+ }
272
+ >
273
+ <SelectTrigger className="h-7">
274
+ <SelectValue placeholder={t("panel.config.selectSpeed")} />
275
+ </SelectTrigger>
276
+ <SelectContent>
277
+ <SelectItem value="slow">{t("panel.config.speedSlow")}</SelectItem>
278
+ <SelectItem value="normal">{t("panel.config.speedNormal")}</SelectItem>
279
+ <SelectItem value="fast">{t("panel.config.speedFast")}</SelectItem>
280
+ </SelectContent>
281
+ </Select>
282
+ </label>
283
+ <div className="flex items-center gap-2">
284
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
285
+ {t("panel.config.uploadPoster")}
286
+ <Input
287
+ type="file"
288
+ accept="image/*"
289
+ className="hidden"
290
+ onChange={async (e) => {
291
+ const file = e.target.files?.[0];
292
+ e.currentTarget.value = "";
293
+ if (!file) return;
294
+ await handleUploadAudioPoster(file);
295
+ }}
296
+ />
297
+ </label>
298
+ <button
299
+ type="button"
300
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
301
+ onClick={() =>
302
+ updateSelectedAudio({
303
+ audioPosterImage: undefined,
304
+ })
305
+ }
306
+ >
307
+ {t("panel.config.clearPoster")}
308
+ </button>
309
+ </div>
310
+ {selectedElement.audioPosterImage ? (
311
+ <img
312
+ src={selectedElement.audioPosterImage}
313
+ alt={t("panel.config.audioPosterAlt")}
314
+ className="h-20 w-full rounded border border-border/60 object-cover"
315
+ />
316
+ ) : null}
317
+ <div className="flex items-center gap-1">
318
+ <div className="text-[11px] text-muted-foreground">{t("panel.config.audioPoster")}</div>
319
+ <ConfigHintIcon label={t("panel.config.audioPoster")}>
320
+ {t("panel.config.audioPosterHint")}
321
+ </ConfigHintIcon>
322
+ </div>
323
+ </>,
324
+ undefined,
325
+ { groupKey: "audioDisplayStyle", defaultOpen: false }
326
+ )}
327
+ </>,
328
+ true,
329
+ [t("panel.material.audio"), "url", t("panel.config.searchKwUpload"), t("panel.config.searchKwRecord"), "icon", t("panel.config.searchKwPoster"), t("panel.config.effect"), t("panel.config.searchKwAutoPause"), "media"]
330
+ )
331
+ }
332
+ </>
333
+ );
334
+ }