Screenshare: preselect the shared window's audio

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 20:06:49 +01:00
parent 4afc78ee8f
commit 7b239c3972
3 changed files with 155 additions and 11 deletions

View file

@ -6,8 +6,8 @@
mangohud mangohud
gamescope gamescope
spotify spotify
# Preselect the last stream's audio source (e.g. Hearthstone) when it's # Preselect stream audio: the shared window's own process if it plays
# running again, instead of resetting to "None" every Go Live. # any, else the last stream's sources if they're running again.
# The Wayland dialog opens instantly with the preview filling in, and # The Wayland dialog opens instantly with the preview filling in, and
# "Pick Another Window" or shift-clicking share bypasses the game # "Pick Another Window" or shift-clicking share bypasses the game
# auto-pick (see xdph.conf in settings/hyprland.nix). # auto-pick (see xdph.conf in settings/hyprland.nix).

View file

@ -146,6 +146,77 @@
const choice = await sendRendererCommand<StreamPick>(IpcCommands.SCREEN_SHARE_PICKER, { const choice = await sendRendererCommand<StreamPick>(IpcCommands.SCREEN_SHARE_PICKER, {
screens: data, screens: data,
skipPicker: false 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 --- a/src/renderer/components/ScreenSharePicker.tsx
+++ b/src/renderer/components/ScreenSharePicker.tsx +++ b/src/renderer/components/ScreenSharePicker.tsx
@@ -82,6 +82,10 @@ @@ -82,6 +82,10 @@
@ -209,7 +280,75 @@
<Paragraph>{source.name}</Paragraph> <Paragraph>{source.name}</Paragraph>
</Card> </Card>
@@ -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}
/>
)}
</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} settings={settings}
setSettings={setSettings} setSettings={setSettings}
skipPicker={skipPicker} skipPicker={skipPicker}
@ -217,7 +356,7 @@
/> />
)} )}
</Modals.ModalContent> </Modals.ModalContent>
@@ -829,6 +848,18 @@ @@ -829,6 +858,18 @@
Go Live Go Live
</Button> </Button>

View file

@ -126,7 +126,8 @@ in
# After answering, it grim-shots the selection to xdph-preview.jpg for # After answering, it grim-shots the selection to xdph-preview.jpg for
# Vesktop's dialog (~70ms vs Electron's ~1s PipeWire thumbnail). grim # 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 # 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 = '' xdg.configFile."hypr/xdph.conf".text = ''
screencopy { screencopy {
allow_token_by_default = true allow_token_by_default = true
@ -138,8 +139,9 @@ in
run=''${XDG_RUNTIME_DIR:-/tmp} run=''${XDG_RUNTIME_DIR:-/tmp}
flag=$run/xdph-pick-manually flag=$run/xdph-pick-manually
preview=$run/xdph-preview.jpg preview=$run/xdph-preview.jpg
selpid=$run/xdph-selection-pid
list=''${XDPH_WINDOW_SHARING_LIST-} list=''${XDPH_WINDOW_SHARING_LIST-}
rm -f "$preview" rm -f "$preview" "$selpid"
printf -v now '%(%s)T' -1 printf -v now '%(%s)T' -1
{ forced=$(< "$flag"); } 2>/dev/null || forced=0 # no trailing newline, so not `read` { forced=$(< "$flag"); } 2>/dev/null || forced=0 # no trailing newline, so not `read`
@ -175,16 +177,19 @@ in
local target=''${1#*/} where=() local target=''${1#*/} where=()
case $target in case $target in
window:*) 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 if (( id < 0 )); then id=$(( id + 4294967296 )); fi # the Qt picker prints it signed
addr=$(entries "$id" '$1 == want { print $4 }') addr=$(entries "$id" '$1 == want { print $4 }')
[ -n "$addr" ] || return 0 [ -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)" ' --argjson mons "$(hyprctl -j monitors)" '
[$mons[] | .activeWorkspace.id, .specialWorkspace.id] as $shown [$mons[] | .activeWorkspace.id, .specialWorkspace.id] as $shown
| .[] | select(.address == $a and (.hidden | not)) | .[] | select(.address == $a)
| select(.workspace.id as $w | any($shown[]; . == $w)) | [.pid, if (.hidden | not) and (.workspace.id as $w | any($shown[]; . == $w))
| "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"') 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 [ -n "$geom" ] || return 0
where=(-g "$geom") where=(-g "$geom")
;; ;;