feat: support video rotation

This commit is contained in:
wj-xiao
2026-01-04 16:48:22 +08:00
parent 052e0263e6
commit 31676d9254
9 changed files with 147 additions and 37 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Alert, Result, Spin } from 'antd';
import clsx from 'clsx';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
@@ -13,6 +13,7 @@ import { VirtualKeyboard } from '@/components/virtual-keyboard';
import {
resolutionAtom,
serialStateAtom,
videoRotationAtom,
videoScaleAtom,
videoStateAtom
} from '@/jotai/device.ts';
@@ -33,17 +34,15 @@ const App = () => {
const serialState = useAtomValue(serialStateAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const setResolution = useSetAtom(resolutionAtom);
const [videoRotation, setVideoRotation] = useAtom(videoRotationAtom);
const [isLoading, setIsLoading] = useState(true);
const [isCameraAvailable, setIsCameraAvailable] = useState(false);
const [shouldSwapDimensions, setShouldSwapDimensions] = useState(false);
useEffect(() => {
const resolution = storage.getVideoResolution();
if (resolution) {
setResolution(resolution);
}
requestMediaPermissions(resolution);
initResolution();
initRotation();
return () => {
camera.close();
@@ -51,6 +50,26 @@ const App = () => {
};
}, []);
useEffect(() => {
setShouldSwapDimensions(videoRotation === 90 || videoRotation === 270);
}, [videoRotation]);
function initResolution() {
const resolution = storage.getVideoResolution();
if (resolution) {
setResolution(resolution);
}
requestMediaPermissions(resolution);
}
function initRotation() {
const rotation = storage.getVideoRotation();
if (rotation) {
setVideoRotation(rotation);
}
}
async function requestMediaPermissions(resolution?: Resolution) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
@@ -112,12 +131,16 @@ const App = () => {
<video
id="video"
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
className={clsx(
'block select-none',
shouldSwapDimensions ? 'min-h-[640px] min-w-[360px]' : 'min-h-[360px] min-w-[640px]',
mouseStyle
)}
style={{
transform: `scale(${videoScale})`,
transform: `scale(${videoScale}) rotate(${videoRotation}deg)`,
transformOrigin: 'center',
maxWidth: '100%',
maxHeight: '100%',
maxWidth: shouldSwapDimensions ? '100vh' : '100%',
maxHeight: shouldSwapDimensions ? '100vw' : '100%',
objectFit: 'scale-down'
}}
autoPlay

View File

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

View File

@@ -0,0 +1,56 @@
import { ReactElement } from 'react';
import { Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { RatioIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { videoRotationAtom } from '@/jotai/device.ts';
import * as storage from '@/libs/storage';
import { Rotation as VideoRotation } from '@/types.ts';
const RotationList: { label: string; value: VideoRotation }[] = [
{ label: '0°', value: 0 },
{ label: '90°', value: 90 },
{ label: '180°', value: 180 },
{ label: '270°', value: 270 }
];
export const Rotation = (): ReactElement => {
const { t } = useTranslation();
const [videoRotation, setVideoRotation] = useAtom(videoRotationAtom);
function updateRotation(rotation: VideoRotation): void {
setVideoRotation(rotation);
storage.setVideoRotation(rotation);
}
const content = (
<>
{RotationList.map((item) => (
<div
key={item.value}
className={clsx(
'flex cursor-pointer select-none items-center space-x-0.5 rounded px-5 py-1.5 hover:bg-neutral-700/60',
item.value === videoRotation ? 'text-blue-500' : 'text-white'
)}
onClick={() => updateRotation(item.value)}
>
<span>{item.label}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [13, 0] }}>
<div className="flex h-[32px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex size-[18px] items-center justify-center">
<RatioIcon size={16} />
</div>
<span>{t('video.rotation')}</span>
</div>
</Popover>
);
};

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { resolutionAtom } from '@/jotai/device.ts';
import { resolutionAtom, videoRotationAtom } from '@/jotai/device.ts';
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
import { device } from '@/libs/device';
import { Key } from '@/libs/device/mouse.ts';
@@ -9,6 +9,7 @@ import { mouseJiggler } from '@/libs/mouse-jiggler';
export const Absolute = () => {
const resolution = useAtomValue(resolutionAtom);
const videoRotation = useAtomValue(videoRotationAtom);
const scrollDirection = useAtomValue(scrollDirectionAtom);
const scrollInterval = useAtomValue(scrollIntervalAtom);
@@ -109,35 +110,41 @@ export const Absolute = () => {
const rect = canvas.getBoundingClientRect();
const videoElement = canvas as HTMLVideoElement;
if (!videoElement.videoWidth || !videoElement.videoHeight) {
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
return { x, y };
}
const videoWidth = videoElement.videoWidth || 1920;
const videoHeight = videoElement.videoHeight || 1080;
const videoRatio = videoElement.videoWidth / videoElement.videoHeight;
const elementRatio = rect.width / rect.height;
const screenVideoRatio =
videoRotation === 90 || videoRotation === 270
? videoHeight / videoWidth
: videoWidth / videoHeight;
let renderedWidth = rect.width;
let renderedHeight = rect.height;
let offsetX = 0;
let offsetY = 0;
const containerWidth = rect.width;
const containerHeight = rect.height;
const containerRatio = containerWidth / containerHeight;
if (videoRatio > elementRatio) {
renderedHeight = rect.width / videoRatio;
offsetY = (rect.height - renderedHeight) / 2;
let renderedWidth: number;
let renderedHeight: number;
if (screenVideoRatio > containerRatio) {
renderedWidth = containerWidth;
renderedHeight = containerWidth / screenVideoRatio;
} else {
renderedWidth = rect.height * videoRatio;
offsetX = (rect.width - renderedWidth) / 2;
renderedHeight = containerHeight;
renderedWidth = containerHeight * screenVideoRatio;
}
const x = (clientX - rect.left - offsetX) / renderedWidth;
const y = (clientY - rect.top - offsetY) / renderedHeight;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const relX = clientX - centerX;
const relY = clientY - centerY;
const finalX = Math.max(0, Math.min(1, x));
const finalY = Math.max(0, Math.min(1, y));
let x = relX / renderedWidth + 0.5;
let y = relY / renderedHeight + 0.5;
return { x: finalX, y: finalY };
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
return { x, y };
}
return () => {
@@ -148,7 +155,7 @@ export const Absolute = () => {
canvas.removeEventListener('click', disableEvent);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution, scrollDirection, scrollInterval]);
}, [resolution, scrollDirection, scrollInterval, videoRotation]);
// disable default events
function disableEvent(event: any) {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { atom } from 'jotai';
import type { Resolution } from '@/types.ts';
import type { Resolution, Rotation } from '@/types.ts';
type VideoState = 'disconnected' | 'connecting' | 'connected';
type SerialState = 'notSupported' | 'disconnected' | 'connecting' | 'connected';
@@ -10,7 +10,9 @@ export const resolutionAtom = atom<Resolution>({
height: 1080
});
export const videoScaleAtom = atom<number>(1.0)
export const videoScaleAtom = atom<number>(1.0);
export const videoRotationAtom = atom<Rotation>(0);
export const videoDeviceIdAtom = atom('');
export const videoStateAtom = atom<VideoState>('disconnected');

View File

@@ -1,10 +1,11 @@
import type { Resolution } from '@/types';
import type { Resolution, Rotation } from '@/types';
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 VIDEO_ROTATION_KEY = 'nanokvm-usb-video-rotation';
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';
@@ -71,6 +72,21 @@ export function setVideoScale(scale: number): void {
localStorage.setItem(VIDEO_SCALE_KEY, String(scale));
}
export function getVideoRotation(): Rotation | null {
const rotation = localStorage.getItem(VIDEO_ROTATION_KEY);
if (rotation) {
const value = Number(rotation);
if (value === 0 || value === 90 || value === 180 || value === 270) {
return value as Rotation;
}
}
return null;
}
export function setVideoRotation(rotation: Rotation): void {
localStorage.setItem(VIDEO_ROTATION_KEY, String(rotation));
}
export function getIsMenuOpen(): boolean {
const state = localStorage.getItem(IS_MENU_OPEN_KEY);
if (!state) {

View File

@@ -3,6 +3,8 @@ export type Resolution = {
height: number;
};
export type Rotation = 0 | 90 | 180 | 270;
export type MediaDevice = {
videoId: string;
videoName: string;