Merge pull request #1 from sipeed/main

Merge from Master.
This commit is contained in:
Geovanny Fajardo
2025-04-18 10:46:50 -04:00
committed by GitHub
19 changed files with 505 additions and 232 deletions

View File

@@ -106,7 +106,7 @@ const App = () => {
<video
id="video"
className={clsx('block select-none', mouseStyle)}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
autoPlay
playsInline

View File

@@ -1,40 +1,22 @@
import { useEffect, useState } from 'react';
import { Button, Modal, Select } from 'antd';
import { useAtom, useAtomValue } from 'jotai';
import { Modal } from 'antd';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import {
resolutionAtom,
serialStateAtom,
videoDeviceIdAtom,
videoStateAtom
} from '@/jotai/device.ts';
import { camera } from '@/libs/camera';
import { device } from '@/libs/device';
import * as storage from '@/libs/storage';
import { serialStateAtom, videoStateAtom } from '@/jotai/device.ts';
type MediaDevice = {
value: string;
label: string;
};
import { SerialPort } from './serial-port';
import { Video } from './video';
export const DeviceModal = () => {
const { t } = useTranslation();
const resolution = useAtomValue(resolutionAtom);
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
const [videoState, setVideoState] = useAtom(videoStateAtom);
const [serialState, setSerialState] = useAtom(serialStateAtom);
const videoState = useAtomValue(videoStateAtom);
const serialState = useAtomValue(serialStateAtom);
const [isOpen, setIsOpen] = useState(false);
const [devices, setDevices] = useState<MediaDevice[]>([]);
const [errMsg, setErrMsg] = useState('');
useEffect(() => {
getVideoDevices(false);
checkSerial();
}, []);
useEffect(() => {
if (videoState === 'connected') {
if (serialState === 'notSupported' || serialState === 'connected') {
@@ -46,102 +28,11 @@ export const DeviceModal = () => {
setIsOpen(true);
}, [videoState, serialState]);
// get video input devices
async function getVideoDevices(autoOpen: boolean) {
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput');
setDevices(videoDevices.map((device) => ({ value: device.deviceId, label: device.label })));
if (autoOpen) {
const deviceId = storage.getVideoDevice();
if (deviceId && videoDevices.some((device) => device.deviceId === deviceId)) {
await selectVideo(deviceId);
}
}
}
// select video input device
async function selectVideo(deviceId: string) {
if (videoState === 'connecting') return;
if (!deviceId) {
setVideoDeviceId('');
return;
}
setVideoState('connecting');
setErrMsg('');
try {
const success = await camera.open(deviceId, resolution.width, resolution.height);
if (!success) return;
const video = document.getElementById('video') as HTMLVideoElement;
if (!video) return;
video.srcObject = camera.getStream();
setVideoState('connected');
setVideoDeviceId(deviceId);
storage.setVideoDevice(deviceId);
} catch (err) {
console.log(err);
setSerialState('disconnected');
setErrMsg(t('camera.failed'));
}
}
// check the web serial api
function checkSerial() {
const isWebSerialSupported = 'serial' in navigator;
const state = isWebSerialSupported ? 'disconnected' : 'notSupported';
setSerialState(state);
}
// select serial port
async function selectSerial() {
if (serialState === 'connecting') return;
setSerialState('connecting');
setErrMsg('');
try {
const port = await navigator.serial.requestPort();
await device.serialPort.init(port);
setSerialState('connected');
} catch (err) {
console.log(err);
setSerialState('disconnected');
setErrMsg(t('serial.failed'));
}
}
return (
<Modal open={isOpen} title={t('modal.title')} footer={null} closable={false} destroyOnClose>
<div className="flex flex-col items-center justify-center space-y-5 py-10">
<Select
value={videoDeviceId || undefined}
style={{ width: 250 }}
options={devices}
allowClear={true}
loading={videoState === 'connecting'}
placeholder={t('modal.selectVideo')}
onChange={selectVideo}
onClick={() => getVideoDevices(false)}
/>
{serialState !== 'notSupported' && (
<Button
type="primary"
className="w-[250px]"
loading={serialState === 'connecting'}
onClick={selectSerial}
>
{t('modal.selectSerial')}
</Button>
)}
<Video setErrMsg={setErrMsg} />
<SerialPort setErrMsg={setErrMsg} />
{errMsg && <span className="text-xs text-red-500">{errMsg}</span>}
</div>

View File

@@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { Button } from 'antd';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { serialStateAtom } from '@/jotai/device.ts';
import { device } from '@/libs/device';
type SerialPortProps = {
setErrMsg: (msg: string) => void;
};
export const SerialPort = ({ setErrMsg }: SerialPortProps) => {
const { t } = useTranslation();
const [serialState, setSerialState] = useAtom(serialStateAtom);
useEffect(() => {
checkSerialPort();
}, []);
function checkSerialPort() {
const isWebSerialSupported = 'serial' in navigator;
const state = isWebSerialSupported ? 'disconnected' : 'notSupported';
setSerialState(state);
}
async function selectSerialPort() {
if (serialState === 'connecting') return;
setSerialState('connecting');
setErrMsg('');
try {
const port = await navigator.serial.requestPort();
await device.serialPort.init(port);
setSerialState('connected');
} catch (err) {
console.log(err);
setSerialState('disconnected');
setErrMsg(t('serial.failed'));
}
}
return (
<>
{serialState !== 'notSupported' && (
<Button
type="primary"
className="w-[250px]"
loading={serialState === 'connecting'}
onClick={selectSerialPort}
>
{t('modal.selectSerial')}
</Button>
)}
</>
);
};

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Select } from 'antd';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { resolutionAtom, videoDeviceIdAtom, videoStateAtom } from '@/jotai/device.ts';
import { camera } from '@/libs/camera';
import * as storage from '@/libs/storage';
import type { MediaDevice } from '@/types';
type VideoProps = {
setErrMsg: (msg: string) => void;
};
export const Video = ({ setErrMsg }: VideoProps) => {
const { t } = useTranslation();
const resolution = useAtomValue(resolutionAtom);
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
const [videoState, setVideoState] = useAtom(videoStateAtom);
const [devices, setDevices] = useState<MediaDevice[]>([]);
useEffect(() => {
getDevices();
}, []);
async function getDevices() {
try {
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput');
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput');
const mediaDevices = videoDevices.map((videoDevice) => {
const device: MediaDevice = {
videoId: videoDevice.deviceId,
videoName: videoDevice.label
};
if (videoDevice.groupId) {
const matchedAudioDevice = audioDevices.find(
(audioDevice) => audioDevice.groupId === videoDevice.groupId
);
if (matchedAudioDevice) {
device.audioId = matchedAudioDevice.deviceId;
device.audioName = matchedAudioDevice.label;
}
}
return device;
});
setDevices(mediaDevices);
} catch (err) {
console.log(err);
setErrMsg(t('camera.failed'));
}
}
async function selectVideo(videoId: string) {
if (videoState === 'connecting') return;
if (!videoId) {
setVideoDeviceId('');
return;
}
const device = devices.find((d) => d.videoId === videoId);
if (!device) {
return;
}
setVideoState('connecting');
setErrMsg('');
try {
await camera.open(videoId, resolution.width, resolution.height, device.audioId);
} catch (err) {
console.log(err);
setErrMsg(t('camera.failed'));
}
const video = document.getElementById('video') as HTMLVideoElement;
if (!video) return;
video.srcObject = camera.getStream();
setVideoState('connected');
setVideoDeviceId(videoId);
storage.setVideoDevice(videoId);
}
return (
<Select
value={videoDeviceId || undefined}
style={{ width: 250 }}
options={devices}
fieldNames={{
value: 'videoId',
label: 'videoName'
}}
allowClear={true}
loading={videoState === 'connecting'}
placeholder={t('modal.selectVideo')}
onChange={selectVideo}
onClick={getDevices}
/>
);
};

View File

@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { resolutionAtom, videoDeviceIdAtom } from '@/jotai/device.ts';
import { camera } from '@/libs/camera';
import * as storage from '@/libs/storage';
import type { MediaDevice } from '@/types';
export const Device = () => {
const { t } = useTranslation();
@@ -15,30 +16,57 @@ export const Device = () => {
const resolution = useAtomValue(resolutionAtom);
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [devices, setDevices] = useState<MediaDevice[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
navigator.mediaDevices.enumerateDevices().then((deviceInfo) => {
const videoDevices = deviceInfo.filter((device) => device.kind === 'videoinput');
setDevices(videoDevices);
});
getDevices();
}, []);
async function selectDevice(deviceId: string) {
async function getDevices() {
try {
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput');
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput');
const mediaDevices = videoDevices.map((videoDevice) => {
const device: MediaDevice = {
videoId: videoDevice.deviceId,
videoName: videoDevice.label
};
if (videoDevice.groupId) {
const matchedAudioDevice = audioDevices.find(
(audioDevice) => audioDevice.groupId === videoDevice.groupId
);
if (matchedAudioDevice) {
device.audioId = matchedAudioDevice.deviceId;
device.audioName = matchedAudioDevice.label;
}
}
return device;
});
setDevices(mediaDevices);
} catch (err) {
console.log(err);
}
}
async function selectDevice(device: MediaDevice) {
if (isLoading) return;
setIsLoading(true);
try {
const success = await camera.open(deviceId, resolution.width, resolution.height);
if (!success) return;
await camera.open(device.videoId, resolution.width, resolution.height, device.audioId);
const video = document.getElementById('video') as HTMLVideoElement;
if (!video) return;
video.srcObject = camera.getStream();
setVideoDeviceId(deviceId);
storage.setVideoDevice(deviceId);
setVideoDeviceId(device.videoId);
storage.setVideoDevice(device.videoId);
} finally {
setIsLoading(false);
}
@@ -46,16 +74,16 @@ export const Device = () => {
const content = (
<>
{devices.map((device: MediaDeviceInfo) => (
{devices.map((device) => (
<div
key={device.deviceId}
key={device.videoId}
className={clsx(
'cursor-pointer rounded px-2 py-1.5 hover:bg-neutral-700/60',
device.deviceId === videoDeviceId ? 'text-blue-500' : 'text-white'
device.videoId === videoDeviceId ? 'text-blue-500' : 'text-white'
)}
onClick={() => selectDevice(device.deviceId)}
onClick={() => selectDevice(device)}
>
{device.label}
{device.videoName}
</div>
))}
</>

View File

@@ -64,8 +64,12 @@ export const Resolution = () => {
}
async function updateResolution(w: number, h: number) {
const success = await camera.open('', w, h);
if (!success) return;
try {
await camera.updateResolution(w, h);
} catch (err) {
console.log(err);
return;
}
const video = document.getElementById('video') as HTMLVideoElement;
if (!video) return;

View File

@@ -1,5 +1,6 @@
const languages = [
{ key: 'en', name: 'English' },
{ key: 'ru', name: 'Русский' },
{ key: 'zh', name: '中文' }
];

View File

@@ -0,0 +1,65 @@
const ru = {
translation: {
serial: {
notSupported:
'Подключение к последовательному порту не поддерживается. Чтобы пользоваться мышью и клавиатурой, используйте браузер Chrome для настольных компьютеров.',
failed: 'Не удалось установить подключение к последовательному порту. Пожалуйста, попробуйте еще раз.'
},
camera: {
tip: 'Ожидание доступа...',
denied: 'Доступ отказан',
authorize:
'Для удаленного рабочего стола требуется доступ к камере. Пожалуйста, разрешите доступ к камере в настройках браузера.',
failed: 'Не удалось подключить камеру. Пожалуйста, попробуйте еще раз.'
},
modal: {
title: 'Выберите устройство USB',
selectVideo: 'Выбрать источник видео',
selectSerial: 'Выбрать последовательный порт'
},
menu: {
serial: 'Последовательный порт',
keyboard: 'Клавиатура',
mouse: 'Мышь'
},
video: {
resolution: 'Разрешение',
customResolution: 'Пользовательское',
device: 'Видеоустройство',
custom: {
title: 'Пользовательское разрешение',
width: 'Ширина',
height: 'Высота',
confirm: 'Ок',
cancel: 'Отмена'
}
},
keyboard: {
paste: 'Вставить текст',
virtualKeyboard: 'Виртуальная клавиатура'
},
mouse: {
cursor: {
title: 'Курсор',
pointer: 'Указатель',
grab: 'Захват',
cell: 'Прицел',
hide: 'Скрытый'
},
mode: 'Режим мыши',
absolute: 'Абсолютное позиционирование',
relative: 'Относительное позиционирование',
direction: 'Направление прокрутки',
scrollUp: 'Обычное',
scrollDown: 'Инвертированное',
requestPointer: 'Используется относительное позиционирование мыши. Чтобы захватить курсор, щелкните по видео на экране'
},
settings: {
language: 'Язык приложения',
document: 'Документация',
download: 'Загрузить'
}
}
};
export default ru;

View File

@@ -2,35 +2,30 @@ class Camera {
id: string = '';
width: number = 1920;
height: number = 1080;
audioId: string = '';
stream: MediaStream | null = null;
public async open(id?: string, width?: number, height?: number): Promise<boolean> {
public async open(id: string, width: number, height: number, audioId?: string) {
if (!id && !this.id) {
return false;
return;
}
try {
this.close();
this.close();
const constraints = {
video: {
deviceId: { exact: id || this.id },
width: { ideal: width || this.width },
height: { ideal: height || this.height }
},
audio: true
};
const constraints = {
video: { deviceId: { exact: id }, width: { ideal: width }, height: { ideal: height } },
audio: audioId ? { deviceId: { exact: audioId } } : false
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
if (id) this.id = id;
if (width) this.width = width;
if (height) this.height = height;
this.id = id;
this.width = width;
this.height = height;
if (audioId) this.audioId = audioId;
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
}
return true;
} catch (err) {
console.log(err);
return false;
}
public async updateResolution(width: number, height: number) {
return this.open(this.id, width, height, this.audioId);
}
public close(): void {

View File

@@ -2,3 +2,10 @@ export type Resolution = {
width: number;
height: number;
};
export type MediaDevice = {
videoId: string;
videoName: string;
audioId?: string;
audioName?: string;
};

View File

@@ -2,8 +2,17 @@
This is the NanoKVM-USB desktop version project.
## Development
Linux build tool chain:
```shell
sudo apt update
sudo apt install -y build-essential python3 libudev-dev
echo "python=/usr/bin/python3.10" >> ~/.npmrc # This should match you pyton version.
```
```shell
cd desktop
pnpm install
@@ -22,3 +31,10 @@ pnpm build:mac
# For Linux
pnpm build:linux
```
# For Linux run and install
```shell
dpkg -i dist/nanokvm-usb_1.0.0_amd64.deb
sudo chown root:root /opt/NanoKVM-USB/chrome-sandbox
sudo chmod 4755 /opt/NanoKVM-USB/chrome-sandbox
```

View File

@@ -1,7 +1,9 @@
appId: com.sipeed.usbkvm
productName: NanoKVM-USB
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
@@ -9,30 +11,39 @@ files:
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: NanoKVM-USB
nsis:
onClick: false
oneClick: false # show installer wizard
perMachine: true # install for all users
allowToChangeInstallationDirectory: true
allowElevation: true # request elevation when needed
artifactName: ${productName}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
createDesktopShortcut: true
createStartMenuShortcut: true
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
NSCameraUsageDescription: Application requests access to the device's camera.
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
hardenedRuntime: true
gatekeeperAssess: false
notarize: false
dmg:
artifactName: ${productName}-${version}.${ext}
sign: true
linux:
target:
- AppImage
@@ -40,13 +51,18 @@ linux:
- deb
maintainer: sipeed.com
category: Utility
appImage:
artifactName: ${productName}-${version}.${ext}
npmRebuild: false
afterSign: "./notarize.js"
publish:
provider: github
owner: sipeed
repo: NanoKVM-USB
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/

View File

@@ -1,6 +1,6 @@
import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, shell, session } from 'electron'
import log from 'electron-log/main'
import icon from '../../resources/icon.png?asset'
@@ -26,6 +26,7 @@ function createWindow(): void {
mainWindow.on('ready-to-show', () => {
mainWindow.show()
mainWindow.maximize()
//mainWindow.webContents.openDevTools()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
@@ -43,6 +44,14 @@ function createWindow(): void {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.sipeed.usbkvm')
session.defaultSession.setPermissionRequestHandler((_, permission, callback) => {
if (permission === 'media') {
callback(true)
} else {
callback(false)
}
})
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})

View File

@@ -114,7 +114,7 @@ const App = (): ReactElement => {
<video
id="video"
className={clsx('block select-none', mouseStyle)}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
autoPlay
playsInline

View File

@@ -5,12 +5,8 @@ import { useTranslation } from 'react-i18next'
import { resolutionAtom, videoDeviceIdAtom, videoStateAtom } from '@renderer/jotai/device'
import { camera } from '@renderer/libs/camera'
import { getVideoDevice, setVideoDevice } from '@renderer/libs/storage'
type MediaDevice = {
value: string
label: string
}
import * as storage from '@renderer/libs/storage'
import type { MediaDevice } from '@renderer/types'
type VideoProps = {
setMsg: (msg: string) => void
@@ -23,43 +19,73 @@ export const Video = ({ setMsg }: VideoProps): ReactElement => {
const [videoState, setVideoState] = useAtom(videoStateAtom)
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom)
const [videoDevices, setVideoDevices] = useState<MediaDevice[]>([])
const [devices, setDevices] = useState<MediaDevice[]>([])
useEffect(() => {
getVideoDevices(true)
getDevices(true)
}, [])
async function getVideoDevices(autoOpen: boolean): Promise<void> {
const allDevices = await navigator.mediaDevices.enumerateDevices()
async function getDevices(autoOpen: boolean): Promise<void> {
try {
const allDevices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput')
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput')
const devices = allDevices
.filter((device) => device.kind === 'videoinput')
.map((device) => ({ value: device.deviceId, label: device.label }))
const mediaDevices = videoDevices.map((videoDevice) => {
const device: MediaDevice = {
videoId: videoDevice.deviceId,
videoName: videoDevice.label
}
setVideoDevices(devices)
if (videoDevice.groupId) {
const matchedAudioDevice = audioDevices.find(
(audioDevice) => audioDevice.groupId === videoDevice.groupId
)
if (matchedAudioDevice) {
device.audioId = matchedAudioDevice.deviceId
device.audioName = matchedAudioDevice.label
}
}
if (autoOpen) {
const deviceId = getVideoDevice()
if (deviceId && devices.some((device) => device.value === deviceId)) {
await selectVideo(deviceId)
return device
})
setDevices(mediaDevices)
if (autoOpen) {
const videoId = storage.getVideoDevice()
if (!videoId) return
const device = mediaDevices.find((d) => d.videoId === videoId)
if (!device) return
await openCamera(device.videoId, device.audioId)
}
} catch (err) {
console.log(err)
setMsg(t('camera.failed'))
}
}
async function selectVideo(deviceId: string): Promise<void> {
if (!deviceId) {
async function selectDevice(videoId: string): Promise<void> {
if (!videoId) {
setVideoDeviceId('')
return
}
if (videoState === 'connecting') return
setVideoState('connecting')
setMsg('')
const device = devices.find((d) => d.videoId === videoId)
if (!device) {
return
}
await openCamera(device.videoId, device.audioId)
}
async function openCamera(videoId: string, audioId?: string): Promise<void> {
try {
const success = await camera.open(deviceId, resolution.width, resolution.height)
if (!success) return
await camera.open(videoId, resolution.width, resolution.height, audioId)
const video = document.getElementById('video') as HTMLVideoElement
if (!video) return
@@ -67,8 +93,8 @@ export const Video = ({ setMsg }: VideoProps): ReactElement => {
video.srcObject = camera.getStream()
setVideoState('connected')
setVideoDeviceId(deviceId)
setVideoDevice(deviceId)
setVideoDeviceId(videoId)
storage.setVideoDevice(videoId)
} catch (err) {
const msg = err instanceof Error ? err.message : t('camera.failed')
setMsg(msg)
@@ -79,12 +105,16 @@ export const Video = ({ setMsg }: VideoProps): ReactElement => {
<Select
value={videoDeviceId || undefined}
style={{ width: 280 }}
options={videoDevices}
options={devices}
fieldNames={{
value: 'videoId',
label: 'videoName'
}}
allowClear={true}
loading={videoState === 'connecting'}
placeholder={t('modal.selectVideo')}
onChange={selectVideo}
onClick={() => getVideoDevices(false)}
onChange={selectDevice}
onClick={() => getDevices(false)}
/>
)
}

View File

@@ -8,36 +8,73 @@ import { useTranslation } from 'react-i18next'
import { resolutionAtom, videoDeviceIdAtom } from '@renderer/jotai/device'
import { camera } from '@renderer/libs/camera'
import * as storage from '@renderer/libs/storage'
import type { MediaDevice } from '@renderer/types'
export const Device = (): ReactElement => {
const { t } = useTranslation()
const resolution = useAtomValue(resolutionAtom)
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom)
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
const [devices, setDevices] = useState<MediaDevice[]>([])
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
navigator.mediaDevices.enumerateDevices().then((deviceInfo) => {
const videoDevices = deviceInfo.filter((device) => device.kind === 'videoinput')
setDevices(videoDevices)
})
getDevices()
}, [])
async function selectDevice(deviceId: string): Promise<void> {
async function getDevices(): Promise<void> {
try {
await navigator.mediaDevices.getUserMedia({ video: true })
const allDevices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput')
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput')
const mediaDevices = videoDevices.map((videoDevice) => {
const device: MediaDevice = {
videoId: videoDevice.deviceId,
videoName: videoDevice.label
}
if (videoDevice.groupId) {
const matchedAudioDevice = audioDevices.find(
(audioDevice) => audioDevice.groupId === videoDevice.groupId
)
if (matchedAudioDevice) {
device.audioId = matchedAudioDevice.deviceId
device.audioName = matchedAudioDevice.label
}
}
return device
})
setDevices(mediaDevices)
} catch (err) {
console.log(err)
}
}
async function selectDevice(device: MediaDevice): Promise<void> {
if (isLoading) return
setIsLoading(true)
try {
const success = await camera.open(deviceId, resolution.width, resolution.height)
if (!success) return
await camera.open(device.videoId, resolution.width, resolution.height, device.audioId)
const video = document.getElementById('video') as HTMLVideoElement
if (!video) return
video.srcObject = camera.getStream()
setVideoDeviceId(deviceId)
storage.setVideoDevice(deviceId)
// Start playback explicitly
try {
await video.play()
} catch (err) {
console.error('video.play() failed:', err)
}
setVideoDeviceId(device.videoId)
storage.setVideoDevice(device.videoId)
} finally {
setIsLoading(false)
}
@@ -45,16 +82,16 @@ export const Device = (): ReactElement => {
const content = (
<div className="max-h-[350px] overflow-y-auto">
{devices.map((device: MediaDeviceInfo) => (
{devices.map((device) => (
<div
key={device.deviceId}
key={device.videoId}
className={clsx(
'cursor-pointer rounded px-2 py-1.5 hover:bg-neutral-700/60',
device.deviceId === videoDeviceId ? 'text-blue-500' : 'text-white'
device.videoId === videoDeviceId ? 'text-blue-500' : 'text-white'
)}
onClick={() => selectDevice(device.deviceId)}
onClick={() => selectDevice(device)}
>
{device.label}
{device.videoName}
</div>
))}
</div>

View File

@@ -65,8 +65,12 @@ export const Resolution = (): ReactElement => {
}
async function updateResolution(w: number, h: number): Promise<void> {
const success = await camera.open('', w, h)
if (!success) return
try {
await camera.updateResolution(w, h)
} catch (err) {
console.log(err)
return
}
const video = document.getElementById('video') as HTMLVideoElement
if (!video) return

View File

@@ -2,35 +2,30 @@ class Camera {
id: string = ''
width: number = 1920
height: number = 1080
audioId: string = ''
stream: MediaStream | null = null
public async open(id?: string, width?: number, height?: number): Promise<boolean> {
public async open(id: string, width: number, height: number, audioId?: string): Promise<void> {
if (!id && !this.id) {
return false
return
}
try {
this.close()
this.close()
const constraints = {
video: {
deviceId: { exact: id || this.id },
width: { ideal: width || this.width },
height: { ideal: height || this.height }
},
audio: true
}
this.stream = await navigator.mediaDevices.getUserMedia(constraints)
if (id) this.id = id
if (width) this.width = width
if (height) this.height = height
return true
} catch (err) {
console.log(err)
return false
const constraints = {
video: { deviceId: { exact: id }, width: { ideal: width }, height: { ideal: height } },
audio: audioId ? { deviceId: { exact: audioId } } : false
}
this.id = id
this.width = width
this.height = height
if (audioId) this.audioId = audioId
this.stream = await navigator.mediaDevices.getUserMedia(constraints)
}
public async updateResolution(width: number, height: number): Promise<void> {
return this.open(this.id, width, height, this.audioId)
}
public close(): void {

View File

@@ -3,6 +3,13 @@ export type Resolution = {
height: number
}
export type MediaDevice = {
videoId: string
videoName: string
audioId?: string
audioName?: string
}
export type Mouse = {
left: boolean
right: boolean