From 7b239c397240fdeb56ebce004cdc8cfedccba6ce Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 18 Oct 2026 20:06:49 +0100 Subject: [PATCH] Screenshare: preselect the shared window's audio Co-Authored-By: Claude Opus 5.5 --- hosts/FredOS-Gaming.nix | 4 +- patches/vesktop-wayland-share-dialog.patch | 143 ++++++++++++++++++++- settings/hyprland.nix | 19 ++- 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/hosts/FredOS-Gaming.nix b/hosts/FredOS-Gaming.nix index c2a5aeb..d7a11e8 100644 --- a/hosts/FredOS-Gaming.nix +++ b/hosts/FredOS-Gaming.nix @@ -6,8 +6,8 @@ mangohud gamescope spotify - # Preselect the last stream's audio source (e.g. Hearthstone) when it's - # running again, instead of resetting to "None" every Go Live. + # Preselect stream audio: the shared window's own process if it plays + # any, else the last stream's sources if they're running again. # 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). diff --git a/patches/vesktop-wayland-share-dialog.patch b/patches/vesktop-wayland-share-dialog.patch index 8c9064d..d0d25d3 100644 --- a/patches/vesktop-wayland-share-dialog.patch +++ b/patches/vesktop-wayland-share-dialog.patch @@ -146,6 +146,77 @@ const choice = await sendRendererCommand(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(IpcEvents.VIRT_MIC_START, include), + startSystem: (exclude: Node[]) => invoke(IpcEvents.VIRT_MIC_START_SYSTEM, exclude), --- a/src/renderer/components/ScreenSharePicker.tsx +++ b/src/renderer/components/ScreenSharePicker.tsx @@ -82,6 +82,10 @@ @@ -209,7 +280,75 @@ {source.name} -@@ -760,6 +778,7 @@ +@@ -451,6 +469,7 @@ + granularSelect={Settings.audio?.granularSelect} + setIncludeSources={sources => setSettings(s => ({ ...s, includeSources: sources }))} + setExcludeSources={sources => setSettings(s => ({ ...s, excludeSources: sources }))} ++ selectionReady={!thumbPending} + /> + )} + +@@ -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} @@ -217,7 +356,7 @@ /> )} -@@ -829,6 +848,18 @@ +@@ -829,6 +858,18 @@ Go Live diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 1a4f746..63593c7 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -126,7 +126,8 @@ in # After answering, it grim-shots the selection to xdph-preview.jpg for # Vesktop's dialog (~70ms vs Electron's ~1s PipeWire thumbnail). grim # reads the screen, so a window that isn't visible is skipped and Vesktop - # falls back to the thumbnail. + # falls back to the thumbnail. A shared window's pid goes to + # xdph-selection-pid so Vesktop can preselect that process's audio. xdg.configFile."hypr/xdph.conf".text = '' screencopy { allow_token_by_default = true @@ -138,8 +139,9 @@ in run=''${XDG_RUNTIME_DIR:-/tmp} flag=$run/xdph-pick-manually preview=$run/xdph-preview.jpg + selpid=$run/xdph-selection-pid list=''${XDPH_WINDOW_SHARING_LIST-} - rm -f "$preview" + rm -f "$preview" "$selpid" printf -v now '%(%s)T' -1 { forced=$(< "$flag"); } 2>/dev/null || forced=0 # no trailing newline, so not `read` @@ -175,16 +177,19 @@ in local target=''${1#*/} where=() case $target in window:*) - local id=''${target#window:} addr geom + local id=''${target#window:} addr pid geom if (( id < 0 )); then id=$(( id + 4294967296 )); fi # the Qt picker prints it signed addr=$(entries "$id" '$1 == want { print $4 }') [ -n "$addr" ] || return 0 - geom=$(hyprctl -j clients | jq -r --arg a "$(printf '0x%x' "$addr")" \ + # pid always; geometry only if the window is actually on screen + IFS=$'\t' read -r pid geom <<< "$(hyprctl -j clients | jq -r --arg a "$(printf '0x%x' "$addr")" \ --argjson mons "$(hyprctl -j monitors)" ' [$mons[] | .activeWorkspace.id, .specialWorkspace.id] as $shown - | .[] | select(.address == $a and (.hidden | not)) - | select(.workspace.id as $w | any($shown[]; . == $w)) - | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"') + | .[] | select(.address == $a) + | [.pid, if (.hidden | not) and (.workspace.id as $w | any($shown[]; . == $w)) + then "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])" else "" end] + | @tsv')" + if [ -n "$pid" ]; then printf '%s' "$pid" > "$selpid"; fi [ -n "$geom" ] || return 0 where=(-g "$geom") ;;