feat: call UX overhaul — deafen sync, share dialog, fullscreen redesign
Speaking ring:
- Switch useActiveSpeakers from LiveKit's smoothed isSpeaking / server-
batched ActiveSpeakersChanged to Web Audio API AnalyserNode on each
participant's raw audio MediaStreamTrack. Poll 50ms, RMS threshold 0.03,
250ms hold. Feels real-time vs the old ~500ms lag
- Defensive syncProbe on every tick so probes catch up if TrackPublished
missed (local mic publish race on join)
- Universal speaking overlay on tile (3px emerald border + inset glow,
z-10) so video mode shows the ring too, not just audio mode
Screen sharing:
- Separate "screen" tile per sharer so the sharer's avatar tile stays
intact with its speaking ring. Tile.id is kind-prefixed (user:xxx /
screen:xxx) so focus tracking distinguishes them
- New ScreenShareDialog (quality preset + fps override + displaySurface
hint) opens on the share button. startScreenShare / stopScreenShare
actions in CallContext replace the one-shot toggle
- ScreenShareViewer: plain CSS-only fullscreen overlay (Tauri WKWebView
doesn't implement requestFullscreen), always `h-full w-full
object-contain`, Esc exits
Camera:
- toggleCamera action in CallContext tracks isCameraEnabled
- VideoStub renders real <video> srcObject for the participant's camera
MediaStreamTrack; local preview is mirrored
- Tile video track resolves to Track.Source.Camera publications of the
LocalParticipant / each RemoteParticipant
- Room listens for TrackMuted / TrackUnmuted and re-publishes remote
state so peers switch to avatar placeholder when a camera is disabled
Deafen:
- New isDeafened state + toggleDeafen action. Sets `muted = true` on all
attached `<audio[data-livekit-track]>` plus mutes fresh ones on attach
via module-level flag
- Broadcast state over the LiveKit data channel
({type:'presence', deafened}) so peers can render the headphones-off
badge. Attributes API not used because the self-hosted server may run
older LiveKit versions
- remoteDeafen: Record<identity, bool> exposed via context, bumped on
DataReceived and re-broadcast on ParticipantConnected
Incoming video call:
- acceptIncoming takes an optional CallKind override so the receiver can
answer a video invite with audio only or promote an audio invite to
video on accept
- IncomingCallPanel shows two accept buttons (audio + video) when the
invite is a video call
Audio devices:
- audioSettings adds inputDeviceId + outputDeviceId, persisted
- CallContext uses them on setMicrophoneEnabled, plus new
setAudioInputDevice / setAudioOutputDevice hot-swap actions.
Output swap applies setSinkId to every attached remote-audio element
since LiveKit's own switchActiveDevice only tracks elements it
attached itself
- SettingsPage "Mikrofon" + "Ausgabegerät" selects with devicechange
listener and a permission-probe button
Fullscreen mode:
- Replaced absolute-positioned speaker + floating thumbnails with a real
flex layout. Default = even grid of all tiles. Clicking a tile flips
to big-speaker + horizontal thumbnail strip. Click focused tile =
back to grid
- Controls overlay pinned bottom; content wrapper has pb-24 so tiles
never sit behind the toolbar
- Grid now uses explicit grid-rows-* so cells get a defined 1fr height
(without it, video intrinsic dimensions blew tiles past the container
bounds on Windows)
UI chips:
- Mic-off badge combines isMuted flag AND
localParticipant.isMicrophoneEnabled, so a user with no mic / denied
permission sees the badge + the toolbar button red even though they
never pressed mute
- Deafen badge on tile chips for local + remote (remote driven by the
data-channel broadcast)
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
getScreenShareSettings,
|
||||
PRESET_ORDER,
|
||||
type ScreenSharePreset,
|
||||
} from '../lib/screenShareSettings';
|
||||
import {
|
||||
MonitorShareIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onStart: (opts: {
|
||||
preset: ScreenSharePreset;
|
||||
displaySurface: DisplaySurfaceHint;
|
||||
framerate: number | null;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
|
||||
{ value: null, label: 'Preset-Standard' },
|
||||
{ value: 15, label: '15 fps' },
|
||||
{ value: 30, label: '30 fps' },
|
||||
{ value: 60, label: '60 fps' },
|
||||
];
|
||||
|
||||
// Discord-style pre-share dialog. The OS still owns the final source picker
|
||||
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
|
||||
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
|
||||
// in quality + framerate up-front so the user doesn't have to re-open the
|
||||
// system picker to adjust them mid-call.
|
||||
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const initial = getScreenShareSettings();
|
||||
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function handleStart() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onStart({ preset, displaySurface: surface, framerate });
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'screenshare failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const presetParams = getPresetParams(preset);
|
||||
const effectiveFps = framerate ?? presetParams.framerate;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5 p-5">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<SurfaceOption
|
||||
active={surface === null}
|
||||
onClick={() => setSurface(null)}
|
||||
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
|
||||
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
|
||||
/>
|
||||
<SurfaceOption
|
||||
active={surface === 'monitor'}
|
||||
onClick={() => setSurface('monitor')}
|
||||
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
|
||||
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
|
||||
/>
|
||||
<SurfaceOption
|
||||
active={surface === 'window'}
|
||||
onClick={() => setSurface('window')}
|
||||
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
|
||||
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
||||
</p>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
||||
className="w-full 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"
|
||||
>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FRAMERATE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
onClick={() => setFramerate(opt.value)}
|
||||
className={
|
||||
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
|
||||
(framerate === opt.value
|
||||
? 'border-accent bg-accent/15 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-fg-muted">
|
||||
{t('app:call.share_fps_effective', {
|
||||
defaultValue: 'Effektiv: {{fps}} fps',
|
||||
fps: effectiveFps,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t('app:call.share_hint', {
|
||||
defaultValue:
|
||||
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStart()}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>
|
||||
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
|
||||
</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SurfaceOption({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
sub,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
sub: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
|
||||
(active
|
||||
? 'border-accent bg-accent/10 text-fg'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<span className="text-xs font-semibold">{label}</span>
|
||||
<span className="text-[10px] text-fg-muted">{sub}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user