feat: audio devices + fullscreen + banner cleanup (v0.7.0)
Audio device selection: - audioSettings: persisted inputDeviceId + outputDeviceId - CallContext: uses stored input deviceId on mic enable, new setAudioInputDevice / setAudioOutputDevice actions that hot-swap without reconnect. Output swap applies HTMLMediaElement.setSinkId to every attached remote-audio element (LiveKit's switchActiveDevice only tracks elements it attached itself) - SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with enumerateDevices, devicechange listener, permission-probe button. setSinkId-unsupported fallback is messaged but non-blocking Fullscreen: - FullscreenCall was absolute inset-0 z-40 which trapped it inside the <main> pane — sidebar + chat-list stayed visible. Switched to fixed inset-0 z-[60] so the call overlays the whole window Discord-style - ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API unreliable under Tauri WKWebView), portalled to document.body when active so no ancestor stacking context can clip it. Esc exits ActiveCallBanner: - cleanup effect returned early when presence was entirely empty, leaving the "1 im Raum" fallback stuck after both peers left. Now schedules dismissLastCall as soon as othersIn.length === 0, with a 3s grace window to absorb presence re-sync flicker Bump tauri version 0.6.0 -> 0.7.0
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -210,17 +210,14 @@ function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
|||||||
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
||||||
|
|
||||||
// Once presence confirms the room is empty, drop the "just left" hint so the
|
// Once presence confirms the room is empty, drop the "just left" hint so the
|
||||||
// banner hides cleanly instead of sticking forever.
|
// banner hides cleanly instead of sticking forever. Grace window handles the
|
||||||
|
// brief gap between hangup and presence re-sync so we don't flicker.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!justLeft) return;
|
if (!justLeft) return;
|
||||||
if (active.length === 0) return;
|
if (othersIn.length > 0) return; // still live — keep banner
|
||||||
if (othersIn.length === 0) {
|
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||||
// Active reports only us (or something stale) — wait, then dismiss.
|
return () => window.clearTimeout(id);
|
||||||
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
}, [justLeft, othersIn.length, dismissLastCall]);
|
||||||
return () => window.clearTimeout(id);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [justLeft, active.length, othersIn.length, dismissLastCall]);
|
|
||||||
|
|
||||||
if (iAmIn) return null;
|
if (iAmIn) return null;
|
||||||
if (othersIn.length === 0 && !justLeft) return null;
|
if (othersIn.length === 0 && !justLeft) return null;
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ function FullscreenCall({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-40 flex flex-col overflow-hidden bg-surface">
|
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
{speaker && (
|
{speaker && (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RemoteTrack } from 'livekit-client';
|
import type { RemoteTrack } from 'livekit-client';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { RemoteScreenShare } from '../context/CallContext';
|
import type { RemoteScreenShare } from '../context/CallContext';
|
||||||
@@ -32,30 +33,31 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
};
|
};
|
||||||
}, [share.track, watching]);
|
}, [share.track, watching]);
|
||||||
|
|
||||||
|
// Esc exits CSS fullscreen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => {
|
if (!isFullscreen) return;
|
||||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('fullscreenchange', onChange);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, []);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
// CSS-only "app fullscreen" — Discord-style: overlay the whole window
|
||||||
|
// including the left sidebar + chat list. Native Fullscreen API is
|
||||||
|
// unreliable in Tauri's WKWebView and doesn't add useful chrome-hiding
|
||||||
|
// beyond what `fixed inset-0 z-[60]` already gives us.
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
const el = containerRef.current;
|
setIsFullscreen((v) => !v);
|
||||||
if (!el) return;
|
|
||||||
if (document.fullscreenElement === el) {
|
|
||||||
void document.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
void el.requestFullscreen();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const viewerNode = (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={
|
className={
|
||||||
'overflow-hidden rounded-xl border border-emerald-500/30 bg-black ' +
|
isFullscreen
|
||||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
|
||||||
|
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
||||||
@@ -130,6 +132,14 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// When in fullscreen, portal out of the call-panel subtree into <body> so
|
||||||
|
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
|
||||||
|
// AppShell's root — portalled node sits above them via z-[60].
|
||||||
|
if (isFullscreen) {
|
||||||
|
return createPortal(viewerNode, document.body);
|
||||||
|
}
|
||||||
|
return viewerNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
|||||||
import {
|
import {
|
||||||
getAudioQualityParams,
|
getAudioQualityParams,
|
||||||
getAudioSettings,
|
getAudioSettings,
|
||||||
|
updateAudioSettings,
|
||||||
} from '../lib/audioSettings';
|
} from '../lib/audioSettings';
|
||||||
import {
|
import {
|
||||||
createCallE2EE,
|
createCallE2EE,
|
||||||
@@ -120,6 +121,12 @@ interface CallContextValue {
|
|||||||
dismissLastCall: () => void;
|
dismissLastCall: () => void;
|
||||||
setCallMode: (mode: CallMode) => void;
|
setCallMode: (mode: CallMode) => void;
|
||||||
setFocusedId: (id: string | null) => void;
|
setFocusedId: (id: string | null) => void;
|
||||||
|
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
|
||||||
|
// hot-swaps the input on an active call without a reconnect.
|
||||||
|
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
|
||||||
|
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
|
||||||
|
// currently-attached remote-audio elements.
|
||||||
|
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CallContext = createContext<CallContextValue | null>(null);
|
const CallContext = createContext<CallContextValue | null>(null);
|
||||||
@@ -482,12 +489,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const inputId = getAudioSettings().inputDeviceId;
|
||||||
await r.localParticipant.setMicrophoneEnabled(true, {
|
await r.localParticipant.setMicrophoneEnabled(true, {
|
||||||
echoCancellation: aParams.echoCancellation,
|
echoCancellation: aParams.echoCancellation,
|
||||||
noiseSuppression: aParams.noiseSuppression,
|
noiseSuppression: aParams.noiseSuppression,
|
||||||
autoGainControl: aParams.autoGainControl,
|
autoGainControl: aParams.autoGainControl,
|
||||||
channelCount: aParams.stereo ? 2 : 1,
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
sampleRate: aParams.sampleRateHz,
|
sampleRate: aParams.sampleRateHz,
|
||||||
|
// Plain string maps to `ideal` — if the device is gone we fall back
|
||||||
|
// to OS default instead of throwing NotFoundError.
|
||||||
|
...(inputId ? { deviceId: inputId } : {}),
|
||||||
});
|
});
|
||||||
} catch (micErr: unknown) {
|
} catch (micErr: unknown) {
|
||||||
console.error('setMicrophoneEnabled failed', micErr);
|
console.error('setMicrophoneEnabled failed', micErr);
|
||||||
@@ -973,6 +984,46 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setFocusedIdState(id);
|
setFocusedIdState(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
|
updateAudioSettings({ inputDeviceId: deviceId });
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
try {
|
||||||
|
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
|
||||||
|
// reconnect. Pass empty string or `default` to revert to OS default.
|
||||||
|
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('switchActiveDevice(audioinput) failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
|
updateAudioSettings({ outputDeviceId: deviceId });
|
||||||
|
const sinkId = deviceId ?? '';
|
||||||
|
// Apply to every <audio> element we've attached to the body. LiveKit's
|
||||||
|
// switchActiveDevice only tracks elements it attached itself; our custom
|
||||||
|
// appendChild path bypasses that, so we iterate and setSinkId manually.
|
||||||
|
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||||
|
'audio[data-livekit-track]',
|
||||||
|
);
|
||||||
|
for (const el of Array.from(els)) {
|
||||||
|
if (typeof el.setSinkId !== 'function') continue;
|
||||||
|
try {
|
||||||
|
await el.setSinkId(sinkId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('setSinkId on audio element failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (r) {
|
||||||
|
try {
|
||||||
|
await r.switchActiveDevice('audiooutput', sinkId || 'default');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('switchActiveDevice(audiooutput) failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Reset UI call-mode state when the call leaves any active phase so the next
|
// Reset UI call-mode state when the call leaves any active phase so the next
|
||||||
// call starts fresh at grid/unfocused.
|
// call starts fresh at grid/unfocused.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1017,6 +1068,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
setCallMode,
|
setCallMode,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
|
setAudioInputDevice,
|
||||||
|
setAudioOutputDevice,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -1039,6 +1092,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
setCallMode,
|
setCallMode,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
|
setAudioInputDevice,
|
||||||
|
setAudioOutputDevice,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1063,6 +1118,15 @@ function attachTrack(
|
|||||||
audio.setAttribute('playsinline', 'true');
|
audio.setAttribute('playsinline', 'true');
|
||||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||||
document.body.appendChild(audio);
|
document.body.appendChild(audio);
|
||||||
|
// Apply persisted sinkId so the element routes to the user's chosen
|
||||||
|
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
||||||
|
// doesn't track custom-appended elements).
|
||||||
|
const sinkId = getAudioSettings().outputDeviceId;
|
||||||
|
if (sinkId && typeof audio.setSinkId === 'function') {
|
||||||
|
void audio.setSinkId(sinkId).catch((err: unknown) => {
|
||||||
|
console.warn('setSinkId on attach failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Video is handled later in M2.6/M3 by a dedicated <video> element.
|
// Video is handled later in M2.6/M3 by a dedicated <video> element.
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ export type AudioQuality = 'voice' | 'hifi';
|
|||||||
|
|
||||||
export interface AudioSettings {
|
export interface AudioSettings {
|
||||||
quality: AudioQuality;
|
quality: AudioQuality;
|
||||||
|
// Preferred input deviceId from enumerateDevices. null = use browser default
|
||||||
|
// (whatever the OS points at). Persisted across sessions, so "grandma's
|
||||||
|
// mic is default" stays even after browser picks the wrong device.
|
||||||
|
inputDeviceId: string | null;
|
||||||
|
// Preferred output (speaker/headphone) deviceId. null = system default.
|
||||||
|
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
||||||
|
outputDeviceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: AudioSettings = {
|
const DEFAULTS: AudioSettings = {
|
||||||
quality: 'voice',
|
quality: 'voice',
|
||||||
|
inputDeviceId: null,
|
||||||
|
outputDeviceId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AudioQualityParams {
|
export interface AudioQualityParams {
|
||||||
@@ -73,6 +82,14 @@ function read(): AudioSettings {
|
|||||||
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
||||||
cached = {
|
cached = {
|
||||||
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
||||||
|
inputDeviceId:
|
||||||
|
typeof parsed.inputDeviceId === 'string' && parsed.inputDeviceId.length > 0
|
||||||
|
? parsed.inputDeviceId
|
||||||
|
: DEFAULTS.inputDeviceId,
|
||||||
|
outputDeviceId:
|
||||||
|
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
||||||
|
? parsed.outputDeviceId
|
||||||
|
: DEFAULTS.outputDeviceId,
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
SUPPORTED_LOCALES,
|
SUPPORTED_LOCALES,
|
||||||
type SupportedLocale,
|
type SupportedLocale,
|
||||||
} from '@chat-app/shared/i18n';
|
} from '@chat-app/shared/i18n';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Avatar } from '../components/Avatar';
|
import { Avatar } from '../components/Avatar';
|
||||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||||
import { LockIcon } from '../components/icons';
|
import { LockIcon } from '../components/icons';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
@@ -142,7 +143,10 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||||
<AudioQualityControls />
|
<AudioDeviceControls />
|
||||||
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
|
<AudioQualityControls />
|
||||||
|
</div>
|
||||||
<div className="mt-3 border-t border-line pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<PttControls />
|
<PttControls />
|
||||||
</div>
|
</div>
|
||||||
@@ -642,3 +646,196 @@ function Toggle({
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio device selection — persisted input + output deviceIds, hot-swap on
|
||||||
|
// active calls. Output swap uses HTMLMediaElement.setSinkId on our attached
|
||||||
|
// <audio> elements (LiveKit's switchActiveDevice only tracks elements it
|
||||||
|
// attached itself).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AudioDeviceControls() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { setAudioInputDevice, setAudioOutputDevice } = useCall();
|
||||||
|
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const [inputId, setInputId] = useState<string | null>(
|
||||||
|
() => getAudioSettings().inputDeviceId,
|
||||||
|
);
|
||||||
|
const [outputId, setOutputId] = useState<string | null>(
|
||||||
|
() => getAudioSettings().outputDeviceId,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
|
||||||
|
'unknown',
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
setInputs(list.filter((d) => d.kind === 'audioinput'));
|
||||||
|
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
|
||||||
|
// If labels are empty, permission hasn't been granted yet — browsers
|
||||||
|
// mask device names until a getUserMedia call succeeds at least once.
|
||||||
|
const hasLabels = list.some(
|
||||||
|
(d) => (d.kind === 'audioinput' || d.kind === 'audiooutput') && d.label.length > 0,
|
||||||
|
);
|
||||||
|
setPermission(hasLabels ? 'granted' : 'unknown');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'enumerateDevices failed');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
const onChange = () => void refresh();
|
||||||
|
try {
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', onChange);
|
||||||
|
} catch {
|
||||||
|
/* some browsers omit devicechange */
|
||||||
|
}
|
||||||
|
const unsubSettings = subscribeAudioSettings((s) => {
|
||||||
|
setInputId(s.inputDeviceId);
|
||||||
|
setOutputId(s.outputDeviceId);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
navigator.mediaDevices.removeEventListener('devicechange', onChange);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
unsubSettings();
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
setPermission('granted');
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setPermission('denied');
|
||||||
|
setError(err instanceof Error ? err.message : 'permission denied');
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const handleInput = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const next = id === '' ? null : id;
|
||||||
|
setInputId(next);
|
||||||
|
await setAudioInputDevice(next);
|
||||||
|
},
|
||||||
|
[setAudioInputDevice],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOutput = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const next = id === '' ? null : id;
|
||||||
|
setOutputId(next);
|
||||||
|
await setAudioOutputDevice(next);
|
||||||
|
},
|
||||||
|
[setAudioOutputDevice],
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputSupported =
|
||||||
|
typeof HTMLAudioElement !== 'undefined' &&
|
||||||
|
typeof HTMLAudioElement.prototype.setSinkId === 'function';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-fg">
|
||||||
|
{t('app:settings.mic_title', { defaultValue: 'Mikrofon' })}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('app:settings.mic_hint', {
|
||||||
|
defaultValue: 'Eingabegerät. Bei aktivem Anruf wird live umgeschaltet.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={inputId ?? ''}
|
||||||
|
onChange={(e) => void handleInput(e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||||||
|
</option>
|
||||||
|
{inputs.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>
|
||||||
|
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{t('app:settings.mic_refresh', { defaultValue: 'Neu laden' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-fg">
|
||||||
|
{t('app:settings.speaker_title', { defaultValue: 'Ausgabegerät' })}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('app:settings.speaker_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Lautsprecher oder Kopfhörer. Wird auf alle aktiven Audio-Streams angewendet.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{!outputSupported && (
|
||||||
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-300">
|
||||||
|
{t('app:settings.speaker_unsupported', {
|
||||||
|
defaultValue: 'Browser unterstützt setSinkId nicht — Ausgabe folgt System-Default.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={outputId ?? ''}
|
||||||
|
disabled={!outputSupported}
|
||||||
|
onChange={(e) => void handleOutput(e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||||||
|
</option>
|
||||||
|
{outputs.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>
|
||||||
|
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permission !== 'granted' && (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||||
|
<span>
|
||||||
|
{t('app:settings.mic_permission_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Ohne Mikrofon-Freigabe erscheinen Gerätenamen nicht. Einmal erlauben und Liste lädt neu.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void requestPermission()}
|
||||||
|
className="shrink-0 cursor-pointer rounded-md bg-amber-600 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
{t('app:settings.mic_grant', { defaultValue: 'Freigeben' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user