Screenshare: grim preview from picker, ~70ms instead of ~1s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-12 20:55:01 +01:00
parent 4a6fb7f5b2
commit d3ce8f66fc
2 changed files with 115 additions and 25 deletions

View file

@ -6,24 +6,26 @@
-import { desktopCapturer, session, Streams } from "electron"; -import { desktopCapturer, session, Streams } from "electron";
+import { app, desktopCapturer, session, Streams } from "electron"; +import { app, desktopCapturer, session, Streams } from "electron";
+import { writeFileSync } from "fs"; +import { readFileSync, statSync, writeFileSync } from "fs";
+import { join } from "path"; +import { join } from "path";
import type { StreamPick } from "renderer/components/ScreenSharePicker"; import type { StreamPick } from "renderer/components/ScreenSharePicker";
import { IpcCommands, IpcEvents } from "shared/IpcEvents"; import { IpcCommands, IpcEvents } from "shared/IpcEvents";
@@ -14,8 +16,39 @@ @@ -14,8 +16,68 @@
const isWayland = const isWayland =
process.platform === "linux" && (process.env.XDG_SESSION_TYPE === "wayland" || !!process.env.WAYLAND_DISPLAY); process.platform === "linux" && (process.env.XDG_SESSION_TYPE === "wayland" || !!process.env.WAYLAND_DISPLAY);
+// renderer's PICK_ANOTHER ("Pick Another Window" in the stream dialog) +// renderer's PICK_ANOTHER ("Pick Another Window" in the stream dialog)
+const PICK_ANOTHER = "vesktop-pick-another"; +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 +// 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. +// game unless this file holds a fresh timestamp, then shows the full picker.
+function forceFullPicker() { +function forceFullPicker() {
+ try { + try {
+ writeFileSync( + writeFileSync(
+ join(process.env.XDG_RUNTIME_DIR ?? "/tmp", "xdph-pick-manually"), + join(runtimeDir, "xdph-pick-manually"),
+ String(Math.floor(Date.now() / 1000)) + String(Math.floor(Date.now() / 1000))
+ ); + );
+ } catch {} + } catch {}
@ -34,6 +36,33 @@
+const PENDING_PREVIEW = "vesktop-pending-preview"; +const PENDING_PREVIEW = "vesktop-pending-preview";
+let pendingPreview: Promise<string | undefined> | undefined; +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 +// Shift held while clicking share = full picker
+let shiftDown = false; +let shiftDown = false;
+ +
@ -51,7 +80,7 @@
const sources = await desktopCapturer.getSources({ const sources = await desktopCapturer.getSources({
types: ["window", "screen"], types: ["window", "screen"],
thumbnailSize: { thumbnailSize: {
@@ -27,14 +60,40 @@ @@ -27,14 +89,39 @@
}); });
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => { session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
@ -63,12 +92,11 @@
+ // let it fetch the preview (PENDING_PREVIEW) when the portal answers. + // let it fetch the preview (PENDING_PREVIEW) when the portal answers.
+ if (shiftDown) forceFullPicker(); + if (shiftDown) forceFullPicker();
+ while (true) { + while (true) {
+ const requestedAt = Date.now();
+ const sources = desktopCapturer + const sources = desktopCapturer
+ .getSources({ types: ["window", "screen"], thumbnailSize: { width: 960, height: 540 } }) + .getSources({ types: ["window", "screen"], thumbnailSize: { width: 960, height: 540 } })
+ .catch(err => console.error("Error during screenshare picker", err)); + .catch(err => console.error("Error during screenshare picker", err));
+ pendingPreview = sources.then(s => + pendingPreview = previewFor(sources, requestedAt);
+ s?.[0] ? "data:image/jpeg;base64," + s[0].thumbnail.toJPEG(85).toString("base64") : undefined
+ );
+ +
+ const stream = await sendRendererCommand<StreamPick>(IpcCommands.SCREEN_SHARE_PICKER, { + const stream = await sendRendererCommand<StreamPick>(IpcCommands.SCREEN_SHARE_PICKER, {
+ screens: [{ id: PENDING_PREVIEW, name: "", url: "" }], + screens: [{ id: PENDING_PREVIEW, name: "", url: "" }],
@ -96,7 +124,7 @@
} }
}) })
.catch(err => console.error("Error during screenshare picker", err)); .catch(err => console.error("Error during screenshare picker", err));
@@ -47,21 +106,6 @@ @@ -47,21 +134,6 @@
url: thumbnail.toDataURL() url: thumbnail.toDataURL()
})); }));

View file

