Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ const en = {
input: 'Please enter the MAC',
ok: 'Ok'
},
recorder: {
title: 'Recorder',
toggleStart: 'Start Recording',
toggleStop: 'Stop Recording'
},
download: {
title: 'Image Downloader',
input: 'Please enter a remote image URL',
Expand Down
5 changes: 5 additions & 0 deletions web/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ const zh = {
input: '请输入MAC地址',
ok: '确定'
},
recorder: {
title: '录屏',
toggleStart: '开始录制',
toggleStop: '停止录制'
},
download: {
title: '下载镜像',
input: '请输入镜像的下载地址',
Expand Down
8 changes: 8 additions & 0 deletions web/src/pages/desktop/menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Microphone } from './microphone';
import { Mouse } from './mouse';
import { Collapse, Expand } from './operations';
import { Power } from './power';
import { Recorder } from './recorder';
import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
Expand Down Expand Up @@ -130,6 +131,13 @@ export const Menu = () => {
</>
)}

{isEnabled('recorder') && (
<>
<Recorder />
<Divider type="vertical" />
</>
)}

<Settings />
{isEnabled('fullscreen') && <Fullscreen />}
{isEnabled('collapse') && <Collapse toggleMenu={setIsMenuExpanded} />}
Expand Down
164 changes: 164 additions & 0 deletions web/src/pages/desktop/menu/recorder/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useEffect, useRef, useState } from 'react';
import { Tooltip } from 'antd';
import { useAtomValue } from 'jotai';
import { Video } from 'lucide-react';
import { useTranslation } from 'react-i18next';

import { videoModeAtom } from '@/jotai/screen.ts';

// Recording relies on captureStream() of the <video> element, which only exists
// in the WebRTC modes (mjpeg renders an <img>, direct modes an offscreen canvas).
const RECORDABLE_MODES = [null, 'h264-webrtc', 'h265-webrtc'];

const MIME_CANDIDATES = [
{ mimeType: 'video/mp4;codecs=avc1.640034', ext: '.mp4', accept: { 'video/mp4': ['.mp4'] } },
{ mimeType: 'video/webm;codecs=vp9', ext: '.webm', accept: { 'video/webm': ['.webm'] } },
{ mimeType: 'video/webm', ext: '.webm', accept: { 'video/webm': ['.webm'] } }
];

const pickMimeType = () => {
return MIME_CANDIDATES.find((c) => MediaRecorder.isTypeSupported(c.mimeType));
};

// Browsers default to 2.5 Mbps regardless of resolution, which ruins desktop
// text. Scale with the actual stream instead (~0.12 bit/pixel/frame).
const getVideoBitrate = (track?: MediaStreamTrack) => {
const { width = 1920, height = 1080, frameRate = 30 } = track?.getSettings() ?? {};
return Math.min(60_000_000, Math.max(8_000_000, Math.round(width * height * frameRate * 0.12)));
};

export const Recorder = () => {
const { t } = useTranslation();
const videoMode = useAtomValue(videoModeAtom);
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 isSupported = 'showSaveFilePicker' in window && RECORDABLE_MODES.includes(videoMode);

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();

// Finalize the file if unmounted (e.g. video mode switch) while recording
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
}
};
}, []);

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 () => {
try {
const videoElement = document.getElementById('screen') as HTMLVideoElement | null;
const stream: MediaStream | undefined = (videoElement as any)?.captureStream?.();
if (!stream) {
return;
}

const container = pickMimeType();
if (!container) {
return;
}

const handle = await (window as any).showSaveFilePicker({
suggestedName: `recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}${container.ext}`,
types: [
{
description: 'Sipeed NanoKVM Recorder',
accept: container.accept
}
]
});

const writable = await handle.createWritable();
fileWritableRef.current = writable;

const recorder = new MediaRecorder(stream, {
mimeType: container.mimeType,
videoBitsPerSecond: getVideoBitrate(stream.getVideoTracks()[0])
});

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 (
<Tooltip
title={isRecording ? t('recorder.toggleStop') : t('recorder.toggleStart')}
placement="bottom"
mouseEnterDelay={0.6}
>
<div
className={`flex h-[28px] cursor-pointer items-center justify-center rounded p-1 text-white hover:bg-neutral-700/70 ${isSupported ? '' : 'pointer-events-none opacity-40'}`}
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>
</Tooltip>
);
};
2 changes: 2 additions & 0 deletions web/src/pages/desktop/menu/settings/appearance/menu-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
NetworkIcon,
PowerIcon,
TerminalSquareIcon,
VideoIcon,
Volume2Icon,
XIcon
} from 'lucide-react';
Expand All @@ -34,6 +35,7 @@ export const MenuIcons = () => {
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
{ key: 'wol', icon: <NetworkIcon size={16} /> },
{ key: 'power', icon: <PowerIcon size={16} /> },
{ key: 'recorder', icon: <VideoIcon size={16} /> },
{ key: 'fullscreen', icon: <MaximizeIcon size={16} />, label: 'fullscreen.toggle' },
{ key: 'collapse', icon: <XIcon size={16} />, label: 'menu.collapse' }
];
Expand Down