diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 282c1a55..49057b16 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 92d1d270..daa3be39 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -164,6 +164,11 @@ const zh = { input: '请输入MAC地址', ok: '确定' }, + recorder: { + title: '屏幕录制', + toggleStart: '开始屏幕录制(仅 https 模式)', + toggleStop: '停止屏幕录制' + }, download: { title: '下载镜像', input: '请输入镜像的下载地址', diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index 97cc63c1..0307a870 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -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(); @@ -94,6 +95,13 @@ export const Menu = () => { (key) => !menuDisabledItems.includes(key) ) && } + {!menuDisabledItems.includes('recorder') && ( + <> + + + + )} + {!menuDisabledItems.includes('power') && ( <> diff --git a/web/src/pages/desktop/menu/recorder/index.tsx b/web/src/pages/desktop/menu/recorder/index.tsx new file mode 100644 index 00000000..d14423c4 --- /dev/null +++ b/web/src/pages/desktop/menu/recorder/index.tsx @@ -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(); + const fileWritableRef = useRef(null); + const timerRef = useRef(null); + const startTimeRef = useRef(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 ( + +
+
+
+ ); +}; diff --git a/web/src/pages/desktop/menu/settings/appearance/menu-bar.tsx b/web/src/pages/desktop/menu/settings/appearance/menu-bar.tsx index 29abd1cf..ee062fd7 100644 --- a/web/src/pages/desktop/menu/settings/appearance/menu-bar.tsx +++ b/web/src/pages/desktop/menu/settings/appearance/menu-bar.tsx @@ -6,7 +6,8 @@ import { FileJsonIcon, NetworkIcon, PowerIcon, - TerminalSquareIcon + TerminalSquareIcon, + VideoIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -24,7 +25,8 @@ export const MenuBar = () => { { key: 'script', icon: }, { key: 'terminal', icon: }, { key: 'wol', icon: }, - { key: 'power', icon: } + { key: 'power', icon: }, + { key: 'recorder', icon: } ]; function updateItems(key: string) {