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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user