--- a/src/main/screenShare.ts +++ b/src/main/screenShare.ts @@ -4,7 +4,9 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { desktopCapturer, session, Streams } from "electron"; +import { app, desktopCapturer, session, Streams } from "electron"; +import { readFileSync, statSync, writeFileSync } from "fs"; +import { join } from "path"; import type { StreamPick } from "renderer/components/ScreenSharePicker"; import { IpcCommands, IpcEvents } from "shared/IpcEvents"; @@ -14,8 +16,68 @@ const isWayland = process.platform === "linux" && (process.env.XDG_SESSION_TYPE === "wayland" || !!process.env.WAYLAND_DISPLAY); +// renderer's PICK_ANOTHER ("Pick Another Window" in the stream dialog) +const PICK_ANOTHER = "vesktop-pick-another"; + +const runtimeDir = process.env.XDG_RUNTIME_DIR ?? "/tmp"; + +// FredOS: the XDPH custom picker (settings/hyprland.nix) auto-picks a running +// game unless this file holds a fresh timestamp, then shows the full picker. +function forceFullPicker() { + try { + writeFileSync( + join(runtimeDir, "xdph-pick-manually"), + String(Math.floor(Date.now() / 1000)) + ); + } catch {} +} + +// Wayland: the dialog opens before the portal answers and asks for its +// preview with this id; resolves to a JPEG data URL, or undefined if cancelled. +const PENDING_PREVIEW = "vesktop-pending-preview"; +let pendingPreview: Promise | undefined; + +// The FredOS picker grim-shots its selection to xdph-preview.jpg ~70ms after +// answering; use that, else Electron's thumbnail (~1s). No sources at all +// (picker closed) resolves undefined so the dialog closes. +function previewFor(sources: Promise, requestedAt: number) { + const file = join(runtimeDir, "xdph-preview.jpg"); + const jpeg = (data: Buffer) => "data:image/jpeg;base64," + data.toString("base64"); + + return new Promise(resolve => { + let done = false; + const finish = (url?: string) => { + if (!done) resolve(url); + done = true; + }; + + sources.then(s => finish(s?.[0] ? jpeg(s[0].thumbnail.toJPEG(85)) : undefined)); + + const poll = () => { + if (done) return; + try { + if (statSync(file).mtimeMs >= requestedAt) return finish(jpeg(readFileSync(file))); + } catch {} + setTimeout(poll, 25); + }; + poll(); + }); +} + +// Shift held while clicking share = full picker +let shiftDown = false; + export function registerScreenShareHandler() { + app.on("browser-window-created", (_, win) => { + win.webContents.on("before-input-event", (_, input) => { + if (input.key === "Shift") shiftDown = input.type === "keyDown"; + }); + win.on("blur", () => (shiftDown = false)); + }); + handle(IpcEvents.CAPTURER_GET_LARGE_THUMBNAIL, async (_, id: string) => { + if (id === PENDING_PREVIEW) return pendingPreview; + const sources = await desktopCapturer.getSources({ types: ["window", "screen"], thumbnailSize: { @@ -27,14 +89,39 @@ }); session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => { - // request full resolution on wayland right away because we always only end up with one result anyway - const width = isWayland ? 1920 : 176; + if (isWayland) { + // Electron only resolves getSources once the thumbnail exists, which on + // PipeWire takes a full ~1s refresh tick. Open the dialog right away and + // let it fetch the preview (PENDING_PREVIEW) when the portal answers. + if (shiftDown) forceFullPicker(); + while (true) { + const requestedAt = Date.now(); + const sources = desktopCapturer + .getSources({ types: ["window", "screen"], thumbnailSize: { width: 960, height: 540 } }) + .catch(err => console.error("Error during screenshare picker", err)); + pendingPreview = previewFor(sources, requestedAt); + + const stream = await sendRendererCommand(IpcCommands.SCREEN_SHARE_PICKER, { + screens: [{ id: PENDING_PREVIEW, name: "", url: "" }], + skipPicker: true + }).catch(() => null); + if (stream === null) return callback({}); + + const source = (await sources)?.[0]; + if (!source) return callback({}); + if (stream.id !== PICK_ANOTHER) return callback({ video: source }); + + // re-run the portal, this time with the full picker + forceFullPicker(); + } + } + const sources = await desktopCapturer .getSources({ types: ["window", "screen"], thumbnailSize: { - width, - height: width * (9 / 16) + width: 176, + height: 176 * (9 / 16) } }) .catch(err => console.error("Error during screenshare picker", err)); @@ -47,21 +134,6 @@ url: thumbnail.toDataURL() })); - if (isWayland) { - const video = data[0]; - if (video) { - const stream = await sendRendererCommand(IpcCommands.SCREEN_SHARE_PICKER, { - screens: [video], - skipPicker: true - }).catch(() => null); - - if (stream === null) return callback({}); - } - - callback(video ? { video: sources[0] } : {}); - return; - } - const choice = await sendRendererCommand(IpcCommands.SCREEN_SHARE_PICKER, { screens: data, skipPicker: false --- a/src/renderer/components/ScreenSharePicker.tsx +++ b/src/renderer/components/ScreenSharePicker.tsx @@ -82,6 +82,10 @@ url: string; } +// Returned instead of a source id when "Pick Another Window" is clicked; main +// re-runs the portal with the full picker (keep in sync with main/screenShare.ts). +export const PICK_ANOTHER = "vesktop-pick-another"; + export let currentSettings: StreamSettings | null = null; const logger = new Logger("VesktopScreenShare"); @@ -351,23 +355,33 @@ source, settings, setSettings, - skipPicker + skipPicker, + close }: { source: Source; settings: StreamSettings; setSettings: Dispatch>; skipPicker: boolean; + close: () => void; }) { const Settings = useSettings(); const qualitySettings = State.store.screenshareQuality!; - const [thumb] = useAwaiter( - () => (skipPicker ? Promise.resolve(source.url) : VesktopNative.capturer.getLargeThumbnail(source.id)), + // Wayland opens this before the portal answers (no url yet): the preview + // arrives later, and none at all means the picker was cancelled. + const [thumb, , thumbPending] = useAwaiter( + () => + skipPicker && source.url + ? Promise.resolve(source.url) + : VesktopNative.capturer.getLargeThumbnail(source.id), { fallbackValue: source.url, deps: [source.id] } ); + useEffect(() => { + if (skipPicker && !thumbPending && !thumb) close(); + }, [thumbPending]); const openSettings = () => { openModal(props => ( @@ -385,7 +399,11 @@
What you're streaming - + {thumb ? ( + + ) : ( +
+ )} {source.name} @@ -760,6 +778,7 @@ settings={settings} setSettings={setSettings} skipPicker={skipPicker} + close={close} /> )} @@ -829,6 +848,18 @@ Go Live + {skipPicker && ( + + )} + {selected && !skipPicker ? (