BareRTC/src/lib/WebRTC.js
Noah Petherbridge ca2bbba38c Show when booted admins reopen your camera
* As chat admins require the ability to moderate the chat room, they
  were allowed to reopen user webcams even after they had been booted.
  But a conflict with front-end logic meant the user wouldn't be
  notified when the booted admin reopened their camera.
* This bug has been fixed and front-end messaging updated to make it
  clear what the difference is when booting a regular vs. admin user.
2025-07-16 21:08:21 -07:00

2035 lines
94 KiB
JavaScript

/*
WebRTC and Webcam related functionality for BareRTC.
Most methods here are called by App.vue with some funcs reused by sub-components, e.g.
for showing the webcam video buttons on the Who List and other places.
*/
import interact from 'interactjs';
import hark from 'hark';
import VideoFlag from './VideoFlag.js';
import LocalStorage from './LocalStorage.js';
// WebRTC configuration.
const configuration = {
iceServers: TURN.URLs.map(val => {
let row = {
urls: val,
};
if (val.indexOf('turn:') === 0) {
row.username = TURN.Username;
row.credential = TURN.Credential;
}
return row;
})
};
// Webcam sizes: ideal is 640x480 as this is the most friendly for end users, e.g. if everyone
// broadcasted at 720p users on weaker hardware would run into problems sooner.
const WebcamWidth = 640,
WebcamHeight = 480;
// For webcams that can not transmit at 640x480 (e.g. ultra widescreen), allow them to choose
// the nearest resolution but no more than 720p.
const WebcamMaxWidth = 1280,
WebcamMaxHeight = 720;
const ReactNudgeNsfwMessageID = -451;
// An API surface layer of functions.
class WebRTCController {
// The caller configures:
// - nsfw (bool): the BareRTC PermitNSFW setting, which controls some status options.
// - isAdmin (func): return a boolean if the current user is operator.
// - currentStatus (func): return the name of the user's current status.
constructor() {
}
// Vue mixin for the main App.vue
getMixin() {
return {
data: this.getData(),
watch: this.getWatches(),
computed: this.getComputed(),
methods: this.getMethods(),
}
}
// Vue.js data mixin for App.vue to store all our webcam-related state.
getData() {
return {
// The user's local webcam and settings.
webcam: {
busy: false,
active: false,
elem: null, // <video id="localVideo"> element
stream: null, // MediaStream object
muted: false, // our outgoing mic is muted, not by default
autoMute: false, // mute our mic automatically when going live (user option)
nsfw: false, // user has flagged their camera to be NSFW
mutual: false, // user wants viewers to share their own videos
mutualOpen: false, // user wants to open video mutually
nonExplicit: false, // user prefers not to see explicit cameras
vipOnly: false, // only show camera to fellow VIP users
noWatermark: false, // user opts out of their own QR code watermark
rememberExpresslyClosed: true, // remember cams we expressly closed
autoMuteWebcams: false, // auto-mute other cameras' audio channels
// My watermark image for screen recording protection.
// Set after login in setWatermark.
watermark: null,
// Who all is watching me? map of users.
watching: {},
// Scaling setting for the videos drawer, so the user can
// embiggen the webcam sizes so a suitable size.
videoScale: "",
videoScaleOptions: [
["", "Default size"],
["x1", "50% larger videos"],
["x2", "2x larger videos"],
["x3", "3x larger videos"],
["x4", "4x larger videos (not recommended)"],
],
// Available cameras and microphones for the Settings modal.
gettingDevices: false, // busy indicator for refreshing devices
videoDevices: [],
videoDeviceID: null,
audioDevices: [],
audioDeviceID: null,
// Advanced: automatically share your webcam when the page loads.
autoshare: false,
// After we get a device selected, remember it (by name) so that we
// might hopefully re-select it by default IF we are able to enumerate
// devices before they go on camera the first time.
preferredDeviceNames: {
video: null,
audio: null,
},
// Detect dark video streams.
darkVideo: {
canvas: null, // <canvas> element to screenshot video into
ctx: null, // Canvas context2d
interval: null, // interval loop
lastImage: null, // data: uri of last screenshot taken
lastAverage: [], // last average RGB color
lastAverageColor: "rgba(255, 0, 255, 1)",
tooDarkFrames: 0, // frame counter for dark videos
tooDarkFramesLimit: 4, // frames in a row of too dark before cut
// Configuration thresholds: how dark is too dark? (0-255)
// NOTE: 0=disable the feature.
threshold: 10,
},
},
// WebRTC sessions with other users.
WebRTC: {
// Streams per username.
streams: {},
muted: {}, // muted bool per username
booted: {}, // booted bool per username
invited: {}, // usernames we had invited
poppedOut: {}, // popped-out video per username
speaking: {}, // speaking boolean per username
// RTCPeerConnections per username.
pc: {},
// Video stream freeze detection.
frozenStreamInterval: {}, // map usernames to intervals
frozenStreamDetected: {}, // map usernames to bools
// Debounce connection attempts since now every click = try to connect.
debounceOpens: {}, // map usernames to bools
// Timeouts for open camera attempts. e.g.: when you click to view
// a camera, the icon changes to a spinner for a few seconds to see
// whether the video goes on to open.
openTimeouts: {}, // map usernames to timeouts
// Map of usernames whose cameras were expressly closed by us.
// Example: we have mutualOpen enabled, someone opens our cam
// so we open theirs back, and then we expressly 'X' out and
// close their camera. If they close and re-open ours, we don't
// want to auto-open their cam back because we had previously
// closed out of it manually.
//
// Usernames are added when we 'X' out of their video,
// and usernames are removed when we expressly re-open their
// video (e.g., by clicking their Who List video button).
expresslyClosed: {}, // bool per username
// Rate limit pre-emptive boots: to avoid a user coming thru the entire Who List
// and kicking everyone off their cam when they aren't even watching yet.
preemptBootRateLimit: {
counter: 0, // how many people have they booted so far
cooldownAt: null, // Date for cooldown once they reach the threshold
// Configuration settings:
maxFreeBoots: 10, // first 10 are free
cooldownTTL: 60, // then must wait this number of seconds before each boot
}
},
/* For internal use only */
// VideoFlag: VideoFlag,
// LocalStorage: LocalStorage,
};
}
// Watchers to be imported for App.vue
getWatches() {
return {
"webcam.videoScale": function () {
document.querySelectorAll(".video-feeds > .feed").forEach(node => {
node.style.width = null;
node.style.height = null;
});
LocalStorage.set('videoScale', this.webcam.videoScale);
},
// Webcam preferences that the user can edit while live.
"webcam.nsfw": function () {
this.webcam.wasServerNSFW = false;
LocalStorage.set('videoExplicit', this.webcam.nsfw);
if (this.webcam.active) {
this.sendMe();
}
// Respect users who have the NonExplicit cam setting checked, and close
// their cameras when you toggle to red.
this.unWatchNonExplicitVideo();
},
"webcam.mutual": function () {
LocalStorage.set('videoMutual', this.webcam.mutual);
if (this.webcam.active) {
this.sendMe();
}
},
"webcam.mutualOpen": function () {
LocalStorage.set('videoMutualOpen', this.webcam.mutualOpen);
if (this.webcam.active) {
this.sendMe();
}
},
"webcam.nonExplicit": function () {
LocalStorage.set('videoNonExplicit', this.webcam.nonExplicit);
// Turn off NSFW if this is on.
if (this.webcam.nonExplicit && this.webcam.nsfw) {
// Note: this toggle will also sendMe().
this.webcam.nsfw = false;
} else if (this.webcam.active) {
this.sendMe();
}
},
"webcam.vipOnly": function () {
LocalStorage.set('videoVipOnly', this.webcam.vipOnly);
if (this.webcam.active) {
this.sendMe();
}
// If we have toggled this while already connected to people:
// Hang up on any that have a mutual viewership requirement, if
// they can not see our VIP-only camera.
for (let username of Object.keys(this.WebRTC.pc)) {
if (this.whoMap[username] != undefined && this.isVideoNotAllowed(this.whoMap[username])) {
this.closeVideo(username);
}
}
},
"webcam.noWatermark": function () {
LocalStorage.set('videoNoWatermark', this.webcam.noWatermark);
if (this.webcam.active) {
this.sendMe();
}
},
"webcam.rememberExpresslyClosed": function () {
LocalStorage.set('rememberExpresslyClosed', this.webcam.rememberExpresslyClosed);
},
"webcam.autoMuteWebcams": function () {
LocalStorage.set('autoMuteWebcams', this.webcam.autoMuteWebcams);
},
"webcam.autoshare": function () {
LocalStorage.set('videoAutoShare', this.webcam.autoshare);
},
};
}
// Vue.js mixin for the App.vue
getComputed() {
return {
myVideoFlag() {
// Compute the current user's video status flags.
let status = 0;
if (!this.webcam.active) return 0; // unset all flags if not active now
if (this.webcam.active) status |= this.VideoFlag.Active;
if (this.webcam.muted) status |= this.VideoFlag.Muted;
if (this.webcam.nsfw && this.config.permitNSFW) status |= this.VideoFlag.NSFW;
if (this.webcam.mutual) status |= this.VideoFlag.MutualRequired;
if (this.webcam.mutualOpen) status |= this.VideoFlag.MutualOpen;
if (this.webcam.nonExplicit) status |= this.VideoFlag.NonExplicit;
if (this.webcam.vipOnly && this.isVIP) status |= this.VideoFlag.VipOnly;
if (this.webcam.noWatermark) status |= this.VideoFlag.NoWatermark;
return status;
},
anyVideosOpen() {
// Return if any videos are open.
return this.webcam.active || this.numVideosOpen > 0;
},
numVideosOpen() {
// Return the count of other peoples videos we have open.
return Object.keys(this.WebRTC.streams).length;
},
sortedWatchingList() {
let result = [];
for (let username of Object.keys(this.webcam.watching)) {
let user = this.getUser(username);
result.push(user);
}
switch (this.whoSort) {
case "broadcasting":
result.sort((a, b) => {
return (b.video & this.VideoFlag.Active) - (a.video & this.VideoFlag.Active);
});
break;
case "nsfw":
result.sort((a, b) => {
let left = (a.video & (this.VideoFlag.Active | this.VideoFlag.NSFW)),
right = (b.video & (this.VideoFlag.Active | this.VideoFlag.NSFW));
return right - left;
});
break;
case "blue":
result.sort((a, b) => {
let left = (a.video & this.VideoFlag.Active) ? (a.video & this.VideoFlag.NSFW ? 1 : 2) : 0,
right = (b.video & this.VideoFlag.Active) ? (b.video & this.VideoFlag.NSFW ? 1 : 2) : 0;
return right - left;
});
break;
case "status":
result.sort((a, b) => {
if (a.status === b.status) return 0;
return b.status < a.status ? -1 : 1;
});
break;
case "op":
result.sort((a, b) => {
return b.op - a.op;
});
break;
case "emoji":
result.sort((a, b) => {
if (a.emoji === b.emoji) return 0;
return a.emoji < b.emoji ? -1 : 1;
})
break;
case "login":
result.sort((a, b) => {
return b.loginAt - a.loginAt;
});
break;
case "gender":
result.sort((a, b) => {
if (a.gender === b.gender) return 0;
let left = a.gender || 'z',
right = b.gender || 'z';
return left < right ? -1 : 1;
})
break;
case "z-a":
result = result.reverse();
}
// Default ordering from ChatServer = a-z
return result;
},
};
}
// Vue.js mixin for the App.vue
getMethods() {
return {
// Start WebRTC with the other username.
startWebRTC(username, isOfferer) {
// this.ChatClient(`Begin WebRTC with ${username}.`);
let pc = new RTCPeerConnection(configuration);
// Store uni-directional PeerConnections:
// - If we are reading video from the other (offerer)
// - If we are sending video to the other (answerer)
if (this.WebRTC.pc[username] == undefined) {
this.WebRTC.pc[username] = {};
}
if (isOfferer) {
this.WebRTC.pc[username].offerer = pc;
} else {
this.WebRTC.pc[username].answerer = pc;
}
this.DebugChannel(`[WebRTC] Starting WebRTC with: ${username} (I am the: ${isOfferer ? 'offerer' : 'answerer'})`);
// Keep a pointer to the current channel being established (for candidate/SDP).
this.WebRTC.pc[username].connecting = pc;
// 'onicecandidate' notifies us whenever an ICE agent needs to deliver a
// message to the other peer through the signaling server.
pc.onicecandidate = event => {
if (event.candidate) {
this.client.send({
action: "candidate",
username: username,
candidate: JSON.stringify(event.candidate),
});
}
};
// If the user is offerer let the 'negotiationneeded' event create the offer.
if (isOfferer) {
pc.onnegotiationneeded = () => {
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient);
};
}
// When a remote stream arrives.
pc.ontrack = event => {
// If we were not expecting to receive this video (e.g. somebody is requesting our
// cam and sending their video on the offer, but we don't want to auto-open their
// video, so don't use it)
if (!isOfferer && !this.webcam.mutualOpen) {
this.DebugChannel(`[WebRTC] The offerer ${username} gave us a video, but we don't auto-open their video.`);
return;
}
const stream = event.streams[0];
// Had we expressly closed this user's cam before? e.g.: if we have auto-open their
// video enabled, and we 'X' out, and they reopen ours - we may be receiving their
// video right now. If we had expressly closed it, do not accept their video
// and hang up the connection.
if (this.WebRTC.expresslyClosed[username] && this.webcam.rememberExpresslyClosed) {
if (!isOfferer) {
return;
}
}
// A booted admin?
if (this.isBootedAdmin(username)) {
return;
}
// We've received a video! If we had an "open camera spinner timeout",
// clear it before it expires.
if (this.WebRTC.openTimeouts[username] != undefined) {
clearTimeout(this.WebRTC.openTimeouts[username]);
delete (this.WebRTC.openTimeouts[username]);
}
// Do we already have it?
// this.ChatClient(`Received a video stream from ${username}.`);
if (this.WebRTC.streams[username] == undefined ||
this.WebRTC.streams[username].id !== stream.id) {
this.WebRTC.streams[username] = stream;
}
window.requestAnimationFrame(() => {
let $ref = document.getElementById(`videofeed-${username}`);
$ref.srcObject = stream;
// Are we muting their videos by default?
if (this.webcam.autoMuteWebcams) {
this.WebRTC.muted[username] = true;
$ref.muted = true;
}
// Set up the speech detector.
this.initSpeakingEvents(username, $ref);
});
// Inform them they are being watched.
this.sendWatch(username, true);
// Set a mute video handler to detect freezes.
stream.getVideoTracks().forEach(videoTrack => {
let freezeDetected = () => {
this.DebugChannel("[WebRTC] A video freeze was detected from:", username);
// Wait some seconds to see if the stream has recovered on its own
setTimeout(() => {
// Flag it as likely frozen.
if (videoTrack.muted) {
this.WebRTC.frozenStreamDetected[username] = true;
}
}, 7500); // 7.5s
};
videoTrack.onmute = freezeDetected;
// Double check for frozen streams on an interval.
if (this.WebRTC.frozenStreamInterval[username]) {
// Clear the existing interval (e.g. audio+video track sets up the
// interval twice right now, don't overwrite and lose the interval)
clearInterval(this.WebRTC.frozenStreamInterval[username]);
}
this.WebRTC.frozenStreamInterval[username] = setInterval(() => {
if (videoTrack.muted) freezeDetected();
}, 3000);
});
};
// ANSWERER: add our video to the connection so that the offerer (the one who
// clicked on our video icon to watch us) can receive it.
if (!isOfferer && this.webcam.active) {
this.DebugChannel(`[WebRTC] Answerer: attaching my video to the connection with: ${username}`);
let stream = this.webcam.stream;
stream.getTracks().forEach(track => {
pc.addTrack(track, stream)
});
}
// OFFERER: If we were already broadcasting our own video, and the answerer
// has the "auto-open your video" setting enabled, attach our video to the initial
// offer right now.
if (isOfferer) {
let shouldOfferVideo = (
(this.whoMap[username].video & this.VideoFlag.MutualOpen) // They auto-open us
&& this.webcam.active // Our camera is active (to add it)
&& !this.isBooted(username) // We had not booted them off ours before
&& !this.isMutedUser(username) // We had not muted them before
// If our webcam is NSFW and the viewer prefers not to see explicit,
// do not send our camera on this offer.
&& (!this.webcam.nsfw || !(this.whoMap[username].video & this.VideoFlag.NonExplicit))
);
// Attach our video on the outgoing offer, so that on the answerer's side our
// local video pops up on their screen.
if (shouldOfferVideo) {
this.DebugChannel(`[WebRTC] Offerer: I am attaching my video to the connection with: ${username}`)
let stream = this.webcam.stream;
stream.getTracks().forEach(track => {
pc.addTrack(track, stream)
});
} else {
// We aren't offering video, but still want to receive audio/video. Add a receive-only
// transceiver to this offer. NOTE: in the legacy WebRTC API we could put offerToReceiveVideo
// and offerToReceiveAudio in the createOffer() call later, but the modern WebRTC has removed
// those options and Safari only supports the modern way. Adding a receive-only transceiver
// here is the modern way to do it that Safari will be happy with.
this.DebugChannel(`[WebRTC] Offer: I am attaching a receive-only video/audio transceiver to the connection with: ${username}`);
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
}
}
// If we are the offerer, begin the connection.
if (isOfferer) {
this.DebugChannel(`[WebRTC] Offerer: create the offer and send it to ${username}`);
pc.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
}).then(this.localDescCreated(pc, username)).catch(this.ChatClient);
}
},
// Common handler function for
localDescCreated(pc, username) {
return (desc) => {
pc.setLocalDescription(desc).then(() => {
this.DebugChannel(`[WebRTC] Local description created; sending SDP message to ${username}:<br><br>${JSON.stringify(pc.localDescription)}`);
this.client.send({
action: "sdp",
username: username,
description: JSON.stringify(pc.localDescription),
});
}).catch(e => {
console.error(`Error sending WebRTC negotiation message (SDP): ${e}`);
});
};
},
// BareRTC Protocol Handlers relating to WebRTC
sendOpen(username) {
// Sending an open message to connect to someone's camera
this.DebugChannel(`[WebRTC] Sending "open" message to ask to connect to: ${username}`);
this.client.send({
action: "open",
username: username,
});
},
onOpen(msg) {
// Response for the opener to begin WebRTC connection.
this.DebugChannel(`[WebRTC] Received "open" echo from chat server to connect to: ${msg.username}`);
this.startWebRTC(msg.username, true);
},
onRing(msg) {
// Request from a viewer to see our broadcast.
this.DebugChannel(`[WebRTC] Received "ring" message from chat server to share my video with: ${msg.username}`);
this.startWebRTC(msg.username, false);
},
sendBoot(username) {
this.client.send({
action: "boot",
username: username,
});
},
sendUnboot(username) {
this.client.send({
action: "unboot",
username: username,
});
},
sendInviteVideo(username) {
this.WebRTC.invited[username] = true;
this.client.send({
action: "video-invite",
usernames: [username],
});
},
sendInviteVideoBulk() {
let usernames = Object.keys(this.WebRTC.invited);
if (usernames.length === 0) return;
// Re-send the invite list on reconnect to server.
this.client.send({
action: "video-invite",
usernames: usernames,
});
},
isInvited(username) {
return this.WebRTC.invited[username] != undefined;
},
onCandidate(msg) {
// Handle inbound WebRTC signaling messages proxied by the websocket.
if (this.WebRTC.pc[msg.username] == undefined || !this.WebRTC.pc[msg.username].connecting) {
return;
}
let pc = this.WebRTC.pc[msg.username].connecting;
// XX: WebRTC candidate/SDP messages JSON stringify their inner payload so that the
// Go back-end server won't re-order their json keys (Safari on Mac OS is very sensitive
// to the keys being re-ordered during the handshake, in ways that NO OTHER BROWSER cares
// about at all). Re-parse the JSON stringified object here.
let candidate = JSON.parse(msg.candidate);
this.DebugChannel(`[WebRTC] ICE candidate from ${msg.username}:<br><br>${msg.candidate}`);
// Add the new ICE candidate.
pc.addIceCandidate(candidate).catch(e => {
console.error(`addIceCandidate: ${e}`);
});
},
onSDP(msg) {
// WebRTC Session Description Protocol messages proxied by the websocket.
if (this.WebRTC.pc[msg.username] == undefined || !this.WebRTC.pc[msg.username].connecting) {
return;
}
let pc = this.WebRTC.pc[msg.username].connecting;
this.DebugChannel(`[WebRTC] Received SDP message from ${msg.username}:<br><br>${msg.description}`);
// XX: WebRTC candidate/SDP messages JSON stringify their inner payload so that the
// Go back-end server won't re-order their json keys (Safari on Mac OS is very sensitive
// to the keys being re-ordered during the handshake, in ways that NO OTHER BROWSER cares
// about at all). Re-parse the JSON stringified object here.
let message = JSON.parse(msg.description);
// Add the new ICE candidate.
pc.setRemoteDescription(new RTCSessionDescription(message)).then(() => {
this.DebugChannel(`[WebRTC] <strong>setRemoteDescription</strong> called back OK!<br>Our pc.remoteDescription.type is: ${pc.remoteDescription.type}`);
// When receiving an offer let's answer it.
if (pc.remoteDescription.type === 'offer') {
this.DebugChannel(`[WebRTC] Answerer: create SDP answer message for ${msg.username}`);
pc.createAnswer().then(this.localDescCreated(pc, msg.username)).catch(this.ChatClient);
} else {
this.DebugChannel(`[WebRTC] pc.remoteDescription.type was not 'offer', we do not need to create an SDP Answer message.`);
}
}).catch(this.DebugChannel);
},
onWatch(msg) {
// Notify in chat if this was the first watch (viewer may send multiple per each track they received)
if (this.prefs.watchNotif && this.webcam.watching[msg.username] != true) {
this.ChatServer(
`<strong>${msg.username}</strong> is now watching your camera.`,
);
}
this.webcam.watching[msg.username] = true;
this.playSound("Watch");
},
onUnwatch(msg) {
this.DebugChannel(`<strong>BareRTC Unwatch</strong> received from: ${msg.username}<br><br>` +
`Current watching: ${JSON.stringify(Object.keys(this.webcam.watching))}<br>` +
`Was in watch list: ${this.webcam.watching[msg.username] == undefined ? 'NO!' : 'YES'}<br>` +
`My camera is: ${this.webcam.active ? 'active': 'not active'}`,
);
// If the user leaves our watching list, play the sound.
if (this.webcam.watching[msg.username] != undefined) {
this.playSound("Unwatch");
}
// The user has closed our video feed.
delete (this.webcam.watching[msg.username]);
this.cleanupPeerConnections();
},
sendWatch(username, watching) {
// Send the watch or unwatch message to backend.
this.client.send({
action: watching ? "watch" : "unwatch",
username: username,
});
},
isWatching(username) {
// Is the current user watching this target user?
if (this.WebRTC.pc[username] != undefined && this.WebRTC.streams[username] != undefined) {
return true;
}
return false;
},
isWatchingMe(username) {
// Return whether the user is watching your camera
return this.webcam.watching[username] === true;
},
isUsernameCamNSFW(username) {
// returns true if the username is broadcasting and NSFW, false otherwise.
// (used for the color coding of their nickname on their video box - so assumed they are broadcasting)
if (this.whoMap[username] != undefined && this.whoMap[username].video & this.VideoFlag.NSFW) {
return true;
}
return false;
},
isUsernameCamNoWatermark(username) {
// returns true if the user is broadcasting and has the NoWatermark flag set.
if (this.whoMap[username] != undefined && this.whoMap[username].video & this.VideoFlag.NoWatermark) {
return true;
}
return false;
},
/**********************************
* Front-end methods and handlers *
**********************************/
// Start broadcasting my webcam.
// - force=true to skip the NSFW modal prompt (this param is passed by the button in that modal)
// - changeCamera=true to re-negotiate WebRTC connections with a new camera device (invoked by the Settings modal)
startVideo({ force = false, changeCamera = false }) {
if (this.webcam.busy) return;
// Is a moderation rule in place?
if (this.jwt.rules.IsNoBroadcastRule) {
return this.modalAlert({
title: "Broadcasting video is not allowed for you",
message: this.config.strings.ModRuleErrorNoBroadcast || "A chat room moderation rule is currently in place which restricts your ability to broadcast your webcam.\n\nPlease contact a chat operator for more information.",
});
}
// Before they go on cam the first time, ATTEMPT to get their device names.
// - If they had never granted permission, we won't get the names of
// the devices and no big deal.
// - If they had given permission before, we can present a nicer experience
// for them and enumerate their devices before they go on originally.
if (!changeCamera && !force) {
// Initial broadcast: did they select device IDs?
this.getDevices();
}
// Show the broadcast settings modal the first time.
if (!force) {
this.nsfwModalCast.visible = true;
return;
}
let mediaParams = {
audio: true,
video: {
width: { ideal: WebcamWidth, max: WebcamMaxWidth },
height: { ideal: WebcamHeight, max: WebcamMaxHeight },
},
};
// Name the specific devices chosen by the user.
if (this.webcam.videoDeviceID) {
mediaParams.video.deviceId = { exact: this.webcam.videoDeviceID };
}
if (this.webcam.audioDeviceID) {
mediaParams.audio = {
deviceId: { exact: this.webcam.audioDeviceID },
};
}
this.webcam.busy = true;
navigator.mediaDevices.getUserMedia(mediaParams).then(stream => {
this.webcam.active = true;
this.webcam.elem.srcObject = stream;
this.webcam.stream = stream;
// Save our mutuality prefs.
LocalStorage.set('videoMutual', this.webcam.mutual);
LocalStorage.set('videoMutualOpen', this.webcam.mutualOpen);
LocalStorage.set('videoAutoMute', this.webcam.autoMute);
LocalStorage.set('videoVipOnly', this.webcam.vipOnly);
LocalStorage.set('videoExplicit', this.webcam.nsfw);
// Auto-mute our camera? Two use cases:
// 1. The user marked their cam as muted but then changed video device,
// so we set the mute to match their preference as shown on their UI.
// 2. The user opted to auto-mute their camera from the get to on their
// NSFW broadcast modal popup.
if (this.webcam.muted || this.webcam.autoMute) {
// Mute their audio tracks.
this.webcam.stream.getAudioTracks().forEach(track => {
track.enabled = false;
});
// Set their front-end mute toggle to match (in case of autoMute).
this.webcam.muted = true;
}
// If we started as Explicit, hang up on any NonExplicit camera we had open.
this.unWatchNonExplicitVideo();
// Tell backend the camera is ready.
this.sendMe();
// Record the selected device IDs.
this.webcam.videoDeviceID = stream.getVideoTracks()[0].getSettings().deviceId;
this.webcam.audioDeviceID = stream.getAudioTracks()[0].getSettings().deviceId;
// For debugging: log to the debug channel what the chosen resolution is for the user.
let videoSettings = stream.getVideoTracks()[0].getSettings();
this.DebugChannel(`navigator.getUserMedia(): chosen video resolution is ${videoSettings.width}x${videoSettings.height}`);
// Collect video and audio devices to let the user change them in their settings.
this.getDevices().then(() => {
// Store their names on localStorage in case we can reselect them by name
// on the user's next visit.
this.storePreferredDeviceNames();
});
// If we have changed devices, reconnect everybody's WebRTC channels for your existing watchers.
if (changeCamera) {
this.updateWebRTCStreams();
}
// Begin dark video detection as soon as the video is ready to capture frames.
this.webcam.elem.addEventListener("canplaythrough", this.initDarkVideoDetection);
// Begin monitoring for speaking events.
this.initSpeakingEvents(this.username, this.webcam.elem);
}).catch(err => {
this.ChatClient(`Webcam error: ${err}<br><br>Please see the <a href="/about#troubleshooting">troubleshooting guide</a> for help.`);
}).finally(() => {
this.webcam.busy = false;
})
},
getDevices() {
// Collect video and audio devices.
if (!navigator.mediaDevices?.enumerateDevices) {
console.log("enumerateDevices() not supported.");
return;
}
this.webcam.gettingDevices = true;
return navigator.mediaDevices.enumerateDevices().then(devices => {
this.webcam.videoDevices = [];
this.webcam.audioDevices = [];
devices.forEach(device => {
// If we can't get the device label, disregard it.
// It can happen if the user has not yet granted permission.
if (!device.label) {
return;
}
if (device.kind === 'videoinput') {
// console.log(`Video device ${device.deviceId} ${device.label}`);
this.webcam.videoDevices.push({
id: device.deviceId,
label: device.label,
});
} else if (device.kind === 'audioinput') {
// console.log(`Audio device ${device.deviceId} ${device.label}`);
this.webcam.audioDevices.push({
id: device.deviceId,
label: device.label,
});
}
});
// If we don't have the user's current active device IDs (e.g., they have
// not yet started their video the first time), see if we can pre-select
// by their preferred device names.
if (!this.webcam.videoDeviceID && this.webcam.preferredDeviceNames.video) {
for (let dev of this.webcam.videoDevices) {
if (dev.label === this.webcam.preferredDeviceNames.video) {
this.webcam.videoDeviceID = dev.id;
}
}
}
if (!this.webcam.audioDeviceID && this.webcam.preferredDeviceNames.audio) {
for (let dev of this.webcam.audioDevices) {
if (dev.label === this.webcam.preferredDeviceNames.audio) {
this.webcam.audioDeviceID = dev.id;
}
}
}
}).catch(err => {
this.ChatClient(`Error listing your cameras and microphones: ${err.name}: ${err.message}`);
}).finally(() => {
// In the settings modal, let the spinner spin for a moment.
setTimeout(() => {
this.webcam.gettingDevices = false;
}, 500);
})
},
storePreferredDeviceNames() {
// This function looks up the names of the user's selected video/audio device.
// When they come back later, IF we are able to enumerate devices before they
// go on for the first time, we might pre-select their last device by name.
// NOTE: on iOS apparently device IDs shuffle every single time so only names
// may be reliable!
if (this.webcam.videoDeviceID) {
for (let dev of this.webcam.videoDevices) {
if (dev.id === this.webcam.videoDeviceID && dev.label) {
this.webcam.preferredDeviceNames.video = dev.label;
}
}
}
if (this.webcam.audioDeviceID) {
for (let dev of this.webcam.audioDevices) {
if (dev.id === this.webcam.audioDeviceID && dev.label) {
this.webcam.preferredDeviceNames.audio = dev.label;
}
}
}
// Put them on localStorage.
LocalStorage.set('preferredDeviceNames', this.webcam.preferredDeviceNames);
},
// The 'Explicit' button at the top of the page: toggle the user's NSFW setting but
// with some smarts in case the user was just marked NSFW by an admin.
topNavExplicitButtonClicked() {
if (this.webcam.wasServerNSFW) {
this.webcam.wasServerNSFW = false;
this.ChatClient(
`Notice: your webcam was already marked as "Explicit" recently by the chat server.<br><br>` +
`If you were recently notified that a chat moderator has marked your camera as 'explicit' (red) for you, then ` +
`you do not need to do anything: your camera is marked Explicit already. Please leave it as Explicit if you are ` +
`being sexual on camera.<br><br>` +
`If you really mean to <strong>remove</strong> the Explicit label (and turn your camera 'blue'), then click on the ` +
`Explicit button at the top of the page one more time to confirm.`,
);
return;
}
this.webcam.nsfw = !this.webcam.nsfw;
},
// Replace your video/audio streams for your watchers (on camera changes)
updateWebRTCStreams() {
console.log("Re-negotiating video and audio channels to your watchers.");
for (let username of Object.keys(this.WebRTC.pc)) {
let pc = this.WebRTC.pc[username];
if (pc.answerer != undefined) {
let oldTracks = pc.answerer.getSenders();
let newTracks = this.webcam.stream.getTracks();
// Remove and replace the tracks.
for (let old of oldTracks) {
if (old.track.kind === 'audio') {
for (let replace of newTracks) {
if (replace.kind === 'audio') {
old.replaceTrack(replace);
}
}
}
else if (old.track.kind === 'video') {
for (let replace of newTracks) {
if (replace.kind === 'video') {
old.replaceTrack(replace);
}
}
}
}
}
}
},
// Begin connecting to someone else's webcam.
openVideoByUsername(username, force) {
if (this.whoMap[username] != undefined) {
this.openVideo(this.whoMap[username], force);
return;
}
this.ChatClient("Couldn't open video by username: not found.");
},
setSkipNSFWModal() {
// Set the "don't show again" on NSFW modal.
LocalStorage.set("skip-nsfw-modal", true);
},
openVideo(user, force) {
// Close the NSFW warning modal if it was open.
this.nsfwModalView.visible = false;
if (user.username === this.username) {
this.ChatClient("You can already see your own webcam.");
return;
}
// Operator safety when hidden.
if (this.status === 'hidden') {
this.ChatClient("Your chat status is currently set to 'hidden' and you would break the illusion by opening this camera.");
return;
}
// A chat moderation rule?
if (this.jwt.rules.IsNoVideoRule) {
return this.modalAlert({
title: "Videos are not available to you",
message: this.config.strings.ModRuleErrorNoVideo || "A chat room moderation rule is currently in place which restricts your ability to watch webcams.\n\n" +
"Please contact a chat operator for more information.",
});
}
// If we have muted the target, we shouldn't view their video.
if (this.isMutedUser(user.username) && !this.isOp) {
this.ChatClient(`You have muted <strong>${user.username}</strong> and so should not see their camera.`);
return;
}
// If we have booted the target off our cam, we shouldn't view their video.
if (this.isBooted(user.username) && !this.isOp) {
this.ChatClient(`You had kicked <strong>${user.username}</strong> off your camera and so it wouldn't be right to still watch their camera.`);
return;
}
// Is the target user NSFW? Go thru the modal.
let dontShowAgain = LocalStorage.get("skip-nsfw-modal") === true;
if ((user.video & this.VideoFlag.NSFW) && !dontShowAgain && !force) {
this.nsfwModalView.user = user;
this.nsfwModalView.visible = true;
return;
}
// If the local user had expressly closed this user's camera before, forget
// this action because the user now is expressly OPENING this camera on purpose.
delete (this.WebRTC.expresslyClosed[user.username]);
// Debounce so we don't spam too much for the same user.
if (this.WebRTC.debounceOpens[user.username]) return;
this.WebRTC.debounceOpens[user.username] = true;
setTimeout(() => {
delete (this.WebRTC.debounceOpens[user.username]);
}, 5000);
// Camera is already open? Then disconnect the connection.
if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.pc[user.username].offerer != undefined) {
this.DebugChannel(`OpenVideo(${user.username}): already had a connection open, closing it first.`);
this.closeVideo(user.username, "offerer");
}
// If this user is NonExplicit and your camera is red...
let theirVideo = this.whoMap[user.username].video;
if (this.webcam.active && this.webcam.nsfw && (theirVideo & VideoFlag.Active) && (theirVideo & VideoFlag.NonExplicit)) {
this.ChatClient(
`<strong>${user.username}</strong> prefers not to have Explicit cammers watch their video, and your camera is currently marked as Explicit.`
);
return;
}
// Conversely: if we are NonExplicit we should not open NSFW videos.
if (this.webcam.active && this.webcam.nonExplicit && (theirVideo & VideoFlag.Active) && (theirVideo & VideoFlag.NSFW)) {
this.ChatClient(
`You have said you do not want to see Explicit videos and <strong>${user.username}</strong> has an Explicit camera.`
);
return;
}
// If this user requests mutual viewership...
if (this.isVideoNotAllowed(user) && !this.isOp) {
this.ChatClient(
`<strong>${user.username}</strong> has requested that you should share your own camera too before opening theirs.`
);
return;
}
// Set a timeout: the video icon becomes a spinner and we wait a while
// to see if the connection went thru. This gives the user feedback and we
// can avoid a spammy 'ChatClient' notification message.
if (this.WebRTC.openTimeouts[user.username] != undefined) {
clearTimeout(this.WebRTC.openTimeouts[user.username]);
delete (this.WebRTC.openTimeouts[user.username]);
}
this.WebRTC.openTimeouts[user.username] = setTimeout(() => {
this.ChatClient(
`There was an error opening <strong>${user.username}</strong>'s camera.`,
);
delete (this.WebRTC.openTimeouts[user.username]);
}, 10000);
// Send the ChatServer 'open' command.
this.sendOpen(user.username);
// Responsive CSS -> go to chat panel to see the camera
this.openChatPanel();
},
closeVideo(username, name) {
// Clean up any lingering camera freeze states.
delete (this.WebRTC.frozenStreamDetected[username]);
if (this.WebRTC.frozenStreamInterval[username]) {
clearInterval(this.WebRTC.frozenStreamInterval[username]);
delete (this.WebRTC.frozenStreamInterval[username]);
}
if (name === "offerer") {
// We are closing another user's video stream.
delete (this.WebRTC.streams[username]);
delete (this.WebRTC.muted[username]);
delete (this.WebRTC.poppedOut[username]);
// Should we close the WebRTC PeerConnection? If they were watching our video back, closing
// the connection MAY cause our video to freeze on their side: if we have the "auto-open my viewer's
// camera" option set, and our viewer sent their video on their open request, and they still have
// our camera open, do not close the connection so we don't freeze their side of the video.
if (this.WebRTC.pc[username] != undefined && this.WebRTC.pc[username].offerer != undefined) {
if (this.webcam.mutualOpen && this.isWatchingMe(username)) {
console.log(`OFFERER(${username}): Close video locally only: do not hang up the connection.`);
} else {
this.WebRTC.pc[username].offerer.close();
delete (this.WebRTC.pc[username]);
}
}
// Inform backend we have closed it.
this.sendWatch(username, false);
this.cleanupPeerConnections();
return;
} else if (name === "answerer") {
// Should we close the WebRTC PeerConnection? If they were watching our video back, closing
// the connection MAY cause our video to freeze on their side: if we have the "auto-open my viewer's
// camera" option set, and our viewer sent their video on their open request, and they still have
// our camera open, do not close the connection so we don't freeze their side of the video.
if (this.WebRTC.pc[username] != undefined && this.WebRTC.pc[username].answerer != undefined) {
if (this.webcam.mutualOpen && this.isWatchingMe(username)) {
console.log(`ANSWERER(${username}): Close video locally only: do not hang up the connection.`);
} else {
this.WebRTC.pc[username].answerer.close();
delete (this.WebRTC.pc[username]);
}
}
this.cleanupPeerConnections();
return;
}
// A user has logged off the server. Clean up any WebRTC connections.
delete (this.WebRTC.streams[username]);
delete (this.webcam.watching[username]);
if (this.WebRTC.pc[username] != undefined) {
if (this.WebRTC.pc[username].offerer) {
this.WebRTC.pc[username].offerer.close();
}
if (this.WebRTC.pc[username].answerer) {
this.WebRTC.pc[username].answerer.close();
}
delete (this.WebRTC.pc[username]);
delete (this.WebRTC.muted[username]);
delete (this.WebRTC.poppedOut[username]);
}
// Clean up any lingering camera freeze states.
delete (this.WebRTC.frozenStreamDetected[username]);
if (this.WebRTC.frozenStreamInterval[username]) {
clearInterval(this.WebRTC.frozenStreamInterval[username]);
delete (this.WebRTC.frozenStreamInterval[username]);
}
// Inform backend we have closed it.
this.sendWatch(username, false);
this.cleanupPeerConnections();
},
expresslyCloseVideo(username, name) {
// Like closeVideo but communicates the user's intent to expressly
// close the video for a user. e.g. they clicked the 'X' icon to
// close it out. As opposed to a user went off camera and were closed
// out automatically.
this.WebRTC.expresslyClosed[username] = true;
return this.closeVideo(username, name);
},
closeOpenVideos() {
// Close all videos open of other users.
for (let username of Object.keys(this.WebRTC.streams)) {
this.closeVideo(username, "offerer");
}
},
cleanupPeerConnections() {
// Helper function to check and clean up WebRTC PeerConnections.
//
// This is fired on Unwatch and CloseVideo events, to double check
// which videos the local user has open + who online is watching our
// video, to close out any lingering WebRTC connections.
for (let username of Object.keys(this.WebRTC.pc)) {
let pc = this.WebRTC.pc[username];
// Is their video on our screen?
if (this.WebRTC.streams[username] != undefined) {
continue;
}
// Are they watching us?
if (this.isWatchingMe(username)) {
continue;
}
// Are they an admin that we booted?
if (this.isBootedAdmin(username)) {
continue;
}
// The WebRTC connections should be closed out.
if (pc.answerer != undefined) {
console.log("Clean up WebRTC answerer connection with " + username);
pc.answerer.close();
delete (this.WebRTC.pc[username]);
}
if (pc.offerer != undefined) {
console.log("Clean up WebRTC offerer connection with " + username);
pc.offerer.close();
delete (this.WebRTC.pc[username]);
}
}
},
muteAllVideos() {
// Mute the mic of all open videos.
let count = 0;
for (let username of Object.keys(this.WebRTC.streams)) {
if (this.WebRTC.muted[username]) continue;
// Find the <video> tag to mute it.
this.WebRTC.muted[username] = true;
let $ref = document.getElementById(`videofeed-${username}`);
if ($ref) {
$ref.muted = this.WebRTC.muted[username];
}
count++;
}
if (count > 0) {
this.ChatClient(`You have muted the audio on ${count} video${count === 1 ? '' : 's'}.`);
}
},
unMutualVideo() {
// If we had our camera on to watch a video of someone who wants mutual cameras,
// and then we turn ours off: we should unfollow the ones with mutual video.
if (this.webcam.active) return;
for (let row of this.whoList) {
let username = row.username;
// If this user expressly invited us to watch, skip.
if ((row.video & this.VideoFlag.Active) && (row.video & this.VideoFlag.Invited)) {
continue;
}
if ((row.video & this.VideoFlag.MutualRequired) && this.WebRTC.pc[username] != undefined) {
this.closeVideo(username);
}
}
},
unWatchNonExplicitVideo() {
if (!this.webcam.active) return;
// If we are watching cameras with the NonExplicit setting, and our camera has become
// explicit, excuse ourselves from their watch list.
if (this.webcam.nsfw) {
for (let username of Object.keys(this.WebRTC.streams)) {
let user = this.whoMap[username];
// Our video is Explicit and theirs is NonExplicit.
if ((user.video & VideoFlag.Active) && (user.video & VideoFlag.NonExplicit)) {
this.closeVideo(username);
}
}
}
// Conversely: if we are NonExplicit we do not watch Explicit videos.
if (this.webcam.nonExplicit) {
for (let username of Object.keys(this.WebRTC.streams)) {
let user = this.whoMap[username];
if ((user.video & VideoFlag.Active) && (user.video & VideoFlag.NSFW)) {
this.closeVideo(username);
}
}
}
},
// Invite a user to watch your camera even if they normally couldn't see.
inviteToWatch(username) {
this.modalConfirm({
title: "Invite to watch my webcam",
icon: "fa fa-video",
message: `Do you want to invite @${username} to watch your camera?\n\n` +
`This will give them permission to see your camera even if, normally, they would not be able to. ` +
`For example, if you have the option enabled to "require my viewer to be on webcam too" and @${username} is not on camera, ` +
`by inviting them to watch they will be allowed to see your camera anyway.\n\n` +
`This permission will be granted for the remainder of your chat session, but you can boot them ` +
`off your camera if you change your mind later.`,
buttons: ['Invite to watch', 'Cancel'],
}).then(() => {
this.sendInviteVideo(username);
this.ChatClient(
`You have granted <strong>@${username}</strong> permission to watch your webcam. This will be in effect for the remainder of your chat session. ` +
"Note: if you change your mind later, you can boot them from your camera or block them from watching by using the button " +
"in their profile card.",
);
});
},
isUsernameOnCamera(username) {
return this.whoMap[username]?.video & VideoFlag.Active;
},
// Video button classes for a user.
webcamIconClass(user) {
// Return the icon to show on a video button.
// - Usually a video icon
// - May be a crossed-out video if isVideoNotAllowed
// - Or an eyeball for cameras already opened
// - Or a spinner if we are actively trying to open the video
// Current user sees their own self camera always.
if (user.username === this.username && this.webcam.active) {
return 'fa-eye';
}
// In spinner mode? (Trying to open the video)
if (this.WebRTC.openTimeouts[user.username] != undefined) {
return 'fa-spinner fa-spin';
}
// Already opened?
if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.streams[user.username] != undefined) {
return 'fa-eye';
}
if (this.isVideoNotAllowed(user)) return 'fa-video-slash';
return 'fa-video';
},
webcamButtonClass(username) {
// This styles the convenient video button that appears in the header bar
// of DM threads if your chat partner is on camera.
let video = this.whoMap[username].video;
if (!(video & VideoFlag.Active)) {
return "";
}
if (video & VideoFlag.NSFW) {
return "is-danger";
}
return "is-link";
},
isVideoNotAllowed(user) {
// Returns whether the video button to open a user's cam will be not allowed (crossed out)
// If the user is under the NoVideo rule, always cross it out.
if (this.jwt.rules.IsNoVideoRule) return true;
// If this user expressly invited us to watch.
if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.Invited)) {
return false;
}
// Mutual video sharing is required on this camera, and ours is not active
if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.MutualRequired)) {
// A nuance to the mutual video required: if we DO have our cam on, but ours is VIP only, and the
// user we want to watch can't see that our cam is on, then honor their wishes.
if (this.webcam.active && this.isVIP && this.webcam.vipOnly && this.whoMap[user.username] != undefined && !this.whoMap[user.username].vip) {
// Our cam is active, but the non-VIP user won't see it on, so they won't expect
// us to be able to open their camera.
return true;
}
if (!this.webcam.active) {
// Our cam is not broadcasting, but they requested it should be: not allowed.
return true;
}
}
// We have muted them and it wouldn't be appropriate to still watch their video but not get their messages.
if (this.isMutedUser(user.username) || this.isBooted(user.username)) {
return true;
}
// This person is NonExplicit and our camera is Explicit.
if (this.webcam.active && this.webcam.nsfw && (user.video & VideoFlag.Active) && (user.video & VideoFlag.NonExplicit)) {
return true;
}
// Conversely: if we are NonExplicit we should not be able to watch Explicit videos.
if (this.webcam.active && this.webcam.nonExplicit && (user.video & VideoFlag.Active) && (user.video & VideoFlag.NSFW)) {
return true;
}
return false;
},
// Functions to help users 'nudge' each other into marking their cams as Explicit.
sendNudgeNsfw(username) {
// Send a nudge to the username. This is triggered by their profile modal: if the user is
// on blue camera, others on chat can anonymously nudge them into marking their camera as red.
// Nudges are a special kind of emoji reaction (so we could add this feature in frontend only without
// a server side deployment to support it).
this.client.send({
action: 'react',
msgID: ReactNudgeNsfwMessageID,
message: username,
});
},
onNudgeNsfw(msg) {
// Handler for a nudge NSFW react message.
if (msg.message !== this.username) {
return; // Not for us
}
// Sanity check that we are on blue camera.
if (!this.webcam.active || this.webcam.nsfw) {
return;
}
// Only show this if we have at least 2 watchers.
let watchers = Object.keys(this.webcam.watching).length;
if (watchers < 2) {
return;
}
// Show a nice message on chat.
this.ChatServer(
`<strong>Your webcam is <span class="has-text-danger">Hot!</span></strong> <i class="fa fa-fire has-text-danger"></i><br><br>` +
`Somebody who is watching your camera thinks that your webcam should be tagged as <span class="has-text-danger"><i class="fa fa-fire mx-1"></i> Explicit</span>.<br><br>` +
`In case you forgot to do so, please click on the '<i class="fa fa-fire has-text-danger"></i> Explicit' button at the top of the page to turn your camera 'red.' Thank you! <i class="fa fa-heart has-text-danger"></i>`,
);
},
// Show who watches our video.
showViewers() {
// TODO: for now, ChatClient is our bro.
let users = Object.keys(this.webcam.watching);
if (users.length === 0) {
this.ChatClient("There is currently nobody viewing your camera.");
} else {
this.ChatClient("Your current webcam viewers are:<br><br>" + users.join(", "));
}
// Also focus the Watching list.
this.whoTab = 'watching';
// TODO: if mobile, show the panel - this width matches
// the media query in chat.css
if (screen.width < 1024) {
this.openWhoPanel();
}
},
// Boot someone off your video.
bootUser(username) {
// Un-boot?
if (this.isBooted(username)) {
this.modalConfirm({
title: "Unboot user",
icon: "fa fa-user-xmark",
message: `Allow ${username} to watch your webcam again?`
}).then(() => {
this.sendUnboot(username);
delete (this.WebRTC.booted[username]);
})
return;
}
// Check if they are currently rate limited from pre-emptive boots of folks
// who are not even watching their camera yet.
if (this.rateLimitPreemptiveBoots(username, false)) return;
// Get the target user.
const user = this.getUser(username);
// The dialog message, which varies if the booted user is an admin.
let message = `Kick ${username} off your camera? `;
if (user.op) {
message += "Note: chat operators will be able to reopen your webcam."
} else {
message += `This will also prevent them from seeing that your camera `+
` is active for the remainder of your chat session.`
}
// Boot them off our webcam.
this.modalConfirm({
title: "Boot user",
icon: "fa fa-user-xmark",
message: message,
}).then(() => {
// Ping their rate limiter.
if (this.rateLimitPreemptiveBoots(username, true)) return;
this.doBootUser(username);
if (user.op) {
this.ChatClient(
`You have booted ${username}<sup class="fa fa-peace has-text-warning is-size-7"></sup> ` +
`off your camera. <strong>Note:</strong> Chat operators ` +
`will be able to reopen your webcam.`,
);
return;
}
this.ChatClient(
`You have booted ${username} off your camera. They will no longer be able ` +
`to connect to your camera, or even see that your camera is active at all -- ` +
`to them it appears as though you had turned yours off.<br><br>This will be ` +
`in place for the remainder of your time in chat, until you log off. ` +
`<strong>Note:</strong> If you wish to undo this, you can allow them to watch again ` +
`by opening their profile card.`
);
});
},
doBootUser(username) {
// Inner function to actually boot a user, bypassing any confirm or rate limit modals.
this.sendBoot(username);
this.WebRTC.booted[username] = true;
// Close the WebRTC peer connections.
if (this.WebRTC.pc[username] != undefined) {
this.closeVideo(username);
}
// Remove them from our list.
delete (this.webcam.watching[username]);
},
isBooted(username) {
// Admins never count as booted.
if (this.isBootedAdmin(username)) {
return false;
}
return this.WebRTC.booted[username] === true;
},
isBootedAdmin(username) {
return (this.WebRTC.booted[username] === true || this.muted[username] === true) &&
this.whoMap[username] != undefined &&
this.whoMap[username].op;
},
rateLimitPreemptiveBoots(username, ping=false) {
// Rate limit abusive pre-emptive booting behavior: if the target is not even currently watching
// your camera, limit how many and how frequently you can boot them off.
//
// Returns true if limited, false otherwise.
let cooldownAt = this.WebRTC.preemptBootRateLimit.cooldownAt,
cooldownTTL = this.WebRTC.preemptBootRateLimit.cooldownTTL,
now = new Date().getTime();
if (!this.isWatchingMe(username)) {
// Within the 'free' boot limits?
if (this.WebRTC.preemptBootRateLimit.counter < this.WebRTC.preemptBootRateLimit.maxFreeBoots) {
if (!ping) return false;
this.WebRTC.preemptBootRateLimit.counter++;
}
// Begin enforcing a cooldown TTL after a while.
if (this.WebRTC.preemptBootRateLimit.counter >= this.WebRTC.preemptBootRateLimit.maxFreeBoots) {
// Currently throttled?
if (cooldownAt !== null) {
if (now < cooldownAt) {
let delta = cooldownAt - now;
this.modalAlert({
title: "You are doing that too often",
message: "You have been pre-emptively booting an unusual number of people who weren't even watching your camera yet.\n\n" +
`Please wait ${cooldownTTL} seconds between any additional pre-emptive boots.\n\n` +
`You may try again after ${delta/1000} seconds.`,
});
return true;
}
}
// Refresh the timer on pings.
if (ping) {
this.WebRTC.preemptBootRateLimit.cooldownAt = now + (cooldownTTL * 1000);
}
}
}
return false;
},
// Stop broadcasting.
stopVideo() {
this.stopDarkVideoDetection();
// Close all WebRTC sessions.
for (let username of Object.keys(this.WebRTC.pc)) {
this.closeVideo(username, "answerer");
}
// Hang up on mutual cameras.
this.unMutualVideo();
// Close the local camera devices completely.
this.webcam.stream.getTracks().forEach(track => {
track.stop();
});
// Reset all front-end state.
this.webcam.elem.srcObject = null;
this.webcam.stream = null;
this.webcam.active = false;
this.webcam.muted = false;
this.whoTab = "online";
// Tell backend our camera state.
this.sendMe();
},
// Mute my microphone if broadcasting.
muteMe() {
this.webcam.muted = !this.webcam.muted;
this.webcam.stream.getAudioTracks().forEach(track => {
track.enabled = !this.webcam.muted;
});
// Communicate our local mute to others.
this.sendMe();
},
isSourceMuted(username) {
// See if the webcam broadcaster muted their mic at the source
if (this.whoMap[username] != undefined && this.whoMap[username].video & this.VideoFlag.Muted) {
return true;
}
return false;
},
isMuted(username) {
return this.WebRTC.muted[username] === true;
},
muteVideo(username) {
this.WebRTC.muted[username] = !this.isMuted(username);
// Find the <video> tag to mute it.
let $ref = document.getElementById(`videofeed-${username}`);
if ($ref) {
$ref.muted = this.WebRTC.muted[username];
}
},
setVideoVolume(username, volume) {
// Set the volume on their video.
let $ref = document.getElementById(`videofeed-${username}`);
if ($ref) {
$ref.volume = volume / 100;
}
},
// Pop out a user's video.
popoutVideo(username) {
this.WebRTC.poppedOut[username] = !this.WebRTC.poppedOut[username];
// If not popped out, reset CSS positioning.
window.requestAnimationFrame(this.makeDraggableVideos);
},
// Outside of Vue, attach draggable video scripts to DOM.
makeDraggableVideos() {
let $panel = document.querySelector("#video-feeds");
interact('.popped-in').unset();
// Give popped out videos to the root of the DOM so they can
// be dragged anywhere on the page.
window.requestAnimationFrame(() => {
document.querySelectorAll('.popped-out').forEach(node => {
// $panel.removeChild(node);
document.body.appendChild(node);
});
document.querySelectorAll('.popped-in').forEach(node => {
// document.body.removeChild(node);
$panel.appendChild(node);
node.style.top = null;
node.style.left = null;
node.setAttribute('data-x', 0);
node.setAttribute('data-y', 0);
});
});
interact('.popped-out').draggable({
// enable inertial throwing
inertia: true,
// keep the element within the area of it's parent
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
})
],
listeners: {
// call this function on every dragmove event
move(event) {
let target = event.target;
let x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx
let y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy
target.style.top = `${y}px`;
target.style.left = `${x}px`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
},
// call this function on every dragend event
end(event) {
console.log(
'moved a distance of ' +
(Math.sqrt(Math.pow(event.pageX - event.x0, 2) +
Math.pow(event.pageY - event.y0, 2) | 0))
.toFixed(2) + 'px')
}
}
}).resizable({
edges: { left: true, right: true, bottom: true },
listeners: {
move(event) {
var target = event.target
var x = (parseFloat(target.getAttribute('data-x')) || 0)
var y = (parseFloat(target.getAttribute('data-y')) || 0)
// update the element's style
target.style.width = event.rect.width + 'px'
target.style.height = event.rect.height + 'px'
// translate when resizing from top or left edges
x += event.deltaRect.left
y += event.deltaRect.top
target.style.top = `${y}px`;
target.style.left = `${x}px`;
target.setAttribute('data-x', x)
target.setAttribute('data-y', y)
}
},
modifiers: [
// keep the edges inside the parent
interact.modifiers.restrictEdges({
outer: 'parent'
}),
// minimum size
interact.modifiers.restrictSize({
min: { width: 100, height: 50 }
})
],
inertia: true
})
},
// Webcam "is speaking" functions.
initSpeakingEvents(username, element) {
// element is the <video> element, with the video stream
// (whether from getUserMedia or WebRTC) on srcObject.
let stream = element.srcObject,
feedElem = element.closest('div.feed'),
options = {},
speechEvents = hark(stream, options);
speechEvents.on('speaking', () => {
feedElem.classList.add('is-speaking');
this.WebRTC.speaking[username] = true;
});
speechEvents.on('stopped_speaking', () => {
feedElem.classList.remove('is-speaking');
this.WebRTC.speaking[username] = false;
});
},
// Dark video detection.
initDarkVideoDetection() {
if (this.webcam.darkVideo.canvas === null) {
let canvas = document.querySelector("#darkVideoCanvas"),
ctx = canvas.getContext('2d');
canvas.width = WebcamWidth;
canvas.height = WebcamHeight;
this.webcam.darkVideo.canvas = canvas;
this.webcam.darkVideo.ctx = ctx;
}
// Reset the dark frame counter.
this.webcam.darkVideo.tooDarkFrames = 0;
if (this.webcam.darkVideo.interval !== null) {
clearInterval(this.webcam.darkVideo.interval);
}
this.webcam.darkVideo.interval = setInterval(() => {
this.darkVideoInterval();
}, 5000);
},
stopDarkVideoDetection() {
if (this.webcam.darkVideo.interval !== null) {
clearInterval(this.webcam.darkVideo.interval);
}
// Remove the canplaythrough event, it will be re-added if the user restarts their cam.
this.webcam.elem.removeEventListener("canplaythrough", this.initDarkVideoDetection);
},
darkVideoInterval() {
if (!this.webcam.active) { // safety
this.stopDarkVideoDetection();
return;
}
// Take a screenshot from the user's local webcam.
let canvas = this.webcam.darkVideo.canvas,
ctx = this.webcam.darkVideo.ctx;
ctx.drawImage(this.webcam.elem, 0, 0, canvas.width, canvas.height);
// Debugging: export the screenshot to a data URI.
let img = canvas.toDataURL('image/jpeg');
this.webcam.darkVideo.lastImage = img;
// Get average RGB value.
let rgb = this.getAverageRGB(ctx);
if (rgb === null) {
return;
}
this.webcam.darkVideo.lastAverage = rgb;
this.webcam.darkVideo.lastAverageColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 1)`;
// If they are exempt from the dark video rule, do not check their camera color.
if (this.jwt.rules.IsNoDarkVideoRule) return;
// If the average total color is below the threshold (too dark of a video).
let averageBrightness = Math.floor((rgb[0] + rgb[1] + rgb[2]) / 3);
if (averageBrightness < this.webcam.darkVideo.threshold) {
// Count for how many frames their camera is too dark.
this.webcam.darkVideo.tooDarkFrames++;
// After too long, cut their camera.
if (this.webcam.darkVideo.tooDarkFrames >= this.webcam.darkVideo.tooDarkFramesLimit) {
this.stopVideo();
this.ChatClient(`
Your webcam was too dark to see anything and has been turned off.<br><br>
<strong>Note:</strong> if your camera did not look dark to you and you believe there
may have been an error, please
<button type="button" onclick="SendMessage('/debug-dark-video')" class="button is-small is-link is-outlined">click here</button> to see
diagnostic information and contact a chat room moderator for assistance.
`);
}
} else {
this.webcam.darkVideo.tooDarkFrames = 0;
}
},
getAverageRGB(ctx) {
// Helper function to compute the average color of a <canvas>.
// Ref: https://stackoverflow.com/a/2541680
const blockSize = 16; // only visit every N pixels
let img = null,
rgb = [0, 0, 0];
try {
img = ctx.getImageData(0, 0, WebcamWidth, WebcamHeight);
} catch(e) {
// Not supported.
return null;
}
let length = img.data.length,
i = 0,
count = 0,
firstColor = [],
allSame = true;
while ((i += blockSize * 4) < length) {
count++;
let thisColor = [
img.data[i],
img.data[i+1],
img.data[i+2]
]
rgb[0] += thisColor[0];
rgb[1] += thisColor[1];
rgb[2] += thisColor[2];
// Also check whether every sampled pixel is THE SAME color,
// to detect users broadcasting a solid (bright) color.
if (firstColor.length === 0) {
firstColor = [ rgb[0], rgb[1], rgb[2] ];
} else if (allSame) {
if (firstColor[0] !== thisColor[0] ||
firstColor[1] !== thisColor[1] ||
firstColor[2] !== thisColor[2]
) {
allSame = false;
}
}
}
// If all sampled colors were the same solid image: red flag!
if (allSame) {
return [0, 0, 0];
}
rgb[0] = Math.floor(rgb[0]/count);
rgb[1] = Math.floor(rgb[1]/count);
rgb[2] = Math.floor(rgb[2]/count);
return rgb;
},
// Scale the video zoom setting up or down.
scaleVideoSize(bigger) {
// Find the current size index.
let currentSize = 0;
for (let option of this.webcam.videoScaleOptions) {
if (option[0] === this.webcam.videoScale) {
break;
}
currentSize++;
}
// Adjust it.
if (bigger) {
currentSize++;
} else {
currentSize--;
}
// Constrain it.
if (currentSize < 0) {
currentSize = 0;
} else if (currentSize >= this.webcam.videoScaleOptions.length) {
currentSize = this.webcam.videoScale.length - 1;
}
// Set it.
this.webcam.videoScale = this.webcam.videoScaleOptions[currentSize][0];
},
// Safety feature: if the user deletes the QR code watermark in their browser's dev
// tools, this function is called.
onDeletedWatermark(username) {
// Report this to your main website.
this.doCustomReport({
message: {
channel: 'n/a',
username: this.username,
at: new Date(),
message: 'User has deleted the webcam watermark QR code image!',
},
classification: 'Community Safety Violation',
comment: `The user ${this.username} is trying to circumvent the webcam watermarking system!\n\n` +
`It has been detected that the QR code overlay had been removed from the page, possibly via ${this.username} ` +
`using their browser's dev tools to do so.\n\nSpecifically, the webcam being broadcast by @${username} has had its ` +
`watermark element removed from the web page.`
});
// Broadcast our shame.
this.client.send({
action: "message",
channel: "lobby",
message: `Hey @all I have been very naughty and have been caught red-handed tampering with the webcam QR code watermarks ` +
`here on the chat room. A special apology to @${username} whose camera I was messing with. I will now see myself out ` +
`of the chat room as I hang my head in shame.`,
});
this.panic();
},
}
}
/***************************
* Common, stand-alone functions used by multiple components.
* Important: these do not use Vue properties but are instead
* self contained and safe to call from anywhere.
***************************/
// Very similar to webcamIconClass but stand-alone and shared by
// multiple components like WhoListRow and MessageBox.
videoButtonClass(user, isVideoNotAllowed) {
let result = "";
// VIP background if their cam is set to VIPs only
if ((user.video & VideoFlag.Active) && (user.video & VideoFlag.VipOnly)) {
result = "has-background-vip ";
}
// Invited to watch: green border but otherwise red/blue icon.
if ((user.video & VideoFlag.Active) && (user.video & VideoFlag.Invited)) {
// Invited to watch; green border but blue/red icon.
result += "video-button-invited ";
}
// Colors and/or cursors.
if ((user.video & VideoFlag.Active) && (user.video & VideoFlag.NSFW)) {
// Explicit camera: red border
result += "is-danger is-outlined";
} else if ((user.video & VideoFlag.Active) && !(user.video & VideoFlag.NSFW)) {
// Normal camera: blue border
result += "is-link is-outlined";
} else if (isVideoNotAllowed) {
// Default: grey border and not-allowed cursor.
result += "cursor-notallowed";
}
return result;
}
videoButtonTitle(user) {
// Mouse-over title text for the video button.
let parts = ["Open video stream"];
if (user.video & VideoFlag.MutualRequired) {
parts.push("mutual video sharing required");
}
if (user.video & VideoFlag.MutualOpen) {
parts.push("will auto-open your video");
}
if (user.video & VideoFlag.VipOnly) {
parts.push(`${this.vipConfig.Name} only`);
}
if (user.video & VideoFlag.NonExplicit) {
parts.push("prefers non-explicit video");
}
return parts.join("; ");
}
}
const WebRTC = new WebRTCController();
export default WebRTC;