mirror of
https://github.com/sipeed/NanoKVM-USB.git
synced 2026-11-09 17:04:27 -06:00
feat: support video rotation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
56
browser/src/components/menu/video/rotation.tsx
Normal file
56
browser/src/components/menu/video/rotation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -25,6 +25,7 @@ const en = {
|
||||
video: {
|
||||
resolution: 'Resolution',
|
||||
scale: 'Scale',
|
||||
rotation: 'Rotation',
|
||||
customResolution: 'Custom',
|
||||
device: 'Device',
|
||||
custom: {
|
||||
|
||||
@@ -23,6 +23,7 @@ const zh = {
|
||||
video: {
|
||||
resolution: '分辨率',
|
||||
scale: '缩放',
|
||||
rotation: '旋转',
|
||||
customResolution: '自定义',
|
||||
device: '设备',
|
||||
custom: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,6 +3,8 @@ export type Resolution = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type Rotation = 0 | 90 | 180 | 270;
|
||||
|
||||
export type MediaDevice = {
|
||||
videoId: string;
|
||||
videoName: string;
|
||||
|
||||
Reference in New Issue
Block a user