feat: add ignore_virtual, persist settings, re-design share menu

This commit is contained in:
Curve 2024-05-31 16:34:20 +02:00
parent c8353d170f
commit c7dc001e63
No known key found for this signature in database
GPG key ID: 460F6C466BD35813
5 changed files with 310 additions and 264 deletions

View file

@ -10,6 +10,8 @@ import { join } from "path";
import { IpcEvents } from "shared/IpcEvents"; import { IpcEvents } from "shared/IpcEvents";
import { STATIC_DIR } from "shared/paths"; import { STATIC_DIR } from "shared/paths";
import { Settings } from "./settings";
type LinkData = Parameters<PatchBayType["link"]>[0]; type LinkData = Parameters<PatchBayType["link"]>[0];
type Node = Record<string, string>; type Node = Record<string, string>;
@ -67,17 +69,17 @@ function getRendererAudioServicePid() {
); );
} }
ipcMain.handle(IpcEvents.VIRT_MIC_LIST, (_, props?: string[]) => { ipcMain.handle(IpcEvents.VIRT_MIC_LIST, () => {
const audioPid = getRendererAudioServicePid(); const audioPid = getRendererAudioServicePid();
const targets = obtainVenmic() const targets = obtainVenmic()
?.list(props) ?.list(Settings.store.audioGranularSelect ? ["application.process.id"] : undefined)
.filter(s => s["application.process.id"] !== audioPid); .filter(s => s["application.process.id"] !== audioPid);
return targets ? { ok: true, targets, hasPipewirePulse } : { ok: false, isGlibCxxOutdated }; return targets ? { ok: true, targets, hasPipewirePulse } : { ok: false, isGlibCxxOutdated };
}); });
ipcMain.handle(IpcEvents.VIRT_MIC_START, (_, targets: Node[], workaround?: boolean) => { ipcMain.handle(IpcEvents.VIRT_MIC_START, (_, targets: Node[]) => {
const pid = getRendererAudioServicePid(); const pid = getRendererAudioServicePid();
const data: LinkData = { const data: LinkData = {
@ -85,22 +87,42 @@ ipcMain.handle(IpcEvents.VIRT_MIC_START, (_, targets: Node[], workaround?: boole
exclude: [{ "application.process.id": pid }] exclude: [{ "application.process.id": pid }]
}; };
if (workaround) { const settings = Settings.store;
if (settings.audioIgnoreInputMedia ?? true) {
data.exclude?.push({ "media.class": "Stream/Input/Audio" });
}
if (settings.audioIgnoreVirtual ?? true) {
data.exclude?.push({ "node.virtual": "true" });
}
if (settings.audioWorkaround) {
data.workaround = [{ "application.process.id": pid, "media.name": "RecordStream" }]; data.workaround = [{ "application.process.id": pid, "media.name": "RecordStream" }];
} }
return obtainVenmic()?.link(data); return obtainVenmic()?.link(data);
}); });
ipcMain.handle(IpcEvents.VIRT_MIC_START_SYSTEM, (_, workaround?: boolean, onlyDefaultSpeakers?: boolean) => { ipcMain.handle(IpcEvents.VIRT_MIC_START_SYSTEM, () => {
const pid = getRendererAudioServicePid(); const pid = getRendererAudioServicePid();
const settings = Settings.store;
const data: LinkData = { const data: LinkData = {
exclude: [{ "application.process.id": pid }], exclude: [{ "application.process.id": pid }],
only_default_speakers: onlyDefaultSpeakers only_default_speakers: settings.audioOnlyDefaultSpeakers
}; };
if (workaround) { if (settings.audioIgnoreInputMedia ?? true) {
data.exclude?.push({ "media.class": "Stream/Input/Audio" });
}
if (settings.audioIgnoreVirtual ?? true) {
data.exclude?.push({ "node.virtual": "true" });
}
if (settings.audioWorkaround) {
data.workaround = [{ "application.process.id": pid, "media.name": "RecordStream" }]; data.workaround = [{ "application.process.id": pid, "media.name": "RecordStream" }];
} }

View file

@ -62,13 +62,12 @@ export const VesktopNative = {
}, },
/** only available on Linux. */ /** only available on Linux. */
virtmic: { virtmic: {
list: (props?: string[]) => list: () =>
invoke< invoke<
{ ok: false; isGlibCxxOutdated: boolean } | { ok: true; targets: Node[]; hasPipewirePulse: boolean } { ok: false; isGlibCxxOutdated: boolean } | { ok: true; targets: Node[]; hasPipewirePulse: boolean }
>(IpcEvents.VIRT_MIC_LIST, props), >(IpcEvents.VIRT_MIC_LIST),
start: (targets: Node[], workaround?: boolean) => invoke<void>(IpcEvents.VIRT_MIC_START, targets, workaround), start: (targets: Node[]) => invoke<void>(IpcEvents.VIRT_MIC_START, targets),
startSystem: (workaround?: boolean, onlyDefaultSpeakers?: boolean) => startSystem: () => invoke<void>(IpcEvents.VIRT_MIC_START_SYSTEM),
invoke<void>(IpcEvents.VIRT_MIC_START_SYSTEM, workaround, onlyDefaultSpeakers),
stop: () => invoke<void>(IpcEvents.VIRT_MIC_STOP) stop: () => invoke<void>(IpcEvents.VIRT_MIC_STOP)
}, },
arrpc: { arrpc: {

View file

@ -6,7 +6,7 @@
import "./screenSharePicker.css"; import "./screenSharePicker.css";
import { closeModal, Logger, Margins, Modals, ModalSize, openModal, useAwaiter } from "@vencord/types/utils"; import { closeModal, Logger, Modals, ModalSize, openModal, useAwaiter } from "@vencord/types/utils";
import { findStoreLazy, onceReady } from "@vencord/types/webpack"; import { findStoreLazy, onceReady } from "@vencord/types/webpack";
import { import {
Button, Button,
@ -22,6 +22,7 @@ import {
import { Node } from "@vencord/venmic"; import { Node } from "@vencord/venmic";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { addPatch } from "renderer/patches/shared"; import { addPatch } from "renderer/patches/shared";
import { useSettings } from "renderer/settings";
import { isLinux, isWindows } from "renderer/utils"; import { isLinux, isWindows } from "renderer/utils";
const StreamResolutions = ["480", "720", "1080", "1440"] as const; const StreamResolutions = ["480", "720", "1080", "1440"] as const;
@ -48,10 +49,6 @@ interface StreamSettings {
audio: boolean; audio: boolean;
audioSources?: AudioSources; audioSources?: AudioSources;
contentHint?: string; contentHint?: string;
workaround?: boolean;
ignoreInputMedia?: boolean;
onlyDefaultSpeakers?: boolean;
granularSelect?: boolean;
} }
export interface StreamPick extends StreamSettings { export interface StreamPick extends StreamSettings {
@ -133,9 +130,9 @@ export function openScreenSharePicker(screens: Source[], skipPicker: boolean) {
didSubmit = true; didSubmit = true;
if (v.audioSources && v.audioSources !== "None") { if (v.audioSources && v.audioSources !== "None") {
if (v.audioSources === "Entire System") { if (v.audioSources === "Entire System") {
await VesktopNative.virtmic.startSystem(v.workaround); await VesktopNative.virtmic.startSystem();
} else { } else {
await VesktopNative.virtmic.start(v.audioSources, v.workaround); await VesktopNative.virtmic.start(v.audioSources);
} }
} }
resolve(v); resolve(v);
@ -172,6 +169,86 @@ function ScreenPicker({ screens, chooseScreen }: { screens: Source[]; chooseScre
); );
} }
function AudioSettingsModal({
modalProps,
close,
setAudioSources
}: {
modalProps: any;
close: () => void;
setAudioSources: (s: AudioSources) => void;
}) {
const Settings = useSettings();
return (
<Modals.ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<Modals.ModalHeader className="vcd-screen-picker-header">
<Forms.FormTitle tag="h2">Venmic Settings</Forms.FormTitle>
<Modals.ModalCloseButton onClick={close} />
</Modals.ModalHeader>
<Modals.ModalContent className="vcd-screen-picker-modal">
<Switch
onChange={v => (Settings.audioWorkaround = v)}
value={Settings.audioWorkaround ?? 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={v => (Settings.audioOnlyDefaultSpeakers = v)}
value={Settings.audioOnlyDefaultSpeakers ?? true}
note={<>When sharing entire desktop audio, only share apps that play to the default speakers.</>}
>
Only Default Speakers
</Switch>
<Switch
hideBorder
onChange={v => (Settings.audioIgnoreInputMedia = v)}
value={Settings.audioIgnoreInputMedia ?? true}
note={<>Exclude nodes that are intended to capture audio.</>}
>
Ignore Inputs
</Switch>
<Switch
hideBorder
onChange={v => (Settings.audioIgnoreVirtual = v)}
value={Settings.audioIgnoreVirtual ?? true}
note={
<>
Exclude virtual nodes, such as nodes belonging to sinks, this might be useful when using
"mix bussing"
</>
}
>
Ignore Virtual
</Switch>
<Switch
hideBorder
onChange={value => {
Settings.audioGranularSelect = value;
setAudioSources("None");
}}
value={Settings.audioGranularSelect ?? false}
note={<>Allow to select applications more granularly.</>}
>
Granular Selection
</Switch>
</Modals.ModalContent>
<Modals.ModalFooter className="vcd-screen-picker-footer">
<Button color={Button.Colors.TRANSPARENT} onClick={close}>
Back
</Button>
</Modals.ModalFooter>
</Modals.ModalRoot>
);
}
function StreamSettings({ function StreamSettings({
source, source,
settings, settings,
@ -183,6 +260,8 @@ function StreamSettings({
setSettings: Dispatch<SetStateAction<StreamSettings>>; setSettings: Dispatch<SetStateAction<StreamSettings>>;
skipPicker: boolean; skipPicker: boolean;
}) { }) {
const Settings = useSettings();
const [thumb] = useAwaiter( const [thumb] = useAwaiter(
() => (skipPicker ? Promise.resolve(source.url) : VesktopNative.capturer.getLargeThumbnail(source.id)), () => (skipPicker ? Promise.resolve(source.url) : VesktopNative.capturer.getLargeThumbnail(source.id)),
{ {
@ -191,132 +270,129 @@ function StreamSettings({
} }
); );
const openSettings = () => {
const key = openModal(props => (
<AudioSettingsModal
modalProps={props}
close={() => props.onClose()}
setAudioSources={sources => setSettings(s => ({ ...s, audioSources: sources }))}
/>
));
};
return ( return (
<div className={isLinux ? "vcd-screen-picker-settings-grid" : ""}> <div>
<div> <Forms.FormTitle>What you're streaming</Forms.FormTitle>
<Forms.FormTitle>What you're streaming</Forms.FormTitle> <Card className="vcd-screen-picker-card vcd-screen-picker-preview">
<Card className="vcd-screen-picker-card vcd-screen-picker-preview"> <img
<img src={thumb}
src={thumb} alt=""
alt="" className={isLinux ? "vcd-screen-picker-preview-img-linux" : "vcd-screen-picker-preview-img"}
className={isLinux ? "vcd-screen-picker-preview-img-linux" : "vcd-screen-picker-preview-img"} />
/> <Text variant="text-sm/normal">{source.name}</Text>
<Text variant="text-sm/normal">{source.name}</Text> </Card>
</Card>
<Forms.FormTitle>Stream Settings</Forms.FormTitle> <Forms.FormTitle>Stream Settings</Forms.FormTitle>
<Card className="vcd-screen-picker-card"> <Card className="vcd-screen-picker-card">
<div className="vcd-screen-picker-quality"> <div className="vcd-screen-picker-quality">
<section> <section>
<Forms.FormTitle>Resolution</Forms.FormTitle> <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"> <div className="vcd-screen-picker-radios">
{StreamResolutions.map(res => ( <label
<label className="vcd-screen-picker-radio"
className="vcd-screen-picker-radio" data-checked={settings.contentHint === "motion"}
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>
{isWindows && (
<Switch
value={settings.audio}
onChange={checked => setSettings(s => ({ ...s, audio: checked }))}
hideBorder
className="vcd-screen-picker-audio"
> >
Stream With Audio <Text variant="text-sm/bold">Prefer Smoothness</Text>
</Switch> <input
)} type="radio"
</section> name="contenthint"
</div> value="motion"
</Card> checked={settings.contentHint === "motion"}
</div> 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>
{isWindows && (
<Switch
value={settings.audio}
onChange={checked => setSettings(s => ({ ...s, audio: checked }))}
hideBorder
className="vcd-screen-picker-audio"
>
Stream With Audio
</Switch>
)}
</section>
</div>
<div>
{isLinux && ( {isLinux && (
<AudioSourcePickerLinux <AudioSourcePickerLinux
openSettings={openSettings}
audioSources={settings.audioSources} audioSources={settings.audioSources}
workaround={settings.workaround} granularSelect={Settings.audioGranularSelect}
ignoreInputMedia={settings.ignoreInputMedia}
onlyDefaultSpeakers={settings.onlyDefaultSpeakers}
granularSelect={settings.granularSelect}
setAudioSources={sources => setSettings(s => ({ ...s, audioSources: sources }))} setAudioSources={sources => setSettings(s => ({ ...s, audioSources: sources }))}
setWorkaround={value => setSettings(s => ({ ...s, workaround: value }))}
setOnlyDefaultSpeakers={value => setSettings(s => ({ ...s, onlyDefaultSpeakers: value }))}
setIgnoreInputMedia={value => setSettings(s => ({ ...s, ignoreInputMedia: value }))}
setGranularSelect={value => setSettings(s => ({ ...s, granularSelect: value }))}
/> />
)} )}
</div> </Card>
</div> </div>
); );
} }
@ -387,30 +463,16 @@ function mapToAudioItem(node: AudioSource, granularSelect?: boolean): AudioItem[
function AudioSourcePickerLinux({ function AudioSourcePickerLinux({
audioSources, audioSources,
workaround,
onlyDefaultSpeakers,
ignoreInputMedia,
granularSelect, granularSelect,
setAudioSources, setAudioSources,
setWorkaround, openSettings
setIgnoreInputMedia,
setOnlyDefaultSpeakers,
setGranularSelect
}: { }: {
audioSources?: AudioSources; audioSources?: AudioSources;
workaround?: boolean;
onlyDefaultSpeakers?: boolean;
ignoreInputMedia?: boolean;
granularSelect?: boolean; granularSelect?: boolean;
setAudioSources(s: AudioSources): void; openSettings: () => void;
setWorkaround(b: boolean): void; setAudioSources: (s: AudioSources) => void;
setIgnoreInputMedia(b: boolean): void;
setOnlyDefaultSpeakers(b: boolean): void;
setGranularSelect(b: boolean): void;
}) { }) {
const properties = granularSelect ? ["application.process.id"] : undefined; const [sources, _, loading] = useAwaiter(() => VesktopNative.virtmic.list(), {
const [sources, _, loading] = useAwaiter(() => VesktopNative.virtmic.list(properties), {
fallbackValue: { ok: true, targets: [], hasPipewirePulse: true } fallbackValue: { ok: true, targets: [], hasPipewirePulse: true }
}); });
@ -455,109 +517,71 @@ function AudioSourcePickerLinux({
list.findIndex(x => x.name === value.name) === index; list.findIndex(x => x.name === value.name) === index;
return ( return (
<> <div>
<Forms.FormTitle>Audio Settings</Forms.FormTitle> {loading ? (
<Card className="vcd-screen-picker-card"> <Forms.FormTitle>Loading Audio Sources...</Forms.FormTitle>
{loading ? ( ) : (
<Forms.FormTitle>Loading Audio Sources...</Forms.FormTitle> <Forms.FormTitle>Audio Source</Forms.FormTitle>
) : ( )}
<Forms.FormTitle>Audio Source</Forms.FormTitle>
)}
{!sources.ok && sources.isGlibCxxOutdated && ( {!sources.ok && sources.isGlibCxxOutdated && (
<Forms.FormText> <Forms.FormText>
Failed to retrieve Audio Sources because your C++ library is too old to run Failed to retrieve Audio Sources because your C++ library is too old to run
<a href="https://github.com/Vencord/venmic" target="_blank"> <a href="https://github.com/Vencord/venmic" target="_blank">
venmic venmic
</a> </a>
. See{" "} . See{" "}
<a href="https://gist.github.com/Vendicated/b655044ffbb16b2716095a448c6d827a" target="_blank"> <a href="https://gist.github.com/Vendicated/b655044ffbb16b2716095a448c6d827a" target="_blank">
this guide this guide
</a>{" "} </a>{" "}
for possible solutions. for possible solutions.
</Forms.FormText> </Forms.FormText>
)} )}
{hasPipewirePulse || ignorePulseWarning ? ( {hasPipewirePulse || ignorePulseWarning ? (
allSources && ( allSources && (
<Select <Select
options={allSources options={allSources
.map(target => mapToAudioItem(target, granularSelect)) .map(target => mapToAudioItem(target, granularSelect))
.flat() .flat()
.filter(uniqueName) .filter(uniqueName)
.map(({ name, value }) => ({ .map(({ name, value }) => ({
label: name, label: name,
value: value, value: value,
default: name === "None" default: name === "None"
}))} }))}
isSelected={isSelected} isSelected={isSelected}
select={update} select={update}
serialize={String} serialize={String}
/> popoutPosition="top"
) />
) : ( )
<Text variant="text-sm/normal"> ) : (
Could not find pipewire-pulse. See{" "} <Text variant="text-sm/normal">
<a Could not find pipewire-pulse. See{" "}
href="https://gist.github.com/the-spyke/2de98b22ff4f978ebf0650c90e82027e#install" <a
target="_blank" href="https://gist.github.com/the-spyke/2de98b22ff4f978ebf0650c90e82027e#install"
> target="_blank"
this guide >
</a>{" "} this guide
on how to switch to pipewire. <br /> </a>{" "}
You can still continue, however, please{" "} on how to switch to pipewire. <br />
<b>beware that you can only share audio of apps that are running under pipewire</b>. You can still continue, however, please{" "}
<br /> <b>beware that you can only share audio of apps that are running under pipewire</b>.
<br /> <br />
<a onClick={() => setIgnorePulseWarning(true)}>I know what I'm doing</a> <br />
</Text> <a onClick={() => setIgnorePulseWarning(true)}>I know what I'm doing</a>
)} </Text>
)}
<Forms.FormDivider className={Margins.top16 + " " + Margins.bottom16} /> <Button
color={Button.Colors.TRANSPARENT}
<Switch onClick={openSettings}
onChange={setWorkaround} className="vcd-screen-picker-settings-button"
value={workaround ?? false} >
note={ Open Audio Settings
<> </Button>
Work around an issue that causes the microphone to be shared instead of the correct audio. </div>
Only enable if you're experiencing this issue.
</>
}
>
Microphone Workaround
</Switch>
<Switch
hideBorder
onChange={setOnlyDefaultSpeakers}
disabled={audioSources !== "Entire System"}
value={onlyDefaultSpeakers ?? true}
note={<>When sharing entire desktop audio, only share apps that play to the default speakers.</>}
>
Only Default Speakers
</Switch>
<Switch
hideBorder
onChange={setIgnoreInputMedia}
value={ignoreInputMedia ?? true}
note={<>Ignore Nodes that are intended to capture audio.</>}
>
Ignore Input Media
</Switch>
<Switch
hideBorder
onChange={value => {
setGranularSelect(value);
setAudioSources("None");
}}
value={granularSelect ?? false}
note={<>Allow to select applications more granularly.</>}
>
Granular Selection
</Switch>
</Card>
</>
); );
} }

View file

@ -11,17 +11,6 @@
gap: 1em; 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 { .vcd-screen-picker-card {
flex-grow: 1; flex-grow: 1;
} }
@ -68,7 +57,7 @@
} }
.vcd-screen-picker-preview-img-linux { .vcd-screen-picker-preview-img-linux {
width: 100%; width: 60%;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
@ -120,6 +109,11 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.vcd-screen-picker-settings-button {
margin-left: auto;
margin-top: 0.3rem;
}
.vcd-screen-picker-radios { .vcd-screen-picker-radios {
display: flex; display: flex;
width: 100%; width: 100%;

View file

@ -30,6 +30,13 @@ export interface Settings {
splashBackground?: string; splashBackground?: string;
spellCheckLanguages?: string[]; spellCheckLanguages?: string[];
audioWorkaround?: boolean;
audioGranularSelect?: boolean;
audioIgnoreVirtual?: boolean;
audioIgnoreInputMedia?: boolean;
audioOnlyDefaultSpeakers?: boolean;
} }
export interface State { export interface State {