Skip to content

Commit 4af9f72

Browse files
committed
feat(desktop): add video rotate
1 parent 0487cc2 commit 4af9f72

9 files changed

Lines changed: 177 additions & 18 deletions

File tree

desktop/src/renderer/src/App.tsx

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { ReactElement, useEffect, useState } from 'react'
1+
import { ReactElement, useEffect, useRef, useState } from 'react'
22
import { Result, Spin } from 'antd'
33
import clsx from 'clsx'
4-
import { useAtomValue, useSetAtom } from 'jotai'
4+
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
55
import { useTranslation } from 'react-i18next'
66
import { useMediaQuery } from 'react-responsive'
77

@@ -14,13 +14,14 @@ import { VirtualKeyboard } from '@renderer/components/virtual-keyboard'
1414
import {
1515
resolutionAtom,
1616
serialPortStateAtom,
17+
videoRotateAtom,
1718
videoScaleAtom,
1819
videoStateAtom
1920
} from '@renderer/jotai/device'
2021
import { isKeyboardEnableAtom } from '@renderer/jotai/keyboard'
2122
import { mouseStyleAtom } from '@renderer/jotai/mouse'
2223
import { camera } from '@renderer/libs/camera'
23-
import { getVideoResolution } from '@renderer/libs/storage'
24+
import * as storage from '@renderer/libs/storage'
2425
import type { Resolution } from '@renderer/types'
2526

2627
type State = 'loading' | 'success' | 'failed'
@@ -30,17 +31,93 @@ const App = (): ReactElement => {
3031
const isBigScreen = useMediaQuery({ minWidth: 850 })
3132

3233
const videoScale = useAtomValue(videoScaleAtom)
34+
const [videoRotate, setVideoRotate] = useAtom(videoRotateAtom)
3335
const videoState = useAtomValue(videoStateAtom)
3436
const serialPortState = useAtomValue(serialPortStateAtom)
3537
const mouseStyle = useAtomValue(mouseStyleAtom)
3638
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom)
3739
const setResolution = useSetAtom(resolutionAtom)
40+
const canvasRef = useRef<HTMLCanvasElement>(null)
41+
const videoRef = useRef<HTMLVideoElement>(null)
42+
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null)
3843

3944
const [state, setState] = useState<State>('loading')
4045
const [isConnected, setIsConnected] = useState(false)
4146

