diff --git a/package.json b/package.json index 25b42bc..33394c5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "livekit-server-sdk": "^0.5.1" }, "devDependencies": { - "@league-of-foundry-developers/foundry-vtt-types": "^0.8.9-0", + "@league-of-foundry-developers/foundry-vtt-types": "^0.8.9-2", "buffer": "^6.0.3", "copy-webpack-plugin": "^9.0.1", "crypto-browserify": "^3.12.0", diff --git a/src/LiveKitAVClient.ts b/src/LiveKitAVClient.ts index a94935d..3ad2717 100644 --- a/src/LiveKitAVClient.ts +++ b/src/LiveKitAVClient.ts @@ -1,9 +1,16 @@ -import { connect as liveKitConnect, LogLevel, RoomState } from "livekit-client"; +import { + connect as liveKitConnect, + ConnectOptions, + LocalTrack, + LogLevel, + RoomState, +} from "livekit-client"; import { LANG_NAME, MODULE_NAME } from "./utils/constants"; import * as log from "./utils/logging"; import LiveKitClient from "./LiveKitClient"; import { getGame } from "./utils/helpers"; +import { ConnectionSettings } from "../types/avclient-livekit"; /** * An AVClient implementation that uses WebRTC and the LiveKit library. @@ -12,11 +19,11 @@ import { getGame } from "./utils/helpers"; * @param {AVSettings} settings The audio/video settings being used */ export default class LiveKitAVClient extends AVClient { - room: any; - _liveKitClient: any; + room: string | null; + _liveKitClient: LiveKitClient; audioBroadcastEnabled: boolean; - constructor(master, settings) { + constructor(master: AVMaster, settings: AVSettings) { super(master, settings); this._liveKitClient = new LiveKitClient(this); @@ -30,7 +37,7 @@ export default class LiveKitAVClient extends AVClient { * Is audio broadcasting push-to-talk enabled? * @returns {boolean} */ - get isVoicePTT() { + get isVoicePTT(): boolean { return this.settings.client.voice.mode === "ptt"; } @@ -38,7 +45,7 @@ export default class LiveKitAVClient extends AVClient { * Is audio broadcasting always enabled? * @returns {boolean} */ - get isVoiceAlways() { + get isVoiceAlways(): boolean { return this.settings.client.voice.mode === "always"; } @@ -46,16 +53,17 @@ export default class LiveKitAVClient extends AVClient { * Is audio broadcasting voice-activation enabled? * @returns {boolean} */ - get isVoiceActivated() { - return this.settings.client.voice.mode === "activity"; + get isVoiceActivated(): boolean { + // This module does not allow for voice activation + return false; } /** * Is the current user muted? * @returns {boolean} */ - get isMuted() { - return this.settings.client.users[getGame().user?.id || ""]?.muted; + get isMuted(): boolean { + return this.settings.client.users[getGame().user?.id || ""]?.muted || false; } /* -------------------------------------------- */ @@ -67,7 +75,7 @@ export default class LiveKitAVClient extends AVClient { * This will be called only once when the Game object is first set-up. * @return {Promise} */ - async initialize() { + async initialize(): Promise { log.debug("LiveKitAVClient initialize"); if (this.settings.get("client", "voice.mode") === "activity") { @@ -89,10 +97,13 @@ export default class LiveKitAVClient extends AVClient { * This function should return a boolean for whether the connection attempt was successful. * @return {Promise} Was the connection attempt successful? */ - async connect() { + async connect(): Promise { log.debug("LiveKitAVClient connect"); - const connectionSettings: any = this.settings.get("world", "server"); + const connectionSettings = this.settings.get( + "world", + "server" + ) as ConnectionSettings; // Set a room name if one doesn't yet exist if (!connectionSettings.room) { @@ -105,31 +116,46 @@ export default class LiveKitAVClient extends AVClient { log.debug("Meeting room name:", this.room); // Set the user's metadata - const metadata = { + const metadata = JSON.stringify({ fvttUserId: getGame().user?.id, - }; + }); + + const userName = getGame().user?.name; + + if (!this.room || !userName) { + log.error( + "Missing required room information, cannot connect. room:", + this.room, + "username:", + userName + ); + return false; + } // Get an access token const accessToken = this._liveKitClient.getAccessToken( connectionSettings.username, connectionSettings.password, this.room, - getGame().user?.name, + userName, metadata ); - const localTracks: any = []; + const localTracks: LocalTrack[] = []; if (this._liveKitClient.audioTrack) localTracks.push(this._liveKitClient.audioTrack); if (this._liveKitClient.videoTrack) localTracks.push(this._liveKitClient.videoTrack); // Set the livekit connection options - const livekitConnectionOptions: any = { + const livekitConnectionOptions: ConnectOptions = { tracks: localTracks, }; - if (getGame().settings.get(MODULE_NAME, "livekitTrace")) { + if ( + getGame().settings.get(MODULE_NAME, "debug") && + getGame().settings.get(MODULE_NAME, "livekitTrace") + ) { log.debug("Setting livekit trace logging"); livekitConnectionOptions.logLevel = LogLevel.trace; } @@ -173,7 +199,7 @@ export default class LiveKitAVClient extends AVClient { * This function should return a boolean for whether a valid disconnection occurred. * @return {Promise} Did a disconnection occur? */ - async disconnect() { + async disconnect(): Promise { log.debug("LiveKitAVClient disconnect"); if ( this._liveKitClient.liveKitRoom && @@ -196,7 +222,7 @@ export default class LiveKitAVClient extends AVClient { * Each object key should be a device id and the key should be a human-readable label. * @returns {Promise<{object}>} */ - async getAudioSinks() { + async getAudioSinks(): Promise> { return this._getSourcesOfType("audiooutput"); } @@ -207,7 +233,7 @@ export default class LiveKitAVClient extends AVClient { * Each object key should be a device id and the key should be a human-readable label. * @returns {Promise<{object}>} */ - async getAudioSources() { + async getAudioSources(): Promise> { return this._getSourcesOfType("audioinput"); } @@ -218,7 +244,7 @@ export default class LiveKitAVClient extends AVClient { * Each object key should be a device id and the key should be a human-readable label. * @returns {Promise<{object}>} */ - async getVideoSources() { + async getVideoSources(): Promise> { return this._getSourcesOfType("videoinput"); } @@ -230,9 +256,11 @@ export default class LiveKitAVClient extends AVClient { * @returns {Promise<{object}>} * @private */ - async _getSourcesOfType(kind) { + async _getSourcesOfType( + kind: MediaDeviceKind + ): Promise> { const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.reduce((obj, device) => { + return devices.reduce((obj: Record, device) => { if (device.kind === kind) { obj[device.deviceId] = device.label || getGame().i18n.localize("WEBRTC.UnknownDevice"); @@ -250,7 +278,7 @@ export default class LiveKitAVClient extends AVClient { * The current user should also be included as a connected user in addition to all peers. * @return {string[]} The connected User IDs */ - getConnectedUsers() { + getConnectedUsers(): string[] { const connectedUsers: string[] = Array.from( this._liveKitClient.liveKitParticipants.keys() ); @@ -267,7 +295,7 @@ export default class LiveKitAVClient extends AVClient { * @return {MediaStream|null} The MediaStream for the user, or null if the user does not have * one */ - getMediaStreamForUser() { + getMediaStreamForUser(): MediaStream | null { log.debug("getMediaStreamForUser called but is not used with", MODULE_NAME); return null; } @@ -278,7 +306,7 @@ export default class LiveKitAVClient extends AVClient { * Is outbound audio enabled for the current user? * @return {boolean} */ - isAudioEnabled() { + isAudioEnabled(): boolean { return this.audioBroadcastEnabled; } @@ -288,7 +316,7 @@ export default class LiveKitAVClient extends AVClient { * Is outbound video enabled for the current user? * @return {boolean} */ - isVideoEnabled() { + isVideoEnabled(): boolean { let videoTrackEnabled = false; if ( this._liveKitClient.videoTrack && @@ -308,7 +336,7 @@ export default class LiveKitAVClient extends AVClient { * @param {boolean} enable Whether the outbound audio track should be enabled (true) or * disabled (false) */ - toggleAudio(enable) { + toggleAudio(enable: boolean): void { log.debug("Toggling audio:", enable); // If "always on" broadcasting is not enabled, don't proceed @@ -326,7 +354,7 @@ export default class LiveKitAVClient extends AVClient { * activation modes. * @param {boolean} broadcast Whether outbound audio should be sent to connected peers or not? */ - toggleBroadcast(broadcast) { + toggleBroadcast(broadcast: boolean): void { log.debug("Toggling broadcast audio:", broadcast); this.audioBroadcastEnabled = broadcast; @@ -342,7 +370,7 @@ export default class LiveKitAVClient extends AVClient { * @param {boolean} enable Whether the outbound video track should be enabled (true) or * disabled (false) */ - toggleVideo(enable) { + toggleVideo(enable: boolean): void { if (!this._liveKitClient.videoTrack) { log.debug("toggleVideo called but no video track available"); return; @@ -364,7 +392,10 @@ export default class LiveKitAVClient extends AVClient { * @param {string} userId The User ID to set to the element * @param {HTMLVideoElement} videoElement The HTMLVideoElement to which the video should be set */ - async setUserVideo(userId, videoElement) { + async setUserVideo( + userId: string, + videoElement: HTMLVideoElement + ): Promise { log.debug("Setting video element:", videoElement, "for user:", userId); // Make sure the room is active first @@ -422,19 +453,17 @@ export default class LiveKitAVClient extends AVClient { * Handle changes to A/V configuration settings. * @param {object} changed The settings which have changed */ - onSettingsChanged(changed) { + onSettingsChanged(changed: DeepPartial): void { log.debug("onSettingsChanged:", changed); const keys = new Set(Object.keys(foundry.utils.flattenObject(changed))); // Change audio source const audioSourceChange = ["client.audioSrc"].some((k) => keys.has(k)); - if (audioSourceChange) - this._liveKitClient.changeAudioSource(changed.client.audioSrc); + if (audioSourceChange) this._liveKitClient.changeAudioSource(); // Change video source const videoSourceChange = ["client.videoSrc"].some((k) => keys.has(k)); - if (videoSourceChange) - this._liveKitClient.changeVideoSource(changed.client.videoSrc); + if (videoSourceChange) this._liveKitClient.changeVideoSource(); // Change voice broadcasting mode const modeChange = [ diff --git a/src/LiveKitClient.ts b/src/LiveKitClient.ts index 8af4dc9..eefbb24 100644 --- a/src/LiveKitClient.ts +++ b/src/LiveKitClient.ts @@ -1,41 +1,50 @@ import { AccessToken } from "livekit-server-sdk"; import { + CreateAudioTrackOptions, createLocalAudioTrack, createLocalVideoTrack, + CreateVideoTrackOptions, + LocalAudioTrack, + LocalVideoTrack, + Participant, ParticipantEvent, + RemoteAudioTrack, + RemoteParticipant, + RemoteTrack, + RemoteTrackPublication, + RemoteVideoTrack, + Room, RoomEvent, RoomState, Track, TrackEvent, + VideoTrack, } from "livekit-client"; import { LANG_NAME } from "./utils/constants"; import * as log from "./utils/logging"; import { getGame } from "./utils/helpers"; +import LiveKitAVClient from "./LiveKitAVClient"; export default class LiveKitClient { - audioTrack: any; - avMaster: any; - liveKitAvClient: any; - liveKitParticipants: Map; - liveKitRoom: any; - settings: any; - videoTrack: any; - windowClickListener: any; - - render: (...args: unknown[]) => void; - constructor(liveKitAvClient) { - this.audioTrack = null; + avMaster: AVMaster; + liveKitAvClient: LiveKitAVClient; + settings: AVSettings; + render: () => void; + + audioTrack: LocalAudioTrack | null = null; + liveKitParticipants: Map = new Map(); + liveKitRoom: Room | null = null; + videoTrack: LocalVideoTrack | null = null; + windowClickListener: EventListener | null = null; + + constructor(liveKitAvClient: LiveKitAVClient) { this.avMaster = liveKitAvClient.master; this.liveKitAvClient = liveKitAvClient; - this.liveKitParticipants = new Map(); - this.liveKitRoom = null; this.settings = liveKitAvClient.settings; - this.videoTrack = null; - this.windowClickListener = null; this.render = debounce( this.avMaster.render.bind(this.liveKitAvClient), - 2001 + 2000 ); } @@ -44,19 +53,26 @@ export default class LiveKitClient { /* -------------------------------------------- */ addAllParticipants() { + if (!this.liveKitRoom) { + log.warn( + "Attempting to add participants before the LiveKit room is available" + ); + return; + } + // Add our user to the participants list - this.liveKitParticipants.set( - getGame().user?.id, - this.liveKitRoom.localParticipant - ); + const userId = getGame().user?.id; + if (userId) { + this.liveKitParticipants.set(userId, this.liveKitRoom.localParticipant); + } // Set up all other users - this.liveKitRoom.participants.forEach((participant) => { + this.liveKitRoom.participants.forEach((participant: RemoteParticipant) => { this.onParticipantConnected(participant); }); } - addConnectionButtons(element) { + addConnectionButtons(element: JQuery) { if (element.length !== 1) { log.warn("Can't find CameraView configure element", element); return; @@ -91,7 +107,7 @@ export default class LiveKitClient { } } - addStatusIndicators(userId) { + addStatusIndicators(userId: string) { // Get the user camera view and notification bar const userCameraView = ui.webrtc?.getUserCameraView(userId); const userNotificationBar = @@ -136,7 +152,11 @@ export default class LiveKitClient { }); } - async attachAudioTrack(userId, userAudioTrack, audioElement) { + async attachAudioTrack( + userId: string, + userAudioTrack: RemoteAudioTrack, + audioElement: HTMLAudioElement + ) { if (userAudioTrack.attachedElements.includes(audioElement)) { log.debug( "Audio track", @@ -149,10 +169,12 @@ export default class LiveKitClient { } // Set audio output device + // @ts-expect-error - sinkId is currently an experimental property and not in the defined types if (audioElement.sinkId === undefined) { log.warn("Your web browser does not support output audio sink selection"); } else { const requestedSink = this.settings.get("client", "audioSink"); + // @ts-expect-error - setSinkId is currently an experimental method and not in the defined types await audioElement.setSinkId(requestedSink).catch((error) => { log.error( "An error occurred when requesting the output audio device:", @@ -169,11 +191,11 @@ export default class LiveKitClient { userAudioTrack.attach(audioElement); // Set the parameters - audioElement.volume = this.settings.getUser(userId).volume; - audioElement.muted = this.settings.get("client", "muteAll"); + audioElement.volume = this.settings.getUser(userId)?.volume || 1.0; + audioElement.muted = this.settings.get("client", "muteAll") === true; } - attachVideoTrack(userVideoTrack, videoElement) { + attachVideoTrack(userVideoTrack: VideoTrack, videoElement: HTMLVideoElement) { if (userVideoTrack.attachedElements.includes(videoElement)) { log.debug( "Video track", @@ -198,17 +220,22 @@ export default class LiveKitClient { this.settings.get("client", "audioSrc") === "disabled" ) { if (this.audioTrack) { - await this.liveKitRoom.localParticipant.unpublishTrack(this.audioTrack); + this.liveKitRoom?.localParticipant.unpublishTrack(this.audioTrack); this.audioTrack.stop(); this.audioTrack = null; } else { await this.initializeAudioTrack(); if (this.audioTrack) { - await this.liveKitRoom.localParticipant.publishTrack(this.audioTrack); + await this.liveKitRoom?.localParticipant.publishTrack( + this.audioTrack + ); } } } else { - this.audioTrack.restartTrack(this.getAudioParams()); + const audioParams = this.getAudioParams(); + if (audioParams) { + this.audioTrack.restartTrack(audioParams); + } } } @@ -218,30 +245,43 @@ export default class LiveKitClient { this.settings.get("client", "videoSrc") === "disabled" ) { if (this.videoTrack) { - await this.liveKitRoom.localParticipant.unpublishTrack(this.videoTrack); + this.liveKitRoom?.localParticipant.unpublishTrack(this.videoTrack); this.videoTrack.detach(); this.videoTrack.stop(); this.videoTrack = null; } else { await this.initializeVideoTrack(); if (this.videoTrack) { - await this.liveKitRoom.localParticipant.publishTrack(this.videoTrack); - this.attachVideoTrack( - this.videoTrack, - ui.webrtc?.getUserVideoElement(getGame().user?.id || "") + await this.liveKitRoom?.localParticipant.publishTrack( + this.videoTrack + ); + const userVideoElement = ui.webrtc?.getUserVideoElement( + getGame().user?.id || "" ); + if (userVideoElement instanceof HTMLVideoElement) { + this.attachVideoTrack(this.videoTrack, userVideoElement); + } } } } else { - this.videoTrack.restartTrack(this.getVideoParams()); + const videoParams = this.getVideoParams(); + if (videoParams) { + this.videoTrack.restartTrack(videoParams); + } } } - getAccessToken(apiKey, secretKey, roomName, userName, metadata) { + getAccessToken( + apiKey: string, + secretKey: string, + roomName: string, + userName: string, + metadata: string + ): string { const accessToken = new AccessToken(apiKey, secretKey, { ttl: "10h", identity: userName, - metadata: JSON.stringify(metadata), + metadata: metadata, }); accessToken.addGrant({ roomJoin: true, room: roomName }); @@ -250,33 +290,42 @@ export default class LiveKitClient { return accessTokenJwt; } - getAudioParams() { + getAudioParams(): CreateAudioTrackOptions | false { // Determine whether the user can send audio const audioSrc = this.settings.get("client", "audioSrc"); const canBroadcastAudio = this.avMaster.canUserBroadcastAudio( - getGame().user?.id + getGame().user?.id || "" ); - return audioSrc && audioSrc !== "disabled" && canBroadcastAudio + + return typeof audioSrc === "string" && + audioSrc !== "disabled" && + canBroadcastAudio ? { deviceId: { ideal: audioSrc }, } : false; } - getParticipantAudioTrack(userId) { - let audioTrack: any = null; - this.liveKitParticipants.get(userId).audioTracks.forEach((publication) => { - if (publication.kind === Track.Kind.Audio) { + getParticipantAudioTrack(userId: string): RemoteAudioTrack | null { + let audioTrack: RemoteAudioTrack | null = null; + this.liveKitParticipants.get(userId)?.audioTracks.forEach((publication) => { + if ( + publication.kind === Track.Kind.Audio && + publication.track instanceof RemoteAudioTrack + ) { audioTrack = publication.track; } }); return audioTrack; } - getParticipantVideoTrack(userId) { - let videoTrack: any = null; - this.liveKitParticipants.get(userId).videoTracks.forEach((publication) => { - if (publication.kind === Track.Kind.Video) { + getParticipantVideoTrack(userId: string): RemoteVideoTrack | null { + let videoTrack: RemoteVideoTrack | null = null; + this.liveKitParticipants.get(userId)?.videoTracks.forEach((publication) => { + if ( + publication.kind === Track.Kind.Video && + publication.track instanceof RemoteVideoTrack + ) { videoTrack = publication.track; } }); @@ -291,7 +340,10 @@ export default class LiveKitClient { * @param {HTMLVideoElement} videoElement The HTMLVideoElement of the user * @return {HTMLVideoElement|null} */ - getUserAudioElement(userId, videoElement: any = null) { + getUserAudioElement( + userId: string, + videoElement: HTMLVideoElement | null = null + ): HTMLAudioElement | null { // Find an existing audio element let audioElement = ui.webrtc?.element.find( `.camera-view[data-user=${userId}] audio.user-audio` @@ -312,7 +364,12 @@ export default class LiveKitClient { .change(this.onVolumeChange.bind(this)); } - return audioElement; + if (audioElement instanceof HTMLAudioElement) { + return audioElement; + } + + // The audio element was not found or created + return null; } async initializeLocalTracks() { @@ -341,7 +398,7 @@ export default class LiveKitClient { this.audioTrack && !( this.liveKitAvClient.isVoiceAlways && - this.avMaster.canUserShareAudio(getGame().user?.id) + this.avMaster.canUserShareAudio(getGame().user?.id || "") ) ) { this.audioTrack.mute(); @@ -367,13 +424,13 @@ export default class LiveKitClient { // Check that mute/hidden/broadcast is toggled properly for the track if ( this.videoTrack && - !this.avMaster.canUserShareVideo(getGame().user?.id) + !this.avMaster.canUserShareVideo(getGame().user?.id || "") ) { this.videoTrack.mute(); } } - onAudioPlaybackStatusChanged(canPlayback) { + onAudioPlaybackStatusChanged(canPlayback: boolean) { if (!canPlayback) { log.warn("Cannot play audio/video, waiting for user interaction"); this.windowClickListener = @@ -413,14 +470,16 @@ export default class LiveKitClient { // TODO: Add some incremental back-off reconnect logic here } - onIsSpeakingChanged(userId, speaking) { - ui.webrtc?.setUserIsSpeaking(userId, speaking); + onIsSpeakingChanged(userId: string | undefined, speaking: boolean) { + if (userId) { + ui.webrtc?.setUserIsSpeaking(userId, speaking); + } } - onParticipantConnected(participant) { + onParticipantConnected(participant: RemoteParticipant) { log.debug("Participant connected:", participant); - const { fvttUserId } = JSON.parse(participant.metadata); + const { fvttUserId } = JSON.parse(participant.metadata || ""); const fvttUser = getGame().users?.get(fvttUserId); if (!fvttUser) { @@ -453,11 +512,11 @@ export default class LiveKitClient { this.render(); } - onParticipantDisconnected(participant) { + onParticipantDisconnected(participant: RemoteParticipant) { log.debug("Participant disconnected:", participant); // Remove the participant from the ID mapping - const { fvttUserId } = JSON.parse(participant.metadata); + const { fvttUserId } = JSON.parse(participant.metadata || ""); this.liveKitParticipants.delete(fvttUserId); // Call a debounced render @@ -477,8 +536,11 @@ export default class LiveKitClient { ); } - onRemoteTrackMuteChanged(publication, participant) { - const { fvttUserId } = JSON.parse(participant.metadata); + onRemoteTrackMuteChanged( + publication: RemoteTrackPublication, + participant: RemoteParticipant + ) { + const { fvttUserId } = JSON.parse(participant.metadata || ""); const userCameraView = ui.webrtc?.getUserCameraView(fvttUserId); if (userCameraView) { @@ -495,15 +557,19 @@ export default class LiveKitClient { } } - onRenderCameraViews(cameraviews, html) { + onRenderCameraViews(cameraviews: CameraViews, html: JQuery) { const cameraBox = html.find(`[data-user="${getGame().user?.id}"]`); const element = cameraBox.find('[data-action="configure"]'); this.addConnectionButtons(element); } - async onTrackSubscribed(track, publication, participant) { + async onTrackSubscribed( + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant + ) { log.debug("onTrackSubscribed:", track, publication, participant); - const { fvttUserId } = JSON.parse(participant.metadata); + const { fvttUserId } = JSON.parse(participant.metadata || ""); const videoElement = ui.webrtc?.getUserVideoElement(fvttUserId); if (!videoElement) { @@ -519,7 +585,9 @@ export default class LiveKitClient { if (publication.kind === Track.Kind.Audio) { // Get the audio element for the user const audioElement = this.getUserAudioElement(fvttUserId, videoElement); - await this.attachAudioTrack(fvttUserId, track, audioElement); + if (audioElement) { + await this.attachAudioTrack(fvttUserId, track, audioElement); + } } else if (publication.kind === Track.Kind.Video) { this.attachVideoTrack(track, videoElement); } else { @@ -527,16 +595,20 @@ export default class LiveKitClient { } } - async onTrackUnSubscribed(track, publication, participant) { + async onTrackUnSubscribed( + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant + ) { log.debug("onTrackUnSubscribed:", track, publication, participant); - await track.detach(); + track.detach(); } /** * Change volume control for a stream * @param {Event} event The originating change event from interaction with the range input */ - onVolumeChange(event) { + onVolumeChange(event: JQuery.ChangeEvent) { const input = event.currentTarget; const box = input.closest(".camera-view"); const volume = AudioHelper.inputToVolume(input.value); @@ -544,27 +616,32 @@ export default class LiveKitClient { } onWindowClick() { - log.info("User interaction; retrying A/V"); - window.removeEventListener("click", this.windowClickListener); - this.render(); + if (this.windowClickListener) { + window.removeEventListener("click", this.windowClickListener); + this.render(); + } } - getVideoParams() { + getVideoParams(): CreateVideoTrackOptions | false { // Configure whether the user can send video const videoSrc = this.settings.get("client", "videoSrc"); const canBroadcastVideo = this.avMaster.canUserBroadcastVideo( - getGame().user?.id + getGame().user?.id || "" ); - return videoSrc && videoSrc !== "disabled" && canBroadcastVideo + return typeof videoSrc === "string" && + videoSrc !== "disabled" && + canBroadcastVideo ? { deviceId: { ideal: videoSrc }, - width: { ideal: 320 }, - height: { ideal: 240 }, + resolution: { + width: { ideal: 320 }, + height: { ideal: 240 }, + }, } : false; } - setAudioEnabledState(enable) { + setAudioEnabledState(enable: boolean) { if (!this.audioTrack) { log.debug("setAudioEnabledState called but no audio track available"); return; @@ -579,7 +656,7 @@ export default class LiveKitClient { } } - setConnectionButtons(connected) { + setConnectionButtons(connected: boolean) { const userCameraView = ui.webrtc?.getUserCameraView( getGame().user?.id || "" ); @@ -600,7 +677,7 @@ export default class LiveKitClient { } setLocalParticipantCallbacks() { - this.liveKitRoom.localParticipant + this.liveKitRoom?.localParticipant .on( ParticipantEvent.IsSpeakingChanged, this.onIsSpeakingChanged.bind(this, getGame().user?.id) @@ -615,20 +692,22 @@ export default class LiveKitClient { setLocalTrackCallbacks() { // Set up local track callbacks - this.liveKitRoom.localParticipant.tracks.forEach((publication) => { + this.liveKitRoom?.localParticipant.tracks.forEach((publication) => { const { track } = publication; - track - .on(TrackEvent.Muted, (...args) => { - log.debug("Local TrackEvent Muted:", args); - }) - .on(TrackEvent.Unmuted, (...args) => { - log.debug("Local TrackEvent Unmuted:", args); - }); + if (track) { + track + .on(TrackEvent.Muted, (...args) => { + log.debug("Local TrackEvent Muted:", args); + }) + .on(TrackEvent.Unmuted, (...args) => { + log.debug("Local TrackEvent Unmuted:", args); + }); + } }); } - setRemoteParticipantCallbacks(participant) { - const { fvttUserId } = JSON.parse(participant.metadata); + setRemoteParticipantCallbacks(participant: RemoteParticipant) { + const { fvttUserId } = JSON.parse(participant.metadata || ""); participant .on( @@ -641,6 +720,13 @@ export default class LiveKitClient { } setRoomCallbacks() { + if (!this.liveKitRoom) { + log.warn( + "Attempted to set up room callbacks before the LiveKit room is ready" + ); + return; + } + // Set up event callbacks this.liveKitRoom .on( diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 33e0b83..cbfea80 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,21 +1,60 @@ import { LANG_NAME, MODULE_NAME } from "./constants"; import * as log from "./logging"; +/** + * Typescript Interfaces + */ + +// AV Device Info object +interface DeviceInfo { + deviceId: string; + groupId: string; + label: string; + kind: "audio" | "video"; +} + +// Module Settings object +interface ModuleSettingsObject { + name: string; + scope: string; + config: boolean; + default: boolean; + type: BooleanConstructor | undefined; + range?: T extends number + ? { + max: number; + min: number; + step: number; + } + : undefined; + onChange: (value: T) => void; +} + +/** + * Helper methods + */ + /** * Issue a delayed (debounced) reload to the whole window. * Allows settings to get saved before reload */ -export const delayReload = debounce(() => window.location.reload(), 100); +export const delayReload: () => void = debounce( + () => window.location.reload(), + 100 +); -export const sleep = (delay) => +export const sleep: (delay: number) => Promise = (delay: number) => new Promise((resolve) => setTimeout(resolve, delay)); /** * Transform the device info array from enumerated devices into an object with {id: label} keys * @param {Array} list The list of devices */ -export function deviceInfoToObject(list, kind) { - const obj = {}; +export function deviceInfoToObject( + list: DeviceInfo[], + kind: "audio" | "video" +): Record { + const obj: Record = {}; for (let i = 0; i < list.length; i += 1) { if (list[i].kind === kind) { obj[list[i].deviceId] = @@ -44,7 +83,7 @@ export function getGame(): Game { * Dynamically load additional script files, returning when loaded * @param scriptSrc The location of the script file */ -export async function loadScript(scriptSrc) { +export async function loadScript(scriptSrc: string): Promise { log.debug("Loading script:", scriptSrc); return new Promise((resolve, reject) => { // Skip loading script if it is already loaded @@ -70,7 +109,7 @@ export async function loadScript(scriptSrc) { }); } -export function registerModuleSetting(settingsObject) { +export function registerModuleSetting(settingsObject: ModuleSettingsObject) { getGame().settings.register(MODULE_NAME, settingsObject.name, { name: `${LANG_NAME}.${settingsObject.name}`, hint: `${LANG_NAME}.${settingsObject.name}Hint`, diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 0c3f07f..9ac631e 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -1,3 +1,4 @@ +import LiveKitAVClient from "../LiveKitAVClient"; import { MODULE_NAME } from "./constants"; import { getGame } from "./helpers"; import registerModuleSettings from "./registerModuleSettings"; @@ -8,7 +9,6 @@ import registerModuleSettings from "./registerModuleSettings"; Hooks.on("init", () => { // Override voice modes - // @ts-expect-error - TODO: Fix after this is merged: https://github.com/League-of-Foundry-Developers/foundry-vtt-types/pull/1159 AVSettings.VOICE_MODES = { ALWAYS: "always", PTT: "ptt", @@ -25,14 +25,15 @@ Hooks.on(`${MODULE_NAME}DebugSet`, (value: boolean) => { }); Hooks.on("ready", () => { - Hooks.on("renderCameraViews", (cameraViews, cameraViewsElement) => { - // @ts-expect-error - TODO: Fix after this is merged: https://github.com/League-of-Foundry-Developers/foundry-vtt-types/pull/1159 - if (getGame().webrtc?.client?._liveKitClient) { - // @ts-expect-error - TODO: Fix after this is merged: https://github.com/League-of-Foundry-Developers/foundry-vtt-types/pull/1159 - getGame()?.webrtc?.client._liveKitClient.onRenderCameraViews( - cameraViews, - cameraViewsElement - ); + Hooks.on( + "renderCameraViews", + (cameraViews: CameraViews, cameraViewsElement: JQuery) => { + if (getGame().webrtc?.client?._liveKitClient) { + getGame()?.webrtc?.client._liveKitClient.onRenderCameraViews( + cameraViews, + cameraViewsElement + ); + } } - }); + ); }); diff --git a/src/utils/logging.ts b/src/utils/logging.ts index ab08c94..f544797 100644 --- a/src/utils/logging.ts +++ b/src/utils/logging.ts @@ -10,7 +10,10 @@ import { LOG_PREFIX, MODULE_NAME } from "./constants"; * @param {...*} args Arguments to console.debug */ // eslint-disable-next-line import/no-mutable-exports -export let debug = console.debug.bind(console, LOG_PREFIX); +export let debug: (...args: any[]) => void = console.debug.bind( + console, + LOG_PREFIX +); /** * Display info messages on the console if debugging is enabled @@ -18,14 +21,20 @@ export let debug = console.debug.bind(console, LOG_PREFIX); * @param {...*} args Arguments to console.info */ // eslint-disable-next-line import/no-mutable-exports -export let info = console.info.bind(console, LOG_PREFIX); +export let info: (...args: any[]) => void = console.info.bind( + console, + LOG_PREFIX +); /** * Display warning messages on the console * @param {...*} args Arguments to console.warn */ -export const warn = console.warn.bind(console, LOG_PREFIX); +export const warn: (...args: any[]) => void = console.warn.bind( + console, + LOG_PREFIX +); // export function warn(...args) { // console.warn(LOG_PREFIX, ...args); @@ -35,10 +44,13 @@ export const warn = console.warn.bind(console, LOG_PREFIX); * Display error messages on the console * @param {...*} args Arguments to console.error */ -export const error = console.error.bind(console, LOG_PREFIX); +export const error: (...args: any[]) => void = console.error.bind( + console, + LOG_PREFIX +); // Enable debug & info logs if debugging is enabled -export function setDebug(value: any) { +export function setDebug(value: boolean): void { if (value) { debug = console.debug.bind(console, LOG_PREFIX); info = console.info.bind(console, LOG_PREFIX); diff --git a/src/utils/registerModuleSettings.ts b/src/utils/registerModuleSettings.ts index eb2e8c7..0f5d466 100644 --- a/src/utils/registerModuleSettings.ts +++ b/src/utils/registerModuleSettings.ts @@ -3,7 +3,7 @@ import * as helpers from "./helpers"; import { getGame } from "./helpers"; import * as log from "./logging"; -export default function registerModuleSettings() { +export default function registerModuleSettings(): void { helpers.registerModuleSetting({ name: "resetRoom", scope: "world", @@ -11,7 +11,7 @@ export default function registerModuleSettings() { default: false, type: Boolean, onChange: (value) => { - if (value && getGame().user?.isGM) { + if (value === true && getGame().user?.isGM) { log.warn("Resetting meeting room ID"); getGame().settings.set(MODULE_NAME, "resetRoom", false); getGame().webrtc?.client.settings.set( @@ -30,17 +30,17 @@ export default function registerModuleSettings() { config: true, default: false, type: Boolean, - onChange: (value) => log.setDebug(value), + onChange: () => helpers.delayReload(), }); // Set the initial debug level - log.setDebug(getGame().settings.get(MODULE_NAME, "debug")); + log.setDebug(getGame().settings.get(MODULE_NAME, "debug") === true); // Register livekit trace logging setting helpers.registerModuleSetting({ name: "livekitTrace", scope: "world", - config: getGame().settings.get(MODULE_NAME, "debug"), + config: getGame().settings.get(MODULE_NAME, "debug") === true, default: false, type: Boolean, onChange: () => helpers.delayReload(), diff --git a/tsconfig.json b/tsconfig.json index 9bfbedb..bfcb2d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,13 @@ { "compilerOptions": { - // "outDir": "./built", - // "allowJs": true, - "target": "ES6", + "moduleResolution": "Node", "sourceMap": true, - "types": ["@league-of-foundry-developers/foundry-vtt-types"], - "moduleResolution": "node", - "strictNullChecks": true - // "noImplicitAny": true, + "strict": true, + "target": "ES2020", + "types": [ + "@league-of-foundry-developers/foundry-vtt-types", + "./types/avclient-livekit" + ] }, "include": ["./src/**/*"], "exclude": ["node_modules"] diff --git a/types/avclient-livekit.d.ts b/types/avclient-livekit.d.ts new file mode 100644 index 0000000..fb6b617 --- /dev/null +++ b/types/avclient-livekit.d.ts @@ -0,0 +1,39 @@ +import LiveKitAVClient from "../src/LiveKitAVClient"; + +/** + * Interfaces + */ + +// Custom voice modes to remove ACTIVITY +interface LiveKitVoiceModes { + ALWAYS: "always"; + PTT: "ptt"; +} + +export interface ConnectionSettings { + type: string; + url: string; + room: string; + username: string; + password: string; +} + +/** + * Global settings + */ + +// Set AVSettings.VoiceModes to custom type +declare global { + namespace AVSettings { + interface Overrides { + VoiceModes: LiveKitVoiceModes; + } + } +} + +// Set game.webrtc.client to LiveKitAVClient +declare global { + interface WebRTCConfig { + clientClass: typeof LiveKitAVClient; + } +} diff --git a/yarn.lock b/yarn.lock index 1d72722..5722408 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,10 +7,10 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.3.tgz#90420f9f9c6d3987f176a19a7d8e764271a2f55d" integrity sha512-Fxt+AfXgjMoin2maPIYzFZnQjAXjAL0PHscM5pRTtatFqB+vZxAM9tLp2Optnuw3QOQC40jTNeGYFOMvyf7v9g== -"@league-of-foundry-developers/foundry-vtt-types@^0.8.9-0": - version "0.8.9-0" - resolved "https://registry.yarnpkg.com/@league-of-foundry-developers/foundry-vtt-types/-/foundry-vtt-types-0.8.9-0.tgz#1634a0827d157d34a9e021ab0034b728e763163b" - integrity sha512-/0Y2AqqfsVosYuZLkwE516z2JV5QmE7JqbqHO4zG4u/GY/TeorZqNnew0XqULXIDMb1bjzhPcFoj8cxlCKuegA== +"@league-of-foundry-developers/foundry-vtt-types@^0.8.9-2": + version "0.8.9-2" + resolved "https://registry.yarnpkg.com/@league-of-foundry-developers/foundry-vtt-types/-/foundry-vtt-types-0.8.9-2.tgz#fd0e0da8c27625be19da54979c186cf25b5de33c" + integrity sha512-IPPaqPoVbrAqLsYUUFLWllIOpr04Ru36wsfpnDNNWzW9JM9T6s0WyZ2P1DFrKxfojQFlAcmj7PkwY38dVK16Uw== dependencies: "@types/jquery" "~3.5.6" "@types/simple-peer" "~9.11.1"