mirror of
https://github.com/sipeed/NanoKVM-USB.git
synced 2026-09-11 05:59:57 -05:00
refactor: split camera and microphone permissions
This commit is contained in:
@@ -19,8 +19,9 @@ import {
|
||||
} from '@/jotai/device.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { camera } from '@/libs/camera';
|
||||
import { device } from '@/libs/device';
|
||||
import { camera } from '@/libs/media/camera';
|
||||
import { checkPermission, requestCameraPermission } from '@/libs/media/permission.ts';
|
||||
import * as storage from '@/libs/storage';
|
||||
import type { Resolution } from '@/types.ts';
|
||||
|
||||
@@ -37,7 +38,7 @@ const App = () => {
|
||||
const [videoRotation, setVideoRotation] = useAtom(videoRotationAtom);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCameraAvailable, setIsCameraAvailable] = useState(false);
|
||||
const [isCameraGranted, setIsCameraGranted] = useState(false);
|
||||
const [shouldSwapDimensions, setShouldSwapDimensions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,7 +61,7 @@ const App = () => {
|
||||
setResolution(resolution);
|
||||
}
|
||||
|
||||
requestMediaPermissions(resolution);
|
||||
requestPermission(resolution);
|
||||
}
|
||||
|
||||
function initRotation() {
|
||||
@@ -70,41 +71,28 @@ const App = () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestMediaPermissions(resolution?: Resolution) {
|
||||
async function requestPermission(resolution?: Resolution) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: resolution?.width || 1920 },
|
||||
height: { ideal: resolution?.height || 1080 },
|
||||
frameRate: { ideal: 60 }
|
||||
},
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
sampleRate: 48000
|
||||
}
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const isGranted = await checkPermission('camera');
|
||||
if (isGranted) {
|
||||
setIsCameraGranted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCameraAvailable(true);
|
||||
const isSuccess = await requestCameraPermission(resolution);
|
||||
setIsCameraGranted(isSuccess);
|
||||
} catch (err: any) {
|
||||
console.log('failed to request media permissions: ', err);
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
setIsCameraAvailable(false);
|
||||
} else {
|
||||
setIsCameraAvailable(true);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spin size="large" spinning={isLoading} tip={t('camera.tip')} fullscreen />;
|
||||
}
|
||||
|
||||
if (!isCameraAvailable) {
|
||||
if (!isCameraGranted) {
|
||||
return (
|
||||
<Result
|
||||
status="info"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAtom, useSetAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { serialStateAtom, videoDeviceIdAtom, videoStateAtom } from '@/jotai/device.ts';
|
||||
import { camera } from '@/libs/camera';
|
||||
import { camera } from '@/libs/media/camera.ts';
|
||||
|
||||
import { SerialPort } from './serial-port';
|
||||
import { Video } from './video';
|
||||
|
||||
@@ -18,8 +18,9 @@ export const SerialPort = ({ setErrMsg, onDisconnect }: SerialPortProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
const isWebSerialSupported = 'serial' in navigator;
|
||||
const state = isWebSerialSupported ? 'disconnected' : 'notSupported';
|
||||
setSerialState(state);
|
||||
if (!isWebSerialSupported) {
|
||||
setSerialState('notSupported');
|
||||
}
|
||||
}, [setSerialState]);
|
||||
|
||||
const selectSerialPort = async () => {
|
||||
@@ -45,7 +46,7 @@ export const SerialPort = ({ setErrMsg, onDisconnect }: SerialPortProps) => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
type={serialState === 'connected' ? 'primary' : 'default'}
|
||||
className="w-[250px]"
|
||||
loading={serialState === 'connecting'}
|
||||
onClick={selectSerialPort}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolutionAtom, videoDeviceIdAtom, videoStateAtom } from '@/jotai/device.ts';
|
||||
import { camera } from '@/libs/camera';
|
||||
import { camera } from '@/libs/media/camera';
|
||||
import * as storage from '@/libs/storage';
|
||||
import type { MediaDevice } from '@/types';
|
||||
|
||||
|
||||
78
browser/src/components/menu/audio/index.tsx
Normal file
78
browser/src/components/menu/audio/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { VolumeOffIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { videoDeviceIdAtom, videoStateAtom } from '@/jotai/device.ts';
|
||||
import { camera } from '@/libs/media/camera.ts';
|
||||
import { checkPermission, requestMicrophonePermission } from '@/libs/media/permission.ts';
|
||||
|
||||
export const Audio = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setVideoState = useSetAtom(videoStateAtom);
|
||||
const setVideoDeviceId = useSetAtom(videoDeviceIdAtom);
|
||||
|
||||
const [isGranted, setIsGranted] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkPermission('microphone').then((granted) => {
|
||||
setIsGranted(granted);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function requestPermission() {
|
||||
try {
|
||||
const granted = await requestMicrophonePermission();
|
||||
if (!granted) {
|
||||
setIsModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoDeviceId('');
|
||||
setVideoState('disconnected');
|
||||
setIsGranted(granted);
|
||||
|
||||
camera.close();
|
||||
} catch (err: any) {
|
||||
console.log('failed to request media permissions: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
|
||||
if (isGranted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/70 hover:text-white"
|
||||
onClick={requestPermission}
|
||||
>
|
||||
<VolumeOffIcon size={18} />
|
||||
</div>
|
||||
|
||||
<Modal open={isModalOpen} title={t('audio.tip')} footer={null} onCancel={closeModal}>
|
||||
<div className="whitespace-pre-line py-5">{t('audio.permission')}</div>
|
||||
<a
|
||||
href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_USB/quick_start.html#Authorization"
|
||||
target="_blank"
|
||||
>
|
||||
{t('audio.viewDoc')}
|
||||
</a>
|
||||
|
||||
<div className="flex w-full justify-center pt-8">
|
||||
<Button type="primary" className="min-w-20" onClick={closeModal}>
|
||||
{t('audio.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import Draggable from 'react-draggable';
|
||||
import { serialStateAtom } from '@/jotai/device.ts';
|
||||
import * as storage from '@/libs/storage';
|
||||
|
||||
import { Audio } from './audio';
|
||||
import { Fullscreen } from './fullscreen';
|
||||
import { Keyboard } from './keyboard';
|
||||
import { Mouse } from './mouse';
|
||||
@@ -87,6 +88,7 @@ export const Menu = () => {
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Video />
|
||||
<Audio />
|
||||
|
||||
{serialState === 'connected' && (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Video } from 'lucide-react';
|
||||
|
||||
import { camera } from '@/libs/camera';
|
||||
import { camera } from '@/libs/media/camera';
|
||||
|
||||
export const Recorder = () => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VideoIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolutionAtom, videoDeviceIdAtom } from '@/jotai/device.ts';
|
||||
import { camera } from '@/libs/camera';
|
||||
import { camera } from '@/libs/media/camera';
|
||||
import * as storage from '@/libs/storage';
|
||||
import type { MediaDevice } from '@/types';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolutionAtom } from '@/jotai/device.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { camera } from '@/libs/camera';
|
||||
import { camera } from '@/libs/media/camera.ts';
|
||||
import * as storage from '@/libs/storage';
|
||||
import type { Resolution as VideoResolution } from '@/types';
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ const en = {
|
||||
cancel: 'Cancel'
|
||||
}
|
||||
},
|
||||
audio: {
|
||||
tip: 'Tip',
|
||||
permission:
|
||||
'Microphone access is required to connect your USB audio device. The operating system classifies USB inputs as microphones, so this permission is necessary.\n\nThis action is solely for device connectivity and does not enable audio recording.',
|
||||
viewDoc: 'View document.',
|
||||
ok: 'Ok'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Paste',
|
||||
virtualKeyboard: 'Keyboard',
|
||||
|
||||
@@ -34,6 +34,13 @@ const zh = {
|
||||
cancel: '取消'
|
||||
}
|
||||
},
|
||||
audio: {
|
||||
tip: '提示',
|
||||
permission:
|
||||
'网页需要麦克风权限来获取 USB 设备的音频信号。因为电脑系统会将 USB 音频输入设备识别为麦克风,而非扬声器。\n\n此操作仅用于设备连接,不会录制任何声音。',
|
||||
viewDoc: '查看文档。',
|
||||
ok: '确定'
|
||||
},
|
||||
keyboard: {
|
||||
paste: '粘贴',
|
||||
virtualKeyboard: '虚拟键盘',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { checkPermission } from '@/libs/media/permission.ts';
|
||||
|
||||
class Camera {
|
||||
id: string = '';
|
||||
width: number = 1920;
|
||||
@@ -12,16 +14,18 @@ class Camera {
|
||||
|
||||
this.close();
|
||||
|
||||
const constraints = {
|
||||
video: {
|
||||
deviceId: { exact: id },
|
||||
width: { ideal: width },
|
||||
height: { ideal: height },
|
||||
frameRate: { ideal: 60 },
|
||||
latency: { ideal: 0 },
|
||||
resizeMode: 'none'
|
||||
},
|
||||
audio: audioId
|
||||
const video = {
|
||||
deviceId: { exact: id },
|
||||
width: { ideal: width },
|
||||
height: { ideal: height },
|
||||
frameRate: { ideal: 60 },
|
||||
latency: { ideal: 0 },
|
||||
resizeMode: 'none'
|
||||
};
|
||||
|
||||
const isMicGranted = await checkPermission('microphone');
|
||||
const audio =
|
||||
isMicGranted && audioId
|
||||
? {
|
||||
deviceId: { exact: audioId },
|
||||
echoCancellation: false,
|
||||
@@ -30,14 +34,13 @@ class Camera {
|
||||
sampleRate: 48000,
|
||||
latency: 0
|
||||
}
|
||||
: false
|
||||
};
|
||||
: false;
|
||||
|
||||
this.id = id;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
if (audioId) this.audioId = audioId;
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ video, audio });
|
||||
}
|
||||
|
||||
public async updateResolution(width: number, height: number) {
|
||||
48
browser/src/libs/media/permission.ts
Normal file
48
browser/src/libs/media/permission.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Resolution } from '@/types.ts';
|
||||
|
||||
export async function checkPermission(device: 'camera' | 'microphone'): Promise<boolean> {
|
||||
try {
|
||||
const status = await navigator.permissions.query({
|
||||
name: device as PermissionName
|
||||
});
|
||||
|
||||
return status.state === 'granted';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCameraPermission(resolution?: Resolution) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: resolution?.width || 1920 },
|
||||
height: { ideal: resolution?.height || 1080 },
|
||||
frameRate: { ideal: 60 }
|
||||
}
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return !(err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError');
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestMicrophonePermission() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
sampleRate: 48000
|
||||
}
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.log('failed to request media permissions: ', err);
|
||||
return !(err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user