Skip to content

Commit 92b9996

Browse files
committed
feat: add video rotate
1 parent 699e132 commit 92b9996

9 files changed

Lines changed: 174 additions & 25 deletions

File tree

browser/src/App.tsx

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import { Alert, Result, Spin } from 'antd';
33
import clsx from 'clsx';
4-
import { useAtomValue, useSetAtom } from 'jotai';
4+
import { useAtomValue, useSetAtom, useAtom } from 'jotai';
55
import { useTranslation } from 'react-i18next';
66
import { useMediaQuery } from 'react-responsive';
7-
87
import { DeviceModal } from '@/components/device-modal';
98
import { Keyboard } from '@/components/keyboard';
109
import { Menu } from '@/components/menu';
1110
import { Mouse } from '@/components/mouse';
1211
import { VirtualKeyboard } from '@/components/virtual-keyboard';
13-
import {
14-
resolutionAtom,
15-
serialStateAtom,
16-
videoScaleAtom,
17-
videoStateAtom
18-
} from '@/jotai/device.ts';
12+
import { resolutionAtom, serialStateAtom, videoRotateAtom, videoScaleAtom, videoStateAtom } from '@/jotai/device.ts';
1913
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
2014
import { mouseStyleAtom } from '@/jotai/mouse.ts';
2115
import { camera } from '@/libs/camera';
@@ -28,15 +22,81 @@ const App = () => {
2822
const isBigScreen = useMediaQuery({ minWidth: 850 });
2923

3024
const mouseStyle = useAtomValue(mouseStyleAtom);
31-
const videoScale = useAtomValue(videoScaleAtom);
25+
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
26+
const [videoRotate, setVideoRotate] = useAtom(videoRotateAtom);
3227
const videoState = useAtomValue(videoStateAtom);
3328
const serialState = useAtomValue(serialStateAtom);
3429
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
3530
const setResolution = useSetAtom(resolutionAtom);
31+
const canvasRef = useRef<HTMLCanvasElement>(null);
32+
const videoRef = useRef<HTMLVideoElement>(null);
33+
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null);
3634

3735
const [isLoading, setIsLoading] = useState(true);
3836
const [isCameraAvailable, setIsCameraAvailable] = useState(false);
3937

