Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
thororen 2026-08-23 09:34:53 -04:00
commit 961b9b2cad
No known key found for this signature in database
10 changed files with 593 additions and 62 deletions

View file

@ -11,7 +11,6 @@
html,
body,
#container {
position: absolute;
left: 0;
top: 0;
width: 100%;
@ -19,6 +18,29 @@
margin: 0;
padding: 0;
overflow: hidden;
background-color: #1e1e1e;
}
body {
display: flex;
flex-direction: column;
}
#title-bar {
display: none;
}
@media all and (display-mode: window-controls-overlay) {
#title-bar {
display: flex;
align-items: center;
padding-left: 14px;
font-family: Consolas, Courier New, monospace;
line-height: 1;
height: env(titlebar-area-height, 0px);
app-region: drag;
color: white;
}
}
header {
@ -62,6 +84,7 @@
style="background: #1e1e1e; color: #ffffff; border: 1px solid transparent; padding: 5px 10px; border-radius: 4px; transition: background 0.3s, border 0.3s;">Redo</button>
</div>
</header>
<div id="title-bar">Equicord QuickCSS Editor</div>
<div id="container"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.50.0/min/vs/loader.js"

View file

@ -35,6 +35,7 @@ import { isTruthy } from "@utils/guards";
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { PluginTarget } from "@utils/pluginTargets";
import { useAwaiter, useCleanupEffect, useIntersection } from "@utils/react";
import { PluginTag, PluginTags } from "@utils/types";
import { Alerts, ConfirmModal, lodash, openModal, Parser, React, SearchableSelect, Select, TextInput, Toasts, Tooltip, useCallback, useMemo, useRef, useState } from "@webpack/common";
@ -109,13 +110,14 @@ const enum SearchStatus {
API_PLUGINS
}
export const ExcludedReasons: Record<"web" | "discordDesktop" | "vesktop" | "equibop" | "desktop" | "dev", string> = {
export const ExcludedReasons: Record<PluginTarget, string> = {
desktop: "Discord Desktop app or Vesktop/Equibop",
discordDesktop: "Discord Desktop app",
vesktop: "Vesktop/Equibop apps",
equibop: "Vesktop/Equibop apps",
web: "Vesktop/Equibop apps & Discord web",
dev: "Developer version of Equicord"
dev: "Developer version of Equicord",
browser: "Web Browser version of Equicord"
};
function ExcludedPluginsList({ search }: { search: string; }) {

24
src/globals.d.ts vendored
View file

@ -66,6 +66,30 @@ declare global {
export var unsafeWindow: any;
interface Window extends Record<PropertyKey, any> { }
interface MediaTrackConstraintSet {
restrictOwnAudio?: boolean;
suppressLocalAudioPlayback?: boolean;
}
type CaptureController = {
decreaseZoomLevel(): Promise<undefined>;
forwardWheel(): Promise<undefined>;
getSupportedZoomLevels(): number[];
increaseZoomLevel(): Promise<undefined>;
resetZoomLevel(): Promise<undefined>;
setFocusBehavior(): undefined;
};
interface DisplayMediaStreamOptions {
surfaceSwitching?: "include" | "exclude";
selfBrowserSurface?: "include" | "exclude";
systemAudio?: "include" | "exclude";
monitorTypeSurfaces?: "include" | "exclude";
controller?: CaptureController;
preferCurrentTab?: boolean;
}
}
export { };

4
src/modules.d.ts vendored
View file

@ -17,13 +17,15 @@
*/
declare module "~plugins" {
import { PluginTarget } from "@utils/pluginTargets";
const plugins: Record<string, import("./utils/types").Plugin>;
export default plugins;
export const PluginMeta: Record<string, {
folderName: string;
userPlugin: boolean;
}>;
export const ExcludedPlugins: Record<string, "web" | "discordDesktop" | "vesktop" | "equibop" | "desktop" | "dev">;
export const ExcludedPlugins: Record<string, PluginTarget>;
}
declare module "~git-hash" {

View file

@ -10,9 +10,6 @@
margin-left: 0.25em;
}
.vc-member-list-decorators-display-names {
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal
[class*="withDisplayNameStyles"] [class^="name"] {
flex: initial;
}

View file

@ -34,81 +34,107 @@ function fallbackToYoutubeThumbnail(originUrl: string | undefined): string | und
return match ? `https://i.ytimg.com/vi/${match[5]}/maxresdefault.jpg` : undefined;
}
async function fetchCoverArt(releaseGroupMBID: string, originUrl?: string): Promise<string | undefined> {
if (!releaseGroupMBID) return fallbackToYoutubeThumbnail(originUrl);
if (coverArtCache.has(releaseGroupMBID)) {
return coverArtCache.get(releaseGroupMBID);
async function fetchCoverArtArchive(id: string, path: string) {
if (coverArtCache.has(id)) {
return coverArtCache.get(id);
}
const res = await fetch(`https://coverartarchive.org/release-group/${releaseGroupMBID}`);
if (!res.ok) return fallbackToYoutubeThumbnail(originUrl);
const url = await res.json()
.then(json => json.images[0].thumbnails.large ?? fallbackToYoutubeThumbnail(originUrl));
coverArtCache.set(releaseGroupMBID, url);
const url = await fetch(`https://coverartarchive.org${path}`)
.then(res =>
res.ok
? res.json()
: Promise.reject(new Error(`${res.status} ${res.statusText}`))
)
.then(json => json.images[0].thumbnails.large);
coverArtCache.set(id, url);
return url;
}
async function getUrls(additionalInfo: Record<string, string> | undefined, trackName: string, artistName: string, releaseName: string): Promise<Partial<TrackData>> {
async function fetchCoverArt(releaseMBID: string, releaseGroupMBID: string, originUrl?: string): Promise<string | undefined> {
return (
(releaseMBID && await fetchCoverArtArchive(releaseMBID, `/release/${releaseMBID}`).catch(() => null)) ||
(releaseGroupMBID && await fetchCoverArtArchive(releaseGroupMBID, `/release-group/${releaseGroupMBID}`).catch(() => null)) ||
fallbackToYoutubeThumbnail(originUrl)
);
}
async function tryLookup(query: string): Promise<Record<string, any> | undefined> {
const params = new URLSearchParams({
fmt: "json",
limit: "1"
});
return await fetch("https://musicbrainz.org/ws/2/recording/?" + params + "&query=" + query, {
headers: { "User-Agent": VENCORD_USER_AGENT }
})
.then(res => res.ok ? res.json() : Promise.reject(new Error(`${res.status} ${res.statusText}`)))
.then(json => json.recordings?.[0]);
}
async function getUrls(
additionalInfo: Record<string, string> | undefined,
trackName: string,
artistName: string,
releaseName: string
): Promise<Partial<TrackData>> {
// Well tagged music will have MBIDs which we can use directly. These are optional but highly recommended in ListenBrainz scrobbles.
// If your music doesn't have these, it's highly recommended to use https://picard.musicbrainz.org/ to automatically add them
if (additionalInfo?.recording_mbid) {
const { release_group_mbid, release_mbid, recording_mbid, artist_mbids, origin_url } = additionalInfo;
return {
imageURL: await fetchCoverArt(release_group_mbid, origin_url),
trackURL: recording_mbid ? url(`/track/${recording_mbid}`) : undefined,
albumURL: release_group_mbid
? url(`/release-group/${release_group_mbid}`)
: release_mbid
imageURL: await fetchCoverArt(release_mbid, release_group_mbid, origin_url),
trackURL: url(`/track/${recording_mbid}`),
albumURL: release_mbid
? url(`/release/${release_mbid}`)
: release_group_mbid
? url(`/release-group/${release_group_mbid}`)
: undefined,
artistURL: artist_mbids?.length ? url(`/artist/${artist_mbids[0]}`) : undefined,
};
}
// If no MBIDs are present, try to search for the track on MusicBrainz
// If no MBIDs are present, try searching MusicBrainz: first by ISRC (if present), then by name.
const nameQuery = encodeURIComponent(
`artist:"${artistName}" AND recording:"${trackName}"${releaseName ? ` AND release:"${releaseName}"` : ""}`
).replace(/[!()*\-~]/g, "\\$&");
let rawQuery = `artist:"${artistName}" AND recording:"${trackName}"`;
if (releaseName)
rawQuery += ` AND album:"${releaseName}"`;
const query = encodeURIComponent(rawQuery);
const queries = additionalInfo?.isrc
? [encodeURIComponent(`isrc:${additionalInfo.isrc}`), nameQuery]
: [nameQuery];
for (const query of queries) {
if (metadataCache.has(query)) {
return metadataCache.get(query) ?? {};
}
const params = new URLSearchParams({
fmt: "json",
limit: "1"
});
const metadata = await tryLookup(query);
if (!metadata) continue;
const metadata = await fetch("https://musicbrainz.org/ws/2/recording/?" + params + "&query=" + query, {
headers: { "User-Agent": VENCORD_USER_AGENT }
})
.then(res => res.ok ? res.json() : Promise.reject(new Error(`${res.status} ${res.statusText}`)))
.then(json => json.recordings?.[0]);
const artist = metadata["artist-credit"]?.[0]?.artist;
const release = metadata.releases?.find((release: { title: string; }) => release.title === releaseName) || metadata.releases?.[0];
if (!metadata) {
const data = additionalInfo?.origin_url ? { imageURL: fallbackToYoutubeThumbnail(additionalInfo.origin_url) } : {};
const data: Partial<TrackData> = {
imageURL: await fetchCoverArt(release?.id, release?.["release-group"]?.id, additionalInfo?.origin_url),
trackURL: url(`/track/${metadata.id}/`),
albumURL: release?.id
? url(`/release/${release.id}/`)
: release?.["release-group"]?.id
? url(`/release-group/${release["release-group"].id}/`)
: undefined,
artistURL: artist?.id ? url(`/artist/${artist.id}/`) : undefined,
album: additionalInfo?.release_name ?? release?.title ?? "Unknown",
};
metadataCache.set(query, data);
return data;
}
const artist = metadata["artist-credit"]?.[0]?.artist;
const release = metadata.releases?.[0];
const fallback: Partial<TrackData> = additionalInfo?.origin_url
? { imageURL: fallbackToYoutubeThumbnail(additionalInfo.origin_url) }
: {};
metadataCache.set(queries.at(-1)!, fallback);
const data: Partial<TrackData> = {
imageURL: await fetchCoverArt(release?.["release-group"]?.id, additionalInfo?.origin_url),
trackURL: url(`/track/${metadata.id}/`),
albumURL: release?.id ? url(`/release/${release.id}/`) : release?.["release-group"]?.id ? url(`/release-group/${release["release-group"].id}/`) : undefined,
artistURL: artist?.id ? url(`/artist/${artist.id}/`) : undefined,
};
metadataCache.set(query, data);
return data;
return fallback;
}
export const ListenBrainzScrobbler: ScrobblerBackend = {

View file

@ -3,21 +3,36 @@
color-scheme: only light;
}
@media all and not (display-mode: browser) {
@media all and not ((display-mode: browser) or (display-mode: fullscreen)) {
:root {
/* force title bar to be same as WCO bar height, regardless of zoom, device pixel ratio or OS, is only set when WCO is defined, otherwise sets to 0 */
--custom-app-top-bar-height: env(titlebar-area-height, 0px) !important;
}
/* position trailing titlebar elements alongside WCO */
[class*="bar_"] [class*="trailing_"] {
[data-fullscreen] > [class*="bar_"] [class*="trailing_"] {
padding-right: calc(100vw - env(titlebar-area-width, 100%) - env(titlebar-area-x, 0px));
}
/* popout windows like screenshare popout, these properties don't work outside WCO so this is safe */
[data-popout-root="true"] {
[class*="headerWrapper_"] {
app-region: drag;
/* position trailing toolbar elements alongside WCO */
[class*="toolbar_"] {
padding-right: calc(100vw - env(titlebar-area-width, 100%) - env(titlebar-area-x, 0px));
}
}
}
}
/* force hide trailing icons when standalone so there's no ugly pixel overflow */
@media all and (display-mode: standalone) {
[class*="bar_"] [class*="trailing_"] {
[data-fullscreen] > [class*="bar_"] {
[class*="trailing_"],
[class*="leading"] {
display: none;
}
}
}

View file

@ -0,0 +1,320 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { getUserSettingLazy } from "@api/UserSettings";
import {
Divider,
Span,
} from "@components/index";
import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { Checkbox, closeModal, Modal, openModal, Text, } from "@webpack/common";
import managedStyle from "./styles.css?managed";
const cl = classNameFactory("vc-screen-picker-");
class NotAllowedError extends Error {
name = "NotAllowedError";
}
const logger = new Logger("VencordScreenShare");
const getDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices);
function openScreenSharePicker(options: DisplayMediaStreamOptions) {
return new Promise<MediaStream>((resolve, reject) => {
const key = openModal(
props => (
<ModalComponent
modalProps={props}
submit={resolve}
options={options}
close={() => {
props.onClose();
reject(new NotAllowedError("Permission denied by user"));
}}
/>
),
{
onCloseRequest() {
closeModal(key);
reject(new NotAllowedError("Permission denied by user"));
},
onCloseCallback() {
reject(new NotAllowedError("Permission denied by user"));
}
}
);
});
}
function OptionRadio<Settings extends object, Key extends keyof Settings, Options extends ReadonlyArray<string>>(props: {
options: Options;
labels?: Array<string>;
settings: Settings;
settingsKey: Key;
onChange: (option: Options[number]) => void;
}) {
const { options, settings, settingsKey, labels, onChange } = props;
return (
<div className={cl("padding")}>
<div className={cl("option-radios")}>
{options.map((option, idx) => (
<label className={cl("option-radio")} data-checked={settings[settingsKey] === option} key={option}>
<Span weight="bold">{labels?.[idx] ?? option}</Span>
<input
type="radio"
name={settingsKey.toString()}
value={option}
checked={settings[settingsKey] === option}
onChange={() => onChange(option)}
/>
</label>
))}
</div>
</div>
);
}
function ModalComponent({ modalProps, submit, close, options }: {
modalProps: any;
submit: (data: Promise<MediaStream>) => void;
close: () => void;
options: DisplayMediaStreamOptions;
}) {
const liveSettings = settings.use();
const disableStreamPreviewsValue = disableStreamPreviews.useSetting();
async function stream() {
try {
const frameRate = Number(liveSettings.frameRate);
const height = Number(liveSettings.resolution);
// const conn = [...MediaEngineStore.getMediaEngine().connections].find(
// connection => connection.userId === UserStore.getCurrentUser().id
// );
const videoOptions = typeof options?.video !== "boolean" && !!options.video ? options.video : {};
const audioOptions = typeof options?.audio !== "boolean" && !!options.audio ? options.audio : {};
// allow browser to constrain itself
delete videoOptions.width;
submit(
getDisplayMedia({
video: {
...videoOptions,
frameRate,
height,
},
audio: {
...audioOptions,
restrictOwnAudio: true,
},
surfaceSwitching: "include",
systemAudio: liveSettings.systemAudio ? "exclude" : "include"
}).then(t => {
// w3c says this can fail, they don't say why, so just to be safe
try {
const video = t.getVideoTracks()?.[0];
if (video) video.contentHint = liveSettings.contentHint;
} catch { }
return t;
})
);
} catch (error) {
logger.error("Error while submitting stream.", error);
} finally {
close();
}
}
return (
<div className={cl("modal")}>
<Modal
{...modalProps}
size="lg"
actionBarInput={
<div className={cl("summary")}>
<Text variant="text-md/semibold" color="text-strong" className={cl("source-or-preset-name")}>{liveSettings.contentHint === "motion" ? "Gaming" : liveSettings.contentHint === "detail" ? "Screenshare" : "Custom"}</Text>
<Text variant="text-xs/medium" color="text-muted" className={cl("summary-detail")}>
<span>{liveSettings.contentHint === "motion" ? "Smoother video" : liveSettings.contentHint === "detail" ? "Cleaner text" : "User preset"}</span>
<span className={cl("ellipsis")}>•</span>
<span>{liveSettings.resolution}p</span>
<span className={cl("ellipsis")}>•</span>
<span>{liveSettings.frameRate}fps</span>
{liveSettings.systemAudio ? <span className={cl("ellipsis")}>•</span> : ""}
{liveSettings.systemAudio ? <span>Stream Muted</span> : ""}
</Text>
</div>
}
actions={[
{
variant: "primary",
text: "Stream",
onClick: stream
}
]}
>
<div>
<div className={cl("flex", "padding")}>
<section className={cl("quality-section")}>
<Text tag="h2" variant="heading-md/semibold" color="text-strong">Resolution</Text>
<OptionRadio
options={StreamResolution}
settings={liveSettings}
settingsKey="resolution"
onChange={value => (liveSettings.resolution = value)}
/>
</section>
<section className={cl("quality-section")}>
<Text tag="h2" variant="heading-md/semibold" color="text-strong">Frame Rate</Text>
<OptionRadio
options={StreamFps}
settings={liveSettings}
settingsKey="frameRate"
onChange={value => (liveSettings.frameRate = value)}
/>
</section>
</div>
<div>
<Text tag="h2" variant="heading-md/semibold" color="text-strong">Stream Mode</Text>
<div>
<OptionRadio
options={StreamContentHint}
labels={["Smoother video", "Cleaner text", "Custom"]}
settings={liveSettings}
settingsKey="contentHint"
onChange={option => (liveSettings.contentHint = option)}
/>
</div>
</div>
<Divider />
<div className={cl("padding", "pointer")}>
<Checkbox
value={!!liveSettings.systemAudio}
onChange={(_e, value) => (liveSettings.systemAudio = value)}
shape="box"
reverse={true}>
<div className={cl("control-content")}>
<Text tag="h2" variant="heading-md/semibold" color="text-strong">Mute Stream Audio</Text>
<Text variant="text-sm/normal" color="text-subtle">Prevents system audio from being included in your stream.</Text>
</div>
</Checkbox>
</div>
<div className={cl("padding", "pointer")}>
<Checkbox
value={!disableStreamPreviewsValue}
onChange={(_e, value) => disableStreamPreviews.updateSetting(() => !value)}
shape="box"
reverse={true}>
<div className={cl("control-content")}>
<Text tag="h2" variant="heading-md/semibold" color="text-strong">Show Stream Previews</Text>
<Text variant="text-sm/normal" color="text-subtle">Allows others to see a preview of your stream before they join.</Text>
</div>
</Checkbox>
</div>
<Divider />
</div>
</Modal>
</div>
);
}
// this is kinda shit and it really should be done via videoQualityManager
const StreamResolution = ["480", "720", "1080", "1440", "2160"] as const;
const StreamFps = ["15", "30", "60", "120"] as const;
const StreamContentHint = ["motion", "detail", ""] as const;
const settings = definePluginSettings({
resolution: {
type: OptionType.SELECT,
description: "Resolution",
hidden: true,
options: StreamResolution.map(res => ({ label: res, value: res, default: res === "1080" }))
},
frameRate: {
type: OptionType.SELECT,
description: "Frame Rate",
hidden: true,
options: StreamFps.map(fps => ({ label: fps, value: fps, default: fps === "60" }))
},
contentHint: {
type: OptionType.SELECT,
description: "Content Hint",
hidden: true,
options: StreamContentHint.map(hint => ({ label: hint, value: hint, default: hint === "motion" }))
},
systemAudio: {
type: OptionType.BOOLEAN,
description: "Mute system audio",
hidden: true
}
});
const disableStreamPreviews = getUserSettingLazy<boolean>("voiceAndVideo", "disableStreamPreviews")!;
export default definePlugin({
name: "WebScreenShare",
authors: [Devs.ThaUnknown],
description: "Adds a screenshare options menu. Allows for changing resolution, framerate, encoding hints, and system audio settings.",
tags: ["Voice", "Utility"],
enabledByDefault: true,
settings,
managedStyle,
start() {
navigator.mediaDevices.getDisplayMedia = openScreenSharePicker;
},
stop() {
navigator.mediaDevices.getDisplayMedia = getDisplayMedia;
},
patches: [
{
find: "this.getDefaultGoliveQuality()",
replacement: {
match: /this\.getDefaultGoliveQuality\(\)/,
replace: "$self.getGoliveMaxQuality($&)"
}
}
],
// this is a maximum, not a true default, this is later constrained properly in GDM anyways
// and this way we don't pre-emptively neuter the max stream quality
getGoliveMaxQuality(opts: any) {
const framerate = 120;
const height = 2160;
const width = 3840;
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;
}
});

View file

@ -0,0 +1,114 @@
.vc-screen-picker-modal {
display: contents;
header,
[class^="bodySpacerTop_"],
[class^="bodySpacerBottom_"] {
display: none;
}
.vc-screen-picker-option-radios {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
width: 100%;
.vc-screen-picker-option-radio {
text-align: center;
padding: 0.3em;
cursor: pointer;
transition: 50ms ease-in;
transition-property: background-color, color, border-color, opacity;
background-color: var(--control-secondary-background-default);
border-top: 1px solid var(--control-primary-border-default);
border-bottom: 1px solid var(--control-primary-border-default);
overflow: clip;
&:hover {
background-color: var(--control-secondary-background-hover);
color: var(--control-secondary-text-hover);
border-color: var(--control-primary-border-hover);
}
&:first-child {
border-radius: var(--radius-xs, 4px) 0 0 var(--radius-xs, 4px);
border-left: 1px solid var(--control-primary-border-default);
}
&:last-child {
border-radius: 0 var(--radius-xs, 4px) var(--radius-xs, 4px) 0;
border-right: 1px solid var(--control-primary-border-default);
}
}
.vc-screen-picker-option-radio[data-checked="true"] {
background-color: var(--control-primary-background-default);
color: var(--control-primary-text-default);
&:hover {
background-color: var(--control-primary-background-hover);
color: var(--control-primary-text-hover);
}
}
input {
display: none;
}
}
.vc-screen-picker-padding {
padding: var(--space-sm, 12px) 0;
}
.vc-screen-picker-flex {
display: flex;
gap: 1em;
}
.vc-screen-picker-quality-section {
flex: 1 1 auto;
}
.vc-screen-picker-pointer label {
cursor: pointer;
}
.vc-screen-picker-control-content {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: var(--space-4);
justify-content: center;
}
.vc-screen-picker-summary {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
justify-content: center;
.vc-screen-picker-source-or-preset-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-screen-picker-summary-detail {
align-items: center;
display: flex;
gap: var(--space-8);
.vc-screen-picker-ellipsis {
color: var(--background-mod-subtle);
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View file

@ -0,0 +1,8 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const PluginTargets = ["web", "browser", "discordDesktop", "vesktop", "desktop", "dev"] as const;
export type PluginTarget = typeof PluginTargets[number];