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",
|
||||
"productName": "ChatApp",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"identifier": "com.meinname.chatapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm vite:dev",
|
||||
|
||||
@@ -210,17 +210,14 @@ function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
||||
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
||||
|
||||
// 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(() => {
|
||||
if (!justLeft) return;
|
||||
if (active.length === 0) return;
|
||||
if (othersIn.length === 0) {
|
||||
// Active reports only us (or something stale) — wait, then dismiss.
|
||||
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
return undefined;
|
||||
}, [justLeft, active.length, othersIn.length, dismissLastCall]);
|
||||
if (othersIn.length > 0) return; // still live — keep banner
|
||||
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [justLeft, othersIn.length, dismissLastCall]);
|
||||
|
||||
if (iAmIn) return null;
|
||||
if (othersIn.length === 0 && !justLeft) return null;
|
||||
|
||||
@@ -487,7 +487,7 @@ function FullscreenCall({
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{speaker && (
|
||||
<div className="absolute inset-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RemoteTrack } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { RemoteScreenShare } from '../context/CallContext';
|
||||
@@ -32,30 +33,31 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
||||
};
|
||||
}, [share.track, watching]);
|
||||
|
||||
// Esc exits CSS fullscreen.
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
if (!isFullscreen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsFullscreen(false);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
window.addEventListener('keydown', onKey);
|
||||
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 el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement === el) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
setIsFullscreen((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
const viewerNode = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={
|
||||
'overflow-hidden rounded-xl border border-emerald-500/30 bg-black ' +
|
||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
||||
isFullscreen
|
||||
? '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">
|
||||
@@ -130,6 +132,14 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
||||
)}
|
||||
</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 }) {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
||||
import {
|
||||
getAudioQualityParams,
|
||||
getAudioSettings,
|
||||
updateAudioSettings,
|
||||
} from '../lib/audioSettings';
|
||||
import {
|
||||
createCallE2EE,
|
||||
@@ -120,6 +121,12 @@ interface CallContextValue {
|
||||
dismissLastCall: () => void;
|
||||
setCallMode: (mode: CallMode) => 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);
|
||||
@@ -482,12 +489,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setIsE2EEActive(false);
|
||||
}
|
||||
try {
|
||||
const inputId = getAudioSettings().inputDeviceId;
|
||||
await r.localParticipant.setMicrophoneEnabled(true, {
|
||||
echoCancellation: aParams.echoCancellation,
|
||||
noiseSuppression: aParams.noiseSuppression,
|
||||
autoGainControl: aParams.autoGainControl,
|
||||
channelCount: aParams.stereo ? 2 : 1,
|
||||
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) {
|
||||
console.error('setMicrophoneEnabled failed', micErr);
|
||||
@@ -973,6 +984,46 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
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
|
||||
// call starts fresh at grid/unfocused.
|
||||
useEffect(() => {
|
||||
@@ -1017,6 +1068,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
dismissLastCall,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
setAudioInputDevice,
|
||||
setAudioOutputDevice,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
@@ -1039,6 +1092,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
dismissLastCall,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
setAudioInputDevice,
|
||||
setAudioOutputDevice,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1063,6 +1118,15 @@ function attachTrack(
|
||||
audio.setAttribute('playsinline', 'true');
|
||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||
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.
|
||||
|
||||
@@ -9,10 +9,19 @@ export type AudioQuality = 'voice' | 'hifi';
|
||||
|
||||
export interface AudioSettings {
|
||||
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 = {
|
||||
quality: 'voice',
|
||||
inputDeviceId: null,
|
||||
outputDeviceId: null,
|
||||
};
|
||||
|
||||
export interface AudioQualityParams {
|
||||
@@ -73,6 +82,14 @@ function read(): AudioSettings {
|
||||
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
||||
cached = {
|
||||
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;
|
||||
} catch {
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from '@chat-app/shared/i18n';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
@@ -142,7 +143,10 @@ export function SettingsPage() {
|
||||
|
||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||
<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">
|
||||
<PttControls />
|
||||
</div>
|
||||
@@ -642,3 +646,196 @@ function Toggle({
|
||||
</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