diff --git a/app/components-react/editor/elements/SourceSelector.tsx b/app/components-react/editor/elements/SourceSelector.tsx index b500e24d13eb..6b8f1c502ac3 100644 --- a/app/components-react/editor/elements/SourceSelector.tsx +++ b/app/components-react/editor/elements/SourceSelector.tsx @@ -21,6 +21,7 @@ import styles from './SceneSelector.m.less'; import Scrollable from 'components-react/shared/Scrollable'; import HelpTip from 'components-react/shared/HelpTip'; import Translate from 'components-react/shared/Translate'; +import { GuestCamService } from 'app-services'; interface ISourceMetadata { id: string; @@ -30,6 +31,7 @@ interface ISourceMetadata { isLocked: boolean; isStreamVisible: boolean; isRecordingVisible: boolean; + isGuestCamActive: boolean; isFolder: boolean; canShowActions: boolean; parentId?: string; @@ -42,6 +44,7 @@ class SourceSelectorModule { private editorCommandsService = inject(EditorCommandsService); private streamingService = inject(StreamingService); private audioService = inject(AudioService); + private guestCamService = inject(GuestCamService); sourcesTooltip = $t('The building blocks of your scene. Also contains widgets.'); addSourceTooltip = $t('Add a new Source to your Scene. Includes widgets.'); @@ -81,6 +84,7 @@ class SourceSelectorModule { selectiveRecordingEnabled={this.selectiveRecordingEnabled} isStreamVisible={sceneNode.isStreamVisible} isRecordingVisible={sceneNode.isRecordingVisible} + isGuestCamActive={sceneNode.isGuestCamActive} cycleSelectiveRecording={() => this.cycleSelectiveRecording(sceneNode.id)} ref={this.nodeRefs[sceneNode.id]} onDoubleClick={() => this.sourceProperties(sceneNode.id)} @@ -104,6 +108,12 @@ class SourceSelectorModule { const isLocked = itemsForNode.every(i => i.locked); const isRecordingVisible = itemsForNode.every(i => i.recordingVisible); const isStreamVisible = itemsForNode.every(i => i.streamVisible); + const isGuestCamActive = itemsForNode.some(i => { + return ( + this.sourcesService.state.sources[i.sourceId].type === 'mediasoupconnector' && + this.guestCamService.state.guestInfo + ); + }); const isFolder = !isItem(node); return { @@ -114,6 +124,7 @@ class SourceSelectorModule { isLocked, isRecordingVisible, isStreamVisible, + isGuestCamActive, parentId: node.parentId, canShowActions: itemsForNode.length > 0, isFolder, @@ -556,6 +567,7 @@ const TreeNode = React.forwardRef( isStreamVisible: boolean; isRecordingVisible: boolean; selectiveRecordingEnabled: boolean; + isGuestCamActive: boolean; canShowActions: boolean; toggleVisibility: (ev: unknown) => unknown; toggleLock: (ev: unknown) => unknown; @@ -584,6 +596,7 @@ const TreeNode = React.forwardRef( {p.title} {p.canShowActions && ( <> + {p.isGuestCamActive && } {p.selectiveRecordingEnabled && ( ({ produceOk: GuestCamService.state.produceOk, visible: GuestCamService.views.guestVisible, @@ -62,6 +64,8 @@ export default function GuestCamProperties() { guestInfo: GuestCamService.state.guestInfo, volume: GuestCamService.views.deflection, showFirstTimeModal: DismissablesService.views.shouldShow(EDismissable.GuestCamFirstTimeModal), + joinAsGuest: !!GuestCamService.state.joinAsGuestHash, + hostName: GuestCamService.state.hostName, })); const [regeneratingLink, setRegeneratingLink] = useState(false); @@ -78,6 +82,16 @@ export default function GuestCamProperties() { EditorCommandsService.actions.executeCommand('SetDeflectionCommand', source.sourceId, val); } + function getModalContent() { + if (joinAsGuest) { + return ; + } else if (showFirstTimeModal) { + return ; + } else { + return ; + } + } + return ( @@ -138,12 +152,27 @@ export default function GuestCamProperties() {

{$t('Source: %{sourceName}', { sourceName: source?.name })}

- +
+ {joinAsGuest ? ( +
+ {$t('Connected To Host:')} {hostName} + + + +
+ ) : ( + + )} +
`${(v * 100).toFixed(0)}%`} - style={{ width: '100%', margin: '10px 0' }} + style={{ width: '100%', margin: '20px 0' }} />
@@ -162,33 +191,41 @@ export default function GuestCamProperties() { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', - margin: '10px 0 20px', + margin: '10px 0', + height: 32, }} > - - - - + {joinAsGuest ? ( + <> + ) : ( + <> + + + + + + )}
)}
@@ -244,7 +285,7 @@ export default function GuestCamProperties() { }} onCancel={() => WindowsService.actions.closeChildWindow()} > - {showFirstTimeModal ? : } + {getModalContent()}
); @@ -297,3 +338,20 @@ function FirstTimeModalContent() { ); } + +function JoinAsGuestModalContent() { + const { GuestCamService } = Services; + const { hostName } = useVuex(() => ({ hostName: GuestCamService.state.hostName })); + + return ( + <> +

{$t("You're about to join %{name}", { name: hostName })}

+

+ {$t( + "%{name} has invited you to join their stream. When you're ready to join, click the button below.", + { name: hostName }, + )} +

+ + ); +} diff --git a/app/i18n/en-US/guest-cam.json b/app/i18n/en-US/guest-cam.json index b361817cbf7d..5a64ece31b7e 100644 --- a/app/i18n/en-US/guest-cam.json +++ b/app/i18n/en-US/guest-cam.json @@ -26,5 +26,10 @@ "Don't share your invite link with anyone you don't want on your stream. You can invalidate an old link by generating a new one. Do not show this window on stream.": "Don't share your invite link with anyone you don't want on your stream. You can invalidate an old link by generating a new one. Do not show this window on stream.", "Start Collab Cam": "Start Collab Cam", "Collab Cam is not yet sending your video and audio to guests. Start Collab Cam?": "Collab Cam is not yet sending your video and audio to guests. Start Collab Cam?", - "Copy Link": "Copy Link" + "Copy Link": "Copy Link", + "You're about to join %{name}": "You're about to join %{name}", + "%{name} has invited you to join their stream. When you're ready to join, click the button below.": "%{name} has invited you to join their stream. When you're ready to join, click the button below.", + "Waiting for host to begin": "Waiting for host to begin", + "Connected To Host:": "Connected To Host:", + "You are connected as a guest using someone else's invite link. To leave, click the Disconnect button.": "You are connected as a guest using someone else's invite link. To leave, click the Disconnect button." } diff --git a/app/services/guest-cam/index.ts b/app/services/guest-cam/index.ts index 9c7768f5a87e..03812b88c698 100644 --- a/app/services/guest-cam/index.ts +++ b/app/services/guest-cam/index.ts @@ -69,6 +69,9 @@ interface IRoomResponse { interface IIoConfigResponse { url: string; token: string; + host: { + name: string; + }; } type TWebRTCSocketEvent = @@ -144,6 +147,17 @@ interface IGuestCamServiceState { audioSourceId: string; inviteHash: string; guestInfo: IGuest; + + /** + * If we are connecting to as a guest to someone else's stream, this will + * be set to the hash. + */ + joinAsGuestHash: string | null; + + /** + * Name of the host of the room + */ + hostName: string; } interface IInviteLink { @@ -213,6 +227,8 @@ export class GuestCamService extends StatefulService { audioSourceId: '', inviteHash: '', guestInfo: null, + joinAsGuestHash: null, + hostName: null, }; get views() { @@ -238,7 +254,13 @@ export class GuestCamService extends StatefulService { * up the consumer while the producer is being set up. This mutex * ensures that only one operation accesses the plugin at a time. */ - mutex = new Mutex(); + pluginMutex = new Mutex(); + + /** + * Used to ensure we don't try to clean up or start a new socket + * connect while we are in the process of initializing it. + */ + socketMutex = new Mutex(); turnConfig: ITurnConfig; @@ -262,18 +284,7 @@ export class GuestCamService extends StatefulService { // we shouldn't break in this scenario if (this.sourcesService.views.getSourcesByType('mediasoupconnector').length) return; - if (this.consumer) { - this.consumer.destroy(); - this.consumer = null; - } - - if (this.producer) { - this.producer.destroy(); - this.producer = null; - } - - this.socket.disconnect(); - this.socket = null; + this.cleanUpSocketConnection(); } }); @@ -345,39 +356,61 @@ export class GuestCamService extends StatefulService { } async startListeningForGuests() { - // TODO: Handle socket disconnects - if (this.socket) return; + await this.socketMutex.do(async () => { + // TODO: Handle socket disconnects + if (this.socket) return; - if (!this.userService.views.isLoggedIn) return; + if (!this.userService.views.isLoggedIn) return; - await this.ensureInviteLink(); + await this.ensureInviteLink(); - const roomUrl = this.urlService.getStreamlabsApi('streamrooms/current'); - const roomResult = await jfetch(roomUrl, { - headers: authorizedHeaders(this.userService.views.auth.apiToken), - }); + const roomUrl = this.urlService.getStreamlabsApi('streamrooms/current'); + const roomResult = await jfetch(roomUrl, { + headers: authorizedHeaders(this.userService.views.auth.apiToken), + }); - this.log('Room result', roomResult); + this.log('Room result', roomResult); - this.room = roomResult.room; + this.room = roomResult.room; - let ioConfigResult: IIoConfigResponse; + let ioConfigResult: IIoConfigResponse; - if (Utils.env.SLD_GUEST_CAM_HASH) { - const url = this.urlService.getStreamlabsApi( - `streamrooms/io/config/${Utils.env.SLD_GUEST_CAM_HASH}`, - ); - ioConfigResult = await jfetch(url); - } else { - const ioConfigUrl = this.urlService.getStreamlabsApi('streamrooms/io/config'); - ioConfigResult = await jfetch(ioConfigUrl, { - headers: authorizedHeaders(this.userService.views.auth.apiToken), - }); - } + if (Utils.env.SLD_GUEST_CAM_HASH ?? this.state.joinAsGuestHash) { + const url = this.urlService.getStreamlabsApi( + `streamrooms/io/config/${Utils.env.SLD_GUEST_CAM_HASH ?? this.state.joinAsGuestHash}`, + ); + ioConfigResult = await jfetch(url); + } else { + const ioConfigUrl = this.urlService.getStreamlabsApi('streamrooms/io/config'); + ioConfigResult = await jfetch(ioConfigUrl, { + headers: authorizedHeaders(this.userService.views.auth.apiToken), + }); + } - this.log('io Config Result', ioConfigResult); + this.log('io Config Result', ioConfigResult); - this.openSocketConnection(ioConfigResult.url, ioConfigResult.token); + this.SET_HOST_NAME(ioConfigResult.host.name); + + await this.openSocketConnection(ioConfigResult.url, ioConfigResult.token); + }); + } + + /** + * This should be called when we are attempting to join somebody else's + * stream as a guest. + * @param inviteHash The invite code of the room to join + */ + async joinAsGuest(inviteHash: string) { + if (!inviteHash) return; + + // TODO: We should show a nice error message + if (!this.views.sourceId) return; + + await this.cleanUpSocketConnection(); + this.SET_JOIN_AS_GUEST(inviteHash); + this.SET_PRODUCE_OK(false); + await this.startListeningForGuests(); + this.sourcesService.showGuestCamPropertiesBySourceId(this.views.sourceId); } /** @@ -411,8 +444,9 @@ export class GuestCamService extends StatefulService { setProduceOk() { this.SET_PRODUCE_OK(true); - // If a guest is already connected and we are not yet producing, start doing so now - if (!this.producer && this.consumer) { + // If a guest is already connected and we are not yet producing, start doing so now. + // If we are joined as a guest, we should also start producing first. + if (!this.producer && (this.consumer || this.state.joinAsGuestHash)) { this.startProducing(); } } @@ -503,9 +537,9 @@ export class GuestCamService extends StatefulService { let turnConfigResult: ITurnConfig; - if (Utils.env.SLD_GUEST_CAM_HASH) { + if (Utils.env.SLD_GUEST_CAM_HASH ?? this.state.joinAsGuestHash) { const url = this.urlService.getStreamlabsApi( - `streamrooms/turn/config/${Utils.env.SLD_GUEST_CAM_HASH}`, + `streamrooms/turn/config/${Utils.env.SLD_GUEST_CAM_HASH ?? this.state.joinAsGuestHash}`, ); turnConfigResult = await jfetch(url); } else { @@ -644,6 +678,22 @@ export class GuestCamService extends StatefulService { * Disconnects the currently connected guest */ disconnectGuest() { + // Add the stream id to the list of disconnected guests, so we don't + // immediately connect to that same guest again until they are forced + // to refresh the page. + if (this.state.guestInfo && !this.state.joinAsGuestHash) { + this.disconnectedStreamIds.push(this.state.guestInfo.streamId); + } + + this.cleanUpSocketConnection(); + this.startListeningForGuests(); + } + + async cleanUpSocketConnection() { + await this.socketMutex.synchronize(); + + if (!this.socket) return; + if (this.consumer) { this.consumer.destroy(); this.consumer = null; @@ -656,19 +706,13 @@ export class GuestCamService extends StatefulService { this.producer = null; } - // Add the stream id to the list of disconnected guests, so we don't - // immediately connect to that same guest again until they are forced - // to refresh the page. - this.disconnectedStreamIds.push(this.state.guestInfo.streamId); - // TODO: AFAIK there is no way to cleanly recreate the producer without // entirely disconnecting destroying all state on the server. For now, we // disconnect from the socket and start listening to guests again. this.socket.disconnect(); this.socket = null; - this.startListeningForGuests(); - this.SET_GUEST(null); + this.SET_JOIN_AS_GUEST(null); } setVisibility(visible: boolean) { @@ -800,4 +844,14 @@ export class GuestCamService extends StatefulService { private SET_GUEST(guest: IGuest) { this.state.guestInfo = guest; } + + @mutation() + private SET_JOIN_AS_GUEST(inviteHash: string | null) { + this.state.joinAsGuestHash = inviteHash; + } + + @mutation() + private SET_HOST_NAME(name: string) { + this.state.hostName = name; + } } diff --git a/app/services/guest-cam/mediasoup-entity.ts b/app/services/guest-cam/mediasoup-entity.ts index a6de0682c441..2ef275856509 100644 --- a/app/services/guest-cam/mediasoup-entity.ts +++ b/app/services/guest-cam/mediasoup-entity.ts @@ -50,7 +50,7 @@ export abstract class MediasoupEntity { */ async withMutex(fun: () => TReturn) { if (!this.mutexUnlockFunc) { - this.mutexUnlockFunc = await this.guestCamService.mutex.wait(); + this.mutexUnlockFunc = await this.guestCamService.pluginMutex.wait(); } try { diff --git a/app/services/protocol-links.ts b/app/services/protocol-links.ts index 34a4fd4d8448..173f082f25b7 100644 --- a/app/services/protocol-links.ts +++ b/app/services/protocol-links.ts @@ -8,6 +8,7 @@ import { PlatformAppStoreService } from 'services/platform-app-store'; import { UserService } from 'services/user'; import { SettingsService } from './settings'; import { byOS, OS } from 'util/operating-systems'; +import { GuestCamService } from './guest-cam'; function protocolHandler(base: string) { return (target: any, methodName: string, descriptor: PropertyDescriptor) => { @@ -32,6 +33,7 @@ export class ProtocolLinksService extends Service { @Inject() platformAppStoreService: PlatformAppStoreService; @Inject() userService: UserService; @Inject() settingsService: SettingsService; + @Inject() guestCamService: GuestCamService; // Maps base URL components to handler function names private handlers: Dictionary; @@ -118,4 +120,11 @@ export class ProtocolLinksService extends Service { this.settingsService.showSettings(category); } + + @protocolHandler('join') + private guestCamJoin(info: IProtocolLinkInfo) { + const hash = info.path.replace('/', ''); + + this.guestCamService.joinAsGuest(hash); + } } diff --git a/app/services/sources/sources.ts b/app/services/sources/sources.ts index 791502dcab24..bddd43adde5b 100644 --- a/app/services/sources/sources.ts +++ b/app/services/sources/sources.ts @@ -807,7 +807,7 @@ export class SourcesService extends StatefulService { queryParams: { sourceId: source.sourceId }, size: { width: 850, - height: 650, + height: 660, }, }); } diff --git a/app/util/mutex.ts b/app/util/mutex.ts index 9b5298bbe718..9046527aeb5c 100644 --- a/app/util/mutex.ts +++ b/app/util/mutex.ts @@ -21,6 +21,44 @@ export class Mutex { }); } + /** + * Waits until the mutex is unlocked, but does not actually + * lock it. Due to the single-threaded nature of javascript, + * you can safely use the shared resource as long as you don't + * do any async operations. + */ + async synchronize() { + const unlock = await this.wait(); + unlock(); + } + + /** + * Pass a function to be executed when the mutex is available. + * Using `do` ensures that if your operation raises an exception + * or rejects, the mutex won't remain locked forever. This is the + * preferred form as it avoids deadlocks. It also works with async + * functions or any function that returns a promise. + * @param fun The function to execute + * @returns void + */ + async do(fun: () => TReturn) { + const unlock = await this.wait(); + + try { + let val = fun(); + + if (val instanceof Promise) { + val = await val; + } + + unlock(); + return val; + } catch (e: unknown) { + unlock(); + throw e; + } + } + private unlock() { if (!this.locked) return;