@ -117,28 +117,90 @@ in
# or HDT. Gaming's patched Vesktop writes the time to xdph-pick-manually # or HDT. Gaming's patched Vesktop writes the time to xdph-pick-manually
# right before asking the portal ("Pick Another Window", shift-click) # right before asking the portal ("Pick Another Window", shift-click)
# to get the normal picker instead. # to get the normal picker instead.
#
# 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.
xdg.configFile."hypr/xdph.conf".text = '' xdg.configFile."hypr/xdph.conf".text = ''
screencopy { screencopy {
allow_token_by_default = true allow_token_by_default = true
custom_picker_binary = ${pkgs.writeShellScript "share-picker-game-first" '' custom_picker_binary = ${lib.getExe (pkgs.writeShellApplication {
flag="''${XDG_RUNTIME_DIR:-/tmp}/xdph-pick-manually" name = "share-picker-game-first";
printf -v now '%(%s)T' -1 runtimeInputs = with pkgs; [ coreutils gawk jq grim hyprland xdg-desktop-portal-hyprland ];
{ forced=$(< "$flag"); } 2>/dev/null # no trailing newline, so not `read` excludeShellChecks = [ "SC2016" ]; # $ in awk/jq programs is meant literally
echo 0 > "$flag" text = ''
run=''${XDG_RUNTIME_DIR:-/tmp}
flag=$run/xdph-pick-manually
preview=$run/xdph-preview.jpg
list=''${XDPH_WINDOW_SHARING_LIST-}
rm -f "$preview"
game=$(printf '%s' "''${XDPH_WINDOW_SHARING_LIST-}" | ${pkgs.gawk}/bin/awk ' printf -v now '%(%s)T' -1
BEGIN { RS = "\\[HA>\\]"; FS = "\\[H[CTE]>\\]" } { forced=$(< "$flag"); } 2>/dev/null || forced=0 # no trailing newline, so not `read`
$2 ~ /^steam_app_/ && $3 == "Hearthstone" { hs = $1 } echo 0 > "$flag"
$2 ~ /^steam_app_/ && $3 != "" && !other \
&& $3 !~ /^(Battle\.net|Hearthstone Deck Tracker|HearthstoneOverlay)/ { other = $1 }
END { print hs != "" ? hs : other }')
if [ -n "$game" ] && (( now - ''${forced:-0} > 10 )); then # run awk program $2 over the window list as id/class/title/address
echo "[SELECTION]r/window:$game" # columns, with want=$1
exit 0 entries() {
fi printf '%s' "$list" | awk '
exec ${pkgs.xdg-desktop-portal-hyprland}/bin/hyprland-share-picker "$@" BEGIN { RS = "\\[HA>\\]"; FS = "\\[H[CTE]>\\]" }
''} { print $1 "\t" $2 "\t" $3 "\t" $4 }' | awk -F '\t' -v want="$1" "$2"
}
game=$(entries "" '
$2 ~ /^steam_app_/ && $3 == "Hearthstone" { hs = $1 }
$2 ~ /^steam_app_/ && $3 != "" && !other \
&& $3 !~ /^(Battle\.net|Hearthstone Deck Tracker|HearthstoneOverlay)/ { other = $1 }
END { print hs != "" ? hs : other }')
if [ -n "$game" ] && (( now - forced > 10 )); then
selection=r/window:$game
else
out=$(hyprland-share-picker "$@") || true
selection=''${out#*"[SELECTION]"}
if [ "$selection" = "$out" ]; then # closed / failed: pass through
printf '%s\n' "$out"
exit 0
fi
fi
echo "[SELECTION]$selection"
shoot() {
local target=''${1#*/} where=()
case $target in
window:*)
local id=''${target#window:} addr 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")" \
--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])"')
[ -n "$geom" ] || return 0
where=(-g "$geom")
;;
screen:*)
where=(-o "''${target#screen:}")
;;
region:*)
local spec=''${target#region:} x y w h geom
IFS=, read -r x y w h <<< "''${spec#*@}"
geom=$(hyprctl -j monitors | jq -r --arg o "''${spec%@*}" \
--argjson x "$x" --argjson y "$y" --arg wh "''${w}x$h" '
.[] | select(.name == $o) | "\(.x + $x),\(.y + $y) \($wh)"')
[ -n "$geom" ] || return 0
where=(-g "$geom")
;;
*) return 0 ;;
esac
grim "''${where[@]}" -s 0.5 -t jpeg -q 85 "$preview.tmp" && mv -f "$preview.tmp" "$preview"
}
shoot "$selection" > /dev/null 2>&1 < /dev/null &
'';
})}
} }
''; '';