Vesktop: open share dialog instantly, JPEG preview fills in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2eeb29ac80
commit
4a6fb7f5b2
3 changed files with 214 additions and 134 deletions
|
|
@ -8,12 +8,13 @@
|
|||
spotify
|
||||
# Preselect the last stream's audio source (e.g. Hearthstone) when it's
|
||||
# running again, instead of resetting to "None" every Go Live.
|
||||
# "Pick Another Window" in the stream dialog or shift-clicking share
|
||||
# bypasses the game auto-pick (see xdph.conf in settings/hyprland.nix).
|
||||
# The Wayland dialog opens instantly with the preview filling in, and
|
||||
# "Pick Another Window" or shift-clicking share bypasses the game
|
||||
# auto-pick (see xdph.conf in settings/hyprland.nix).
|
||||
(vesktop.overrideAttrs (old: {
|
||||
patches = (old.patches or [ ]) ++ [
|
||||
../patches/vesktop-remember-stream-audio.patch
|
||||
../patches/vesktop-pick-another-window.patch
|
||||
../patches/vesktop-wayland-share-dialog.patch
|
||||
];
|
||||
}))
|
||||
lsfg-vk
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
--- 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 { writeFileSync } from "fs";
|
||||
+import { join } from "path";
|
||||
import type { StreamPick } from "renderer/components/ScreenSharePicker";
|
||||
import { IpcCommands, IpcEvents } from "shared/IpcEvents";
|
||||
|
||||
@@ -14,7 +16,31 @@
|
||||
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";
|
||||
+
|
||||
+// 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(process.env.XDG_RUNTIME_DIR ?? "/tmp", "xdph-pick-manually"),
|
||||
+ String(Math.floor(Date.now() / 1000))
|
||||
+ );
|
||||
+ } catch {}
|
||||
+}
|
||||
+
|
||||
+// 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) => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["window", "screen"],
|
||||
@@ -29,15 +55,19 @@
|
||||
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;
|
||||
- const sources = await desktopCapturer
|
||||
- .getSources({
|
||||
- types: ["window", "screen"],
|
||||
- thumbnailSize: {
|
||||
- width,
|
||||
- height: width * (9 / 16)
|
||||
- }
|
||||
- })
|
||||
- .catch(err => console.error("Error during screenshare picker", err));
|
||||
+ const getSources = () =>
|
||||
+ desktopCapturer
|
||||
+ .getSources({
|
||||
+ types: ["window", "screen"],
|
||||
+ thumbnailSize: {
|
||||
+ width,
|
||||
+ height: width * (9 / 16)
|
||||
+ }
|
||||
+ })
|
||||
+ .catch(err => console.error("Error during screenshare picker", err));
|
||||
+
|
||||
+ if (isWayland && shiftDown) forceFullPicker();
|
||||
+ let sources = await getSources();
|
||||
|
||||
if (!sources) return callback({});
|
||||
|
||||
@@ -48,14 +78,22 @@
|
||||
}));
|
||||
|
||||
if (isWayland) {
|
||||
- const video = data[0];
|
||||
- if (video) {
|
||||
+ let video = data[0];
|
||||
+ while (video) {
|
||||
const stream = await sendRendererCommand<StreamPick>(IpcCommands.SCREEN_SHARE_PICKER, {
|
||||
screens: [video],
|
||||
skipPicker: true
|
||||
}).catch(() => null);
|
||||
|
||||
if (stream === null) return callback({});
|
||||
+ if (stream.id !== PICK_ANOTHER) break;
|
||||
+
|
||||
+ // re-run the portal, this time with the full picker
|
||||
+ forceFullPicker();
|
||||
+ sources = await getSources();
|
||||
+ if (!sources) return callback({});
|
||||
+ const [source] = sources;
|
||||
+ video = source && { id: source.id, name: source.name, url: source.thumbnail.toDataURL() };
|
||||
}
|
||||
|
||||
callback(video ? { video: sources[0] } : {});
|
||||
--- 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");
|
||||
@@ -829,6 +833,18 @@
|
||||
Go Live
|
||||
</Button>
|
||||
|
||||
+ {skipPicker && (
|
||||
+ <Button
|
||||
+ variant="secondary"
|
||||
+ onClick={() => {
|
||||
+ submit({ id: PICK_ANOTHER, audio: false });
|
||||
+ close();
|
||||
+ }}
|
||||
+ >
|
||||
+ Pick Another Window
|
||||
+ </Button>
|
||||
+ )}
|
||||
+
|
||||
{selected && !skipPicker ? (
|
||||
<Button variant="secondary" onClick={() => setSelected(void 0)}>
|
||||
Back
|
||||
210
patches/vesktop-wayland-share-dialog.patch
Normal file
210
patches/vesktop-wayland-share-dialog.patch
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
--- 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 { writeFileSync } from "fs";
|
||||
+import { join } from "path";
|
||||
import type { StreamPick } from "renderer/components/ScreenSharePicker";
|
||||
import { IpcCommands, IpcEvents } from "shared/IpcEvents";
|
||||
|
||||
@@ -14,8 +16,39 @@
|
||||
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";
|
||||
+
|
||||
+// 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(process.env.XDG_RUNTIME_DIR ?? "/tmp", "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<string | undefined> | undefined;
|
||||
+
|
||||
+// 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 +60,40 @@
|
||||
});
|
||||
|
||||
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 sources = desktopCapturer
|
||||
+ .getSources({ types: ["window", "screen"], thumbnailSize: { width: 960, height: 540 } })
|
||||
+ .catch(err => console.error("Error during screenshare picker", err));
|
||||
+ pendingPreview = sources.then(s =>
|
||||
+ s?.[0] ? "data:image/jpeg;base64," + s[0].thumbnail.toJPEG(85).toString("base64") : undefined
|
||||
+ );
|
||||
+
|
||||
+ const stream = await sendRendererCommand<StreamPick>(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 +106,6 @@
|
||||
url: thumbnail.toDataURL()
|
||||
}));
|
||||
|
||||
- if (isWayland) {
|
||||
- const video = data[0];
|
||||
- if (video) {
|
||||
- const stream = await sendRendererCommand<StreamPick>(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<StreamPick>(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<SetStateAction<StreamSettings>>;
|
||||
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 @@
|
||||
<div>
|
||||
<HeadingTertiary className={Margins.bottom8}>What you're streaming</HeadingTertiary>
|
||||
<Card className={cl("card", "preview")}>
|
||||
- <img src={thumb} alt="" className={cl(isLinux ? "preview-img-linux" : "preview-img")} />
|
||||
+ {thumb ? (
|
||||
+ <img src={thumb} alt="" className={cl(isLinux ? "preview-img-linux" : "preview-img")} />
|
||||
+ ) : (
|
||||
+ <div className={cl("preview-img-linux")} style={{ aspectRatio: "16 / 9" }} />
|
||||
+ )}
|
||||
<Paragraph>{source.name}</Paragraph>
|
||||
</Card>
|
||||
|
||||
@@ -760,6 +778,7 @@
|
||||
settings={settings}
|
||||
setSettings={setSettings}
|
||||
skipPicker={skipPicker}
|
||||
+ close={close}
|
||||
/>
|
||||
)}
|
||||
</Modals.ModalContent>
|
||||
@@ -829,6 +848,18 @@
|
||||
Go Live
|
||||
</Button>
|
||||
|
||||
+ {skipPicker && (
|
||||
+ <Button
|
||||
+ variant="secondary"
|
||||
+ onClick={() => {
|
||||
+ submit({ id: PICK_ANOTHER, audio: false });
|
||||
+ close();
|
||||
+ }}
|
||||
+ >
|
||||
+ Pick Another Window
|
||||
+ </Button>
|
||||
+ )}
|
||||
+
|
||||
{selected && !skipPicker ? (
|
||||
<Button variant="secondary" onClick={() => setSelected(void 0)}>
|
||||
Back
|
||||
Loading…
Add table
Add a link
Reference in a new issue