Merge pull request #86 from imguoguo/add-recorder-upstream

feat: add recorder
This commit is contained in:
wenjie
2025-12-04 11:50:24 +08:00
committed by GitHub
4 changed files with 240 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import { Mouse } from './mouse';
import { SerialPort } from './serial-port';
import { Settings } from './settings';
import { Video } from './video';
import { Recorder } from './recorder';
export const Menu = () => {
const serialState = useAtomValue(serialStateAtom);
@@ -55,6 +56,10 @@ export const Menu = () => {
<Divider type="vertical" />
<Recorder />
<Divider type="vertical" />
<Settings />
<Fullscreen />
<div

View File

@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react';
import { Video } from 'lucide-react';
import { camera } from '@/libs/camera';
export const Recorder = () => {
const [isRecording, setIsRecording] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder>();
const fileWritableRef = useRef<FileSystemWritableFileStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const stopTimer = () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
const startTimer = () => {
stopTimer();
startTimeRef.current = Date.now();
setElapsedMs(0);
timerRef.current = window.setInterval(() => {
setElapsedMs(Date.now() - startTimeRef.current);
}, 1000);
};
useEffect(() => {
return () => {
stopTimer();
};
}, []);
const formatElapsed = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
const handleStartRecording = async () => {
const stream = camera.getStream();
if (!stream) {
return;
}
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: `recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.webm`,
types: [
{
description: 'Sipeed NanoKVM-USB Recorder',
accept: { 'video/webm': ['.webm'] }
}
]
});
const writable = await handle.createWritable();
fileWritableRef.current = writable;
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm'
});
recorder.ondataavailable = async (event) => {
if (event.data && event.data.size > 0) {
if (fileWritableRef.current) {
await fileWritableRef.current.write(event.data);
} else {
recorder.stop();
}
}
};
recorder.onstop = async () => {
if (fileWritableRef.current) {
await fileWritableRef.current.close();
fileWritableRef.current = null;
}
stopTimer();
setElapsedMs(0);
setIsRecording(false);
};
recorder.start(1000);
mediaRecorderRef.current = recorder;
setIsRecording(true);
startTimer();
} catch (err) {
console.error(err);
}
};
const handleStopRecording = () => {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
stopTimer();
setElapsedMs(0);
setIsRecording(false);
}
};
return (
<div
className="flex h-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70"
onClick={isRecording ? handleStopRecording : handleStartRecording}
>
<Video className={isRecording ? 'animate-pulse text-red-400' : ''} size={18} />
{isRecording && <span className="p-1 text-xs text-red-300">{formatElapsed(elapsedMs)}</span>}
</div>
);
};

View File

@@ -11,6 +11,7 @@ import { Mouse } from './mouse'
import { SerialPort } from './serial-port'
import { Settings } from './settings'
import { Video } from './video'
import { Recorder } from './recorder'
export const Menu = (): ReactElement => {
const [isMenuOpen, setIsMenuOpen] = useState(false)
@@ -41,6 +42,10 @@ export const Menu = (): ReactElement => {
<Keyboard />
<Divider type="vertical" className="px-[2px]" />
<Recorder />
<Divider type="vertical" className="px-[2px]" />
<Fullscreen />
<Settings />
<div

View File

@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react';
import { Video } from 'lucide-react';
import { camera } from '@renderer/libs/camera'
export const Recorder = () => {
const [isRecording, setIsRecording] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder>();
const fileWritableRef = useRef<FileSystemWritableFileStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const stopTimer = () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
const startTimer = () => {
stopTimer();
startTimeRef.current = Date.now();
setElapsedMs(0);
timerRef.current = window.setInterval(() => {
setElapsedMs(Date.now() - startTimeRef.current);
}, 1000);
};
useEffect(() => {
return () => {
stopTimer();
};
}, []);
const formatElapsed = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
};
const handleStartRecording = async () => {
const stream = camera.getStream();
if (!stream) {
return;
}
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: `recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.webm`,
types: [
{
description: 'Sipeed NanoKVM-USB Recorder',
accept: { 'video/webm': ['.webm'] }
}
]
});
const writable = await handle.createWritable();
fileWritableRef.current = writable;
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm'
});
recorder.ondataavailable = async (event) => {
if (event.data && event.data.size > 0) {
if (fileWritableRef.current) {
await fileWritableRef.current.write(event.data);
} else {
recorder.stop();
}
}
};
recorder.onstop = async () => {
if (fileWritableRef.current) {
await fileWritableRef.current.close();
fileWritableRef.current = null;
}
stopTimer();
setElapsedMs(0);
setIsRecording(false);
};
recorder.start(1000);
mediaRecorderRef.current = recorder;
setIsRecording(true);
startTimer();
} catch (err) {
console.error(err);
}
};
const handleStopRecording = () => {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
stopTimer();
setElapsedMs(0);
setIsRecording(false);
}
};
return (
<div
className="flex h-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70"
onClick={isRecording ? handleStopRecording : handleStartRecording}
>
<Video className={isRecording ? 'animate-pulse text-red-400' : ''} size={18} />
{isRecording && <span className="p-1 text-xs text-red-300">{formatElapsed(elapsedMs)}</span>}
</div>
);
};