/* * Vesktop, a desktop app aiming to give you a snappier Discord Experience * Copyright (c) 2023 Vendicated and Vencord contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import "./screenSharePicker.css"; import { classNameFactory } from "@vencord/types/api/Styles"; import { Button, Card, CogWheel, FormSwitch, Heading, HeadingTertiary, Margins, Paragraph, RestartIcon, Span } from "@vencord/types/components"; import { Logger, useAwaiter, useForceUpdater } from "@vencord/types/utils"; import { onceReady } from "@vencord/types/webpack"; import { closeModal, FluxDispatcher, MediaEngineStore, Modal, openModal, Select, UserStore, useState } from "@vencord/types/webpack/common"; import { Node } from "@vencord/venmic"; import type { Dispatch, SetStateAction } from "react"; import { addPatch } from "renderer/patches/shared"; import { State, useSettings, useVesktopState } from "renderer/settings"; import { isLinux, isWindows } from "renderer/utils"; import { SimpleErrorBoundary } from "./SimpleErrorBoundary"; const StreamResolutions = ["480", "720", "1080", "1440", "2160"] as const; const StreamFps = ["15", "30", "60"] as const; const cl = classNameFactory("vcd-screen-picker-"); export type StreamResolution = (typeof StreamResolutions)[number]; export type StreamFps = (typeof StreamFps)[number]; type SpecialSource = "None" | "Entire System"; type AudioSource = SpecialSource | Node; type AudioSources = SpecialSource | Node[]; interface AudioItem { name: string; value: AudioSource; } interface StreamSettings { audio: boolean; contentHint?: string; includeSources?: AudioSources; excludeSources?: AudioSources; } export interface StreamPick extends StreamSettings { id: string; } interface Source { id: string; name: string; url: string; } export let currentSettings: StreamSettings | null = null; const logger = new Logger("VesktopScreenShare"); addPatch({ patches: [ { find: "this.getDefaultGoliveQuality()", replacement: { match: /this\.getDefaultGoliveQuality\(\)/, replace: "$self.patchStreamQuality($&)" } } ], patchStreamQuality(opts: any) { const { screenshareQuality } = State.store; if (!screenshareQuality) return opts; const framerate = Number(screenshareQuality.frameRate); const height = Number(screenshareQuality.resolution); const width = Math.round(height * (16 / 9)); Object.assign(opts, { bitrateMin: 500000, bitrateMax: 8000000, bitrateTarget: 600000 }); if (opts?.encode) { Object.assign(opts.encode, { framerate, width, height, pixelCount: height * width }); } Object.assign(opts.capture, { framerate, width, height, pixelCount: height * width }); return opts; } }); if (isLinux) { onceReady.then(() => { const ownsStream = (streamKey: string) => { return streamKey.split(":").at(-1) === UserStore.getCurrentUser().id; }; FluxDispatcher.subscribe("STREAM_CLOSE", ({ streamKey }: { streamKey: string }) => { if (!ownsStream(streamKey)) { return; } VesktopNative.virtmic.stop(); }); FluxDispatcher.subscribe("STREAM_UPDATE", ({ streamKey }: { streamKey: string }) => { if (!ownsStream(streamKey)) { return; } VesktopNative.virtmic.unmute(); }); }); } export function openScreenSharePicker(screens: Source[], skipPicker: boolean) { let didSubmit = false; return new Promise((resolve, reject) => { const key = openModal( props => ( { didSubmit = true; if (v.includeSources && v.includeSources !== "None") { if (v.includeSources === "Entire System") { await VesktopNative.virtmic.startSystem( !v.excludeSources || isSpecialSource(v.excludeSources) ? [] : v.excludeSources ); } else { await VesktopNative.virtmic.start(v.includeSources); } } resolve(v); }} close={() => { props.onClose(); if (!didSubmit) reject("Aborted"); }} skipPicker={skipPicker} /> ), { onCloseRequest() { closeModal(key); reject("Aborted"); }, onCloseCallback() { if (!didSubmit) reject("Aborted"); } } ); }); } function ScreenPicker({ screens, chooseScreen }: { screens: Source[]; chooseScreen: (id: string) => void }) { return (
{screens.map(({ id, name, url }) => ( ))}
); } function AudioSettingsModal({ modalProps, close, setAudioSources }: { modalProps: any; close: () => void; setAudioSources: (s: AudioSources) => void; }) { const Settings = useSettings(); return (
(Settings.audio = { ...Settings.audio, mute: v })} value={Settings.audio?.mute ?? true} /> (Settings.audio = { ...Settings.audio, workaround: v })} value={Settings.audio?.workaround ?? false} /> (Settings.audio = { ...Settings.audio, onlySpeakers: v })} value={Settings.audio?.onlySpeakers ?? true} /> When sharing entire desktop audio, only share apps that play to the default speakers. You may want to disable this when using "mix bussing". } hideBorder onChange={v => (Settings.audio = { ...Settings.audio, onlyDefaultSpeakers: v })} value={Settings.audio?.onlyDefaultSpeakers ?? true} /> (Settings.audio = { ...Settings.audio, ignoreInputMedia: v })} value={Settings.audio?.ignoreInputMedia ?? true} /> (Settings.audio = { ...Settings.audio, ignoreVirtual: v })} value={Settings.audio?.ignoreVirtual ?? false} /> (Settings.audio = { ...Settings.audio, ignoreDevices: v, deviceSelect: v ? false : Settings.audio?.deviceSelect }) } value={Settings.audio?.ignoreDevices ?? true} /> { Settings.audio = { ...Settings.audio, granularSelect: value }; setAudioSources("None"); }} value={Settings.audio?.granularSelect ?? false} /> Allow to select devices such as microphones. Requires Ignore Devices to be turned off. } hideBorder onChange={value => { Settings.audio = { ...Settings.audio, deviceSelect: value }; setAudioSources("None"); }} value={Settings.audio?.deviceSelect ?? false} disabled={Settings.audio?.ignoreDevices} />
); } function OptionRadio(props: { options: Array | ReadonlyArray; labels?: Array; settings: Settings; settingsKey: Key; onChange: (option: string) => void; }) { const { options, settings, settingsKey, labels, onChange } = props; return (
{(options as string[]).map((option, idx) => ( ))}
); } function StreamSettingsUi({ source, settings, setSettings, skipPicker }: { source: Source; settings: StreamSettings; setSettings: Dispatch>; skipPicker: boolean; }) { const Settings = useSettings(); const qualitySettings = State.store.screenshareQuality!; const [thumb] = useAwaiter( () => (skipPicker ? Promise.resolve(source.url) : VesktopNative.capturer.getLargeThumbnail(source.id)), { fallbackValue: source.url, deps: [source.id] } ); const openSettings = () => { openModal(props => ( props.onClose()} setAudioSources={sources => setSettings(s => ({ ...s, includeSources: sources, excludeSources: sources })) } /> )); }; return (
What you're streaming {source.name} Stream Settings
Resolution (qualitySettings.resolution = value)} />
Frame Rate (qualitySettings.frameRate = value)} />
Content Type
setSettings(s => ({ ...s, contentHint: option }))} /> Choosing "Prefer Clarity" will result in a significantly lower framerate in exchange for a much sharper and clearer image.
{isWindows && ( setSettings(s => ({ ...s, audio: checked }))} className={cl("audio")} /> )}
{isLinux && ( setSettings(s => ({ ...s, includeSources: sources }))} setExcludeSources={sources => setSettings(s => ({ ...s, excludeSources: sources }))} /> )}
); } function isSpecialSource(value?: AudioSource | AudioSources): value is SpecialSource { return typeof value === "string"; } function mapToAudioItem(node: AudioSource, granularSelect?: boolean, deviceSelect?: boolean): AudioItem[] { if (isSpecialSource(node)) { return [{ name: node, value: node }]; } const mediaClass = node["media.class"]; if (mediaClass?.includes("Video") || mediaClass?.includes("Midi")) { return []; } if (!deviceSelect && node["device.id"]) { return []; } const preferred = ["application.name", "node.description", "node.name", "application.process.binary"] as const; const prop = preferred.find(prop => node[prop]); if (!prop) { return []; } const name = node[prop]!; const items: AudioItem[] = [{ name: name, value: { [prop]: name } }]; if (!granularSelect) { return items; } let granularName = name; const granularProps: Node = { [prop]: name }; const append = (name: string, brackets: string) => { const value = node[name]; if (!value) { return; } granularName += ` ${brackets[0]}${value}${brackets[1]}`; granularProps[name] = value; }; append("application.process.id", "<>"); append("application.process.binary", "()"); append("media.name", "[]"); items.push({ name: granularName, value: granularProps }); return items; } function hasMatchingProps(value: Node, other: Node) { const keys = Object.keys(value); return keys.length === Object.keys(other).length && keys.every(key => value[key] === other[key]); } function isItemSelected(sources?: AudioSources) { return (value: AudioSource) => { if (!sources) { return false; } if (isSpecialSource(sources) || isSpecialSource(value)) { return sources === value; } return sources.some(source => hasMatchingProps(source, value)); }; } function updateItems(setSources: (s: AudioSources) => void, sources?: AudioSources) { return (value: AudioSource) => { if (isSpecialSource(value)) { setSources(value); return; } if (isSpecialSource(sources)) { setSources([value]); return; } if (isItemSelected(sources)(value)) { setSources(sources?.filter(x => !hasMatchingProps(x, value)) ?? "None"); return; } setSources([...(sources || []), value]); }; } function AudioSourcePickerLinux({ includeSources, excludeSources, deviceSelect, granularSelect, openSettings, setIncludeSources, setExcludeSources }: { includeSources?: AudioSources; excludeSources?: AudioSources; deviceSelect?: boolean; granularSelect?: boolean; openSettings: () => void; setIncludeSources: (s: AudioSources) => void; setExcludeSources: (s: AudioSources) => void; }) { const [audioSourcesSignal, refreshAudioSources] = useForceUpdater(true); const [sources, _, loading] = useAwaiter(() => VesktopNative.virtmic.list(), { fallbackValue: { ok: true, targets: [], hasPipewirePulse: true }, deps: [audioSourcesSignal] }); const hasPipewirePulse = sources.ok ? sources.hasPipewirePulse : true; const [ignorePulseWarning, setIgnorePulseWarning] = useState(false); if (!sources.ok && sources.isGlibCxxOutdated) { return ( Failed to retrieve Audio Sources because your C++ library is too old to run venmic . See{" "} this guide {" "} for possible solutions. ); } if (!hasPipewirePulse && !ignorePulseWarning) { return ( Could not find pipewire-pulse. See{" "} this guide {" "} on how to switch to pipewire.
You can still continue, however, please{" "} beware that you can only share audio of apps that are running under pipewire.{" "} setIgnorePulseWarning(true)}>I know what I'm doing!
); } const specialSources: SpecialSource[] = ["None", "Entire System"] as const; const uniqueName = (value: AudioItem, index: number, list: AudioItem[]) => list.findIndex(x => x.name === value.name) === index; const allSources = sources.ok ? [...specialSources, ...sources.targets] .map(target => mapToAudioItem(target, granularSelect, deviceSelect)) .flat() .filter(uniqueName) : []; return ( <>
{loading ? "Loading Sources..." : "Audio Sources"} x.name !== "Entire System") .map(({ name, value }) => ({ label: name, value: value, default: name === "None" }))} isSelected={isItemSelected(excludeSources)} select={updateItems(setExcludeSources, excludeSources)} serialize={JSON.stringify} popoutPosition="top" closeOnSelect={false} renderOptionLabel={o => o.label} />
)}
); } function ModalComponent({ screens, modalProps, submit, close, skipPicker }: { screens: Source[]; modalProps: any; submit: (data: StreamPick) => void; close: () => void; skipPicker: boolean; }) { const [selected, setSelected] = useState(skipPicker ? screens[0].id : void 0); const [settings, setSettings] = useState({ contentHint: "motion", audio: true, includeSources: "None" }); const qualitySettings = (useVesktopState().screenshareQuality ??= { resolution: "720", frameRate: "30" }); function handleGoLive() { currentSettings = settings; try { const frameRate = Number(qualitySettings.frameRate); const height = Number(qualitySettings.resolution); const width = Math.round(height * (16 / 9)); const conn = [...MediaEngineStore.getMediaEngine().connections].find( connection => connection.streamUserId === UserStore.getCurrentUser().id ); if (conn) { conn.videoStreamParameters[0].maxFrameRate = frameRate; conn.videoStreamParameters[0].maxResolution ??= { width: 0, height: 0 }; conn.videoStreamParameters[0].maxResolution.height = height; conn.videoStreamParameters[0].maxResolution.width = width; } submit({ id: selected!, ...settings }); setTimeout(async () => { const conn = [...MediaEngineStore.getMediaEngine().connections].find( connection => connection.streamUserId === UserStore.getCurrentUser().id ); if (!conn) return; // @ts-expect-error incorrect type const track = conn.input.stream.getVideoTracks()[0]; const constraints = { ...track.getConstraints(), frameRate: { min: frameRate, ideal: frameRate }, width: { min: 640, ideal: width, max: width }, height: { min: 480, ideal: height, max: height }, advanced: [{ width: width, height: height }], resizeMode: "none" }; try { await track.applyConstraints(constraints); logger.info("Applied constraints successfully. New constraints:", track.getConstraints()); } catch (e) { logger.error("Failed to apply constraints.", e); } }, 100); } catch (error) { logger.error("Error while submitting stream.", error); } close(); } const showGoBack = selected && !skipPicker; return ( (showGoBack ? setSelected(void 0) : close()) }, { text: "Go Live", disabled: !selected, onClick: handleGoLive } ]} > {!selected ? ( ) : ( s.id === selected)!} settings={settings} setSettings={setSettings} skipPicker={skipPicker} /> )} ); }