diff --git a/packages/client/components/app/interface/settings/UserSettings.tsx b/packages/client/components/app/interface/settings/UserSettings.tsx index ff8ae7fba..2216c5819 100644 --- a/packages/client/components/app/interface/settings/UserSettings.tsx +++ b/packages/client/components/app/interface/settings/UserSettings.tsx @@ -5,6 +5,7 @@ import { Server } from "stoat.js"; import { css } from "styled-system/css"; import { useClient, useClientLifecycle } from "@revolt/client"; +import { CONFIGURATION } from "@revolt/common"; import { useUser } from "@revolt/markdown/users"; import { useModals } from "@revolt/modal"; import { ColouredText, Column, Text, iconSize } from "@revolt/ui"; @@ -205,7 +206,11 @@ const Config: SettingsConfiguration<{ server: Server }> = { { id: "voice", icon: , - title: Voice, + title: CONFIGURATION.ENABLE_VIDEO ? ( + Voice & Video + ) : ( + Voice + ), }, { id: "appearance", diff --git a/packages/client/components/app/interface/settings/user/voice/ScreenShareOptions.tsx b/packages/client/components/app/interface/settings/user/voice/ScreenShareOptions.tsx new file mode 100644 index 000000000..cf5517029 --- /dev/null +++ b/packages/client/components/app/interface/settings/user/voice/ScreenShareOptions.tsx @@ -0,0 +1,55 @@ +import { Trans } from "@lingui-solid/solid/macro"; + +import { useVoice } from "@revolt/rtc"; +import { useState } from "@revolt/state"; +import { ScreenShareQualityName } from "@revolt/state/stores/Voice"; +import { + CategoryButton, + CategorySelectOption, + Checkbox, + Column, + Text, +} from "@revolt/ui"; +import { Symbol } from "@revolt/ui/components/utils/Symbol"; + +export function ScreenShareOptions() { + const { voice } = useState(); + const voiceContext = useVoice(); + + const qualities = voiceContext.getEnabledScreenShareQualities(); + + return ( + + + Screen Share Settings + + + screen_share} + title={Select screen share quality} + options={ + Object.fromEntries( + Object.keys(qualities).map((name) => [ + name, + { + title: qualities[name as ScreenShareQualityName]!.fullName, + }, + ]), + ) as { [key in ScreenShareQualityName]: CategorySelectOption } + } + value={voice.screenShareQuality} + onUpdate={(ns) => (voice.screenShareQuality = ns)} + /> + } + onClick={() => + (voice.screenShareQualityAsk = !voice.screenShareQualityAsk) + } + > + Always Ask for Screen Share Quality + + + + ); +} diff --git a/packages/client/components/app/interface/settings/user/voice/VoiceSettings.tsx b/packages/client/components/app/interface/settings/user/voice/VoiceSettings.tsx index bb10d3e41..c40d73de7 100644 --- a/packages/client/components/app/interface/settings/user/voice/VoiceSettings.tsx +++ b/packages/client/components/app/interface/settings/user/voice/VoiceSettings.tsx @@ -1,8 +1,11 @@ +import { Show } from "solid-js"; + +import { CONFIGURATION } from "@revolt/common"; import { Column } from "@revolt/ui"; +import { ScreenShareOptions } from "./ScreenShareOptions"; import { VoiceInputOptions } from "./VoiceInputOptions"; import { VoiceProcessingOptions } from "./VoiceProcessingOptions"; - /** * Configure voice options */ @@ -11,6 +14,9 @@ export function VoiceSettings() { + + + ); } diff --git a/packages/client/components/modal/modals.tsx b/packages/client/components/modal/modals.tsx index 9da2b50ee..a835c16ee 100644 --- a/packages/client/components/modal/modals.tsx +++ b/packages/client/components/modal/modals.tsx @@ -48,6 +48,7 @@ import { PolicyChangeModal } from "./modals/PolicyChange"; import { RenameSessionModal } from "./modals/RenameSession"; import { ReportContentModal } from "./modals/ReportContent"; import { ResetBotTokenModal } from "./modals/ResetBotToken"; +import { ScreenShareSettingsModal } from "./modals/ScreenShareSettings"; import { ServerIdentityModal } from "./modals/ServerIdentity"; import { ServerInfoModal } from "./modals/ServerInfo"; import { SettingsModal } from "./modals/Settings"; @@ -183,7 +184,8 @@ export function RenderModal(props: ActiveModal & { onClose: () => void }) { return ; case "edit_category": return ; - + case "screen_share_settings": + return ; default: console.error( "Failed to create modal for", diff --git a/packages/client/components/modal/modals/ScreenShareSettings.tsx b/packages/client/components/modal/modals/ScreenShareSettings.tsx new file mode 100644 index 000000000..fdfb1fb98 --- /dev/null +++ b/packages/client/components/modal/modals/ScreenShareSettings.tsx @@ -0,0 +1,84 @@ +import { Trans, useLingui } from "@lingui-solid/solid/macro"; +import { createFormControl, createFormGroup } from "solid-forms"; + +import { useState } from "@revolt/state"; +import { ScreenShareQualityName } from "@revolt/state/stores/Voice"; +import { Column, Dialog, DialogProps, Form2 } from "@revolt/ui"; +import { VideoTrack } from "solid-livekit-components"; + +import { Modals } from "../types"; + +export function ScreenShareSettingsModal( + props: DialogProps & Modals & { type: "screen_share_settings" }, +) { + const { voice } = useState(); + const { t } = useLingui(); + + const group = createFormGroup({ + qualityName: createFormControl( + voice.screenShareQuality || "low", + { required: true }, + ), + dontAsk: createFormControl(false), + }); + + async function onSubmit() { + if (group.controls.dontAsk.value) { + voice.screenShareQuality = group.controls.qualityName.value; + voice.screenShareQualityAsk = false; + } + + props.callback(group.controls.qualityName.value); + props.onClose(); + } + + const submit = Form2.useSubmitHandler(group, onSubmit); + + return ( + { + props.onCancel(); + props.onClose(); + }} + title={t`Screen Share Settings`} + actions={[ + { text: Cancel }, + { + text: Go, + onClick: () => { + onSubmit(); + return false; + }, + }, + ]} + > + +
+ + { + return { + children: quality.fullName, + value: quality.name, + }; + })} + /> + + Don't ask me again + + +
+
+ ); +} diff --git a/packages/client/components/modal/types.ts b/packages/client/components/modal/types.ts index a6c6a020e..0c8cebb52 100644 --- a/packages/client/components/modal/types.ts +++ b/packages/client/components/modal/types.ts @@ -1,3 +1,4 @@ +import { TrackReference } from "solid-livekit-components"; import { API, Bot, @@ -22,6 +23,7 @@ import { ProtocolV1 } from "stoat.js/lib/events/v1"; import type { SettingsConfigurations } from "@revolt/app"; import { CategoryData } from "@revolt/app/menus/CategoryContextMenu"; +import { ScreenShareQualityName } from "@revolt/state/stores/Voice"; export type Modals = | { @@ -314,4 +316,11 @@ export type Modals = type: "edit_category"; server: Server; category: CategoryData; + } + | { + type: "screen_share_settings"; + trackReference: TrackReference; + qualities: { name: string; fullName: string }[]; + callback: (qualityName: ScreenShareQualityName) => void; + onCancel: () => void; }; diff --git a/packages/client/components/rtc/state.tsx b/packages/client/components/rtc/state.tsx index 66a48d6e9..39b1b2165 100644 --- a/packages/client/components/rtc/state.tsx +++ b/packages/client/components/rtc/state.tsx @@ -1,10 +1,10 @@ import { Accessor, - JSX, - Setter, batch, createContext, createSignal, + JSX, + Setter, useContext, } from "solid-js"; import { @@ -13,14 +13,23 @@ import { useTracks, } from "solid-livekit-components"; -import { Room, Track } from "livekit-client"; +import { + Room, + ScreenSharePresets, + Track, + VideoResolution, +} from "livekit-client"; import { DenoiseTrackProcessor } from "livekit-rnnoise-processor"; import { Channel } from "stoat.js"; +import { useClient } from "@revolt/client"; import { CONFIGURATION } from "@revolt/common"; import { ModalController, useModals } from "@revolt/modal"; import { useState } from "@revolt/state"; -import { Voice as VoiceSettings } from "@revolt/state/stores/Voice"; +import { + ScreenShareQualityName, + Voice as VoiceSettings, +} from "@revolt/state/stores/Voice"; import { VoiceCallCardContext } from "@revolt/ui/components/features/voice/callCard/VoiceCallCard"; import { InRoom } from "./components/InRoom"; @@ -33,6 +42,13 @@ type State = | "CONNECTED" | "RECONNECTING"; +type ScreenShareQuality = { + name: ScreenShareQualityName; + resolution: VideoResolution; + fullName: string; + contentHint: string; +}; + class Voice { #settings: VoiceSettings; @@ -66,6 +82,7 @@ class Voice { #setShowBar: Setter; private openModal; + private getClient; constructor(voiceSettings: VoiceSettings, modals: ModalController) { this.#settings = voiceSettings; @@ -108,6 +125,8 @@ class Voice { this.#setShowBar = setShowBar; this.openModal = modals.openModal; + + this.getClient = useClient(); } async connect(channel: Channel, auth?: { url: string; token: string }) { @@ -221,17 +240,159 @@ class Voice { } } + /** + * Get the enabled screen share qualities. "low" will always be enabled. + * Each screen share quality is checked against the limit if the limit is available on the client. + * + * TODO: Translate the fullNames here, I can't figure out how to do it. + * + * @param name The name of the screen share quality to get + * @returns A partial record of ScreenShareQualityName to ScreenShareQuality. Will always contain "low" quality. + */ + getEnabledScreenShareQualities(): Partial< + Record + > { + // Always enable low + const qualities: Partial< + Record + > = { + low: { + name: "low", + resolution: ScreenSharePresets.h720fps30.resolution, + fullName: `720p 30FPS`, + contentHint: "motion", + }, + }; + + if (this.getClient().configured()) { + // TODO: Use new user limits if the user is new - I don't think there's a way to do that now? + const limit = + this.getClient().configuration?.features.limits.default + .video_resolution; + + // TODO: Add more resolutions to stream from if they're enabled. May tie into premium users in the future? + if (limit) { + if ( + (limit[0] === 0 || limit[0] >= 1920) && + (limit[1] === 0 || limit[1] >= 1080) + ) { + qualities.high = { + name: "high", + resolution: ScreenSharePresets.h1080fps30.resolution, + fullName: `1080p 30FPS`, + contentHint: "motion", + }; + const originalResolution = ScreenSharePresets.original.resolution; + originalResolution.frameRate = 5; + originalResolution.aspectRatio = 0; + if (this.getClient().configured()) { + // TODO: Use new user limits if the user is new - I don't think there's a way to do that now? + const limit = + this.getClient().configuration?.features.limits.default + .video_resolution; + if (limit) { + originalResolution.width = limit[0]; + originalResolution.height = limit[1]; + // If both resolutions are limited, set aspect ratio + if ( + originalResolution.height !== 0 && + originalResolution.width !== 0 + ) { + originalResolution.aspectRatio = + originalResolution.width / originalResolution.height; + } + } + } + qualities.text = { + name: "text", + resolution: originalResolution, + fullName: `Source 5FPS`, + contentHint: "text", + }; + } + } + } + return qualities; + } + async toggleScreenshare() { - try { - const room = this.room(); - if (!room) throw "invalid state"; - await room.localParticipant.setScreenShareEnabled( - !room.localParticipant.isScreenShareEnabled, - ); + const room = this.room(); + if (!room) throw "invalid state"; + if (this.screenshare()) { + await room.localParticipant.setScreenShareEnabled(false); this.#setScreenshare(room.localParticipant.isScreenShareEnabled); - } catch (e) { - this.onErr(e); + } else { + const qualities = this.getEnabledScreenShareQualities(); + try { + const localTrack = await room.localParticipant.setScreenShareEnabled( + true, + { + resolution: + this.getEnabledScreenShareQualities()[ + this.#settings.screenShareQuality || "low" + ]?.resolution, + // TODO: Change this to true when enabling screen share audio. + audio: false, + }, + ); + + this.#setScreenshare(room.localParticipant.isScreenShareEnabled); + + if (localTrack) { + const callback = async (qualityName: ScreenShareQualityName) => { + const quality = qualities[qualityName] || qualities.low!; + + if (localTrack.videoTrack) { + await localTrack.videoTrack.mediaStreamTrack.applyConstraints({ + frameRate: { max: quality.resolution.frameRate }, + width: + quality.resolution.width === 0 + ? undefined + : { max: quality.resolution.width }, + height: + quality.resolution.width === 0 + ? undefined + : { max: quality.resolution.height }, + }); + localTrack.videoTrack.mediaStreamTrack.contentHint = + quality.contentHint; + } + }; + + if (this.#settings.screenShareQualityAsk) { + if (Object.keys(qualities).length > 1) { + localTrack.pauseUpstream(); + this.openModal({ + onCancel: async () => { + await room.localParticipant.setScreenShareEnabled(false); + this.#setScreenshare( + room.localParticipant.isScreenShareEnabled, + ); + }, + type: "screen_share_settings", + trackReference: { + participant: room.localParticipant, + publication: localTrack, + source: Track.Source.ScreenShare, + }, + qualities: Object.keys(qualities).map((k) => { + const v = qualities[k as ScreenShareQualityName]!; + return { name: k, fullName: v.fullName }; + }), + callback: async (qualityName) => { + callback(qualityName); + localTrack.resumeUpstream(); + }, + }); + } else { + callback(this.#settings.screenShareQuality || "low"); + } + } + } + } catch (e) { + this.onErr(e); + } } } diff --git a/packages/client/components/state/stores/Voice.ts b/packages/client/components/state/stores/Voice.ts index 9723105e0..6968d7451 100644 --- a/packages/client/components/state/stores/Voice.ts +++ b/packages/client/components/state/stores/Voice.ts @@ -13,6 +13,20 @@ const NoiseSuppresionStates: NoiseSuppresionState[] = [ "enhanced", ]; +/** + * Possible screen share qualities. Low is 720p@30fps, high 1080p@30fps and text is source@5fps. + */ +export type ScreenShareQualityName = "low" | "high" | "text"; + +/** + * Array of available screen share quality names. + */ +export const ScreenShareQualityNames: ScreenShareQualityName[] = [ + "low", + "high", + "text", +]; + export interface TypeVoice { preferredAudioInputDevice?: string; preferredAudioOutputDevice?: string; @@ -21,6 +35,9 @@ export interface TypeVoice { noiseSupression: NoiseSuppresionState; autoGainControl: boolean; + screenShareQuality: ScreenShareQualityName; + screenShareQualityAsk: boolean; + inputVolume: number; outputVolume: number; deafen: boolean; @@ -57,6 +74,8 @@ export class Voice extends AbstractStore<"voice", TypeVoice> { echoCancellation: true, noiseSupression: "browser", autoGainControl: true, + screenShareQuality: "low", + screenShareQualityAsk: true, inputVolume: 1.0, outputVolume: 1.0, deafen: false, @@ -100,6 +119,17 @@ export class Voice extends AbstractStore<"voice", TypeVoice> { data.autoGainControl = input.autoGainControl; } + if ( + input.screenShareQuality && + ScreenShareQualityNames.includes(input.screenShareQuality) + ) { + data.screenShareQuality = input.screenShareQuality; + } + + if (typeof input.screenShareQualityAsk === "boolean") { + data.screenShareQualityAsk = input.screenShareQualityAsk; + } + if (typeof input.inputVolume === "number") { data.inputVolume = input.inputVolume; } @@ -207,6 +237,20 @@ export class Voice extends AbstractStore<"voice", TypeVoice> { this.set("autoGainControl", value); } + /** + * Set screen share quality + */ + set screenShareQuality(value: ScreenShareQualityName) { + this.set("screenShareQuality", value); + } + + /** + * Set screen share quality always ask + */ + set screenShareQualityAsk(value: boolean) { + this.set("screenShareQualityAsk", value); + } + /** * Set input volume */ @@ -270,6 +314,20 @@ export class Voice extends AbstractStore<"voice", TypeVoice> { return this.get().autoGainControl; } + /** + * Get screen share quality + */ + get screenShareQuality(): ScreenShareQualityName | undefined { + return this.get().screenShareQuality; + } + + /** + * Get screen share quality always ask + */ + get screenShareQualityAsk(): boolean { + return this.get().screenShareQualityAsk; + } + /** * Get input volume */ diff --git a/packages/client/components/ui/components/features/voice/callCard/ParticipantTile.tsx b/packages/client/components/ui/components/features/voice/callCard/ParticipantTile.tsx index d2a4551c0..e50336074 100644 --- a/packages/client/components/ui/components/features/voice/callCard/ParticipantTile.tsx +++ b/packages/client/components/ui/components/features/voice/callCard/ParticipantTile.tsx @@ -47,11 +47,16 @@ export function ParticipantTile(props: TileProps) { source: Track.Source.Microphone, }); - const isScreenShareMuted = useIsMuted({ + const isScreenShareAudioMuted = useIsMuted({ participant, source: Track.Source.ScreenShareAudio, }); + const isRemoteScreenShareMuted = useIsMuted({ + participant, + source: Track.Source.ScreenShare, + }); + const isVideoMuted = useIsMuted({ participant, source: Track.Source.Camera, @@ -72,87 +77,89 @@ export function ParticipantTile(props: TileProps) { }; return ( -
voice.toggleFocus(track)} - use:floating={ - isScreenShare() - ? undefined - : { - // TODO: Conflicts with focusing, maybe only show if clicking name itself - // userCard: { - // user: user().user!, - // member: user().member, - // }, - contextMenu: () => ( - - ), - } - } - style={{ ...getHeight() }} - > - - - + +
voice.toggleFocus(track)} + use:floating={ + isScreenShare() + ? undefined + : { + // TODO: Conflicts with focusing, maybe only show if clicking name itself + // userCard: { + // user: user().user!, + // member: user().member, + // }, + contextMenu: () => ( + + ), + } } + style={{ ...getHeight() }} > - { - setVideoDims({ - height: videoRef?.videoHeight || 0, - width: videoRef?.videoWidth || 0, - }); - }} - /> - - - - {user().username} - - {isScreenShare() ? ( - - no_sound - - ) : ( - + - )} - - - -
+ + } + > + { + setVideoDims({ + height: videoRef?.videoHeight || 0, + width: videoRef?.videoWidth || 0, + }); + }} + /> +
+ + + {user().username} + + {isScreenShare() ? ( + + no_sound + + ) : ( + + )} + + + +
+ ); } diff --git a/packages/client/components/ui/components/utils/Form2.tsx b/packages/client/components/ui/components/utils/Form2.tsx index f4b4fc2f6..0e34bb5b4 100644 --- a/packages/client/components/ui/components/utils/Form2.tsx +++ b/packages/client/components/ui/components/utils/Form2.tsx @@ -4,6 +4,7 @@ import { ComponentProps, For, Match, + ParentProps, Show, Switch, splitProps, @@ -16,6 +17,7 @@ import { styled } from "styled-system/jsx"; import { Button, Checkbox, Radio2, Text, TextField } from "../design"; import { TextEditor2 } from "../features/texteditor/TextEditor2"; +import { Row } from "../layout"; import { FileInput } from "./files"; @@ -229,6 +231,48 @@ const FormRadio = ( ); }; +/** + * Form element wrapper for button groups + */ +const FormButtonGroup = (props: { + control: IFormControl; + buttonDefinitions: (Omit< + ParentProps>, + "group" | "groupActive" | "onPress" + > & { value: string })[]; +}) => { + return ( + <> + + + {(buttonDef, index) => ( +