38+
const videoStyle = clsx('block select-none origin-center max-w-full max-h-full object-scale-down', mouseStyle)
39+
40+
const renderFrame = (frame: VideoFrame) => {
41+
const canvas = canvasRef.current;
42+
const ctx = canvasContextRef.current;
43+
if (!canvas || !ctx) return;
44+
45+
ctx.clearRect(0, 0, canvas.width, canvas.height);
46+
47+
ctx.save();
48+
ctx.translate(canvas.width / 2, canvas.height / 2);
49+
ctx.rotate((videoRotate * Math.PI) / 180);
50+
51+
ctx.drawImage(
52+
frame,
53+
-frame.displayWidth / 2,
54+
-frame.displayHeight / 2,
55+
frame.displayWidth,
56+
frame.displayHeight
57+
);
58+
59+
ctx.restore();
60+
};
61+
62+
const setCanvas = () => {
63+
const video = videoRef.current;
64+
const canvas = canvasRef.current;
65+
66+
if (!video || !canvas || video.videoWidth === 0) return;
67+
68+
if (videoRotate % 180 === 0) {
69+
canvas.width = video.videoWidth;
70+
canvas.height = video.videoHeight;
71+
} else {
72+
canvas.width = video.videoHeight;
73+
canvas.height = video.videoWidth;
74+
}
75+
76+
if (videoRotate !== 0) {
77+
processVideoFrames();
78+
}
79+
}
80+
81+
const processVideoFrames = () => {
82+
const video = videoRef.current;
83+
if (video == null || videoRotate === 0) return;
84+
video.requestVideoFrameCallback(() => {
85+
const frame = new VideoFrame(video);
86+
renderFrame(frame);
87+
frame.close();
88+
processVideoFrames();
89+
})
90+
}
91+
92+
useEffect(() => {
93+
if (videoRotate !== 0) {
94+
setCanvas();
95+
if (canvasRef.current !== null)
96+
canvasContextRef.current = canvasRef.current.getContext('2d');
97+
}
98+
}, [videoRotate]);
99+
40100
useEffect(() => {
41101
const resolution = storage.getVideoResolution();
42102
if (resolution) {
@@ -45,6 +105,16 @@ const App = () => {
45105

46106
requestMediaPermissions(resolution);
47107

108+
const rotate = storage.getVideoRotate();
109+
if (rotate) {
110+
setVideoRotate(rotate);
111+
}
112+
113+
const scale = storage.getVideoScale();
114+
if (scale) {
115+
setVideoScale(scale);
116+
}
117+
48118
return () => {
49119
camera.close();
50120
device.serialPort.close();
@@ -112,18 +182,24 @@ const App = () => {
112182

113183
<video
114184
id="video"
115-
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
116-
style={{
117-
transform: `scale(${videoScale})`,
118-
transformOrigin: 'center',
119-
maxWidth: '100%',
120-
maxHeight: '100%',
121-
objectFit: 'scale-down'
122-
}}
185+
className={clsx(videoRotate === 0 ? [videoStyle, "min-h-[480px] min-w-[640px]"] : "hidden")}
186+
ref={videoRef}
123187
autoPlay
124188
playsInline
189+
onLoadedMetadata={setCanvas}
125190
/>
126191

192+
{videoRotate !== 0 &&
193+
<canvas
194+
id="video-canvas"
195+
ref={canvasRef}
196+
className={videoStyle}
197+
style={{
198+
transform: `scale(${videoScale})`,
199+
}}
200+
/>
201+
}
202+
127203
<VirtualKeyboard isBigScreen={isBigScreen} />
128204
</>
129205
);

browser/src/components/menu/video/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { MonitorIcon } from 'lucide-react';
44
import { Device } from './device.tsx';
55
import { Resolution } from './resolution.tsx';
66
import { Scale } from './scale.tsx';
7+
import { Rotate } from './rotate.tsx';
78

89
export const Video = () => {
910
const content = (
1011
<div className="flex flex-col space-y-1">
1112
<Resolution />
1213
<Scale />
14+
<Rotate />
1315
<Device />
1416
</div>
1517
);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { ReactElement } from 'react'
2+
import { Popover } from 'antd'
3+
import { useSetAtom } from 'jotai'
4+
import { RotateCcwIcon } from 'lucide-react'
5+
import { useTranslation } from 'react-i18next'
6+
7+
import { videoRotateAtom } from '@/jotai/device.ts';
8+
import * as storage from '@/libs/storage';
9+
10+
export const Rotate = (): ReactElement => {
11+
const { t } = useTranslation()
12+
const rotates = new Map([
13+
["0", t('video.noRotation')],
14+
["90", "90°"],
15+
["180", "180°"],
16+
["270", "270°"]
17+
]);
18+
19+
const setVideoRotate = useSetAtom(videoRotateAtom)
20+
21+
async function updateRotate(rotate: number): Promise<void> {
22+
setVideoRotate(rotate)
23+
storage.setVideoRotate(rotate)
24+
}
25+
26+
const content = (
27+
<>
28+
{Array.from(rotates).map(([degree, label]) => (
29+
<div
30+
key={degree}
31+
className={
32+
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-700/60'}
33+
onClick={() => updateRotate(parseInt(degree))}
34+
>
35+
<span>{label}</span>
36+
</div>
37+
))}
38+
</>
39+
)
40+
41+
return (
42+
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [13, 0] }}>
43+
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
44+
<div className="flex h-[14px] w-[20px] items-end">
45+
<RotateCcwIcon size={16} />
46+
</div>
47+
<span>{t('video.rotate')}</span>
48+
</div>
49+
</Popover>
50+
)
51+
}

browser/src/components/mouse/absolute.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useRef } from 'react';
22
import { useAtomValue, useSetAtom } from 'jotai';
33

4-
import { resolutionAtom } from '@/jotai/device.ts';
4+
import { resolutionAtom, videoRotateAtom } from '@/jotai/device.ts';
55
import {
66
mouseJigglerModeAtom,
77
mouseLastMoveTimeAtom,
@@ -13,6 +13,7 @@ import { Key } from '@/libs/device/mouse.ts';
1313

1414
export const Absolute = () => {
1515
const resolution = useAtomValue(resolutionAtom);
16+
const videoRotate = useAtomValue(videoRotateAtom);
1617
const scrollDirection = useAtomValue(scrollDirectionAtom);
1718
const scrollInterval = useAtomValue(scrollIntervalAtom);
1819
const mouseJigglerMode = useAtomValue(mouseJigglerModeAtom);
@@ -23,7 +24,7 @@ export const Absolute = () => {
2324

2425
// listen mouse events
2526
useEffect(() => {
26-
const canvas = document.getElementById('video');
27+
const canvas = document.getElementById(videoRotate === 0 ? 'video': 'video-canvas');
2728
if (!canvas) return;
2829

2930
canvas.addEventListener('mousedown', handleMouseDown);
@@ -157,7 +158,7 @@ export const Absolute = () => {
157158
canvas.removeEventListener('click', disableEvent);
158159
canvas.removeEventListener('contextmenu', disableEvent);
159160
};
160-
}, [resolution, scrollDirection, scrollInterval, mouseJigglerMode, setMouseLastMoveTime]);
161+
}, [resolution, scrollDirection, scrollInterval, mouseJigglerMode, setMouseLastMoveTime, videoRotate]);
161162

162163
// disable default events
163164
function disableEvent(event: any) {

browser/src/components/mouse/relative.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { message } from 'antd';
33
import { useAtomValue, useSetAtom } from 'jotai';
44
import { useTranslation } from 'react-i18next';
55

6-
import { resolutionAtom } from '@/jotai/device.ts';
6+
import { resolutionAtom, videoRotateAtom } from '@/jotai/device.ts';
77
import {
88
mouseJigglerModeAtom,
99
mouseLastMoveTimeAtom,
@@ -18,6 +18,7 @@ export const Relative = () => {
1818
const [messageApi, contextHolder] = message.useMessage();
1919

2020
const resolution = useAtomValue(resolutionAtom);
21+
const videoRotate = useAtomValue(videoRotateAtom);
2122
const scrollDirection = useAtomValue(scrollDirectionAtom);
2223
const scrollInterval = useAtomValue(scrollIntervalAtom);
2324
const mouseJigglerMode = useAtomValue(mouseJigglerModeAtom);
@@ -41,7 +42,7 @@ export const Relative = () => {
4142

4243
// listen mouse events
4344
useEffect(() => {
44-
const canvas = document.getElementById('video');
45+
const canvas = document.getElementById(videoRotate === 0 ? 'video': 'video-canvas');
4546
if (!canvas) return;
4647

4748
document.addEventListener('pointerlockchange', handlePointerLockChange);
@@ -150,7 +151,7 @@ export const Relative = () => {
150151
canvas.removeEventListener('wheel', handleWheel);
151152
canvas.removeEventListener('contextmenu', disableEvent);
152153
};
153-
}, [resolution, scrollDirection, scrollInterval, mouseJigglerMode, setMouseLastMoveTime]);
154+
}, [resolution, scrollDirection, scrollInterval, mouseJigglerMode, setMouseLastMoveTime, videoRotate]);
154155

155156
async function send(x: number, y: number, scroll: number) {
156157
await device.sendMouseRelativeData(keyRef.current, x, y, scroll);

browser/src/i18n/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const en = {
2525
video: {
2626
resolution: 'Resolution',
2727
scale: 'Scale',
28+
rotate: 'Rotate',
29+
noRotation: 'No Rotation',
2830
customResolution: 'Custom',
2931
device: 'Device',
3032
custom: {

browser/src/i18n/locales/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const zh = {
2323
video: {
2424
resolution: '分辨率',
2525
scale: '缩放',
26+
rotate: '旋转',
27+
noRotation: '不旋转',
2628
customResolution: '自定义',
2729
device: '设备',
2830
custom: {

browser/src/jotai/device.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export const resolutionAtom = atom<Resolution>({
1010
height: 1080
1111
});
1212

13-
export const videoScaleAtom = atom<number>(1.0)
13+
export const videoScaleAtom = atom<number>(1.0);
14+
export const videoRotateAtom = atom<number>(0);
1415

1516
export const videoDeviceIdAtom = atom('');
1617
export const videoStateAtom = atom<VideoState>('disconnected');

browser/src/libs/storage/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const VIDEO_DEVICE_ID_KEY = 'nanokvm-usb-video-device-id';
55
const VIDEO_RESOLUTION_KEY = 'nanokvm-usb-video-resolution';
66
const CUSTOM_RESOLUTION_KEY = 'nanokvm-usb-custom-resolution';
77
const VIDEO_SCALE_KEY = 'nanokvm-usb-video-scale'
8+
const VIDEO_ROTATE_KEY = 'nanokvm-usb-video-rotate'
89
const IS_MENU_OPEN_KEY = 'nanokvm-is-menu-open';
910
const MOUSE_STYLE_KEY = 'nanokvm-usb-mouse-style';
1011
const MOUSE_MODE_KEY = 'nanokvm-usb-mouse-mode';
@@ -70,6 +71,18 @@ export function setVideoScale(scale: number): void {
7071
localStorage.setItem(VIDEO_SCALE_KEY, String(scale))
7172
}
7273

74+
export function getVideoRotate(): number | null {
75+
const scale = localStorage.getItem(VIDEO_ROTATE_KEY)
76+
if (scale && Number(scale)) {
77+
return Number(scale)
78+
}
79+
return null
80+
}
81+
82+
export function setVideoRotate(scale: number): void {
83+
localStorage.setItem(VIDEO_ROTATE_KEY, String(scale))
84+
}
85+
7386
export function getIsMenuOpen(): boolean {
7487
const state = localStorage.getItem(IS_MENU_OPEN_KEY);
7588
if (!state) {

0 commit comments

Comments
 (0)