feat(admin): add audio recorder UI skeleton (MediaRecorder) and embed into EditorShell
This commit is contained in:
parent
43e6d4b53c
commit
7e0bb3dc53
@ -1,5 +1,6 @@
|
||||
import { Typography } from '@mui/material';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import AdminLayout from '../layout/AdminLayout';
|
||||
import Recorder from '../features/recorder/Recorder';
|
||||
|
||||
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||
return (
|
||||
@ -7,9 +8,12 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
Welcome to VoxBlog Editor
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Coming next: audio recorder, rich editor, AI tools, and Ghost publish flow.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<Recorder />
|
||||
<Typography variant="body1">
|
||||
Coming next: rich editor, AI tools, and Ghost publish flow.
|
||||
</Typography>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
76
apps/admin/src/features/recorder/Recorder.tsx
Normal file
76
apps/admin/src/features/recorder/Recorder.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
|
||||
export default function Recorder() {
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const requestStream = async (): Promise<MediaStream | null> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
return stream;
|
||||
} catch (e) {
|
||||
setError('Microphone permission denied');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
setError('');
|
||||
const stream = await requestStream();
|
||||
if (!stream) return;
|
||||
|
||||
const mr = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mr;
|
||||
chunksRef.current = [];
|
||||
|
||||
mr.ondataavailable = (e: BlobEvent) => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
mr.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
// stop all tracks to release mic
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
};
|
||||
|
||||
mr.start();
|
||||
setRecording(true);
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setRecording(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Audio Recorder</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" disabled={recording} onClick={startRecording}>Start</Button>
|
||||
<Button variant="outlined" disabled={!recording} onClick={stopRecording}>Stop</Button>
|
||||
</Stack>
|
||||
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
|
||||
{audioUrl && (
|
||||
<Box>
|
||||
<audio controls src={audioUrl} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user