parent
55c77395bb
commit
f5af0deb31
@ -0,0 +1,534 @@ |
||||
/* |
||||
* SPDX-License-Identifier: GPL-3.0 |
||||
* Aerocord, a vesktop fork for older microsoft NT releases such as NT 6.0, 6.1, 6.2 and 6.3.
|
||||
* Credits to vendicated and the rest of the vesktop contribuitors for making Vesktop! |
||||
*/ |
||||
|
||||
import "./screenSharePicker.css"; |
||||
|
||||
import { closeModal, Logger, Margins, Modals, ModalSize, openModal, useAwaiter } from "@vencord/types/utils"; |
||||
import { findStoreLazy, onceReady } from "@vencord/types/webpack"; |
||||
import { |
||||
Button, |
||||
Card, |
||||
FluxDispatcher, |
||||
Forms, |
||||
Select, |
||||
Switch, |
||||
Text, |
||||
UserStore, |
||||
useState |
||||
} from "@vencord/types/webpack/common"; |
||||
import type { Dispatch, SetStateAction } from "react"; |
||||
import { addPatch } from "renderer/patches/shared"; |
||||
import { isLinux, isWindows } from "renderer/utils"; |
||||
|
||||
const StreamResolutions = ["480", "720", "1080", "1440"] as const; |
||||
const StreamFps = ["15", "30", "60"] as const; |
||||
|
||||
const MediaEngineStore = findStoreLazy("MediaEngineStore"); |
||||
|
||||
export type StreamResolution = (typeof StreamResolutions)[number]; |
||||
export type StreamFps = (typeof StreamFps)[number]; |
||||
|
||||
interface StreamSettings { |
||||
resolution: StreamResolution; |
||||
fps: StreamFps; |
||||
audio: boolean; |
||||
audioSource?: string; |
||||
contentHint?: string; |
||||
workaround?: boolean; |
||||
onlyDefaultSpeakers?: boolean; |
||||
} |
||||
|
||||
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.localWant=", |
||||
replacement: { |
||||
match: /this.localWant=/, |
||||
replace: "$self.patchStreamQuality(this);$&" |
||||
} |
||||
}, |
||||
{ |
||||
find: "x-google-max-bitrate", |
||||
replacement: [ |
||||
{ |
||||
// eslint-disable-next-line no-useless-escape
|
||||
match: /"x-google-max-bitrate=".concat\(\i\)/, |
||||
replace: '"x-google-max-bitrate=".concat("80_000")' |
||||
}, |
||||
{ |
||||
match: /;level-asymmetry-allowed=1/, |
||||
replace: ";b=AS:800000;level-asymmetry-allowed=1" |
||||
} |
||||
] |
||||
} |
||||
], |
||||
patchStreamQuality(opts: any) { |
||||
if (!currentSettings) return; |
||||
|
||||
const framerate = Number(currentSettings.fps); |
||||
const height = Number(currentSettings.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 |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
if (isLinux) { |
||||
onceReady.then(() => { |
||||
FluxDispatcher.subscribe("STREAM_CLOSE", ({ streamKey }: { streamKey: string }) => { |
||||
const owner = streamKey.split(":").at(-1); |
||||
|
||||
if (owner !== UserStore.getCurrentUser().id) { |
||||
return; |
||||
} |
||||
|
||||
VesktopNative.virtmic.stop(); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
export function openScreenSharePicker(screens: Source[], skipPicker: boolean) { |
||||
let didSubmit = false; |
||||
return new Promise<StreamPick>((resolve, reject) => { |
||||
const key = openModal( |
||||
props => ( |
||||
<ModalComponent |
||||
screens={screens} |
||||
modalProps={props} |
||||
submit={async v => { |
||||
didSubmit = true; |
||||
if (v.audioSource && v.audioSource !== "None") { |
||||
if (v.audioSource === "Entire System") { |
||||
await VesktopNative.virtmic.startSystem(v.workaround); |
||||
} else { |
||||
await VesktopNative.virtmic.start([v.audioSource], v.workaround); |
||||
} |
||||
} |
||||
resolve(v); |
||||
}} |
||||
close={() => { |
||||
props.onClose(); |
||||
if (!didSubmit) reject("Aborted"); |
||||
}} |
||||
skipPicker={skipPicker} |
||||
/> |
||||
), |
||||
{ |
||||
onCloseRequest() { |
||||
closeModal(key); |
||||
reject("Aborted"); |
||||
} |
||||
} |
||||
); |
||||
}); |
||||
} |
||||
|
||||
function ScreenPicker({ screens, chooseScreen }: { screens: Source[]; chooseScreen: (id: string) => void }) { |
||||
return ( |
||||
<div className="vcd-screen-picker-grid"> |
||||
{screens.map(({ id, name, url }) => ( |
||||
<label key={id}> |
||||
<input type="radio" name="screen" value={id} onChange={() => chooseScreen(id)} /> |
||||
|
||||
<img src={url} alt="" /> |
||||
<Text variant="text-sm/normal">{name}</Text> |
||||
</label> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function StreamSettings({ |
||||
source, |
||||
settings, |
||||
setSettings, |
||||
skipPicker |
||||
}: { |
||||
source: Source; |
||||
settings: StreamSettings; |
||||
setSettings: Dispatch<SetStateAction<StreamSettings>>; |
||||
skipPicker: boolean; |
||||
}) { |
||||
const [thumb] = useAwaiter( |
||||
() => (skipPicker ? Promise.resolve(source.url) : VesktopNative.capturer.getLargeThumbnail(source.id)), |
||||
{ |
||||
fallbackValue: source.url, |
||||
deps: [source.id] |
||||
} |
||||
); |
||||
|
||||
return ( |
||||
<div className="vcd-screen-picker-settings-grid"> |
||||
<div> |
||||
<Forms.FormTitle>What you're streaming</Forms.FormTitle> |
||||
<Card className="vcd-screen-picker-card vcd-screen-picker-preview"> |
||||
<img src={thumb} alt="" /> |
||||
<Text variant="text-sm/normal">{source.name}</Text> |
||||
</Card> |
||||
|
||||
<Forms.FormTitle>Stream Settings</Forms.FormTitle> |
||||
|
||||
<Card className="vcd-screen-picker-card"> |
||||
<div className="vcd-screen-picker-quality"> |
||||
<section> |
||||
<Forms.FormTitle>Resolution</Forms.FormTitle> |
||||
<div className="vcd-screen-picker-radios"> |
||||
{StreamResolutions.map(res => ( |
||||
<label |
||||
className="vcd-screen-picker-radio" |
||||
data-checked={settings.resolution === res} |
||||
> |
||||
<Text variant="text-sm/bold">{res}</Text> |
||||
<input |
||||
type="radio" |
||||
name="resolution" |
||||
value={res} |
||||
checked={settings.resolution === res} |
||||
onChange={() => setSettings(s => ({ ...s, resolution: res }))} |
||||
/> |
||||
</label> |
||||
))} |
||||
</div> |
||||
</section> |
||||
|
||||
<section> |
||||
<Forms.FormTitle>Frame Rate</Forms.FormTitle> |
||||
<div className="vcd-screen-picker-radios"> |
||||
{StreamFps.map(fps => ( |
||||
<label className="vcd-screen-picker-radio" data-checked={settings.fps === fps}> |
||||
<Text variant="text-sm/bold">{fps}</Text> |
||||
<input |
||||
type="radio" |
||||
name="fps" |
||||
value={fps} |
||||
checked={settings.fps === fps} |
||||
onChange={() => setSettings(s => ({ ...s, fps }))} |
||||
/> |
||||
</label> |
||||
))} |
||||
</div> |
||||
</section> |
||||
</div> |
||||
<div className="vcd-screen-picker-quality"> |
||||
<section> |
||||
<Forms.FormTitle>Content Type</Forms.FormTitle> |
||||
<div> |
||||
<div className="vcd-screen-picker-radios"> |
||||
<label |
||||
className="vcd-screen-picker-radio" |
||||
data-checked={settings.contentHint === "motion"} |
||||
> |
||||
<Text variant="text-sm/bold">Prefer Smoothness</Text> |
||||
<input |
||||
type="radio" |
||||
name="contenthint" |
||||
value="motion" |
||||
checked={settings.contentHint === "motion"} |
||||
onChange={() => setSettings(s => ({ ...s, contentHint: "motion" }))} |
||||
/> |
||||
</label> |
||||
<label |
||||
className="vcd-screen-picker-radio" |
||||
data-checked={settings.contentHint === "detail"} |
||||
> |
||||
<Text variant="text-sm/bold">Prefer Clarity</Text> |
||||
<input |
||||
type="radio" |
||||
name="contenthint" |
||||
value="detail" |
||||
checked={settings.contentHint === "detail"} |
||||
onChange={() => setSettings(s => ({ ...s, contentHint: "detail" }))} |
||||
/> |
||||
</label> |
||||
</div> |
||||
<div className="vcd-screen-picker-hint-description"> |
||||
<p> |
||||
Choosing "Prefer Clarity" will result in a significantly lower framerate in |
||||
exchange for a much sharper and clearer image. |
||||
</p> |
||||
</div> |
||||
</div> |
||||
</section> |
||||
</div> |
||||
</Card> |
||||
</div> |
||||
|
||||
<div> |
||||
{isWindows && ( |
||||
<Switch |
||||
value={settings.audio} |
||||
onChange={checked => setSettings(s => ({ ...s, audio: checked }))} |
||||
hideBorder |
||||
className="vcd-screen-picker-audio" |
||||
> |
||||
Stream With Audio |
||||
</Switch> |
||||
)} |
||||
|
||||
{isLinux && ( |
||||
<AudioSourcePickerLinux |
||||
audioSource={settings.audioSource} |
||||
workaround={settings.workaround} |
||||
onlyDefaultSpeakers={settings.onlyDefaultSpeakers} |
||||
setAudioSource={source => setSettings(s => ({ ...s, audioSource: source }))} |
||||
setWorkaround={value => setSettings(s => ({ ...s, workaround: value }))} |
||||
setOnlyDefaultSpeakers={value => setSettings(s => ({ ...s, onlyDefaultSpeakers: value }))} |
||||
/> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function AudioSourcePickerLinux({ |
||||
audioSource, |
||||
workaround, |
||||
onlyDefaultSpeakers, |
||||
setAudioSource, |
||||
setWorkaround, |
||||
setOnlyDefaultSpeakers |
||||
}: { |
||||
audioSource?: string; |
||||
workaround?: boolean; |
||||
onlyDefaultSpeakers?: boolean; |
||||
setAudioSource(s: string): void; |
||||
setWorkaround(b: boolean): void; |
||||
setOnlyDefaultSpeakers(b: boolean): void; |
||||
}) { |
||||
const [sources, _, loading] = useAwaiter(() => VesktopNative.virtmic.list(), { |
||||
fallbackValue: { ok: true, targets: [], hasPipewirePulse: true } |
||||
}); |
||||
|
||||
const allSources = sources.ok ? ["None", "Entire System", ...sources.targets] : null; |
||||
const hasPipewirePulse = sources.ok ? sources.hasPipewirePulse : true; |
||||
|
||||
const [ignorePulseWarning, setIgnorePulseWarning] = useState(false); |
||||
|
||||
return ( |
||||
<> |
||||
<Forms.FormTitle>Audio Settings</Forms.FormTitle> |
||||
<Card className="vcd-screen-picker-card"> |
||||
{loading ? ( |
||||
<Forms.FormTitle>Loading Audio Sources...</Forms.FormTitle> |
||||
) : ( |
||||
<Forms.FormTitle>Audio Source</Forms.FormTitle> |
||||
)} |
||||
|
||||
{!sources.ok && sources.isGlibCxxOutdated && ( |
||||
<Forms.FormText> |
||||
Failed to retrieve Audio Sources because your C++ library is too old to run |
||||
<a href="https://github.com/Vencord/venmic" target="_blank"> |
||||
venmic |
||||
</a> |
||||
. See{" "} |
||||
<a href="https://gist.github.com/Vendicated/b655044ffbb16b2716095a448c6d827a" target="_blank"> |
||||
this guide |
||||
</a>{" "} |
||||
for possible solutions. |
||||
</Forms.FormText> |
||||
)} |
||||
|
||||
{hasPipewirePulse || ignorePulseWarning ? ( |
||||
allSources && ( |
||||
<Select |
||||
options={allSources.map(s => ({ label: s, value: s, default: s === "None" }))} |
||||
isSelected={s => s === audioSource} |
||||
select={setAudioSource} |
||||
serialize={String} |
||||
/> |
||||
) |
||||
) : ( |
||||
<Text variant="text-sm/normal"> |
||||
Could not find pipewire-pulse. This usually means that you do not run pipewire as your main |
||||
audio-server. <br /> |
||||
You can still continue, however, please beware that you can only share audio of apps that are |
||||
running under pipewire. |
||||
<br /> |
||||
<a onClick={() => setIgnorePulseWarning(true)}>I know what I'm doing</a> |
||||
</Text> |
||||
)} |
||||
|
||||
<Forms.FormDivider className={Margins.top16 + " " + Margins.bottom16} /> |
||||
|
||||
<Switch |
||||
onChange={setWorkaround} |
||||
value={workaround ?? false} |
||||
note={ |
||||
<> |
||||
Work around an issue that causes the microphone to be shared instead of the correct audio. |
||||
Only enable if you're experiencing this issue. |
||||
</> |
||||
} |
||||
> |
||||
Microphone Workaround |
||||
</Switch> |
||||
|
||||
<Switch |
||||
hideBorder |
||||
onChange={setOnlyDefaultSpeakers} |
||||
disabled={audioSource !== "Entire System"} |
||||
value={onlyDefaultSpeakers ?? true} |
||||
note={ |
||||
<> |
||||
When sharing entire desktop audio, only share apps that play to the default speakers and |
||||
ignore apps that play to other speakers or devices. |
||||
</> |
||||
} |
||||
> |
||||
Only Default Speakers |
||||
</Switch> |
||||
</Card> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
function ModalComponent({ |
||||
screens, |
||||
modalProps, |
||||
submit, |
||||
close, |
||||
skipPicker |
||||
}: { |
||||
screens: Source[]; |
||||
modalProps: any; |
||||
submit: (data: StreamPick) => void; |
||||
close: () => void; |
||||
skipPicker: boolean; |
||||
}) { |
||||
const [selected, setSelected] = useState<string | undefined>(skipPicker ? screens[0].id : void 0); |
||||
const [settings, setSettings] = useState<StreamSettings>({ |
||||
resolution: "720", |
||||
fps: "30", |
||||
contentHint: "motion", |
||||
audio: true |
||||
}); |
||||
|
||||
return ( |
||||
<Modals.ModalRoot {...modalProps} size={ModalSize.MEDIUM}> |
||||
<Modals.ModalHeader className="vcd-screen-picker-header"> |
||||
<Forms.FormTitle tag="h2">ScreenShare</Forms.FormTitle> |
||||
<Modals.ModalCloseButton onClick={close} /> |
||||
</Modals.ModalHeader> |
||||
<Modals.ModalContent className="vcd-screen-picker-modal"> |
||||
{!selected ? ( |
||||
<ScreenPicker screens={screens} chooseScreen={setSelected} /> |
||||
) : ( |
||||
<StreamSettings |
||||
source={screens.find(s => s.id === selected)!} |
||||
settings={settings} |
||||
setSettings={setSettings} |
||||
skipPicker={skipPicker} |
||||
/> |
||||
)} |
||||
</Modals.ModalContent> |
||||
<Modals.ModalFooter className="vcd-screen-picker-footer"> |
||||
<Button |
||||
disabled={!selected} |
||||
onClick={() => { |
||||
currentSettings = settings; |
||||
try { |
||||
const frameRate = Number(settings.fps); |
||||
const height = Number(settings.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.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; |
||||
|
||||
const track = conn.input.stream.getVideoTracks()[0]; |
||||
|
||||
const constraints = { |
||||
...track.getConstraints(), |
||||
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(); |
||||
}} |
||||
> |
||||
Go Live |
||||
</Button> |
||||
|
||||
{selected && !skipPicker ? ( |
||||
<Button color={Button.Colors.TRANSPARENT} onClick={() => setSelected(void 0)}> |
||||
Back |
||||
</Button> |
||||
) : ( |
||||
<Button color={Button.Colors.TRANSPARENT} onClick={close}> |
||||
Cancel |
||||
</Button> |
||||
)} |
||||
</Modals.ModalFooter> |
||||
</Modals.ModalRoot> |
||||
); |
||||
} |
@ -0,0 +1,7 @@ |
||||
/* |
||||
* SPDX-License-Identifier: GPL-3.0 |
||||
* Aerocord, a vesktop fork for older microsoft NT releases such as NT 6.0, 6.1, 6.2 and 6.3.
|
||||
* Credits to vendicated and the rest of the vesktop contribuitors for making Vesktop! |
||||
*/ |
||||
|
||||
export * as ScreenShare from "./ScreenSharePicker"; |
@ -0,0 +1,146 @@ |
||||
.vcd-screen-picker-modal { |
||||
padding: 1em; |
||||
} |
||||
|
||||
.vcd-screen-picker-header h1 { |
||||
margin: 0; |
||||
} |
||||
|
||||
.vcd-screen-picker-footer { |
||||
display: flex; |
||||
gap: 1em; |
||||
} |
||||
|
||||
.vcd-screen-picker-settings-grid { |
||||
gap: 1em; |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr; |
||||
} |
||||
|
||||
.vcd-screen-picker-settings-grid > div { |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
|
||||
.vcd-screen-picker-card { |
||||
flex-grow: 1; |
||||
} |
||||
|
||||
.vcd-screen-picker-grid { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr; |
||||
gap: 2em 1em; |
||||
} |
||||
|
||||
.vcd-screen-picker-grid input { |
||||
appearance: none; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.vcd-screen-picker-selected img { |
||||
border: 2px solid var(--brand-experiment); |
||||
border-radius: 3px; |
||||
} |
||||
|
||||
.vcd-screen-picker-grid label { |
||||
overflow: hidden; |
||||
padding: 4px 0px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.vcd-screen-picker-grid label:hover { |
||||
outline: 2px solid var(--brand-experiment); |
||||
} |
||||
|
||||
|
||||
.vcd-screen-picker-grid div { |
||||
white-space: nowrap; |
||||
text-overflow: ellipsis; |
||||
overflow: hidden; |
||||
text-align: center; |
||||
font-weight: 600; |
||||
margin-inline: 0.5em; |
||||
} |
||||
|
||||
.vcd-screen-picker-card { |
||||
padding: 0.5em; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.vcd-screen-picker-preview img { |
||||
width: 100%; |
||||
margin-bottom: 0.5em; |
||||
} |
||||
|
||||
.vcd-screen-picker-preview { |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: center; |
||||
align-items: center; |
||||
margin-bottom: 1em; |
||||
} |
||||
|
||||
.vcd-screen-picker-radio input { |
||||
display: none; |
||||
} |
||||
|
||||
.vcd-screen-picker-radio { |
||||
background-color: var(--background-secondary); |
||||
border: 1px solid var(--primary-800); |
||||
padding: 0.3em; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.vcd-screen-picker-radio h2 { |
||||
margin: 0; |
||||
} |
||||
|
||||
.vcd-screen-picker-radio[data-checked="true"] { |
||||
background-color: var(--brand-experiment); |
||||
border-color: var(--brand-experiment); |
||||
} |
||||
|
||||
.vcd-screen-picker-radio[data-checked="true"] h2 { |
||||
color: var(--interactive-active); |
||||
} |
||||
|
||||
.vcd-screen-picker-quality { |
||||
display: flex; |
||||
gap: 1em; |
||||
|
||||
margin-bottom: 0.5em; |
||||
} |
||||
|
||||
.vcd-screen-picker-quality section { |
||||
flex: 1 1 auto; |
||||
} |
||||
|
||||
.vcd-screen-picker-radios { |
||||
display: flex; |
||||
width: 100%; |
||||
border-radius: 3px; |
||||
} |
||||
|
||||
.vcd-screen-picker-radios label { |
||||
flex: 1 1 auto; |
||||
text-align: center; |
||||
} |
||||
|
||||
.vcd-screen-picker-radios label:first-child { |
||||
border-radius: 3px 0 0 3px; |
||||
} |
||||
|
||||
.vcd-screen-picker-radios label:last-child { |
||||
border-radius: 0 3px 3px 0; |
||||
} |
||||
|
||||
.vcd-screen-picker-audio { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
.vcd-screen-picker-hint-description { |
||||
color: var(--header-secondary); |
||||
font-size: 14px; |
||||
line-height: 20px; |
||||
font-weight: 400; |
||||
} |
Loading…
Reference in new issue