refactor: split camera and microphone permissions

This commit is contained in:
wj-xiao
2026-02-03 18:12:11 +08:00
parent 43fca484e8
commit 6f62d23f7c
13 changed files with 182 additions and 48 deletions

View File

@@ -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"

View File

@@ -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';

View File

@@ -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}

View File

@@ -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';

View 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>
</>
);
};

View File

@@ -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' && (
<>

View File

@@ -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);

View File

@@ -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';

View File

@@ -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';

View File

@@ -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',

View File

@@ -34,6 +34,13 @@ const zh = {
cancel: '取消'
}
},
audio: {
tip: '提示',
permission:
'网页需要麦克风权限来获取 USB 设备的音频信号。因为电脑系统会将 USB 音频输入设备识别为麦克风,而非扬声器。\n\n此操作仅用于设备连接不会录制任何声音。',
viewDoc: '查看文档。',
ok: '确定'
},
keyboard: {
paste: '粘贴',
virtualKeyboard: '虚拟键盘',

View File

@@ -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) {

View 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');
}
}