mirror of https://github.com/jetkvm/kvm.git
feat: add microphone auto-enable setting with session-based opt-in
Implement session-based microphone control with optional auto-enable: - Microphone disabled by default, only requests permission when enabled - Added persistent "Auto-enable Microphone" setting in audio settings - Setting syncs to backend and works across browsers/devices - Auto-enable respects secure context (HTTPS/localhost only) - Refactored secure context check into reusable utility function - Removed unused audioInputEnabled store properties
This commit is contained in:
parent
d0e6bb1ac6
commit
925b14da1e
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Lydudgang",
|
||||
"audio_settings_title": "Lyd",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
|
||||
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)",
|
||||
"action_bar_extension": "Udvidelse",
|
||||
"action_bar_fullscreen": "Fuldskærm",
|
||||
"action_bar_settings": "Indstillinger",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Audioausgang",
|
||||
"audio_settings_title": "Audio",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren",
|
||||
"audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)",
|
||||
"action_bar_extension": "Erweiterung",
|
||||
"action_bar_fullscreen": "Vollbild",
|
||||
"action_bar_settings": "Einstellungen",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Audio Output",
|
||||
"audio_settings_title": "Audio",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Auto-enable Microphone",
|
||||
"audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)",
|
||||
"action_bar_extension": "Extension",
|
||||
"action_bar_fullscreen": "Fullscreen",
|
||||
"action_bar_settings": "Settings",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Salida de audio",
|
||||
"audio_settings_title": "Audio",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente",
|
||||
"audio_settings_auto_enable_microphone_description": "Habilitar automáticamente el micrófono del navegador al conectar (de lo contrario, debe habilitarlo manualmente en cada sesión)",
|
||||
"action_bar_extension": "Extensión",
|
||||
"action_bar_fullscreen": "Pantalla completa",
|
||||
"action_bar_settings": "Ajustes",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Sortie audio",
|
||||
"audio_settings_title": "Audio",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone",
|
||||
"audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)",
|
||||
"action_bar_extension": "Extension",
|
||||
"action_bar_fullscreen": "Plein écran",
|
||||
"action_bar_settings": "Paramètres",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Uscita audio",
|
||||
"audio_settings_title": "Audio",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono",
|
||||
"audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)",
|
||||
"action_bar_extension": "Estensione",
|
||||
"action_bar_fullscreen": "A schermo intero",
|
||||
"action_bar_settings": "Impostazioni",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Lydutgang",
|
||||
"audio_settings_title": "Lyd",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
|
||||
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)",
|
||||
"action_bar_extension": "Forlengelse",
|
||||
"action_bar_fullscreen": "Fullskjerm",
|
||||
"action_bar_settings": "Innstillinger",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "Ljudutgång",
|
||||
"audio_settings_title": "Ljud",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt",
|
||||
"audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)",
|
||||
"action_bar_extension": "Förlängning",
|
||||
"action_bar_fullscreen": "Helskärm",
|
||||
"action_bar_settings": "Inställningar",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
"audio_settings_output_title": "音频输出",
|
||||
"audio_settings_title": "音频",
|
||||
"audio_settings_usb_label": "USB",
|
||||
"audio_settings_auto_enable_microphone_title": "自动启用麦克风",
|
||||
"audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)",
|
||||
"action_bar_extension": "扩展",
|
||||
"action_bar_fullscreen": "全屏",
|
||||
"action_bar_settings": "设置",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { keys } from "@/keyboardMappings";
|
||||
import notifications from "@/notifications";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { isSecureContext } from "@/utils";
|
||||
|
||||
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
|
||||
// Video and stream related refs and states
|
||||
|
|
@ -33,7 +34,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
||||
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
|
||||
|
||||
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||
const isPointerLockPossible = isSecureContext();
|
||||
|
||||
// Store hooks
|
||||
const settings = useSettingsStore();
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useSettingsStore } from "@/hooks/stores";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import Checkbox from "@components/Checkbox";
|
||||
import notifications from "@/notifications";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { isSecureContext } from "@/utils";
|
||||
|
||||
export default function AudioPopover() {
|
||||
const { send } = useJsonRpc();
|
||||
const { microphoneEnabled, setMicrophoneEnabled } = useSettingsStore();
|
||||
const [audioOutputEnabled, setAudioOutputEnabled] = useState<boolean>(true);
|
||||
const [audioInputEnabled, setAudioInputEnabled] = useState<boolean>(true);
|
||||
const [usbAudioEnabled, setUsbAudioEnabled] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isHttps = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||
const isHttps = isSecureContext();
|
||||
|
||||
useEffect(() => {
|
||||
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||
|
|
@ -25,14 +27,6 @@ export default function AudioPopover() {
|
|||
}
|
||||
});
|
||||
|
||||
send("getAudioInputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to load audio input enabled:", resp.error);
|
||||
} else {
|
||||
setAudioInputEnabled(resp.result as boolean);
|
||||
}
|
||||
});
|
||||
|
||||
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to load USB devices:", resp.error);
|
||||
|
|
@ -60,23 +54,6 @@ export default function AudioPopover() {
|
|||
});
|
||||
}, [send]);
|
||||
|
||||
const handleAudioInputEnabledToggle = useCallback((enabled: boolean) => {
|
||||
setLoading(true);
|
||||
send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||
setLoading(false);
|
||||
if ("error" in resp) {
|
||||
const errorMsg = enabled
|
||||
? m.audio_input_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
|
||||
: m.audio_input_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
|
||||
notifications.error(errorMsg);
|
||||
} else {
|
||||
setAudioInputEnabled(enabled);
|
||||
const successMsg = enabled ? m.audio_input_enabled() : m.audio_input_disabled();
|
||||
notifications.success(successMsg);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="space-y-4 p-4 py-3">
|
||||
|
|
@ -103,7 +80,6 @@ export default function AudioPopover() {
|
|||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<SettingsItem
|
||||
loading={loading}
|
||||
title={m.audio_microphone_title()}
|
||||
description={m.audio_microphone_description()}
|
||||
badge={!isHttps ? m.audio_https_only() : undefined}
|
||||
|
|
@ -111,9 +87,9 @@ export default function AudioPopover() {
|
|||
badgeLink={!isHttps ? "settings/access" : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={audioInputEnabled}
|
||||
checked={microphoneEnabled}
|
||||
disabled={!isHttps}
|
||||
onChange={(e) => handleAudioInputEnabledToggle(e.target.checked)}
|
||||
onChange={(e) => setMicrophoneEnabled(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@ export interface RTCState {
|
|||
transceiver: RTCRtpTransceiver | null;
|
||||
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
||||
|
||||
audioTransceiver: RTCRtpTransceiver | null;
|
||||
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
||||
|
||||
mediaStream: MediaStream | null;
|
||||
setMediaStream: (stream: MediaStream) => void;
|
||||
|
||||
|
|
@ -198,6 +201,9 @@ export const useRTCStore = create<RTCState>(set => ({
|
|||
transceiver: null,
|
||||
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),
|
||||
|
||||
audioTransceiver: null,
|
||||
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => set({ audioTransceiver: transceiver }),
|
||||
|
||||
peerConnectionState: null,
|
||||
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
|
||||
|
||||
|
|
@ -372,8 +378,10 @@ export interface SettingsState {
|
|||
// Audio settings
|
||||
audioOutputEnabled: boolean;
|
||||
setAudioOutputEnabled: (enabled: boolean) => void;
|
||||
audioInputEnabled: boolean;
|
||||
setAudioInputEnabled: (enabled: boolean) => void;
|
||||
microphoneEnabled: boolean;
|
||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||
audioInputAutoEnable: boolean;
|
||||
setAudioInputAutoEnable: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create(
|
||||
|
|
@ -425,8 +433,10 @@ export const useSettingsStore = create(
|
|||
// Audio settings with defaults
|
||||
audioOutputEnabled: true,
|
||||
setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }),
|
||||
audioInputEnabled: true,
|
||||
setAudioInputEnabled: (enabled: boolean) => set({ audioInputEnabled: enabled }),
|
||||
microphoneEnabled: false,
|
||||
setMicrophoneEnabled: (enabled: boolean) => set({ microphoneEnabled: enabled }),
|
||||
audioInputAutoEnable: false,
|
||||
setAudioInputAutoEnable: (enabled: boolean) => set({ audioInputAutoEnable: enabled }),
|
||||
}),
|
||||
{
|
||||
name: "settings",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function SettingsAudioRoute() {
|
|||
if ("error" in resp) {
|
||||
return;
|
||||
}
|
||||
settings.setAudioInputEnabled(resp.result as boolean);
|
||||
settings.setAudioInputAutoEnable(resp.result as boolean);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [send]);
|
||||
|
|
@ -46,7 +46,7 @@ export default function SettingsAudioRoute() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleAudioInputEnabledChange = (enabled: boolean) => {
|
||||
const handleAudioInputAutoEnableChange = (enabled: boolean) => {
|
||||
send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
const errorMsg = enabled
|
||||
|
|
@ -55,7 +55,7 @@ export default function SettingsAudioRoute() {
|
|||
notifications.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
settings.setAudioInputEnabled(enabled);
|
||||
settings.setAudioInputAutoEnable(enabled);
|
||||
const successMsg = enabled ? m.audio_input_enabled() : m.audio_input_disabled();
|
||||
notifications.success(successMsg);
|
||||
});
|
||||
|
|
@ -79,12 +79,12 @@ export default function SettingsAudioRoute() {
|
|||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={m.audio_settings_input_title()}
|
||||
description={m.audio_settings_input_description()}
|
||||
title={m.audio_settings_auto_enable_microphone_title()}
|
||||
description={m.audio_settings_auto_enable_microphone_description()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={settings.audioInputEnabled || false}
|
||||
onChange={(e) => handleAudioInputEnabledChange(e.target.checked)}
|
||||
checked={settings.audioInputAutoEnable || false}
|
||||
onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
useNetworkStateStore,
|
||||
User,
|
||||
useRTCStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
useUpdateStore,
|
||||
useVideoStore,
|
||||
|
|
@ -51,6 +52,7 @@ import {
|
|||
} from "@components/VideoOverlay";
|
||||
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { isSecureContext } from "@/utils";
|
||||
|
||||
export type AuthMode = "password" | "noPassword" | null;
|
||||
|
||||
|
|
@ -111,6 +113,7 @@ export default function KvmIdRoute() {
|
|||
|
||||
const params = useParams() as { id: string };
|
||||
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
|
||||
const { microphoneEnabled, setMicrophoneEnabled, audioInputAutoEnable, setAudioInputAutoEnable } = useSettingsStore();
|
||||
const [queryParams, setQueryParams] = useSearchParams();
|
||||
|
||||
const {
|
||||
|
|
@ -121,6 +124,8 @@ export default function KvmIdRoute() {
|
|||
isTurnServerInUse, setTurnServerInUse,
|
||||
rpcDataChannel,
|
||||
setTransceiver,
|
||||
setAudioTransceiver,
|
||||
audioTransceiver,
|
||||
setRpcHidChannel,
|
||||
setRpcHidUnreliableNonOrderedChannel,
|
||||
setRpcHidUnreliableChannel,
|
||||
|
|
@ -530,26 +535,12 @@ export default function KvmIdRoute() {
|
|||
|
||||
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
|
||||
|
||||
const audioTransceiver = pc.addTransceiver("audio", { direction: "sendrecv" });
|
||||
const audioTrans = pc.addTransceiver("audio", { direction: "sendrecv" });
|
||||
setAudioTransceiver(audioTrans);
|
||||
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 2, // Request stereo input if available
|
||||
}
|
||||
}).then((stream) => {
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (audioTrack && audioTransceiver.sender) {
|
||||
audioTransceiver.sender.replaceTrack(audioTrack);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn("Microphone access denied or unavailable:", err.message);
|
||||
});
|
||||
} else {
|
||||
console.warn("navigator.mediaDevices.getUserMedia is not available in this browser/context");
|
||||
// Enable microphone if auto-enable is on (only works over HTTPS or localhost)
|
||||
if (audioInputAutoEnable && isSecureContext()) {
|
||||
setMicrophoneEnabled(true);
|
||||
}
|
||||
|
||||
const rpcDataChannel = pc.createDataChannel("rpc");
|
||||
|
|
@ -603,6 +594,9 @@ export default function KvmIdRoute() {
|
|||
setRpcHidUnreliableNonOrderedChannel,
|
||||
setRpcHidUnreliableChannel,
|
||||
setTransceiver,
|
||||
setAudioTransceiver,
|
||||
audioInputAutoEnable,
|
||||
setMicrophoneEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -612,6 +606,48 @@ export default function KvmIdRoute() {
|
|||
}
|
||||
}, [peerConnectionState, cleanupAndStopReconnecting]);
|
||||
|
||||
// Handle dynamic microphone enable/disable
|
||||
useEffect(() => {
|
||||
if (!audioTransceiver || !peerConnection) return;
|
||||
|
||||
if (microphoneEnabled) {
|
||||
// Request microphone access
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 2,
|
||||
}
|
||||
}).then((stream) => {
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (audioTrack && audioTransceiver.sender) {
|
||||
audioTransceiver.sender.replaceTrack(audioTrack);
|
||||
console.log("Microphone enabled");
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn("Microphone access denied or unavailable:", err.message);
|
||||
setMicrophoneEnabled(false);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Disable microphone by removing the track
|
||||
if (audioTransceiver.sender.track) {
|
||||
audioTransceiver.sender.track.stop();
|
||||
audioTransceiver.sender.replaceTrack(null);
|
||||
console.log("Microphone disabled");
|
||||
}
|
||||
}
|
||||
}, [microphoneEnabled, audioTransceiver, peerConnection, setMicrophoneEnabled]);
|
||||
|
||||
// Auto-enable microphone when setting is loaded from backend
|
||||
useEffect(() => {
|
||||
if (audioInputAutoEnable && audioTransceiver && peerConnection && !microphoneEnabled && isSecureContext()) {
|
||||
setMicrophoneEnabled(true);
|
||||
}
|
||||
}, [audioInputAutoEnable, audioTransceiver, peerConnection, microphoneEnabled, setMicrophoneEnabled]);
|
||||
|
||||
// Cleanup effect
|
||||
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
|
||||
|
||||
|
|
@ -770,6 +806,16 @@ export default function KvmIdRoute() {
|
|||
|
||||
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||
|
||||
// Load audio input auto-enable setting from backend on mount
|
||||
useEffect(() => {
|
||||
send("getAudioInputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
return;
|
||||
}
|
||||
setAudioInputAutoEnable(resp.result as boolean);
|
||||
});
|
||||
}, [send, setAudioInputAutoEnable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
console.log("Requesting video state");
|
||||
|
|
|
|||
|
|
@ -301,3 +301,7 @@ export function deleteCookie(name: string, domain?: string, path = "/") {
|
|||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function isSecureContext(): boolean {
|
||||
return window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue