Skip to content

Commit

Permalink
breakout: Add breakout room support
Browse files Browse the repository at this point in the history
  • Loading branch information
bekriebel committed Oct 26, 2021
1 parent 226292d commit 8223a15
Show file tree
Hide file tree
Showing 10 changed files with 360 additions and 11 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ Install & enable the module then configure your LiveKit instance as the signalli
**Signalling Server Username:** `ABCDEFGHIJ12345` \<Your LiveKit API Key>
**Signalling Server Password:** `****************` \<Your LiveKit Secret Key\>

### **Breakout Rooms**

A GM can now split the party!

To start a breakout room, right-click on the player you would like to break out in the player list and select `Start A/V breakout`. You will join a different A/V session with that user. You can now right-click on other users and pull them into the breakout room, or start yet another breakout room with another user.

![start breakout example](https://raw.githubusercontent.com/bekriebel/fvtt-module-avclient-livekit/main/images/example_start-breakout.png)

Though the GM will always join the breakout room on creation, they can leave the breakout room themselves by right-clicking on their own username and selecting `Leave A/V Breakout`. Users can also leave a breakout at any time by right-clicking on their own name, and the GM can end all breakout rooms by selecting `End all A/V breakouts`.

![start breakout example](https://raw.githubusercontent.com/bekriebel/fvtt-module-avclient-livekit/main/images/example_end-breakout.png)

## Running your own LiveKit server

There are several examples available for launching your own livekit server:
Expand Down
Binary file added images/example_end-breakout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/example_start-breakout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,13 @@
"LIVEKITAVCLIENT.simulcast": "Simulcast enabled",
"LIVEKITAVCLIENT.simulcastHint": "Use simulcast video tracks with LiveKit. This may help low-resource clients and the expense of some video tracks not being sent or received by them",
"LIVEKITAVCLIENT.serverTypeFVTT": "LiveKit AVClient: Foundry VTT is not supported as a signalling server. Please set a custom signalling server under Audio/Video Configuration",
"LIVEKITAVCLIENT.startAVBreakout": "Start A/V breakout",
"LIVEKITAVCLIENT.joinAVBreakout": "Join A/V breakout",
"LIVEKITAVCLIENT.joiningAVBreakout": "Joining A/V breakout room",
"LIVEKITAVCLIENT.pullToAVBreakout": "Pull to A/V breakout",
"LIVEKITAVCLIENT.leaveAVBreakout": "Leave A/V breakout",
"LIVEKITAVCLIENT.leavingAVBreakout": "Leaving A/V breakout room",
"LIVEKITAVCLIENT.removeFromAVBreakout": "Remove from A/V breakout",
"LIVEKITAVCLIENT.endAllAVBreakouts": "End all A/V breakouts",
"WEBRTC.FVTTSignalingServer": "Foundry VTT (Not supported)"
}
8 changes: 7 additions & 1 deletion lang/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@
"I18N.MAINTAINERS": "@Viriato139ac#0342",

"LIVEKITAVCLIENT.debug": "Habilitar registro de errores",
"LIVEKITAVCLIENT.debugHint": "Habilita CONFIG.debug.av y CONFIG.debug.avclient para crear un registro de errores detallado"
"LIVEKITAVCLIENT.debugHint": "Habilita CONFIG.debug.av y CONFIG.debug.avclient para crear un registro de errores detallado",
"LIVEKITAVCLIENT.startAVBreakout": "Iniciar A/V privada",
"LIVEKITAVCLIENT.joinAVBreakout": "Unirse a A/V privada",
"LIVEKITAVCLIENT.pullToAVBreakout": "Traer a A/V privada",
"LIVEKITAVCLIENT.leaveAVBreakout": "Salir de A/V privada",
"LIVEKITAVCLIENT.removeFromAVBreakout": "Quitar de A/V privada",
"LIVEKITAVCLIENT.endAllAVBreakouts": "Finalizar A/V privadas"
}
8 changes: 6 additions & 2 deletions src/LiveKitAVClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@ export default class LiveKitAVClient extends AVClient {
this.settings.set("world", "server.room", randomID(32));
}

// Set the room name
this.room = connectionSettings.room;
// Set the room name, using breakout room if set
if (this._liveKitClient.breakoutRoom) {
this.room = this._liveKitClient.breakoutRoom;
} else {
this.room = connectionSettings.room;
}
log.debug("Meeting room name:", this.room);

// Set the user's metadata
Expand Down
222 changes: 222 additions & 0 deletions src/LiveKitBreakout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import LiveKitClient from "./LiveKitClient";
import { LANG_NAME, MODULE_NAME } from "./utils/constants";
import { getGame } from "./utils/helpers";

import * as log from "./utils/logging";

