Screenshare: preselect the shared window's audio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4afc78ee8f
commit
7b239c3972
3 changed files with 155 additions and 11 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue