From 05870ef8fa71979845c92b1281174440b4c99f02 Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 12 May 2026 21:31:10 +0200 Subject: [PATCH] feat(call): split resolution/fps in share picker + restore window state after fullscreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../electron/modules/window-fullscreen.ts | 41 +++++- .../src/components/ScreenSharePickerModal.tsx | 118 +++++++++++++++--- 2 files changed, 141 insertions(+), 18 deletions(-) diff --git a/apps/desktop/electron/modules/window-fullscreen.ts b/apps/desktop/electron/modules/window-fullscreen.ts index 95e2c19..f252c55 100644 --- a/apps/desktop/electron/modules/window-fullscreen.ts +++ b/apps/desktop/electron/modules/window-fullscreen.ts @@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron'; import { CHANNELS } from '../ipc-types'; 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(); + ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => { try { // 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). const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow; 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) { console.warn('window setFullscreen failed', err); throw err; diff --git a/apps/desktop/src/components/ScreenSharePickerModal.tsx b/apps/desktop/src/components/ScreenSharePickerModal.tsx index c75fbda..24a7032 100644 --- a/apps/desktop/src/components/ScreenSharePickerModal.tsx +++ b/apps/desktop/src/components/ScreenSharePickerModal.tsx @@ -26,13 +26,65 @@ const TABS = [ ]; 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: '720p60', label: '720p · 60' }, - { id: '1080p60', label: '1080p · 60' }, - { id: '1440p60', label: '1440p · 60' }, + { id: '720p', label: '720p' }, + { id: '1080p', label: '1080p' }, + { 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; export function ScreenSharePickerModal({ onClose }: Props) { @@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) { const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(null); - const [preset, setPreset] = useState( - () => getScreenShareSettings().preset, - ); + const [res, setRes] = useState(() => { + const s = getScreenShareSettings(); + return decomposePreset(s.preset, s.framerateOverride).res; + }); + const [fps, setFps] = useState(() => { + const s = getScreenShareSettings(); + return decomposePreset(s.preset, s.framerateOverride).fps; + }); const [audio, setAudio] = useState( () => getScreenShareSettings().includeSystemAudio, ); @@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) { setError(null); setBusy(true); try { - // Persist quality + audio toggle. Also force-clear any stale duck - // setting users may have inherited from earlier builds — the - // native loopback addon excludes the app's own audio at OS level - // now, so JS-side ducking (which muted the user's incoming peer - // audio) is no longer needed and was causing "I can't hear anyone". + const preset = presetForResFps(res, fps); + // Persist resolution + fps + audio. The duck flag is force-cleared + // because the native loopback addon now excludes the app's own audio + // at OS level; JS-side ducking (which also muted incoming peer audio) + // was causing "I can't hear anyone" on earlier builds. updateScreenShareSettings({ preset, + framerateOverride: fps, includeSystemAudio: audio, duckRemoteAudioWhileSharing: false, }); @@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) { await startScreenShare({ preset, displaySurface: tab === 'screen' ? 'monitor' : 'window', - framerate: null, + framerate: fps, // Forward the picked source id so the native loopback path can // switch into INCLUDE_TARGET_PROCESS_TREE for window-shares // (parses HWND from `window::0`). For screen-shares this @@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
- Qualität + Auflösung
- {QUALITY_PILLS.map((q) => { - const active = preset === q.id; + {RES_PILLS.map((q) => { + const active = res === q.id; return ( + ); + })} +
+
+ +
+ + FPS + +
+ {FPS_PILLS.map((q) => { + const active = fps === q.id; + return ( +