- {speaker.remoteSharing ? (
-
- ) : (
-
-
+ {/* Content area. pb-24 reserves ~96px space at the bottom for the
+ floating controls bar so tiles never sit behind it. */}
+
+ {hasFocus ? (
+ <>
+
onFocusTile(speaker!.id)}
+ title="Zurück zur Übersicht"
+ >
+
+
+ {others.length > 0 && (
+
+ {others.map((p) => (
+
+ onFocusTile(p.id)}
+ />
+
+ ))}
)}
+ >
+ ) : (
+
+
+ {tiles.map((p) => (
+
+ onFocusTile(p.id)}
+ />
+
+ ))}
+
)}
-
- {others.length > 0 && (
-
- {others.slice(0, 4).map((p) => (
-
- onFocusTile(p.userId)}
- />
-
- ))}
-
- )}
-
- {!hintGone && (
-
- Esc zum Verlassen
-
- )}
-
-
- {controls}
-
-
- );
-}
-function FullscreenShare({
- speaker,
- remoteScreenShares,
- conversationMembers,
-}: {
- speaker: Tile;
- remoteScreenShares: StageProps['remoteScreenShares'];
- conversationMembers: StageProps['conversationMembers'];
-}) {
- const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
- const member = conversationMembers.find((m) => m.userId === speaker.userId);
- if (!share) return null;
- return (
-
-
+ {!hintGone && (
+
+ Esc zum Verlassen
+
+ )}
+
+
+ {controls}
+
);
}
diff --git a/apps/desktop/src/components/IncomingCallPanel.tsx b/apps/desktop/src/components/IncomingCallPanel.tsx
index 84ebfed..9571eeb 100644
--- a/apps/desktop/src/components/IncomingCallPanel.tsx
+++ b/apps/desktop/src/components/IncomingCallPanel.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
-import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons';
+import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
interface Props {
conversation: ConversationSummary;
@@ -99,23 +99,37 @@ export function IncomingCallPanel({ conversation }: Props) {
>
)}
-
+
+ {state.mediaKind === 'video' && (
+
+ )}
diff --git a/apps/desktop/src/components/ScreenShareDialog.tsx b/apps/desktop/src/components/ScreenShareDialog.tsx
new file mode 100644
index 0000000..b615d38
--- /dev/null
+++ b/apps/desktop/src/components/ScreenShareDialog.tsx
@@ -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
;
+}
+
+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(initial.displaySurface);
+ const [preset, setPreset] = useState(initial.preset);
+ const [framerate, setFramerate] = useState(initial.framerateOverride);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(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 (
+
+
e.stopPropagation()}
+ className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
+ >
+
+
+
+
+
+ {t('app:call.share_surface', { defaultValue: 'Quelle' })}
+
+
+ setSurface(null)}
+ label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
+ sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
+ />
+ setSurface('monitor')}
+ label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
+ sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
+ />
+ setSurface('window')}
+ label={t('app:call.share_window', { defaultValue: 'Fenster' })}
+ sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
+ />
+
+
+
+
+
+ {t('app:call.share_quality', { defaultValue: 'Qualität' })}
+
+
+
+
+
+
+ {t('app:call.share_fps', { defaultValue: 'Bildrate' })}
+
+
+ {FRAMERATE_OPTIONS.map((opt) => (
+
+ ))}
+
+
+ {t('app:call.share_fps_effective', {
+ defaultValue: 'Effektiv: {{fps}} fps',
+ fps: effectiveFps,
+ })}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {t('app:call.share_hint', {
+ defaultValue:
+ 'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
+ })}
+
+
+
+
+
+
+ );
+}
+
+function SurfaceOption({
+ active,
+ onClick,
+ label,
+ sub,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+ sub: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/components/ScreenShareViewer.tsx b/apps/desktop/src/components/ScreenShareViewer.tsx
index 79cd952..563f190 100644
--- a/apps/desktop/src/components/ScreenShareViewer.tsx
+++ b/apps/desktop/src/components/ScreenShareViewer.tsx
@@ -1,6 +1,5 @@
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';
@@ -51,13 +50,13 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
setIsFullscreen((v) => !v);
};
- const viewerNode = (
+ return (
@@ -104,10 +103,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
playsInline
muted
onDoubleClick={toggleFullscreen}
- className={
- 'block cursor-zoom-in bg-black ' +
- (isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
- }
+ className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
/>
) : (