47+
const videoStyle = clsx(
48+
'block select-none origin-center max-w-full max-h-full object-scale-down',
49+
mouseStyle
50+
)
51+
52+
const renderFrame = (frame: VideoFrame) => {
53+
const canvas = canvasRef.current
54+
const ctx = canvasContextRef.current
55+
if (!canvas || !ctx) return
56+
57+
ctx.clearRect(0, 0, canvas.width, canvas.height)
58+
59+
ctx.save()
60+
ctx.translate(canvas.width / 2, canvas.height / 2)
61+
ctx.rotate((videoRotate * Math.PI) / 180)
62+
63+
ctx.drawImage(
64+
frame,
65+
-frame.displayWidth / 2,
66+
-frame.displayHeight / 2,
67+
frame.displayWidth,
68+
frame.displayHeight
69+
)
70+
71+
ctx.restore()
72+
}
73+
74+
const setCanvas = () => {
75+
const video = videoRef.current
76+
const canvas = canvasRef.current
77+
78+
if (!video || !canvas || video.videoWidth === 0) return
79+
80+
if (videoRotate % 180 === 0) {
81+
canvas.width = video.videoWidth
82+
canvas.height = video.videoHeight
83+
} else {
84+
canvas.width = video.videoHeight
85+
canvas.height = video.videoWidth
86+
}
87+
88+
if (canvasRef.current !== null) {
89+
canvasContextRef.current = canvasRef.current.getContext('2d')
90+
}
91+
92+
if (videoRotate !== 0) {
93+
processVideoFrames()
94+
}
95+
}
96+
97+
const processVideoFrames = () => {
98+
const video = videoRef.current
99+
if (video == null || videoRotate === 0) return
100+
video.requestVideoFrameCallback(() => {
101+
const frame = new VideoFrame(video)
102+
renderFrame(frame)
103+
frame.close()
104+
processVideoFrames()
105+
})
106+
}
107+
42108
useEffect(() => {
43-
const resolution = getVideoResolution()
109+
if (videoRotate !== 0) {
110+
setCanvas()
111+
}
112+
}, [videoRotate])
113+
114+
useEffect(() => {
115+
const rotate = storage.getVideoRotate()
116+
if (rotate) {
117+
setVideoRotate(rotate)
118+
}
119+
120+
const resolution = storage.getVideoResolution()
44121
if (resolution) {
45122
setResolution(resolution)
46123
}
@@ -120,18 +197,24 @@ const App = (): ReactElement => {
120197

121198
<video
122199
id="video"
123-
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
124-
style={{
125-
transform: `scale(${videoScale})`,
126-
transformOrigin: 'center',
127-
maxWidth: '100%',
128-
maxHeight: '100%',
129-
objectFit: 'scale-down'
130-
}}
200+
className={clsx(videoRotate === 0 ? [videoStyle, 'min-h-[480px] min-w-[640px]'] : 'hidden')}
201+
ref={videoRef}
131202
autoPlay
132203
playsInline
204+
onLoadedMetadata={setCanvas}
133205
/>
134206

207+
{videoRotate !== 0 && (
208+
<canvas
209+
id="video-canvas"
210+
ref={canvasRef}
211+
className={videoStyle}
212+
style={{
213+
transform: `scale(${videoScale})`
214+
}}
215+
/>
216+
)}
217+
135218
<VirtualKeyboard isBigScreen={isBigScreen} />
136219
</>
137220
)

desktop/src/renderer/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

55
import { Device } from './device'
66
import { Resolution } from './resolution'
7+
import { Rotate } from './rotate'
78
import { Scale } from './scale'
89

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

desktop/src/renderer/src/components/mouse/absolute.tsx

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

44
import { IpcEvents } from '@common/ipc-events'
5-
import { resolutionAtom } from '@renderer/jotai/device'
5+
import { resolutionAtom, videoRotateAtom } from '@renderer/jotai/device'
66
import { scrollDirectionAtom, scrollIntervalAtom } from '@renderer/jotai/mouse'
77
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
88
import type { Mouse as MouseKey } from '@renderer/types'
@@ -11,6 +11,7 @@ export const Absolute = (): ReactElement => {
1111
const resolution = useAtomValue(resolutionAtom)
1212
const scrollDirection = useAtomValue(scrollDirectionAtom)
1313
const scrollInterval = useAtomValue(scrollIntervalAtom)
14+
const videoRotate = useAtomValue(videoRotateAtom)
1415

1516
const keyRef = useRef<MouseKey>({
1617
left: false,
@@ -20,7 +21,7 @@ export const Absolute = (): ReactElement => {
2021
const lastScrollTimeRef = useRef(0)
2122

2223
useEffect(() => {
23-
const canvas = document.getElementById('video')
24+
const canvas = document.getElementById(videoRotate === 0 ? 'video': 'video-canvas')
2425
if (!canvas) return
2526

2627
canvas.addEventListener('mousedown', handleMouseDown)
@@ -161,7 +162,7 @@ export const Absolute = (): ReactElement => {
161162
canvas.removeEventListener('click', disableEvent)
162163
canvas.removeEventListener('contextmenu', disableEvent)
163164
}
164-
}, [resolution, scrollDirection, scrollInterval])
165+
}, [resolution, scrollDirection, scrollInterval, videoRotate])
165166

166167
function disableEvent(event: MouseEvent): void {
167168
event.preventDefault()

desktop/src/renderer/src/components/mouse/relative.tsx

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

66
import { IpcEvents } from '@common/ipc-events'
7-
import { resolutionAtom } from '@renderer/jotai/device'
7+
import { resolutionAtom, videoRotateAtom } from '@renderer/jotai/device'
88
import { scrollDirectionAtom, scrollIntervalAtom } from '@renderer/jotai/mouse'
99
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
1010
import type { Mouse as MouseKey } from '@renderer/types'
@@ -14,6 +14,7 @@ export const Relative = (): ReactElement => {
1414
const [messageApi, contextHolder] = message.useMessage()
1515

1616
const resolution = useAtomValue(resolutionAtom)
17+
const videoRotate = useAtomValue(videoRotateAtom);
1718
const scrollDirection = useAtomValue(scrollDirectionAtom)
1819
const scrollInterval = useAtomValue(scrollIntervalAtom)
1920

@@ -38,7 +39,7 @@ export const Relative = (): ReactElement => {
3839
}, [])
3940

4041
useEffect(() => {
41-
const canvas = document.getElementById('video')
42+
const canvas = document.getElementById(videoRotate === 0 ? 'video': 'video-canvas');
4243
if (!canvas) return
4344

4445
document.addEventListener('pointerlockchange', handlePointerLockChange)
@@ -149,7 +150,7 @@ export const Relative = (): ReactElement => {
149150
canvas.removeEventListener('wheel', handleWheel)
150151
canvas.removeEventListener('contextmenu', disableEvent)
151152
}
152-
}, [resolution, scrollDirection, scrollInterval])
153+
}, [resolution, scrollDirection, scrollInterval, videoRotate])
153154

154155
function disableEvent(event: MouseEvent): void {
155156
event.preventDefault()

desktop/src/renderer/src/i18n/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const en = {
2727
video: {
2828
resolution: 'Resolution',
2929
scale: 'Scale',
30+
rotate: 'Rotate',
31+
noRotation: 'No Rotation',
3032
customResolution: 'Custom',
3133
device: 'Device',
3234
custom: {

desktop/src/renderer/src/i18n/locales/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const zh = {
2626
video: {
2727
resolution: '分辨率',
2828
scale: '缩放',
29+
rotate: '旋转',
30+
noRotation: '不旋转',
2931
customResolution: '自定义',
3032
device: '设备',
3133
custom: {

desktop/src/renderer/src/jotai/device.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const resolutionAtom = atom<Resolution>({
1111
})
1212

1313
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')

desktop/src/renderer/src/libs/storage/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const LANGUAGE_KEY = 'nanokvm-usb-language'
66
const VIDEO_DEVICE_ID_KEY = 'nanokvm-usb-video-device-id'
77
const VIDEO_RESOLUTION_KEY = 'nanokvm-usb-video-resolution'
88
const VIDEO_SCALE_KEY = 'nanokvm-usb-video-scale'
9+
const VIDEO_ROTATE_KEY = 'nanokvm-usb-video-rotate'
910
const CUSTOM_RESOLUTION_KEY = 'nanokvm-usb-custom-resolution'
1011
const SERIAL_PORT_KEY = 'nanokvm-serial-port'
1112
const IS_MENU_OPEN_KEY = 'nanokvm-is-menu-open'
@@ -78,6 +79,18 @@ export function setVideoScale(scale: number): void {
7879
localStorage.setItem(VIDEO_SCALE_KEY, String(scale))
7980
}
8081

82+
export function getVideoRotate(): number | null {
83+
const scale = localStorage.getItem(VIDEO_ROTATE_KEY)
84+
if (scale && Number(scale)) {
85+
return Number(scale)
86+
}
87+
return null
88+
}
89+
90+
export function setVideoRotate(scale: number): void {
91+
localStorage.setItem(VIDEO_ROTATE_KEY, String(scale))
92+
}
93+
8194
export function getSerialPort(): string | null {
8295
return localStorage.getItem(SERIAL_PORT_KEY)
8396
}

0 commit comments

Comments
 (0)