Merge pull request #50 from finani/video-scale

feat: add video scale
This commit is contained in:
wenjie
2025-10-30 10:21:06 +08:00
committed by GitHub
26 changed files with 354 additions and 12 deletions

View File

@@ -10,7 +10,12 @@ import { Keyboard } from '@/components/keyboard';
import { Menu } from '@/components/menu';
import { Mouse } from '@/components/mouse';
import { VirtualKeyboard } from '@/components/virtual-keyboard';
import { resolutionAtom, serialStateAtom, videoStateAtom } from '@/jotai/device.ts';
import {
resolutionAtom,
serialStateAtom,
videoScaleAtom,
videoStateAtom
} from '@/jotai/device.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { camera } from '@/libs/camera';
@@ -23,6 +28,7 @@ const App = () => {
const isBigScreen = useMediaQuery({ minWidth: 850 });
const mouseStyle = useAtomValue(mouseStyleAtom);
const videoScale = useAtomValue(videoScaleAtom);
const videoState = useAtomValue(videoStateAtom);
const serialState = useAtomValue(serialStateAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
@@ -107,7 +113,13 @@ const App = () => {
<video
id="video"
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
style={{
transform: `scale(${videoScale})`,
transformOrigin: 'center',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'scale-down'
}}
autoPlay
playsInline
/>

View File

@@ -3,11 +3,13 @@ import { MonitorIcon } from 'lucide-react';
import { Device } from './device.tsx';
import { Resolution } from './resolution.tsx';
import { Scale } from './scale.tsx';
export const Video = () => {
const content = (
<div className="flex flex-col space-y-1">
<Resolution />
<Scale />
<Device />
</div>
);

View File

@@ -0,0 +1,58 @@
import { ReactElement, useEffect } from 'react'
import { Popover, Slider } from 'antd'
import { useAtom } from 'jotai'
import { ScalingIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { videoScaleAtom } from '@/jotai/device.ts';
import * as storage from '@/libs/storage';
export const Scale = (): ReactElement => {
const { t } = useTranslation()
const [videoScale, setVideoScale] = useAtom(videoScaleAtom)
useEffect(() => {
const scale = storage.getVideoScale()
if (scale) {
setVideoScale(scale)
}
}, [])
async function updateScale(scale: number): Promise<void> {
setVideoScale(scale)
storage.setVideoScale(scale)
}
const content = (
<div className="h-[150px] w-[60px] py-3">
<Slider
vertical
marks={{
0.5: <span>x0.5</span>,
1: <span>x1.0</span>,
1.5: <span>x1.5</span>,
2: <span>x2.0</span>
}}
range={false}
included={false}
min={0.5}
max={2}
step={0.1}
defaultValue={videoScale}
onChange={updateScale}
/>
</div>
)
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [13, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex h-[14px] w-[20px] items-end">
<ScalingIcon size={16} />
</div>
<span>{t('video.scale')}</span>
</div>
</Popover>
)
}

View File

@@ -4,7 +4,8 @@ const languages = [
{ key: 'zh', name: '中文' },
{ key: 'de', name: 'Deutsch' },
{ key: 'nl', name: 'Nederlands' },
{ key: 'be', name: 'België' }
{ key: 'be', name: 'België' },
{ key: 'ko', name: '한국어' }
];
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));

View File

@@ -24,6 +24,7 @@ const be = {
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Toestel',
custom: {

View File

@@ -24,6 +24,7 @@ const de = {
},
video: {
resolution: 'Auflösung',
scale: 'Skalierung',
customResolution: 'Benutzerdefiniert',
device: 'Gerät',
custom: {

View File

@@ -24,6 +24,7 @@ const en = {
},
video: {
resolution: 'Resolution',
scale: 'Scale',
customResolution: 'Custom',
device: 'Device',
custom: {

View File

@@ -0,0 +1,70 @@
const ko = {
translation: {
serial: {
notSupported:
'시리얼이 지원되지 않습니다. 마우스와 키보드를 사용하려면 Chrome 브라우저를 사용하세요.',
failed: '시리얼 연결에 실패했습니다. 다시 시도해 주세요.'
},
camera: {
tip: '권한을 기다리는 중...',
denied: '권한이 거부되었습니다.',
authorize:
'Target PC 연결에 카메라 권한이 필요합니다. 브라우저 설정에서 카메라 권한을 허용해 주세요.',
failed: '카메라 연결에 실패했습니다. 다시 시도해 주세요.'
},
modal: {
title: 'USB 장치 선택',
selectVideo: '비디오 입력 장치를 선택해 주세요.',
selectSerial: '시리얼 장치를 선택해 주세요.'
},
menu: {
serial: '시리얼',
keyboard: '키보드',
mouse: '마우스'
},
video: {
resolution: '해상도',
scale: '배율',
customResolution: '사용자 정의',
device: '장치',
custom: {
title: '사용자 정의 해상도',
width: '가로',
height: '세로',
confirm: '확인',
cancel: '취소'
}
},
keyboard: {
paste: '붙여넣기',
virtualKeyboard: '가상 키보드',
ctrlAltDel: 'Ctrl + Alt + Delete'
},
mouse: {
cursor: {
title: '커서 모양',
pointer: '포인터',
grab: '손',
cell: '플러스',
hide: '숨기기'
},
mode: '마우스 모드',
absolute: '절대 모드',
relative: '상대 모드',
direction: '휠 방향',
scrollUp: '위로 스크롤',
scrollDown: '아래로 스크롤',
speed: '휠 속도',
fast: '빠르게',
slow: '느리게',
requestPointer: '상대 모드를 사용 중입니다. 마우스 포인터를 가져오려면 스크린을 클릭하세요.'
},
settings: {
language: '언어',
document: '문서',
download: '다운로드'
}
}
};
export default ko;

View File

@@ -24,6 +24,7 @@ const nl = {
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Apparaat',
custom: {

View File

@@ -24,6 +24,7 @@ const ru = {
},
video: {
resolution: 'Разрешение',
scale: 'Масштаб',
customResolution: 'Пользовательское',
device: 'Видеоустройство',
custom: {

View File

@@ -22,6 +22,7 @@ const zh = {
},
video: {
resolution: '分辨率',
scale: '缩放',
customResolution: '自定义',
device: '设备',
custom: {

View File

@@ -10,6 +10,8 @@ export const resolutionAtom = atom<Resolution>({
height: 1080
});
export const videoScaleAtom = atom<number>(1.0)
export const videoDeviceIdAtom = atom('');
export const videoStateAtom = atom<VideoState>('disconnected');

View File

@@ -4,6 +4,7 @@ const LANGUAGE_KEY = 'nanokvm-usb-language';
const VIDEO_DEVICE_ID_KEY = 'nanokvm-usb-video-device-id';
const VIDEO_RESOLUTION_KEY = 'nanokvm-usb-video-resolution';
const CUSTOM_RESOLUTION_KEY = 'nanokvm-usb-custom-resolution';
const VIDEO_SCALE_KEY = 'nanokvm-usb-video-scale'
const IS_MENU_OPEN_KEY = 'nanokvm-is-menu-open';
const MOUSE_STYLE_KEY = 'nanokvm-usb-mouse-style';
const MOUSE_MODE_KEY = 'nanokvm-usb-mouse-mode';
@@ -57,6 +58,18 @@ export function removeCustomResolutions() {
localStorage.removeItem(CUSTOM_RESOLUTION_KEY);
}
export function getVideoScale(): number | null {
const scale = localStorage.getItem(VIDEO_SCALE_KEY)
if (scale && Number(scale)) {
return Number(scale)
}
return null
}
export function setVideoScale(scale: number): void {
localStorage.setItem(VIDEO_SCALE_KEY, String(scale))
}
export function getIsMenuOpen(): boolean {
const state = localStorage.getItem(IS_MENU_OPEN_KEY);
if (!state) {

View File

@@ -11,7 +11,12 @@ import { Keyboard } from '@renderer/components/keyboard'
import { Menu } from '@renderer/components/menu'
import { Mouse } from '@renderer/components/mouse'
import { VirtualKeyboard } from '@renderer/components/virtual-keyboard'
import { resolutionAtom, serialPortStateAtom, videoStateAtom } from '@renderer/jotai/device'
import {
resolutionAtom,
serialPortStateAtom,
videoScaleAtom,
videoStateAtom
} from '@renderer/jotai/device'
import { isKeyboardEnableAtom } from '@renderer/jotai/keyboard'
import { mouseStyleAtom } from '@renderer/jotai/mouse'
import { camera } from '@renderer/libs/camera'
@@ -24,6 +29,7 @@ const App = (): ReactElement => {
const { t } = useTranslation()
const isBigScreen = useMediaQuery({ minWidth: 850 })
const videoScale = useAtomValue(videoScaleAtom)
const videoState = useAtomValue(videoStateAtom)
const serialPortState = useAtomValue(serialPortStateAtom)
const mouseStyle = useAtomValue(mouseStyleAtom)
@@ -115,7 +121,13 @@ const App = (): ReactElement => {
<video
id="video"
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
style={{
transform: `scale(${videoScale})`,
transformOrigin: 'center',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'scale-down'
}}
autoPlay
playsInline
/>

View File

@@ -4,11 +4,13 @@ import { MonitorIcon } from 'lucide-react'
import { Device } from './device'
import { Resolution } from './resolution'
import { Scale } from './scale'
export const Video = (): ReactElement => {
const content = (
<div className="flex flex-col space-y-1">
<Resolution />
<Scale />
<Device />
</div>
)

View File

@@ -0,0 +1,58 @@
import { ReactElement, useEffect } from 'react'
import { Popover, Slider } from 'antd'
import { useAtom } from 'jotai'
import { ScalingIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { videoScaleAtom } from '@renderer/jotai/device'
import * as storage from '@renderer/libs/storage'
export const Scale = (): ReactElement => {
const { t } = useTranslation()
const [videoScale, setVideoScale] = useAtom(videoScaleAtom)
useEffect(() => {
const scale = storage.getVideoScale()
if (scale) {
setVideoScale(scale)
}
}, [setVideoScale])
async function updateScale(scale: number): Promise<void> {
setVideoScale(scale)
storage.setVideoScale(scale)
}
const content = (
<div className="h-[150px] w-[60px] py-3">
<Slider
vertical
marks={{
0.5: <span>x0.5</span>,
1: <span>x1.0</span>,
1.5: <span>x1.5</span>,
2: <span>x2.0</span>
}}
range={false}
included={false}
min={0.5}
max={2}
step={0.1}
defaultValue={videoScale}
onChange={updateScale}
/>
</div>
)
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [13, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex h-[14px] w-[20px] items-end">
<ScalingIcon size={16} />
</div>
<span>{t('video.scale')}</span>
</div>
</Popover>
)
}

View File

@@ -4,7 +4,8 @@ const languages = [
{ key: 'zh', name: '中文' },
{ key: 'de', name: 'Deutsch' },
{ key: 'nl', name: 'Nederlands' },
{ key: 'be', name: 'België' }
{ key: 'be', name: 'België' },
{ key: 'ko', name: '한국어' }
]
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))

View File

@@ -19,6 +19,7 @@ const be = {
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Toestel',
custom: {
@@ -76,5 +77,5 @@ const be = {
}
}
}
export default be
export default be

View File

@@ -19,6 +19,7 @@ const de = {
},
video: {
resolution: 'Auflösung',
scale: 'Skalierung',
customResolution: 'Benutzerdefiniert',
device: 'Gerät',
custom: {
@@ -79,5 +80,5 @@ const de = {
}
}
}
export default de
export default de

View File

@@ -19,6 +19,7 @@ const en = {
},
video: {
resolution: 'Resolution',
scale: 'Scale',
customResolution: 'Custom',
device: 'Device',
custom: {

View File

@@ -0,0 +1,84 @@
const ko = {
translation: {
camera: {
tip: '권한을 기다리는 중...',
denied: '권한이 거부되었습니다.',
authorize:
'Target PC 연결에 카메라 권한이 필요합니다. 설정에서 카메라 권한을 허용해 주세요.',
failed: '카메라 연결에 실패했습니다. 다시 시도해 주세요.'
},
modal: {
title: 'USB 장치 선택',
selectVideo: '비디오 입력 장치를 선택해 주세요.',
selectSerial: '시리얼 장치를 선택해 주세요.'
},
menu: {
serial: '시리얼',
keyboard: '키보드',
mouse: '마우스'
},
video: {
resolution: '해상도',
scale: '배율',
customResolution: '사용자 정의',
device: '장치',
custom: {
title: '사용자 정의 해상도',
width: '가로',
height: '세로',
confirm: '확인',
cancel: '취소'
}
},
keyboard: {
paste: '붙여넣기',
virtualKeyboard: '가상 키보드',
ctrlAltDel: 'Ctrl + Alt + Delete'
},
mouse: {
cursor: {
title: '커서 모양',
pointer: '포인터',
grab: '손',
cell: '플러스',
hide: '숨기기'
},
mode: '마우스 모드',
absolute: '절대 모드',
relative: '상대 모드',
direction: '휠 방향',
scrollUp: '위로 스크롤',
scrollDown: '아래로 스크롤',
speed: '휠 속도',
fast: '빠르게',
slow: '느리게',
requestPointer: '상대 모드를 사용 중입니다. 마우스 포인터를 가져오려면 스크린을 클릭하세요.'
},
settings: {
title: '설정',
appearance: {
title: '화면 설정',
language: '언어',
menu: '메뉴 바',
menuTips: '시작 시 메뉴 바 열기'
},
update: {
title: '업데이트 확인',
latest: '최신 버전을 사용 중입니다.',
outdated: '업데이트가 가능합니다. 지금 업데이트하시겠습니까?',
downloading: '다운로드 중...',
installing: '설치 중...',
failed: '업데이트에 실패했습니다. 다시 시도해 주세요.',
confirm: '확인',
cancel: '취소'
},
about: {
title: '정보',
version: '버전',
community: '커뮤니티'
}
}
}
}
export default ko

View File

@@ -19,6 +19,7 @@ const nl = {
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Apparaat',
custom: {
@@ -76,5 +77,5 @@ const nl = {
}
}
}
export default nl
export default nl

View File

@@ -19,6 +19,7 @@ const ru = {
},
video: {
resolution: 'Разрешение',
scale: 'Масштаб',
customResolution: 'Пользовательское',
device: 'Видеоустройство',
custom: {

View File

@@ -18,6 +18,7 @@ const zh = {
},
video: {
resolution: '分辨率',
scale: '缩放',
customResolution: '自定义',
device: '设备',
custom: {

View File

@@ -10,6 +10,8 @@ export const resolutionAtom = atom<Resolution>({
height: 1080
})
export const videoScaleAtom = atom<number>(1.0)
export const videoDeviceIdAtom = atom('')
export const videoStateAtom = atom<VideoState>('disconnected')

View File

@@ -5,6 +5,7 @@ import { getWithExpiry, setWithExpiry } from './expiry'
const LANGUAGE_KEY = 'nanokvm-usb-language'
const VIDEO_DEVICE_ID_KEY = 'nanokvm-usb-video-device-id'
const VIDEO_RESOLUTION_KEY = 'nanokvm-usb-video-resolution'
const VIDEO_SCALE_KEY = 'nanokvm-usb-video-scale'
const CUSTOM_RESOLUTION_KEY = 'nanokvm-usb-custom-resolution'
const SERIAL_PORT_KEY = 'nanokvm-serial-port'
const IS_MENU_OPEN_KEY = 'nanokvm-is-menu-open'
@@ -64,6 +65,18 @@ export function removeCustomResolutions(): void {
localStorage.removeItem(CUSTOM_RESOLUTION_KEY)
}
export function getVideoScale(): number | null {
const scale = localStorage.getItem(VIDEO_SCALE_KEY)
if (scale && Number(scale)) {
return Number(scale)
}
return null
}
export function setVideoScale(scale: number): void {
localStorage.setItem(VIDEO_SCALE_KEY, String(scale))
}
export function getSerialPort(): string | null {
return localStorage.getItem(SERIAL_PORT_KEY)
}