diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index 00945dca..f4ef424e 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -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", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index 293c5632..0d69ad13 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -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", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index e3f2e966..3813f63f 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -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", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index ffaf68b4..4c4f6c8e 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -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", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index 7e93ba70..81a22fdd 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -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", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index 752a0183..1bc14cf3 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -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", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 4d2a83cc..37ed78b2 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -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", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index fecd999d..fbb5c6f2 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -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", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 0b0be1f3..b89c50e9 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -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": "设置", diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index a7f47404..0583e526 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -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(); diff --git a/ui/src/components/popovers/AudioPopover.tsx b/ui/src/components/popovers/AudioPopover.tsx index 2dda6702..3760843e 100644 --- a/ui/src/components/popovers/AudioPopover.tsx +++ b/ui/src/components/popovers/AudioPopover.tsx @@ -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(true); - const [audioInputEnabled, setAudioInputEnabled] = useState(true); const [usbAudioEnabled, setUsbAudioEnabled] = useState(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 (
@@ -103,7 +80,6 @@ export default function AudioPopover() {
handleAudioInputEnabledToggle(e.target.checked)} + onChange={(e) => setMicrophoneEnabled(e.target.checked)} /> diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index fb1fd7b3..c3282f03 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -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(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", diff --git a/ui/src/routes/devices.$id.settings.audio.tsx b/ui/src/routes/devices.$id.settings.audio.tsx index 63082f2b..e268ae16 100644 --- a/ui/src/routes/devices.$id.settings.audio.tsx +++ b/ui/src/routes/devices.$id.settings.audio.tsx @@ -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() { handleAudioInputEnabledChange(e.target.checked)} + checked={settings.audioInputAutoEnable || false} + onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)} />
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 7cabbf29..fc45bf14 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -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"); diff --git a/ui/src/utils.ts b/ui/src/utils.ts index 29d31ac1..c7628628 100644 --- a/ui/src/utils.ts +++ b/ui/src/utils.ts @@ -301,3 +301,7 @@ export function deleteCookie(name: string, domain?: string, path = "/") { export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } + +export function isSecureContext(): boolean { + return window.location.protocol === "https:" || window.location.hostname === "localhost"; +}