377 lines
15 KiB
Diff
377 lines
15 KiB
Diff
--- 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<string | undefined> | 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<Electron.DesktopCapturerSource[] | void>, requestedAt: number) {
|
|
+ const file = join(runtimeDir, "xdph-preview.jpg");
|
|
+ const jpeg = (data: Buffer) => "data:image/jpeg;base64," + data.toString("base64");
|
|
+
|
|
+ return new Promise<string | undefined>(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<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 +134,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/main/venmic.ts
|
|
+++ b/src/main/venmic.ts
|
|
@@ -6,6 +6,7 @@
|
|
|
|
import type { LinkData, Node, PatchBay as PatchBayType } from "@vencord/venmic";
|
|
import { app, ipcMain } from "electron";
|
|
+import { readFileSync } from "fs";
|
|
import { join } from "path";
|
|
import { IpcEvents } from "shared/IpcEvents";
|
|
import { STATIC_DIR } from "shared/paths";
|
|
@@ -66,6 +67,37 @@
|
|
);
|
|
}
|
|
|
|
+// FredOS: the XDPH picker (settings/hyprland.nix) writes the shared window's
|
|
+// pid here; suggest audio from that process or anything it spawned (Proton's
|
|
+// game exe is usually the window's own process, gamescope's game a child).
|
|
+function suggestedSources(targets: Node[]): Node[] {
|
|
+ let root: number;
|
|
+ try {
|
|
+ root = Number(readFileSync(join(process.env.XDG_RUNTIME_DIR ?? "/tmp", "xdph-selection-pid"), "utf8"));
|
|
+ } catch {
|
|
+ return [];
|
|
+ }
|
|
+ if (!root) return [];
|
|
+
|
|
+ const fromRoot = (pid: number) => {
|
|
+ for (let depth = 0; pid > 1 && depth < 32; depth++) {
|
|
+ if (pid === root) return true;
|
|
+ try {
|
|
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
+ pid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]); // ppid, after "(comm) state"
|
|
+ } catch {
|
|
+ return false;
|
|
+ }
|
|
+ }
|
|
+ return false;
|
|
+ };
|
|
+
|
|
+ const names = targets
|
|
+ .filter(t => t["application.name"] && fromRoot(Number(t["application.process.id"])))
|
|
+ .map(t => t["application.name"]);
|
|
+ return [...new Set(names)].map(name => ({ "application.name": name }));
|
|
+}
|
|
+
|
|
ipcMain.handle(IpcEvents.VIRT_MIC_LIST, () => {
|
|
const audioPid = getRendererAudioServicePid();
|
|
|
|
@@ -75,7 +107,9 @@
|
|
?.list(granularSelect ? ["node.name"] : undefined)
|
|
.filter(s => s["application.process.id"] !== audioPid);
|
|
|
|
- return targets ? { ok: true, targets, hasPipewirePulse } : { ok: false, isGlibCxxOutdated };
|
|
+ return targets
|
|
+ ? { ok: true, targets, hasPipewirePulse, suggested: suggestedSources(targets) }
|
|
+ : { ok: false, isGlibCxxOutdated };
|
|
});
|
|
|
|
ipcMain.handle(IpcEvents.VIRT_MIC_START, (_, include: Node[]) => {
|
|
--- a/src/preload/VesktopNative.ts
|
|
+++ b/src/preload/VesktopNative.ts
|
|
@@ -88,7 +88,8 @@
|
|
virtmic: {
|
|
list: () =>
|
|
invoke<
|
|
- { ok: false; isGlibCxxOutdated: boolean } | { ok: true; targets: Node[]; hasPipewirePulse: boolean }
|
|
+ | { ok: false; isGlibCxxOutdated: boolean }
|
|
+ | { ok: true; targets: Node[]; hasPipewirePulse: boolean; suggested: Node[] }
|
|
>(IpcEvents.VIRT_MIC_LIST),
|
|
start: (include: Node[]) => invoke<void>(IpcEvents.VIRT_MIC_START, include),
|
|
startSystem: (exclude: Node[]) => invoke<void>(IpcEvents.VIRT_MIC_START_SYSTEM, exclude),
|
|
--- 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>
|
|
|
|
@@ -451,6 +469,7 @@
|
|
granularSelect={Settings.audio?.granularSelect}
|
|
setIncludeSources={sources => setSettings(s => ({ ...s, includeSources: sources }))}
|
|
setExcludeSources={sources => setSettings(s => ({ ...s, excludeSources: sources }))}
|
|
+ selectionReady={!thumbPending}
|
|
/>
|
|
)}
|
|
</Card>
|
|
@@ -578,7 +597,8 @@
|
|
granularSelect,
|
|
openSettings,
|
|
setIncludeSources,
|
|
- setExcludeSources
|
|
+ setExcludeSources,
|
|
+ selectionReady
|
|
}: {
|
|
includeSources?: AudioSources;
|
|
excludeSources?: AudioSources;
|
|
@@ -587,30 +607,38 @@
|
|
openSettings: () => void;
|
|
setIncludeSources: (s: AudioSources) => void;
|
|
setExcludeSources: (s: AudioSources) => void;
|
|
+ selectionReady: boolean;
|
|
}) {
|
|
const [audioSourcesSignal, refreshAudioSources] = useForceUpdater(true);
|
|
const [sources, _, loading] = useAwaiter(() => VesktopNative.virtmic.list(), {
|
|
- fallbackValue: { ok: true, targets: [], hasPipewirePulse: true },
|
|
+ fallbackValue: { ok: true, targets: [], hasPipewirePulse: true, suggested: [] },
|
|
deps: [audioSourcesSignal]
|
|
});
|
|
|
|
const hasPipewirePulse = sources.ok ? sources.hasPipewirePulse : true;
|
|
const [ignorePulseWarning, setIgnorePulseWarning] = useState(false);
|
|
|
|
- // Once the first source list arrives, preselect whichever of the last
|
|
- // stream's audio sources are running again (e.g. the same game).
|
|
+ // Once the shared window is known (its preview arrived), preselect audio:
|
|
+ // whatever that window's process plays (FredOS picker hint), else
|
|
+ // whichever of the last stream's sources are running again.
|
|
const [restored, setRestored] = useState(false);
|
|
useEffect(() => {
|
|
- if (restored || loading || !sources.ok) return;
|
|
+ if (restored || !selectionReady) return;
|
|
setRestored(true);
|
|
|
|
- const last = State.store.screenshareAudio;
|
|
- if (includeSources !== "None" || !last || last === "None") return;
|
|
- if (last === "Entire System") return setIncludeSources(last);
|
|
-
|
|
- const running = last.filter(l => sources.targets.some(t => hasMatchingProps(l, t)));
|
|
- if (running.length) setIncludeSources(running);
|
|
- }, [loading]);
|
|
+ VesktopNative.virtmic.list().then(fresh => {
|
|
+ refreshAudioSources();
|
|
+ if (!fresh.ok || includeSources !== "None") return;
|
|
+ if (fresh.suggested.length) return setIncludeSources(fresh.suggested);
|
|
+
|
|
+ const last = State.store.screenshareAudio;
|
|
+ if (!last || last === "None") return;
|
|
+ if (last === "Entire System") return setIncludeSources(last);
|
|
+
|
|
+ const running = last.filter(l => fresh.targets.some(t => hasMatchingProps(l, t)));
|
|
+ if (running.length) setIncludeSources(running);
|
|
+ });
|
|
+ }, [selectionReady]);
|
|
|
|
if (!sources.ok && sources.isGlibCxxOutdated) {
|
|
return (
|
|
@@ -760,6 +788,7 @@
|
|
settings={settings}
|
|
setSettings={setSettings}
|
|
skipPicker={skipPicker}
|
|
+ close={close}
|
|
/>
|
|
)}
|
|
</Modals.ModalContent>
|
|
@@ -829,6 +858,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
|