export function addContextOptions(
contextOptions: ContextMenuEntry[],
liveKitClient: LiveKitClient
): void {
// Add breakout options to the playerlist context menus
contextOptions.push(
{
name: getGame().i18n.localize(`${LANG_NAME}.startAVBreakout`),
icon: '<i class="fa fa-comment"></i>',
condition: (players) => {
const userId: string = players[0].dataset.userId || "";
const liveKitBreakoutRoom = liveKitClient.settings.get(
"client",
`users.${userId}.liveKitBreakoutRoom`
);
return (
getGame().user?.isGM === true &&
!liveKitBreakoutRoom &&
userId !== getGame().user?.id &&
!liveKitClient.isUserExternal(userId)
);
},
callback: (players) => {
const breakoutRoom = randomID(32);
startBreakout(players.data("user-id"), breakoutRoom, liveKitClient);
breakout(breakoutRoom, liveKitClient);
},
},
{
name: getGame().i18n.localize(`${LANG_NAME}.joinAVBreakout`),
icon: '<i class="fas fa-comment-dots"></i>',
condition: (players) => {
const userId: string = players[0].dataset.userId || "";
const liveKitBreakoutRoom = liveKitClient.settings.get(
"client",
`users.${userId}.liveKitBreakoutRoom`
);
return (
getGame().user?.isGM === true &&
!!liveKitBreakoutRoom &&
liveKitClient.breakoutRoom !== liveKitBreakoutRoom &&
userId !== getGame().user?.id
);
},
callback: (players) => {
const userId: string = players[0].dataset.userId || "";
const liveKitBreakoutRoom = liveKitClient.settings.get(
"client",
`users.${userId}.liveKitBreakoutRoom`
);
if (typeof liveKitBreakoutRoom === "string") {
breakout(liveKitBreakoutRoom, liveKitClient);
}
},
},
{
name: getGame().i18n.localize(`${LANG_NAME}.pullToAVBreakout`),
icon: '<i class="fas fa-comments"></i>',
condition: (players) => {
const userId: string = players[0].dataset.userId || "";
const liveKitBreakoutRoom = liveKitClient.settings.get(
"client",
`users.${userId}.liveKitBreakoutRoom`
);
return (
getGame().user?.isGM === true &&
!!liveKitClient.breakoutRoom &&
liveKitBreakoutRoom !== liveKitClient.breakoutRoom &&
userId !== getGame().user?.id &&
!liveKitClient.isUserExternal(userId)
);
},
callback: (players) => {
startBreakout(
players.data("user-id"),
liveKitClient.breakoutRoom,
liveKitClient
);
},
},
{
name: getGame().i18n.localize(`${LANG_NAME}.leaveAVBreakout`),
icon: '<i class="fas fa-comment-slash"></i>',
condition: (players) => {
const userId: string = players[0].dataset.userId || "";
return userId === getGame().user?.id && !!liveKitClient.breakoutRoom;
},
callback: () => {
breakout(null, liveKitClient);
},
},
{
name: getGame().i18n.localize(`${LANG_NAME}.removeFromAVBreakout`),
icon: '<i class="fas fa-comment-slash"></i>',
condition: (players) => {
const userId: string = players[0].dataset.userId || "";
const liveKitBreakoutRoom = liveKitClient.settings.get(
"client",
`users.${userId}.liveKitBreakoutRoom`
);
return (
getGame().user?.isGM === true &&
!!liveKitBreakoutRoom &&
userId !== getGame().user?.id
);
},
callback: (players) => {
if (typeof players[0].dataset.userId === "string") {
endUserBreakout(players[0].dataset.userId, liveKitClient);
}
},
},
{
name: getGame().i18n.localize(`${LANG_NAME}.endAllAVBreakouts`),
icon: '<i class="fas fa-ban"></i>',
condition: (players) => {
const userId: string = players[0].dataset.userId || "";
return getGame().user?.isGM === true && userId === getGame().user?.id;
},
callback: () => {
endAllBreakouts(liveKitClient);
},
}
);
}

export function breakout(
breakoutRoom: string | null,
liveKitClient: LiveKitClient
): void {
if (breakoutRoom === liveKitClient.breakoutRoom) {
// Already in this room, skip
return;
}

if (!breakoutRoom) {
ui.notifications?.info(
`${getGame().i18n.localize(`${LANG_NAME}.leavingAVBreakout`)}`
);
} else {
ui.notifications?.info(
`${getGame().i18n.localize(`${LANG_NAME}.joiningAVBreakout`)}`
);
}

log.debug("Switching to breakout room:", breakoutRoom);
// log.info("Switching to breakout room:", breakoutRoom);
// log.warn("Switching to breakout room:", breakoutRoom);
liveKitClient.breakoutRoom = breakoutRoom;
getGame().webrtc?.connect();
}

function startBreakout(
userId: string,
breakoutRoom: string | null,
liveKitClient: LiveKitClient
): void {
if (!getGame().user?.isGM) {
log.warn("Only a GM can start a breakout conference room");
return;
}

liveKitClient.settings.set(
"client",
`users.${userId}.liveKitBreakoutRoom`,
breakoutRoom
);
getGame().socket?.emit(
`module.${MODULE_NAME}`,
{
action: "breakout",
userId,
breakoutRoom,
},
{ recipients: [userId] }
);
}

