feat(call): split resolution/fps in share picker + restore window state after fullscreen

ScreenSharePickerModal now exposes Auflösung (Auto · 720p · 1080p · 1440p
· 4K) and FPS (30 · 60) as separate pill rows instead of bundled quality
presets — users can pick "1440p · 30 fps" or "4K · 30 fps" which the old
preset list didn't surface. The underlying screenShareSettings framerateOverride
slot already existed; the modal just stopped resetting it to null on every
start and now plumbs the chosen FPS through to startScreenShare.

Cinema-mode fullscreen on Windows had two defects:

1. Maximized → fullscreen left the taskbar drawn on top of the window
   because DWM kept the maximized work-area constraints. We now unmaximize
   first so DWM recomposes cleanly and setFullScreen actually covers the
   whole monitor including the taskbar strip.

2. Esc out of cinema came back as a small floating window even when the
   user had been maximized before clicking the Vollbild button — the
   unmaximize from (1) was never undone. We now memo the pre-fullscreen
   maximized flag per window-id and call win.maximize() once the
   leave-full-screen event has fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-12 21:31:10 +02:00
parent f9e340dbec
commit 05870ef8fa
2 changed files with 141 additions and 18 deletions
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types'; import { CHANNELS } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void { export function register(mainWindow: BrowserWindow): void {
// Per-window maximize-before-fullscreen memo. We have to drop the
// maximized flag on Windows before setFullScreen so DWM recomposes
// cleanly (taskbar quirk), but Electron doesn't remember that the
// window WAS maximized — exiting fullscreen would leave it as a small
// floating window. Track it ourselves keyed by window-id so a future
// multi-window setup doesn't cross-pollute state.
const wasMaximized = new Map<number, boolean>();
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => { ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
try { try {
// Prefer the BrowserWindow that issued the IPC so multi-window setups // Prefer the BrowserWindow that issued the IPC so multi-window setups
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
// registered against (matches autostart.ts's app-singleton shape). // registered against (matches autostart.ts's app-singleton shape).
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow; const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return; if (!win || win.isDestroyed()) return;
win.setFullScreen(!!enabled); const id = win.id;
if (enabled) {
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
// taskbar drawn on top of the window because DWM keeps the
// maximized work-area constraints. Drop the maximize flag first
// so setFullScreen covers the whole monitor cleanly. Remember the
// pre-fullscreen state so the exit path can restore it.
if (process.platform === 'win32') {
const was = win.isMaximized();
wasMaximized.set(id, was);
if (was) win.unmaximize();
}
win.setFullScreen(true);
} else {
win.setFullScreen(false);
// Restore maximize if we dropped it on entry. setFullScreen(false)
// emits 'leave-full-screen' asynchronously; maximize() needs to
// wait until the window is back in normal mode or it silently
// no-ops. The event fires same-tick in Electron 33, but we listen
// for it once just to be safe across versions.
if (process.platform === 'win32' && wasMaximized.get(id)) {
wasMaximized.delete(id);
const restore = (): void => {
if (!win.isDestroyed()) win.maximize();
};
if (win.isFullScreen()) {
win.once('leave-full-screen', restore);
} else {
restore();
}
}
}
} catch (err: unknown) { } catch (err: unknown) {
console.warn('window setFullscreen failed', err); console.warn('window setFullscreen failed', err);
throw err; throw err;
@@ -26,13 +26,65 @@ const TABS = [
]; ];
type TabId = (typeof TABS)[number]['id']; type TabId = (typeof TABS)[number]['id'];
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [ // Resolution and framerate are picked independently. The preset table
// (screenShareSettings.ts) still provides the per-tier bitrate/dimension
// caps, so we map (res, fps) → existing preset and rely on
// `framerateOverride` for the non-default framerate combinations
// (e.g. 1440p · 30, 4K · 30).
type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k';
type FpsChoice = 30 | 60;
const RES_PILLS: { id: ResChoice; label: string }[] = [
{ id: 'auto', label: 'Auto' }, { id: 'auto', label: 'Auto' },
{ id: '720p60', label: '720p · 60' }, { id: '720p', label: '720p' },
{ id: '1080p60', label: '1080p · 60' }, { id: '1080p', label: '1080p' },
{ id: '1440p60', label: '1440p · 60' }, { id: '1440p', label: '1440p' },
{ id: '4k', label: '4K' },
]; ];
const FPS_PILLS: { id: FpsChoice; label: string }[] = [
{ id: 30, label: '30 fps' },
{ id: 60, label: '60 fps' },
];
function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset {
switch (res) {
case 'auto':
return 'auto';
case '720p':
return fps === 60 ? '720p60' : '720p30';
case '1080p':
return fps === 60 ? '1080p60' : '1080p30';
case '1440p':
return '1440p60';
case '4k':
return '4k60';
}
}
function decomposePreset(
p: ScreenSharePreset,
framerateOverride: number | null,
): { res: ResChoice; fps: FpsChoice } {
const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60;
switch (p) {
case 'auto':
return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 };
case '720p30':
return { res: '720p', fps: 30 };
case '720p60':
return { res: '720p', fps: 60 };
case '1080p30':
return { res: '1080p', fps: 30 };
case '1080p60':
return { res: '1080p', fps: 60 };
case '1440p60':
return { res: '1440p', fps: fallback };
case '4k60':
return { res: '4k', fps: fallback };
}
}
const THUMBNAIL_REFRESH_MS = 3500; const THUMBNAIL_REFRESH_MS = 3500;
export function ScreenSharePickerModal({ onClose }: Props) { export function ScreenSharePickerModal({ onClose }: Props) {
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [preset, setPreset] = useState<ScreenSharePreset>( const [res, setRes] = useState<ResChoice>(() => {
() => getScreenShareSettings().preset, const s = getScreenShareSettings();
); return decomposePreset(s.preset, s.framerateOverride).res;
});
const [fps, setFps] = useState<FpsChoice>(() => {
const s = getScreenShareSettings();
return decomposePreset(s.preset, s.framerateOverride).fps;
});
const [audio, setAudio] = useState<boolean>( const [audio, setAudio] = useState<boolean>(
() => getScreenShareSettings().includeSystemAudio, () => getScreenShareSettings().includeSystemAudio,
); );
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
// Persist quality + audio toggle. Also force-clear any stale duck const preset = presetForResFps(res, fps);
// setting users may have inherited from earlier builds — the // Persist resolution + fps + audio. The duck flag is force-cleared
// native loopback addon excludes the app's own audio at OS level // because the native loopback addon now excludes the app's own audio
// now, so JS-side ducking (which muted the user's incoming peer // at OS level; JS-side ducking (which also muted incoming peer audio)
// audio) is no longer needed and was causing "I can't hear anyone". // was causing "I can't hear anyone" on earlier builds.
updateScreenShareSettings({ updateScreenShareSettings({
preset, preset,
framerateOverride: fps,
includeSystemAudio: audio, includeSystemAudio: audio,
duckRemoteAudioWhileSharing: false, duckRemoteAudioWhileSharing: false,
}); });
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
await startScreenShare({ await startScreenShare({
preset, preset,
displaySurface: tab === 'screen' ? 'monitor' : 'window', displaySurface: tab === 'screen' ? 'monitor' : 'window',
framerate: null, framerate: fps,
// Forward the picked source id so the native loopback path can // Forward the picked source id so the native loopback path can
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares // switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
// (parses HWND from `window:<HWND>:0`). For screen-shares this // (parses HWND from `window:<HWND>:0`). For screen-shares this
@@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
<div className="flex flex-wrap items-center gap-x-5 gap-y-3"> <div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted"> <span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
Qualität Auflösung
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
{QUALITY_PILLS.map((q) => { {RES_PILLS.map((q) => {
const active = preset === q.id; const active = res === q.id;
return ( return (
<button <button
key={q.id} key={q.id}
type="button" type="button"
onClick={() => setPreset(q.id)} onClick={() => setRes(q.id)}
className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
}
>
{q.label}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
FPS
</span>
<div className="flex gap-1">
{FPS_PILLS.map((q) => {
const active = fps === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setFps(q.id)}
className={ className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' + 'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active (active