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 @@ -186,6 +186,11 @@ const en = {
input: 'Please enter the MAC',
ok: 'Ok'
},
recorder: {
title: 'Screen Recorder',
toggleStart: 'Start Recording (https mode only)',
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 @@ -164,6 +164,11 @@ const zh = {
input: '请输入MAC地址',
ok: '确定'
},
recorder: {
title: '屏幕录制',
toggleStart: '开始屏幕录制(仅 https 模式)',
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 @@ -20,6 +20,7 @@ import { Script } from './script';
import { Settings } from './settings';
import { Terminal } from './terminal';
import { Wol } from './wol';
import { Recorder } from './recorder';

export const Menu = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -94,6 +95,13 @@ export const Menu = () => {
(key) => !menuDisabledItems.includes(key)
) && <Divider type="vertical" />}

{!menuDisabledItems.includes('recorder') && (
<>
<Recorder />
<Divider type="vertical" />
</>
)}

{!menuDisabledItems.includes('power') && (
<>
<Power />
Expand Down
125 changes: 125 additions & 0 deletions web/src/pages/desktop/menu/recorder/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from 'react';
import { Tooltip } from 'antd';
import { Video } from 'lucide-react';
import { useTranslation } from 'react-i18next';

export const Recorder = () => {
const { t } = useTranslation();
const videoElement = document.getElementById('screen') as HTMLVideoElement;
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 = (videoElement as any).captureStream();
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 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 (
<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 ${'showSaveFilePicker' in window ? '' : 'pointer-events-none'}`}
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>
);
};
6 changes: 4 additions & 2 deletions web/src/pages/desktop/menu/settings/appearance/menu-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
FileJsonIcon,
NetworkIcon,
PowerIcon,
TerminalSquareIcon
TerminalSquareIcon,
VideoIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';

Expand All @@ -24,7 +25,8 @@ export const MenuBar = () => {
{ key: 'script', icon: <FileJsonIcon size={16} className="text-neutral-400" /> },
{ key: 'terminal', icon: <TerminalSquareIcon size={16} className="text-neutral-400" /> },
{ key: 'wol', icon: <NetworkIcon size={16} className="text-neutral-400" /> },
{ key: 'power', icon: <PowerIcon size={16} className="text-neutral-400" /> }
{ key: 'power', icon: <PowerIcon size={16} className="text-neutral-400" /> },
{ key: 'recorder', icon: <VideoIcon size={16} className="text-neutral-400" /> }
];

function updateItems(key: string) {
Expand Down