function endUserBreakout(userId: string, liveKitClient: LiveKitClient) {
if (!getGame().user?.isGM) {
log.warn("Only a GM can end a user's breakout conference");
return;
}

liveKitClient.settings.set(
"client",
`users.${userId}.liveKitBreakoutRoom`,
""
);
getGame().socket?.emit(
`module.${MODULE_NAME}`,
{
action: "breakout",
userId,
breakoutRoom: null,
},
{ recipients: [userId] }
);
}

function endAllBreakouts(liveKitClient: LiveKitClient): void {
if (!getGame().user?.isGM) {
log.warn("Only a GM can end all breakout conference rooms");
return;
}

getGame().socket?.emit(`module.${MODULE_NAME}`, {
action: "breakout",
userId: null,
breakoutRoom: null,
});

if (liveKitClient.breakoutRoom) {
breakout(null, liveKitClient);
}
}
61 changes: 61 additions & 0 deletions src/LiveKitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { LANG_NAME, MODULE_NAME } from "./utils/constants";
import * as log from "./utils/logging";
import { getGame } from "./utils/helpers";
import LiveKitAVClient from "./LiveKitAVClient";
import { SocketMessage } from "../types/avclient-livekit";
import { addContextOptions, breakout } from "./LiveKitBreakout";

export enum ConnectionState {
Disconnected = "disconnected",
Expand All @@ -46,6 +48,7 @@ export default class LiveKitClient {

audioBroadcastEnabled = false;
audioTrack: LocalAudioTrack | null = null;
breakoutRoom: string | null = null;
connectionState: ConnectionState = ConnectionState.Disconnected;
initState: InitState = InitState.Uninitialized;
liveKitParticipants: Map<string, Participant> = new Map();
Expand Down Expand Up @@ -466,6 +469,11 @@ export default class LiveKitClient {
}
}

isUserExternal(userId: string): boolean {
// TODO: Implement this when adding external user support
return false;
}

onAudioPlaybackStatusChanged(canPlayback: boolean): void {
if (!canPlayback) {
log.warn("Cannot play audio/video, waiting for user interaction");
Expand Down Expand Up @@ -508,6 +516,18 @@ export default class LiveKitClient {
// TODO: Add some incremental back-off reconnect logic here
}

onGetUserContextOptions(
playersElement: JQuery<HTMLElement>,
contextOptions: ContextMenuEntry[]
): void {
// Don't add breakout options if AV is disabled
if (this.settings.get("world", "mode") === AVSettings.AV_MODES.DISABLED) {
return;
}

addContextOptions(contextOptions, this);
}

onIsSpeakingChanged(userId: string | undefined, speaking: boolean): void {
if (userId) {
ui.webrtc?.setUserIsSpeaking(userId, speaking);
Expand Down Expand Up @@ -543,6 +563,15 @@ export default class LiveKitClient {
// Save the participant to the ID mapping
this.liveKitParticipants.set(fvttUserId, participant);

// Clear breakout room cache if user is joining the main conference
if (!this.breakoutRoom) {
this.settings.set(
"client",
`users.${fvttUserId}.liveKitBreakoutRoom`,
""
);
}

// Set up remote participant callbacks
this.setRemoteParticipantCallbacks(participant);

Expand All @@ -561,6 +590,19 @@ export default class LiveKitClient {
const { fvttUserId } = JSON.parse(participant.metadata || "");
this.liveKitParticipants.delete(fvttUserId);

// Clear breakout room cache if user is leaving a breakout room
if (
this.settings.get("client", `users.${fvttUserId}.liveKitBreakoutRoom`) ===
this.liveKitAvClient.room &&
this.liveKitAvClient.room === this.breakoutRoom
) {
this.settings.set(
"client",
`users.${fvttUserId}.liveKitBreakoutRoom`,
""
);
}

// Call a debounced render
this.render();
}
Expand All @@ -578,6 +620,25 @@ export default class LiveKitClient {
);
}

onSocketEvent(message: SocketMessage, userId: string): void {
log.debug("Socket event:", message, "from:", userId);
switch (message.action) {
case "breakout":
// Allow only GMs to issue breakout requests. Ignore requests that aren't for us.
if (
getGame().users?.get(userId)?.isGM &&
(typeof message.breakoutRoom === "string" ||
message.breakoutRoom === null) &&
(!message.userId || message.userId === getGame().user?.id)
) {
breakout(message.breakoutRoom, this);
}
break;
default:
log.warn("Unknown socket event:", message);
}
}

onTrackMuteChanged(
publication: TrackPublication,
participant: Participant
Expand Down
Loading

0 comments on commit 8223a15

Please sign in to comment.