/* * Copyright (c) 2026 Steve Seguin. All Rights Reserved. * * Use of this source code is governed by the APGLv3 open-source license * that can be found in the LICENSE file in the root of the source * tree. Alternative licencing options can be made available on request. * */ /*jshint esversion: 6 */ ///// For the debug output, uncomment this section. /* let lastLogTime = performance.now(); // Initialize with the current time function getTimeStamp() { const now = performance.now(); const timeSinceLastLog = now - lastLogTime; lastLogTime = now; // Update lastLogTime to the current time return timeSinceLastLog.toFixed(0); // Return time with three decimals for milliseconds } function getStackTrace() { const obj = {}; Error.captureStackTrace(obj, getStackTrace); return obj.stack; } function getLineNumber() { const e = new Error(); const frame = e.stack.split("\n")[3]; // Change the index if needed const lineNumber = frame.split(":").reverse()[1]; return lineNumber; } function log(msg) { const timeStamp = getTimeStamp(); const lineNumber = getLineNumber(); console.log(`${timeStamp}ms [Line ${lineNumber}]:`, msg); } function warnlog(msg) { const timeStamp = getTimeStamp(); const lineNumber = getLineNumber(); console.warn(`${timeStamp}ms [Line ${lineNumber}]:`, msg); } function errorlog(msg) { const timeStamp = getTimeStamp(); const lineNumber = getLineNumber(); console.error(`${timeStamp}ms [Line ${lineNumber}]:`, msg); } */ /////// var formSubmitting = true; var activatedPreview = false; var screensharesupport = true; var FirefoxEnumerated = false; var Callbacks = []; var CtrlPressed = false; // global var MousePressed = false; // global var AltPressed = false; var KeyPressedTimeout = 0; var PPTKeyPressed = false; var translation = false; var miscTranslations = { // i can replace this list from time to time from the generated one in blank.json using translate.js start: "START", "new-display-name": "Enter a new Display Name for this stream", "submit-error-report": "Press OK to submit any error logs to VDO.Ninja. Error logs may contain private information.", "director-redirect-1": "The director wishes to redirect you to the URL: ", "director-redirect-2": "\n\nPress OK to be redirected.", "add-a-label": "Add a label", "audio-processing-disabled": "Audio processing is disabled with this guest. Can't mute or change volume", "not-the-director": "You are not the director of this room. You will have limited to no control. See &codirector on how to become a co-director.", "room-is-claimed": "The room is already claimed by someone else.\n\nOnly the first person to join a room is the assigned director.\n\nRefresh after the first director leaves to claim.", "token-room-is-claimed": "The room is claimed by someone else.\n\nJoin as a guest or co-director instead.", "room-is-claimed-codirector": "The room is already claimed by someone else.\n\nTrying to join as a co-director...", "streamid-already-published": "The stream ID you are publishing to is already in use.\n\nPlease try with a different invite link or refresh to retry again.\n\nYou will now be disconnected.", "streamid-already-published-obvious": "The stream ID you are publishing to is already in use.\n\nPlease consider using a password or a more varied/unique stream ID to avoid this issue.\n\nYou will now be disconnected.", "director": "Director", "unknown-user": "Unknown User", "room-test-not-good": "The room name 'test' is very commonly used and may not be secure.\n\nAre you sure you wish to proceed?", "load-previous-session": "Would you like to load your previous session's settings?", "enter-password": "Please enter the password below: \n\n(Note: Passwords are case-sensitive and you will not be alerted if it is incorrect.)", "enter-password-2": "Please enter the password below: \n\n(Note: Passwords are case-sensitive.)", "enter-director-password": "Please enter the director's password:\n\n(Note: Passwords are case-sensitive and you will not be alerted if it is incorrect.)", "password-incorrect": "The password was incorrect.\n\nRefresh and try again.", "enter-display-name": "Please enter your display name:", "enter-new-display-name": "Enter a new Display Name for this stream", "what-bitrate": "This remote guest will save the recording directly to their local disk.\n\n - The recording can fail, so have backup recordings going!\n\n - This record option does not use Internet bandwidth and offers a high quality recording\n\n - Guests using iPhones, Androids, or Safari will often have issues - bewarned.", "what-bitrate-gdrive": "This remote guest will save the recording directly to their local disk, as well as send a copy to your Google Drive (recordings folder).\n\n - The recording can fail however, so have backup recordings going!\n\n - Guests using iPhones, Androids, or Safari will often have issues - bewarned.", "enter-website": "Enter a website URL to share", "press-ok-to-record": " - Keep this browser tab active when recording.\n\n - This recording option will record to your local download folder.\n\n - Quality may depend on the Internet connection between you and the guest.\n\n - Recordings may possibly fail; have a backup option!", "no-streamID-provided": "No streamID was provided; one will be generated randomily.\n\nStream ID: ", "alphanumeric-only": "Info: Only AlphaNumeric characters should be used for the stream ID.\n\nThe offending characters have been replaced by an underscore", "stream-id-too-long": "The Stream ID should be less than 64 alPhaNuMeric characters long.\n\nWe will trim it to length.", "share-with-trusted": "Share only with those you trust", "pass-recommended": "A password is recommended", "insecure-room-name": "Insecure room name.", "allowed-chars": "Allowed chars", "transfer": "transfer", "armed": "armed", "transfer-guest-to-room": "Transfer guests to room:\n\n(Please note: rooms must share the same password)", "transfer-guest-to-url": "Transfer guests to new website URL.\n\nGuests will be prompted to accept unless they are using &consent", "change-url": "change URL", "mute-in-scene": "mute in scene", "unmute-guest": "unmute guest", "unmute": "unmute", "undeafen": "undeafen", "deafen": "deafen guest", "unblind": "unblind", "blind": "blind guest", "mute-guest": "mute guest", "mute": "mute", "unhide": "unhide guest", "hide-guest": "Hide", "insecure-stream-id": "⚠️ Insecure stream ID detected\n\nIt is strongly advised that a password is used if using a short non-unique stream ID.\n\nThis is just a warning that can be ignored.", "confirm-disconnect-users": "Are you sure you wish to disconnect these users?", "confirm-disconnect-user": "Are you sure you wish to disconnect this user?", "enter-new-codirector-password": "Enter a co-director password to use", "control-room-co-director": "Control Room: Co-Director", "volume-control": "Volume control for local playback only", "signal-meter": "Video packet loss indicator of video preview; green is good, red is bad. Flame implies CPU is overloaded. May not reflect the packet loss seen by scenes or other guests.", "waiting-for-the-stream": "Waiting for the stream. Tip: Adding &cleanoutput to the URL will hide this spinner, or click to retry, which will also hide it.", "main-director": "Main Director", "co-director": "Co-Director", "share-a-screen": "Share a screen", "stop-screen-sharing": "Stop screen sharing", "you-have-been-transferred": "You've been transferred to a different room", "you-have-been-activated": "The director has now allowed you to see others in the room", "you-not-yet-activated": "Please wait until the director brings you into the room", "you-are-no-longer-a-co-director": "You are no longer a co-director as you were transferred.", "transferred": "Transferred", "room-changed": "Your room has changed", "headphones-tip": "Tip: Use headphones to avoid audio echo issues.", "camera-tip-c922": "Tip: To achieve 60-fps with a C922 webcam, low-light compensation needs to be turned off, exposure set to auto, and 720p used.", "camera-tip-camlink": "Tip: A Cam Link may glitch green/purple if accessed elsewhere while already in use.", "samsung-a-series": "Samsung A-series phones may have issues with Chrome; if so, try Firefox Mobile instead or switch video codecs.", "screen-permissions-denied": "Permission to capture denied. Ensure your browser has screen record system permissions\n\n1.On your Mac, choose Apple menu > System Preferences, click Security & Privacy , then click Privacy.\n2.Select Screen Recording.\n3.Select the checkbox next to your browser to allow it to record your screen.", "change-audio-output-device": "Audio could not be captured.\n\nIf you need audio, please make sure you have an audio output device available.\n\nSome gaming headsets (ie: Logitech/Corsair) also may need to be set to 2-channel output to work, as surround sound drivers may cause problems", "prompt-access-request": " is trying to view your stream. Allow them?", "confirm-reload-user": "Are you sure you wish to reload this user's browser?", "webrtc-is-blocked": "⚠ This browser has either blocked WebRTC or does not support it.\n\nThis site will not work without it.\n\nDisable any browser extensions or privacy settings that may be blocking WebRTC, or try a different browser.", "not-clean-session": "Video effects or canvas rendering failed.\n\nCheck to ensure any remotely hosted images are cross-origin allowed.", "ios-no-screen-share": "Sorry, but your iOS browser does not support screen-sharing.\n\nPlease see this guide for an alternative method to do so.", "mobile-no-screen-share": "Sorry, your mobile browser does not support screen-sharing.\n\nThe The native apps do offer basic support for it though.", "no-screen-share-supported": "Sorry, your browser does not support screen-sharing.\n\nPlease use the desktop versions of Firefox or Chrome instead.", "no-screen-share-supported-firefox": "Sorry, your browser does not support screen-sharing.\n\nYour Firefox settings may be configured to block it or you've accessed the site insecurely.", "speech-not-suppoted": "⚠ Speech Recognition is not supported by this browser", "blue-yeti-tip": "Tip: Blue Yeti microphones may experience issues being overly loud. Please see here for a solution or disable auto-gain in VDO.Ninja.", "sample-rate-too-high": "Your audio playback device has its sample rate set very high. If having audio issues, try using 48-kHz instead.", "site-not-responsive": "

Notice: The system cannot be accessed or is currently slow to respond.

\nIf a routing issue, try adding &proxy to the URL; you can also try https://proxy.vdo.ninja or a VPN if the service is blocked in your country.\n\nIf the main service is down, a backup version is also available here: https://backup.vdo.ninja\n\nContact steve@seguin.email for added help.\n\nThis service requires the use of Websockets over port 443.", "no-audio-source-detected": "No audio source was detected.

Please see the documention for a guide on how to capture application-based audio.", "viewer-count": "Total outbound p2p connections of this remote stream", "enter-url-for-widget": "Enter a URL for a page to embed as a sidebar", "director-password": "Enter the main director's password", "vision-disabled": "The Director has disabled your vision temporarily

", "invalid-remote-code": "Invalid remote control code.\n\nUse the field below to try again with a different passcode.", "invalid-remote-code-obs": "Invalid remote control code.\n\nThe remote OBS system needs a matching passcode set using &remote.\n\nSee the documentation for help..", "request-rejected-obs": "The request was rejected.\n\nThe remote OBS system needs a matching passcode set using &remote.\n\nSee the documentation for help.", "remote-token-rejected": "The remote request failed; the &remote token did not match or the remote user does not allow remote control.", "remote-control-failed": "The remote control request failed.", "remote-peer-connected": "Remote peer connected to video stream.\n\nConnection to handshake server being killed on request. This increases security, but the peer will not be able to reconnect automatically on connection failure.\n\nPress OK to start the stream!", "director-denied": "⚠️ The main director denied you as a co-director\n\nYou will only be able to preview streams; you will not be able to control or change anything.", "only-main-director": "Only the main director can transfer this guest", "request-failed": "The request failed; you can't apply this action", "tokens-did-not-match": "The remote request failed; the remote token did not match or the remote user does not allow remote control.", "token-not-director": "The request failed; the remote user did not recognize you as the director.\n\nRefreshing may help in some cases, if you are indeed the director.", "approved-as-director": "The director approved you as a co-director", "you-are-a-codirector": "You are a co-director of this room; you have partial director control assigned to you.", "this-is-you": "This is you, a co-director.
You are also a performer.", "preview-meshcast-disabled": "You can't adjust the preview bitrate for Meshcast or WHIP-based streams", "no-network": "Network connection lost 🤷‍♀️❌📶", "no-network-details": "Network connection lost. 🤷‍♀️❌📶\n\nHave you lost your Internet connection?", "enter-password-if-desired": "Enter a password if provided, otherwise just click Cancel", "your-screenshare": "Your screenshare", "your-camera": "Your camera", "accept-inbound-caller": "Accept the inbound telephone caller?", "disable-video": "Disable Video", "show-more-options": "Show more options", "system-default": "System Default" }; function getTranslation(key) { // when using this, instead of miniTranslate, if the user changes the language, it might not update. Used mainly when you don't want any HTML () being including in the translation if (translation.innerHTML && key in translation.innerHTML) { // these are the proper translations return translation.innerHTML[key]; } else if (key in miscTranslations) { // i guess these can be transitioned to innerHTML return miscTranslations[key]; } else { warnlog("misc translation not found"); return key.replaceAll("-", " "); // } } // Extract hostname from TURN server URL for QoS tracking // Handles formats: turn:host:port, turns:host:port, turn:user@host:port, turns:[ipv6]:port function extractTurnHostnameFromUrl(turnUrl) { if (!turnUrl || typeof turnUrl !== "string") return null; var cleaned = turnUrl.replace(/^turns?:/i, ""); cleaned = cleaned.split("?")[0]; var atIndex = cleaned.lastIndexOf("@"); if (atIndex !== -1) cleaned = cleaned.slice(atIndex + 1); if (cleaned[0] === "[") { var end = cleaned.indexOf("]"); return end !== -1 ? cleaned.slice(1, end) : null; } return cleaned.split(":")[0] || null; } // Build QoS allowlist from a list of TURN server configurations function buildQosTurnAllowlist(turnlist) { var allowlist = []; if (!turnlist || !Array.isArray(turnlist)) return allowlist; turnlist.forEach(function(turn) { var urls = turn.urls || turn.url || []; if (typeof urls === "string") urls = [urls]; urls.forEach(function(u) { var host = extractTurnHostnameFromUrl(u); if (host && !allowlist.includes(host)) { allowlist.push(host); } }); }); return allowlist; } if (typeof session === "undefined") { // make sure to init the WebRTC if not exists. var session = WebRTC.Media; session.streamID = session.generateStreamID(); errorlog("Serious error: WebRTC session didn't load in time"); } try { // this is just in case orientationchange gets removed.. if (!window.onorientationchange && screen.orientation) { // onorientationchange is deprecated. window.onorientationchange = function () { log("screen.orientation triggered.. but nothing linked"); }; screen.orientation.addEventListener("change", window.onorientationchange); } } catch (e) { errorlog(e); } function getSlotColor(index) { var slotColorPalette = [ "#00AAAA", "#FF0000", "#0000FF", "#AA00AA", "#00FF00", "#AAAA00", "#AACC44", "#CCAA44", "#CC44AA", "#44AACC" ]; index = parseInt(index); if (!isFinite(index) || index < 0) { index = 0; } if (!slotColorPalette[index]) { var hue = (index * 47) % 360; slotColorPalette[index] = "hsl(" + hue + ", 65%, 55%)"; } return slotColorPalette[index]; } function getSlotColorBySlotNumber(slotNumber) { var slotIndex = parseInt(slotNumber); if (!isFinite(slotIndex) || slotIndex <= 0) { return null; } return getSlotColor(slotIndex - 1); } function applySlotColor(element, slotNumber) { if (!element) { return; } var color = getSlotColorBySlotNumber(slotNumber); if (color) { element.style.background = color; element.style.backgroundColor = color; } else { element.style.removeProperty("background"); element.style.removeProperty("background-color"); } } function populateSlotPicker(maxSlots) { var picker = document.getElementById("slotPicker"); if (!picker) { return; } var html = "

Assign to slot:


"; html += '
Unset
'; for (var i = 1; i <= session.maxAvailableSlots; i++) { var color = getSlotColor(i - 1); html += '
Slot ' + i + "
"; } picker.innerHTML = html; } function positionAlertModalNearEvent(modal, event) { if (!modal || !event) { return; } try { var margin = 16; var scrollX = window.pageXOffset || document.documentElement.scrollLeft || 0; var scrollY = window.pageYOffset || document.documentElement.scrollTop || 0; var viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; modal.classList.add("alertModal--anchored"); modal.style.transform = "none"; var modalWidth = modal.offsetWidth || 0; var modalHeight = modal.offsetHeight || 0; var desiredLeft = event.pageX - modalWidth / 2; var desiredTop = event.pageY + 24; if (!isFinite(desiredLeft)) { desiredLeft = scrollX + margin; } if (!isFinite(desiredTop)) { desiredTop = scrollY + margin; } var maxLeft = scrollX + viewportWidth - modalWidth - margin; var maxTop = scrollY + viewportHeight - modalHeight - margin; modal.style.left = Math.max(scrollX + margin, Math.min(maxLeft, desiredLeft)) + "px"; modal.style.top = Math.max(scrollY + margin, Math.min(maxTop, desiredTop)) + "px"; } catch (err) { errorlog(err); } } (function (w) { w.URLSearchParams = w.URLSearchParams || function (searchString) { var self = this; searchString = searchString.replace("??", "?"); self.searchString = searchString; self.get = function (name) { var results = new RegExp("[?&]" + name + "=([^&#]*)").exec(self.searchString); if (results == null) { return null; } else { return decodeURI(results[1]) || 0; } }; }; })(window); var urlEdited = window.location.search.replace(/\?\?/g, "?"); urlEdited = urlEdited.replace(/\?/g, "&"); urlEdited = urlEdited.replace(/\&/, "?"); var urlParams = new URLSearchParams(urlEdited); if (urlParams.has("invite") || urlParams.has("i") || urlParams.has("code")) { session.decodeInvite(urlParams.get("invite") || urlParams.get("i") || urlParams.get("code")); } else if (urlParams.has("preset")) { try { let preset = urlParams.get("preset") || "1"; // default to preset 1 if none provided let xhttp = new XMLHttpRequest(); xhttp.open("GET", "presets.json", false); // blocking xhttp.setRequestHeader("Content-Type", "application/json"); // expecting json response xhttp.send(); if (xhttp.status === 200) { const response = JSON.parse(xhttp.responseText); let presetString = ""; if (Array.isArray(response)) { let index = (parseInt(preset) || 1) - 1; presetString = response[index]; } else if (typeof response === "object" && response !== null) { presetString = response[preset]; } if (!presetString.startsWith("?") || presetString.startsWith("&")) { presetString = "?" + presetString; } session.preset = presetString; let newURL = presetString + "&" + urlParams.toString(); newURL = newURL.replace(/\?/g, "&"); newURL = newURL.replace(/\&/, "?"); urlParams = new URLSearchParams(newURL); if (urlParams.has("invite") || urlParams.has("i") || urlParams.has("code")) { session.decodeInvite(urlParams.get("invite") || urlParams.get("i") || urlParams.get("code")); } } else { errorlog(xhttp.statusTex); } } catch (error) { errorlog(error); } } if (session.decrypted) { session.decrypted = session.decrypted + urlEdited.replace("?", "&"); session.decrypted = session.decrypted.replace(/\?/g, "&"); session.decrypted = session.decrypted.replace(/\&/, "?"); urlParams = new URLSearchParams(session.decrypted); //session.decrypted = true; } else if (urlEdited !== window.location.search) { warnlog(window.location.search + " changed to " + urlEdited); if (!session.nohistory) { window.history.pushState({ path: urlEdited.toString() }, "", urlEdited.toString()); } } delete urlEdited; var isIFrame = false; if (parent && window.location !== window.parent.location) { isIFrame = true; } function mapToAll(targets, callback, parentElement = document) { // js helper if (!targets) { return; } if (!parentElement) { return; } const target = parentElement.querySelectorAll(targets); for (let i = 0; i < target.length; i++) { callback(target[i]); } } function changeParam(url, paramName, paramValue) { paramName = paramName.replace("?", ""); var qind = url.indexOf("?"); url = url.replace("?", "&"); var params = url.substring(qind + 1).split("&"); var query = ""; var match = false; for (var i = 0; i < params.length; i++) { var tokens = params[i].split("="); var name = tokens[0]; var value = ""; if (tokens.length > 1 && tokens[1] !== "") { value = tokens[1]; } if (name == paramName) { if (match) { continue; } // already matched the first time. match = true; value = paramValue; } if (value !== "") { value = "=" + value; } if (query == "") { query = "?" + name + value; } else { query = query + "&" + name + value; } } return url.substring(0, qind) + query; } function saveRoom(ele) { //this.title = "Quick load settings stored locally"; session.sticky = true; ele.parentNode.removeChild(ele); setStorage("permission", "yes"); setStorage("settings", encodeURI(window.location.href), 999); } function updateURL(param, force = false, cleanUrl = false) { if (session.decrypted) { return; } param = param.replace("?", ""); var para = param.split("="); if (cleanUrl) { if (history.pushState) { var href = new URL(cleanUrl); if (para.length == 1) { href = changeParam(cleanUrl, para[0], ""); } else { href = changeParam(cleanUrl, para[0], para[1]); } log("--" + href.toString()); if (!session.nohistory) { window.history.pushState({ path: href.toString() }, "", href.toString()); } } } else if (!urlParams.has(para[0])) { // don't need to replace as it doesn't exist. if (history.pushState) { var href = window.location.href; href = href.replace("??", "?"); var arr = href.split("?"); var newurl; if (arr.length > 1 && arr[1] !== "") { newurl = href + "&" + param; } else { newurl = href + "?" + param; } if (!session.nohistory) { window.history.pushState({ path: newurl.toString() }, "", newurl.toString()); } } } else if (force) { if (history.pushState) { var href = new URL(window.location.href); if (para.length == 1) { href = changeParam(window.location.href, para[0], ""); } else { href = changeParam(window.location.href, para[0], para[1]); } log("---" + href.toString()); if (!session.nohistory) { window.history.pushState({ path: href.toString() }, "", href.toString()); } } } if (session.sticky) { setStorage("settings", encodeURI(window.location.href), 999); } urlParams = new URLSearchParams(window.location.search); if (session.preset) { let newURL = session.preset + "&" + urlParams.toString(); newURL = newURL.replace(/\?/g, "&"); newURL = newURL.replace(/\&/, "?"); urlParams = new URLSearchParams(newURL); } } /* function changeGuestSettings(ele){ var eles = ele.querySelectorAll('[data-param]'); var UUID = ele.dataset.UUID; var settings = {}; for (var i = 0;i< eles.length; i++){ if (eles[i].tagName.toLowerCase() == "input"){ if (eles[i].checked===true){ settings[eles[i].dataset.param] = true; } else if (eles[i].checked===false){ settings[eles[i].dataset.param] = false; } else { settings[eles[i].dataset.param] = eles[i].value; } } } warnlog(settings); if (!settings.changepassword){ delete settings.password; } delete settings.changepassword; if (!settings.changeroom){ // send Migration message delete settings.roomid; } delete settings.roomid; delete settings.changeroom; warnlog(UUID); var msg = {}; msg.changeParams = settings; session.sendRequest(msg, UUID); closeModal(); } */ // proper room migration needs to happen; in sync. // updateMixer after settings changed // password needs to be special cased // room shouldn't be sent function applyNewParams(changeParams) { for (var key in changeParams) { session[key] = changeParams[key]; log(key); } log(changeParams); updateMixer(); } function submitDebugLog(msg = false) { try { if (navigator.userAgent) { var _, userAgent = navigator.userAgent; appendDebugLog({ userAgent: userAgent }); } if (navigator.platform) { appendDebugLog({ userAgent: navigator.platform }); } } catch (e) { } window.focus(); var res = confirm(getTranslation("submit-error-report")); if (res) { var request = new XMLHttpRequest(); var recordResults = session.streamID + "_" + parseInt(Date.now()); request.open("POST", "https://reports.vdo.ninja/?name=" + recordResults); // php, well, whatever. if (!session.cleanOutput) { warnUser("Report any details of your bug report to steve@seguin.email, along with the following link: https://reports.vdo.ninja/?name=" + recordResults + "", false, false); } console.log("Report any details of your bug report to steve@seguin.email, along with the following ID: " + recordResults); request.send(JSON.stringify(errorReport)); errorReport = []; if (document.getElementById("reportbutton")) { getById("reportbutton").classList.add("hidden"); } } } function URLFromFiles(files) { const promises = files.map(file => fetch(file).then(response => response.text())); return Promise.all(promises).then(texts => { const text = texts.join(""); const blob = new Blob([text], { type: "application/javascript" }); return URL.createObjectURL(blob); }); } function detectCPUSupport() { let cpuThreads = navigator.hardwareConcurrency; if (cpuThreads) { return cpuThreads + " threads"; } return false; } function detectGPUSupport() { try { const gl = document.createElement("canvas").getContext("webgl"); if (!gl) { return false; } if (!Firefox) { try { const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); // chrome if (debugInfo) { return gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); } } catch (e) { } } try { return gl.getParameter(gl.RENDERER) || false; // firefox } catch (e) { } } catch (e) { } return false; } function isOperaGX() { return (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(" OPR/75") >= 0; } function isSamsungASeries() { return navigator.userAgent.includes("; SM-A") || false; } function getChromiumVersion() { var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); return raw ? parseInt(raw[2], 10) : false; } function getiOSVersion() { try { var agent = navigator.userAgent; var start = agent.indexOf("OS "); if ((agent.indexOf("iPhone") > -1 || agent.indexOf("iPad") > -1) && start > -1) { return window.Number(agent.substr(start + 3, 3).replace("_", ".")); } return 0; } catch (e) { return 0; } return 0; } function safariVersion() { var ver = 0; try { ver = navigator.appVersion.split("Version/"); if (ver.length > 1) { ver = ver[1].split(" Safari"); } if (ver.length > 1) { ver = ver[0].split("."); } if (ver.length > 1) { ver = parseInt(ver[0]); } else { ver = 0; } } catch (e) { return 0; } return ver; } function isIntelMac() { // Check if it's a Mac but not Apple Silicon if (macOS && navigator.userAgent.indexOf("Intel") >= 0) { return true; } return false; } function judgePerformance() { try { if (SafariVersion && SafariVersion >= 17 && (iOS || iPad)) { // iphone xr or newer return 0; } const cores = typeof navigator.hardwareConcurrency === 'number' ? navigator.hardwareConcurrency : 0; if (isIntelMac()) { if (cores < 6) { // yes. they are that bad. return 2; } else { return 1; } } if (session.mobile && (cores >= 4)) { // assume hardware encoded acceleration return 0; } if (!cores) { return 1; } else if (cores < 4) { return 2; } else if (cores > 8) { return 0; } return 1 } catch (e) { return 1; // 99% safe default } } try { var iOS = !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); // used by main.js also var iPad = navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform); var macOS = navigator.userAgent.indexOf("Mac OS X") != -1; macOS = macOS && !(iOS || iPad); var Firefox = navigator.userAgent.indexOf("Firefox") >= 0; if (Firefox) { Firefox = parseInt(navigator.userAgent.split("irefox/").pop()) || true; } var Android = navigator.userAgent.toLowerCase().indexOf("android") > -1; //&& ua.indexOf("mobile"); var ChromiumVersion = getChromiumVersion(); var OperaGx = isOperaGX(); var SafariVersion = safariVersion() || getiOSVersion(); // I should rename this to webkit if (iOS || iPad) { // iOS doesn't yet allow actual browsers, cause it's abusing its duopoly. if (SafariVersion) { if (Firefox) { Firefox = false; // I should rename this to gecko } if (ChromiumVersion) { ChromiumVersion = false; // I should rename this to chromium } } } var SamsungASeries = isSamsungASeries(); var isVingester = navigator.userAgent.indexOf("Vingester") >= 0; var gpgpuSupport = detectGPUSupport(); // graphics ; not supported on ios log(gpgpuSupport); var cpuSupport = detectCPUSupport(); // thread count ; supported on ios log(cpuSupport); var iPhone12Up = false; var isMELD = false; if (typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.includes("Meld/")) { isMELD = true; } if (iOS && !iPad) { if (window.devicePixelRatio.toFixed(2) >= 3 && window.screen.height > 800 && window.screen.width != 414) { // for reference, https://www.ios-resolution.com/ iPhone12Up = true; // iPhone SE is left out. } } session.quality_wb = judgePerformance(); // try to estimate what resolution to use for encoding when not in a room. if (session.quality_room < session.quality_wb) { session.quality_room = session.quality_wb; } } catch (e) { errorlog(e); } const needsLegacyWakeLock = () => { try { if ("wakeLock" in navigator) { if (Firefox) { return true; } if ((iOS || iPad) && SafariVersion < 16.4) { return true; } if (typeof session !== "undefined") { if (session.forceLegacyWakeLock) { return true; } if (session.wakeLockActive) { return false; } } return true; } } catch (e) { } return true; // No Wake Lock API or no active lock; need legacy keep alive for mobile }; function removeLegacyKeepAlivePlayer() { const keepAlive = document.getElementById("keepAlivePlayer"); if (keepAlive) { keepAlive.remove(); } } function ensureLegacyKeepAlivePlayer(ignoreVideoPresence = false) { if (typeof session === "undefined" || !session.mobile) { removeLegacyKeepAlivePlayer(); return false; } if (!needsLegacyWakeLock()) { removeLegacyKeepAlivePlayer(); return false; } if (!session.streamSrc) { return true; } try { if ( !ignoreVideoPresence && session.streamSrc.getVideoTracks && session.streamSrc.getVideoTracks().length ) { removeLegacyKeepAlivePlayer(); return false; } } catch (e) { errorlog(e); } if (!document.getElementById("keepAlivePlayer")) { let fakeElement = document.createElement("video"); fakeElement.autoplay = true; fakeElement.loop = true; fakeElement.muted = true; fakeElement.src = "./media/micro.mp4"; fakeElement.style.width = "1px"; fakeElement.style.height = "1px"; fakeElement.controls = false; fakeElement.id = "keepAlivePlayer"; getById("main").appendChild(fakeElement); } return true; } function startLegacyKeepAliveLoop(ignoreVideoPresence = false) { if (typeof session === "undefined" || !session.mobile) { return; } if (session.keepAliveInterval) { if (ignoreVideoPresence) { session.keepAliveIgnoreVideo = true; } return; } session.keepAliveIgnoreVideo = !!ignoreVideoPresence; const runner = () => { const keepRunning = ensureLegacyKeepAlivePlayer(session.keepAliveIgnoreVideo); if (!keepRunning) { clearInterval(session.keepAliveInterval); session.keepAliveInterval = null; session.keepAliveIgnoreVideo = false; } }; const shouldContinue = ensureLegacyKeepAlivePlayer(session.keepAliveIgnoreVideo); if (!shouldContinue) { session.keepAliveIgnoreVideo = false; return; } session.keepAliveInterval = setInterval(runner, 4000); } if (session.audioCtx && session.audioCtx.sampleRate && session.audioCtx.sampleRate > 192000) { console.warn("Your audio playback device has a very high sample-rate set of " + session.audioCtx.sampleRate + "-Hz. If having audio problems, lower to at least 192000-Hz, but preferably 48000-Hz."); if (!session.cleanOutput) { miniTranslate(getById("audioTipContextSR"), "sample-rate-too-high"); getById("audioTipSR").classList.remove("hidden"); } } if (isVingester) { console.warn("If Vingester isn't able to capture audio, get a fixed version of Vingester from here: https://github.com/steveseguin/vingester/releases/"); } function isAlphaNumeric(str) { var code, i, len; for (i = 0, len = str.length; i < len; i++) { code = str.charCodeAt(i); if ( !(code > 47 && code < 58) && // numeric (0-9) !(code > 64 && code < 91) && // upper alpha (A-Z) !(code > 96 && code < 123) ) { // lower alpha (a-z) return false; } } return true; } function convertStringToArrayBufferView(str) { var bytes = new Uint8Array(str.length); for (var iii = 0; iii < str.length; iii++) { bytes[iii] = str.charCodeAt(iii); } return bytes; } function toHexString(byteArray) { return Array.prototype.map .call(byteArray, function (byte) { return ("0" + (byte & 0xff).toString(16)).slice(-2); }) .join(""); } function toByteArray(hexString) { var result = []; for (var i = 0; i < hexString.length; i += 2) { result.push(parseInt(hexString.substr(i, 2), 16)); } return new Uint8Array(result); } function playAllVideos() { if (session.firstPlayTriggered && session.audioCtx.state == "suspended") { // added oct 9th 2022 try { session.audioCtx.resume(); } catch (e) { warnlog(e); } } for (var i in session.rpcs) { if (session.rpcs[i].whip) { continue; } try { if (session.rpcs[i].videoElement) { log("I: " + i); if (session.rpcs[i].videoElement.paused) { setTimeout( function (UUID) { session.rpcs[UUID].videoElement .play() .then(_ => { log("playing 3 "); if (session.audioEffects === true || session.pushLoudness) { log("updateIncomingAudioElement('" + UUID + "')"); updateIncomingAudioElement(UUID); } }) .catch(errorlog); }, 0, i ); } else if (session.audioEffects === true || session.pushLoudness) { updateIncomingAudioElement(i); log("updateIncomingAudioElement('" + i + "')"); } } } catch (e) { errorlog(e); } } } var videoElements = Array.from(document.querySelectorAll("video")); var audioElements = Array.from(document.querySelectorAll("audio")); var mediaStreamCounter = 0; function createMediaStream() { mediaStreamCounter += 1; return new MediaStream(); } var deleteOldMediaTimeout = null; function deleteOldMedia(timed = false) { if (!timed) { if (!deleteOldMediaTimeout) { deleteOldMediaTimeout = setTimeout(function () { deleteOldMediaTimeout = null; deleteOldMedia(true); }, 2000); } return; } log("CHECKING FOR OLD MEDIA"); var i = videoElements.length; while (i--) { //if ((videoElements[i].id == "videosource") || (videoElements[i].id == "previewWebcam")){continue;} // exclude this one, for safety reasons. (Also, iOS safari blanks the video if streams are detached and moved between video elements) if (videoElements[i].isConnected === false) { if (videoElements[i].srcObject == null || (videoElements[i].srcObject && videoElements[i].srcObject.active === false)) { if (videoElements[i].dataset && videoElements[i].dataset.UUID) { if (videoElements[i].dataset.UUID in session.rpcs) { continue; } // still active, so lets not delete it. } videoElements[i].pause(); videoElements[i].removeAttribute("id"); videoElements[i].removeAttribute("src"); // empty source videoElements[i].load(); videoElements[i].remove(); videoElements[i] = null; videoElements.splice(i, 1); } } } i = audioElements.length; while (i--) { if (audioElements[i].isConnected === false) { if (audioElements[i].srcObject == null || (audioElements[i].srcObject && audioElements[i].srcObject.active === false)) { if (audioElements[i].dataset && audioElements[i].dataset.UUID) { if (audioElements[i].dataset.UUID in session.rpcs) { continue; } // still active, so lets not delete it. } audioElements[i].pause(); audioElements[i].id = null; audioElements[i].removeAttribute("src"); // empty source audioElements[i].load(); audioElements[i].remove(); audioElements[i] = null; audioElements.splice(i, 1); } } } } function createAudioElement() { try { deleteOldMedia(); } catch (e) { errorlog(e); } var a = document.createElement("audio"); audioElements.push(a); return a; } function compare_deltas(a, b) { var aa = a.delta || 0; var bb = b.delta || 0; if (aa > bb) { return 1; } if (aa < bb) { return -1; } return 0; } async function fetchWithTimeout(URL, timeout = 8000) { // ref: https://dmitripavlutin.com/timeout-fetch-request/ try { const controller = new AbortController(); const timeout_id = setTimeout(() => controller.abort(), timeout); const response = await fetch(URL, { ...{ timeout: timeout }, signal: controller.signal }); clearTimeout(timeout_id); return response; } catch (e) { errorlog(e); return await fetch(URL); // iOS 11.x/12.0 } } function createVideoElement() { try { deleteOldMedia(); } catch (e) { errorlog(e); } var v = document.createElement("video"); videoElements.push(v); if (typeof session.volume == "number") { v.volume = session.volume; // setting default volume log("setting volume to manual"); } return v; } function getTimezone() { if (session.tz !== false) { return session.tz; } const stdTimezoneOffset = () => { var jan = new Date(0, 1); var jul = new Date(6, 1); return Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset()); }; var today = new Date(); const isDstObserved = today => { return today.getTimezoneOffset() < stdTimezoneOffset(); }; if (isDstObserved(today)) { return today.getTimezoneOffset() + 60; } else { return today.getTimezoneOffset(); } } function promptUser(eleId, UUID = null) { if (session.beepToNotify) { playtone(); } if (document.getElementById("modalBackdrop")) { getById("promptModal").innerHTML = ""; // Delete modal getById("promptModal").remove(); getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); } zindex = document.querySelectorAll("#promptModal").length + document.querySelectorAll(".alertModal").length; modalTemplate = `
×
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end getById("promptModalMessage").innerHTML = getById(eleId).innerHTML; miniTranslate(getById("promptModal")); if (UUID) { getById("promptModalMessage").dataset.UUID = UUID; } document.getElementById("modalBackdrop").addEventListener("click", closeModal); getById("promptModal").addEventListener("click", function (e) { e.stopPropagation(); return false; }); } async function delay(ms) { return await new Promise((resolve, reject) => { setTimeout(resolve, ms); }); } var Prompts = {}; async function promptAlt(inputText, block = false, asterix = false, value = false, time = false, recording = false, hotkey = false, field = null) { var result = null; if (session.beepToNotify) { playtone(); } await new Promise((resolve, reject) => { var promptID = "pid_" + Math.random().toString(36).substr(2, 9); Prompts[promptID] = {}; Prompts[promptID].resolve = resolve; Prompts[promptID].reject = reject; var zindex = 32 + document.querySelectorAll(".promptModal").length + document.querySelectorAll(".alertModal").length; if (block) { var backdropClass = "opaqueBackdrop"; } else { var backdropClass = "modalBackdrop"; } inputText = "" + inputText.replace("\n", "
") + ""; inputText = inputText.replace(/\n/g, "
"); var type = "text"; if (asterix) { type = "password"; } if (time) { modalTemplate = `
`; } else if (recording) { modalTemplate = `
`; } else if (field && field.type === "file") { modalTemplate = `
`; } else if (hotkey) { modalTemplate = `
`; } else if (field && field.type === "select") { modalTemplate = `
`; } else if (field && field.placeholder) { modalTemplate = `
`; } else { modalTemplate = `
`; } document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end // Only add select listener if we have a select field if (field && field.type === "select") { document.getElementById(`select_${promptID}`).addEventListener("change", (e) => { const input = document.getElementById(`input_${promptID}`); if (e.target.value === "[Custom]") { input.style.display = "block"; input.focus(); } else { input.style.display = "none"; result = e.target.value; } }); } document.getElementById("input_" + promptID).focus(); if (value !== false) { if (time) { document.getElementById("input_" + promptID).value = parseInt(value / 60); document.getElementById("input_" + promptID + "_sec").value = parseInt(value) % 60; } else { document.getElementById("input_" + promptID).value = value; } } if (time) { document.getElementById("input_" + promptID).addEventListener("keyup", function (event) { if (event.key === "Enter") { document.getElementById("input_" + promptID + "_sec").focus(); } }); document.getElementById("input_" + promptID + "_sec").addEventListener("keyup", function (event) { if (event.key === "Enter") { document.getElementById("submit_" + promptID).focus(); } }); document.getElementById("countup_" + promptID).addEventListener("click", function (event) { if (document.getElementById("countup_" + promptID).checked) { document.getElementById("input_" + promptID).disabled = true; document.getElementById("input_" + promptID + "_sec").disabled = true; } else { document.getElementById("input_" + promptID).disabled = false; document.getElementById("input_" + promptID + "_sec").disabled = false; delete document.getElementById("input_" + promptID).disabled; delete document.getElementById("input_" + promptID + "_sec").disabled; } }); } else if (field && field.type === "file") { document.getElementById(`input_${promptID}`).addEventListener("change", async (e) => { const file = e.target.files[0]; if (file) { result = file; if (file.type.startsWith('image/')) { const preview = document.getElementById(`preview_${promptID}`); const reader = new FileReader(); reader.onload = (e) => { preview.innerHTML = ``; }; reader.readAsDataURL(file); } } }); } else { document.getElementById("input_" + promptID).addEventListener("keyup", function (event) { if (event.key === "Enter") { var pid = event.target.dataset.pid; if (field && field.type === "select") { const selectEl = document.getElementById(`select_${pid}`); if (selectEl.value === "[Custom]") { result = document.getElementById(`input_${pid}`).value; } else { result = selectEl.value; } } else { result = document.getElementById("input_" + pid).value; } document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); } }); } try { document.getElementById("submit_" + promptID).addEventListener("click", async function (event) { var pid = event.target.dataset.pid; if (time) { result = parseInt(document.getElementById("input_" + pid + "_sec").value) + parseInt(document.getElementById("input_" + pid).value) * 60; if (document.getElementById("countup_" + promptID).checked) { result = 0; } } else if (field && field.type === "select") { const selectEl = document.getElementById(`select_${pid}`); if (selectEl.value === "[Custom]") { result = document.getElementById(`input_${pid}`).value; } else { result = selectEl.value; } } else if (field && field.type === "file") { if (result) { await handleImageUpload(result, field); } } else { result = document.getElementById("input_" + pid).value; } document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } try { document.getElementById("cancel_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } try { document.getElementById("close_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } getById("modal_" + promptID).addEventListener("click", function (e) { e.stopPropagation(); return false; }); miniTranslate(getById("modal_" + promptID)); return; }); return result; } async function promptRecordingOptions(inputText, block = false, defaultOptions = {}) { var result = null; // console.log(defaultOptions); var defaultVideoBitrate = 6000; var defaultAudioBitrate = 80; if (defaultOptions.audioOnly && !defaultOptions.usePCM) { defaultAudioBitrate = defaultOptions.bitrate || 80; } else if (!defaultOptions.audioOnly) { defaultVideoBitrate = defaultOptions.bitrate || 6000; } if (session.beepToNotify) { playtone(); } // Helper functions for bitrate controls function updateBitrateControls(promptID) { const audioOnly = document.getElementById(`audioOnly_${promptID}`).checked; const usePCM = document.getElementById(`usePCM_${promptID}`).checked; const pcmContainer = document.getElementById(`audioFormatContainer_${promptID}`); const slider = document.getElementById(`bitrateSlider_${promptID}`); const value = document.getElementById(`bitrateValue_${promptID}`); const bitrateLabel = document.getElementById(`bitrateLabel_${promptID}`); // PCM option always available pcmContainer.style.opacity = '1'; pcmContainer.style.pointerEvents = 'auto'; bitrateLabel.parentNode.style.opacity = "1"; bitrateLabel.parentNode.style.pointerEvents = 'auto'; //document.getElementById(`usePCM_${promptID}`).disabled = false; // Update bitrate controls based on mode if (audioOnly && usePCM) { // Audio-only PCM mode bitrateLabel.textContent = "Uncompressed PCM16 audio"; bitrateLabel.parentNode.style.opacity = "0.5"; bitrateLabel.parentNode.style.pointerEvents = 'none'; slider.disabled = true; value.disabled = true; slider.value = ""; value.value = ""; } else if (audioOnly && !usePCM) { // Audio-only OPUS mode bitrateLabel.textContent = "Audio recording bitrate (OPUS):"; slider.disabled = false; value.disabled = false; slider.min = "1"; slider.max = "128"; slider.value = defaultAudioBitrate; slider.step = "1"; value.value = defaultAudioBitrate; } else if (!audioOnly && usePCM) { // Video + PCM audio mode bitrateLabel.textContent = "Video bitrate (with PCM16 audio):"; slider.disabled = false; value.disabled = false; slider.min = "50"; slider.max = "10000"; slider.value = defaultVideoBitrate; slider.step = "100"; value.value = defaultVideoBitrate; } else { // Video + OPUS audio mode bitrateLabel.textContent = "Recording Bitrate:"; slider.disabled = false; value.disabled = false; slider.min = "50"; slider.max = "10000"; slider.value = defaultVideoBitrate; slider.step = "100"; value.value = defaultVideoBitrate; } updateBitrateQuality(promptID); } function updateBitrateValue(promptID) { const value = document.getElementById(`bitrateSlider_${promptID}`).value; document.getElementById(`bitrateValue_${promptID}`).value = value; updateBitrateQuality(promptID); } function updateBitrateSlider(promptID) { const value = document.getElementById(`bitrateValue_${promptID}`).value; document.getElementById(`bitrateSlider_${promptID}`).value = value; updateBitrateQuality(promptID); } function updateBitrateQuality(promptID) { const audioOnly = document.getElementById(`audioOnly_${promptID}`).checked; const usePCM = document.getElementById(`usePCM_${promptID}`).checked; const value = parseInt(document.getElementById(`bitrateValue_${promptID}`).value); const qualitySpan = document.getElementById(`bitrateQuality_${promptID}`); if (audioOnly && usePCM) { qualitySpan.textContent = "N/A"; return; } if (audioOnly) { if (value < 32) qualitySpan.textContent = "Low"; else if (value >= 80) qualitySpan.textContent = "High"; else qualitySpan.textContent = "Medium"; defaultAudioBitrate = value; } else { if (value < 2000) qualitySpan.textContent = "Low"; else if (value >= 6000) qualitySpan.textContent = "High"; else qualitySpan.textContent = "Medium"; defaultVideoBitrate = value; } } await new Promise((resolve, reject) => { var promptID = "pid_" + Math.random().toString(36).substr(2, 9); Prompts[promptID] = {}; Prompts[promptID].resolve = resolve; Prompts[promptID].reject = reject; var zindex = 32 + document.querySelectorAll(".promptModal").length + document.querySelectorAll(".alertModal").length; var backdropClass = block ? "opaqueBackdrop" : "modalBackdrop"; inputText = "

Recording setup


" + inputText.replace(/\n/g, "
"); const modalTemplate = `start record
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Set default values if provided if (defaultOptions.audioOnly) { document.getElementById(`audioOnly_${promptID}`).checked = true; updateBitrateControls(promptID); } if (defaultOptions.usePCM) { document.getElementById(`usePCM_${promptID}`).checked = true; if (defaultOptions.audioOnly) { const bitrateLabel = document.getElementById(`bitrateLabel_${promptID}`); bitrateLabel.textContent = "Uncompressed PCM16 audio"; bitrateLabel.parentNode.style.opacity = "0.5"; bitrateLabel.parentNode.style.pointerEvents = 'none'; } else { const bitrateLabel = document.getElementById(`bitrateLabel_${promptID}`); bitrateLabel.textContent = "Video bitrate (with PCM16 audio):"; } } // Add event listeners document.getElementById(`audioOnly_${promptID}`).addEventListener("change", () => updateBitrateControls(promptID)); document.getElementById(`usePCM_${promptID}`).addEventListener("change", () => updateBitrateControls(promptID)); document.getElementById(`bitrateSlider_${promptID}`).addEventListener("input", () => updateBitrateValue(promptID)); document.getElementById(`bitrateValue_${promptID}`).addEventListener("change", () => updateBitrateSlider(promptID)); // Submit handler document.getElementById(`submit_${promptID}`).addEventListener("click", function (event) { const pid = event.target.dataset.pid; result = { audioOnly: document.getElementById(`audioOnly_${pid}`).checked, usePCM: document.getElementById(`usePCM_${pid}`).checked, bitrate: parseInt(document.getElementById(`bitrateValue_${pid}`).value) }; document.getElementById(`modal_${pid}`).remove(); document.getElementById(`modalBackdrop_${pid}`).remove(); Prompts[pid].resolve(); }); // Cancel handler document.getElementById(`cancel_${promptID}`).addEventListener("click", function (event) { const pid = event.target.dataset.pid; result = null; document.getElementById(`modal_${pid}`).remove(); document.getElementById(`modalBackdrop_${pid}`).remove(); Prompts[pid].resolve(); }); // Close handler document.getElementById(`close_${promptID}`).addEventListener("click", function (event) { const pid = event.target.dataset.pid; result = null; document.getElementById(`modal_${pid}`).remove(); document.getElementById(`modalBackdrop_${pid}`).remove(); Prompts[pid].resolve(); }); // Stop propagation on modal click document.getElementById(`modal_${promptID}`).addEventListener("click", function (e) { e.stopPropagation(); return false; }); // Translate if needed miniTranslate(document.getElementById(`modal_${promptID}`)); }); return result; } async function promptAltRecord(inputText, block = false, asterix = false, value = false) { var result = null; if (session.beepToNotify) { playtone(); } await new Promise((resolve, reject) => { var promptID = "pid_" + Math.random().toString(36).substr(2, 9); Prompts[promptID] = {}; Prompts[promptID].resolve = resolve; Prompts[promptID].reject = reject; var zindex = 32 + document.querySelectorAll(".promptModal").length + document.querySelectorAll(".alertModal").length; if (block) { var backdropClass = "opaqueBackdrop"; } else { var backdropClass = "modalBackdrop"; } inputText = "" + inputText.replace("\n", "
") + ""; inputText = inputText.replace(/\n/g, "
"); var type = "text"; if (asterix) { type = "password"; } if (time) { modalTemplate = `
`; } else if (recording) { modalTemplate = `
`; } else if (hotkey) { modalTemplate = `
`; } else { modalTemplate = `
`; } document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end document.getElementById("input_" + promptID).focus(); if (value !== false) { if (time) { document.getElementById("input_" + promptID).value = parseInt(value / 60); document.getElementById("input_" + promptID + "_sec").value = parseInt(value) % 60; } else { document.getElementById("input_" + promptID).value = value; } } if (time) { document.getElementById("input_" + promptID).addEventListener("keyup", function (event) { if (event.key === "Enter") { document.getElementById("input_" + promptID + "_sec").focus(); } }); document.getElementById("input_" + promptID + "_sec").addEventListener("keyup", function (event) { if (event.key === "Enter") { document.getElementById("submit_" + promptID).focus(); } }); document.getElementById("countup_" + promptID).addEventListener("click", function (event) { if (document.getElementById("countup_" + promptID).checked) { document.getElementById("input_" + promptID).disabled = true; document.getElementById("input_" + promptID + "_sec").disabled = true; } else { document.getElementById("input_" + promptID).disabled = false; document.getElementById("input_" + promptID + "_sec").disabled = false; delete document.getElementById("input_" + promptID).disabled; delete document.getElementById("input_" + promptID + "_sec").disabled; } }); } else { document.getElementById("input_" + promptID).addEventListener("keyup", function (event) { if (event.key === "Enter") { var pid = event.target.dataset.pid; result = document.getElementById("input_" + pid).value; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); } }); } try { document.getElementById("submit_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; if (time) { result = parseInt(document.getElementById("input_" + pid + "_sec").value) + parseInt(document.getElementById("input_" + pid).value) * 60; if (document.getElementById("countup_" + promptID).checked) { result = 0; } } else { result = document.getElementById("input_" + pid).value; } document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } try { document.getElementById("cancel_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } try { document.getElementById("close_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } getById("modal_" + promptID).addEventListener("click", function (e) { e.stopPropagation(); return false; }); miniTranslate(getById("modal_" + promptID)); return; }); return result; } async function promptRecord() { var result = null; if (session.beepToNotify) { playtone(); } await new Promise((resolve, reject) => { var promptID = "pid_" + Math.random().toString(36).substr(2, 9); Prompts[promptID] = {}; Prompts[promptID].resolve = resolve; Prompts[promptID].reject = reject; var zindex = 1030 + document.querySelectorAll(".promptModal").length; var backdropClass = "modalBackdrop"; var inputText = "RECORD SETTINGS"; inputText = "" + inputText.replace("\n", "
") + ""; inputText = inputText.replace(/\n/g, "
"); var type = "text"; modalTemplate = `
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end var gdrive = getById(`input_${promptID}_gdrive`); gdrive.onchange = async function () { if (this.checked) { this.uploadLink = await session.gdrive.startResumableUpload(); } else { this.uploadLink = null; } }; document.getElementById("input_" + promptID).focus(); document.getElementById("input_" + promptID).addEventListener("keyup", function (event) { if (event.key === "Enter") { var pid = event.target.dataset.pid; result = document.getElementById("input_" + pid).value; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); } }); try { document.getElementById("submit_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; if (time) { result = parseInt(document.getElementById("input_" + pid + "_sec").value) + parseInt(document.getElementById("input_" + pid).value) * 60; if (document.getElementById("countup_" + promptID).checked) { result = 0; } } else { result = document.getElementById("input_" + pid).value; } document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } try { document.getElementById("cancel_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } try { document.getElementById("close_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); } catch (e) { } getById("modal_" + promptID).addEventListener("click", function (e) { e.stopPropagation(); return false; }); miniTranslate(getById("modal_" + promptID)); return; }); return result; } async function promptTransfer(value = null, bcmode = null, updateurl = null, queueMode = null) { var result = { roomid: null }; if (session.beepToNotify) { playtone(); } await new Promise((resolve, reject) => { var promptID = "pid_" + Math.random().toString(36).substr(2, 9); Prompts[promptID] = {}; Prompts[promptID].resolve = resolve; Prompts[promptID].reject = reject; var zindex = 30 + document.querySelectorAll(".promptModal").length; var backdropClass = "modalBackdrop"; var inputText = "" + getTranslation("transfer-guest-to-room").replace("\n", "
") + ""; inputText = inputText.replace(/\n/g, "
"); modalTemplate = `
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end document.getElementById("input_" + promptID).focus(); if (value !== null) { document.getElementById("input_" + promptID).value = value; } if (bcmode !== null) { document.getElementById("broadcast_" + promptID).checked = bcmode; } if (queueMode !== null) { document.getElementById("queued_" + promptID).checked = queueMode; } if (updateurl !== null) { document.getElementById("private_" + promptID).checked = updateurl; } document.getElementById("input_" + promptID).addEventListener("keyup", function (event) { if (event.key === "Enter") { var pid = event.target.dataset.pid; var room = document.getElementById("input_" + pid).value; var updateurl = document.getElementById("private_" + pid).checked; var broadcast = document.getElementById("broadcast_" + pid).checked; var queue = document.getElementById("queued_" + pid).checked; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); result = { roomid: room, updateurl: updateurl, broadcast: broadcast, queue: queue }; } }); document.getElementById("submit_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; var room = document.getElementById("input_" + pid).value; var updateurl = document.getElementById("private_" + pid).checked; var broadcast = document.getElementById("broadcast_" + pid).checked; var queue = document.getElementById("queued_" + pid).checked; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); result = { roomid: room, updateurl: updateurl, broadcast: broadcast, queue: queue }; }); document.getElementById("cancel_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); document.getElementById("close_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; document.getElementById("modal_" + pid).remove(); document.getElementById("modalBackdrop_" + pid).remove(); Prompts[pid].resolve(); }); getById("modal_" + promptID).addEventListener("click", function (e) { e.stopPropagation(); return false; }); miniTranslate(getById("modal_" + promptID)); return; }); return result; } function youveBeenTransferred() { getChatMessage(getTranslation("you-have-been-transferred"), (label = false), (director = false), (overlay = true)); // "you-have-been-transferred" getById("head2").innerHTML = getTranslation("room-changed"); // not sure this is right?? miniTranslate(getById("head2"), "room-changed"); if (session.director) { getById("head4").innerHTML = getTranslation("you-are-no-longer-a-co-director"); //"You are no longer a co-director as you were transferred."; // } if (session.label) { document.title = session.label + " - " + getTranslation("transferred"); } else { document.title = getTranslation("transferred"); } hideHomeCheck(); } function youveBeenActivated() { if (session.queueType == 3 || session.queueType == 4) { // For both hold modes, publish to any deferred peers upon activation. // queueType 3: All peers were deferred (needsPublishing=true) // queueType 4: Only non-director peers were deferred; directors already receiving closeModal(false, "123"); for (var UUID in session.pcs) { if (session.pcs[UUID].needsPublishing) { session.initialPublish(UUID); } } } getChatMessage(getTranslation("you-have-been-activated"), (label = false), (director = false), (overlay = true)); hideHomeCheck(); } function youreWaitingToBeActivated() { // getChatMessage( getTranslation("you-not-yet-activated"), label = false, director = false, overlay = true); warnUser(getTranslation("you-not-yet-activated"), false, false, 123); hideHomeCheck(); } async function confirmAlt(inputText, block = false, context = null) { var result = null; if (session.beepToNotify) { playtone(); } await new Promise((resolve, reject) => { var promptID = "pid_" + Math.random().toString(36).substr(2, 9); Prompts[promptID] = {}; Prompts[promptID].resolve = resolve; Prompts[promptID].reject = reject; Prompts[promptID].context = context; var zindex = 33 + document.querySelectorAll(".promptModal").length + document.querySelectorAll(".alertModal").length; if (block) { var backdropClass = "opaqueBackdrop"; } else { var backdropClass = "modalBackdrop"; } inputText = "" + inputText.replace("\n", "
") + ""; inputText = inputText.replace(/\n/g, "
"); modalTemplate = `
]/g, "") : ''}" style="z-index:${zindex + 1}">
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end document.getElementById("submit_" + promptID).focus(); document.getElementById("submit_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; result = true; getById("modalBackdrop_" + pid).remove(); getById("modal_" + pid).remove(); Prompts[pid].resolve(); }); document.getElementById("cancel_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; getById("modalBackdrop_" + pid).remove(); getById("modal_" + pid).remove(); Prompts[pid].resolve(); }); document.getElementById("close_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; getById("modalBackdrop_" + pid).remove(); getById("modal_" + pid).remove(); Prompts[pid].resolve(); }); getById("modal_" + promptID).addEventListener("click", function (e) { e.stopPropagation(); return false; }); miniTranslate(getById("modal_" + promptID)); return; }); return result; } async function confirmHangupWithBlock(inputText) { // Similar to confirmAlt but includes a "block from rejoining" checkbox // Returns: { confirmed: boolean, block: boolean } var result = { confirmed: false, block: false }; if (session.beepToNotify) { playtone(); } await new Promise((resolve, reject) => { var promptID = "pid_" + Math.random().toString(36).substr(2, 9); Prompts[promptID] = {}; Prompts[promptID].resolve = resolve; Prompts[promptID].reject = reject; var zindex = 33 + document.querySelectorAll(".promptModal").length + document.querySelectorAll(".alertModal").length; var backdropClass = "modalBackdrop"; inputText = "" + inputText.replace("\n", "
") + ""; inputText = inputText.replace(/\n/g, "
"); modalTemplate = `
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); document.getElementById("submit_" + promptID).focus(); document.getElementById("submit_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; result.confirmed = true; result.block = document.getElementById("blockUser_" + pid).checked; getById("modalBackdrop_" + pid).remove(); getById("modal_" + pid).remove(); Prompts[pid].resolve(); }); document.getElementById("cancel_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; getById("modalBackdrop_" + pid).remove(); getById("modal_" + pid).remove(); Prompts[pid].resolve(); }); document.getElementById("close_" + promptID).addEventListener("click", function (event) { var pid = event.target.dataset.pid; getById("modalBackdrop_" + pid).remove(); getById("modal_" + pid).remove(); Prompts[pid].resolve(); }); getById("modal_" + promptID).addEventListener("click", function (e) { e.stopPropagation(); return false; }); miniTranslate(getById("modal_" + promptID)); return; }); return result; } var modalTimeout = null; function warnUser(message, timeout = false, sanitize = true, modalID = false) { // Allows for multiple alerts to stack better. // Every modal and backdrop has an increasing z-index // to block the previous modal if (!message) { return; } if (document.getElementById("modalBackdrop")) { getById("alertModal").innerHTML = ""; // Delete modal getById("alertModal").remove(); getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); } zindex = 31 + document.querySelectorAll(".alertModal").length + document.querySelectorAll(".promptModal").length; try { if (sanitize) { message = sanitizeChat(message, 2000); } message = message.replace(/\n/g, "
"); } catch (e) { errorlog(message); } if (!modalID) { modalID = Math.floor(Math.random() * 999) + 1000; } modalTemplate = `
× ${message}
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end document.getElementById("modalBackdrop").addEventListener("click", closeModal); clearTimeout(modalTimeout); if (timeout) { modalTimeout = setTimeout(closeModal, timeout, false, modalID); } getById("alertModal").addEventListener("click", function (e) { e.stopPropagation(); return false; }); return modalID; } function closeModal(ele = false, modalID = false) { if (modalID && !ele) { // Check for alertModal with data-modalID (warnUser modals) var alertModalMatch = document.querySelector("#alertModal[data-modalID='" + modalID + "']"); // Check for promptModal with data-context (confirmAlt modals like approval popups) var ctx = ("" + modalID).replace(/["<>]/g, ""); var promptModalMatch = document.querySelector('.promptModal[data-context="' + ctx + '"]'); if (promptModalMatch) { // Close the confirmAlt modal by context (programmatic dismissal) try { var modalIdAttr = promptModalMatch.id; // e.g., "modal_pid_abc123" if (modalIdAttr && modalIdAttr.startsWith("modal_")) { var pid = modalIdAttr.substring(6); // Extract "pid_abc123" // Remove modal and backdrop promptModalMatch.remove(); var backdrop = document.getElementById("modalBackdrop_" + pid); if (backdrop) { backdrop.remove(); } // Do NOT resolve the promise - let it stay pending // Resolving would trigger the denial dialog in promptApproval if (typeof Prompts !== "undefined" && Prompts[pid]) { delete Prompts[pid]; // Clean up reference, but don't resolve } } else { promptModalMatch.remove(); } } catch (e) { warnlog(e); } return; } if (!alertModalMatch) { return; } } clearTimeout(modalTimeout); try { getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); getById("alertModal").innerHTML = ""; // Delete modal getById("alertModal").remove(); getById("promptModal").innerHTML = ""; // Delete modal getById("promptModal").remove(); query(".modalBackdrop").innerHTML = ""; // Delete modal query(".modalBackdrop").remove(); if (ele && ele.innerHTML && ele.remove) { ele.innerHTML = ""; // Delete specific modal ele.remove(); } } catch (e) { warnlog(e); } if (session.timeoutTriggered) { session.warnUserTriggered = false; } } var sanitizeStreamID = function (streamID) { streamID = streamID.trim(); if (streamID.length < 1) { streamID = session.generateStreamID(8); if (!session.cleanOutput) { warnUser(getTranslation("no-streamID-provided") + streamID, false, false); } } var streamID_sanitized = streamID.replace(/[\W]+/g, "_"); if (streamID !== streamID_sanitized) { if (!session.cleanOutput) { warnUser(getTranslation("alphanumeric-only"), false, false); } } if (streamID_sanitized.length > 64) { streamID_sanitized = streamID_sanitized.substring(0, 70); // leave room for salting if (!session.cleanOutput) { warnUser(getTranslation("stream-id-too-long"), false, false); } } return streamID_sanitized; }; var checkStrength = function (string) { var matcher = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{7,30}$/; if (string.match(matcher)) { return true; } else if (string.length > 20) { return true; } else { return false; } }; var checkStrengthRoom = function () { var result1 = checkStrength(getById("videoname1").value); var result2 = getById("passwordRoom").value.length; var target = getById("securityLevelRoom"); target.style.display = "block"; if (result1) { if (result2) { target.innerHTML = "" + getTranslation("share-with-trusted") + ""; } else { target.innerHTML = "" + getTranslation("pass-recommended") + ""; } } else { target.innerHTML = "" + getTranslation("insecure-room-name") + " " + getTranslation("allowed-chars") + ": A-Z, a-z, 0-9, _"; } }; var emojiShortCodes = { ":joy:": "😂", ":heart:": "❤️", ":heart_eyes:": "😍", ":sob:": "😭", ":blush:": "😊", ":unamused:": "😒", ":two_hearts:": "💕", ":weary:": "😩", ":ok_hand:": "👌", ":pensive:": "😔", ":smirk:": "😏", ":grin:": "😁", ":wink:": "😉", ":thumbsup:": "👍", ":pray:": "🙏", ":relieved:": "😌", ":notes:": "🎶", ":flushed:": "😳", ":raised_hands:": "🙌", ":see_no_evil:": "🙈", ":cry:": "😢", ":sunglasses:": "😎", ":v:": "✌️", ":eyes:": "👀", ":sweat_smile:": "😅", ":sparkles:": "✨", ":sleeping:": "😴", ":smile:": "😄", ":purple_heart:": "💜", ":broken_heart:": "💔", ":blue_heart:": "💙", ":confused:": "😕", ":disappointed:": "😞", ":yum:": "😋", ":neutral_face:": "😐", ":sleepy:": "😪", ":clap:": "👏", ":cupid:": "💘", ":heartpulse:": "💗", ":kiss:": "💋", ":point_right:": "👉", ":scream:": "😱", ":fire:": "🔥", ":rage:": "😡", ":smiley:": "😃", ":tada:": "🎉", ":tired_face:": "😫", ":camera:": "📷", ":rose:": "🌹", ":muscle:": "💪", ":skull:": "💀", ":sunny:": "☀️", ":yellow_heart:": "💛", ":triumph:": "😤", ":laughing:": "😆", ":sweat:": "😓", ":point_left:": "👈", ":grinning:": "😀", ":mask:": "😷", ":green_heart:": "💚", ":wave:": "👋", ":persevere:": "😣", ":heartbeat:": "💓", ":crown:": "👑", ":innocent:": "😇", ":headphones:": "🎧", ":confounded:": "😖", ":angry:": "😠", ":grimacing:": "😬", ":star2:": "🌟", ":gun:": "🔫", ":raising_hand:": "🙋", ":thumbsdown:": "👎", ":dancer:": "💃", ":musical_note:": "🎵", ":no_mouth:": "😶", ":dizzy:": "💫", ":fist:": "✊", ":point_down:": "👇", ":no_good:": "🙅", ":boom:": "💥", ":tongue:": "👅", ":poop:": "💩", ":cold_sweat:": "😰", ":gem:": "💎", ":ok_woman:": "🙆", ":pizza:": "🍕", ":joy_cat:": "😹", ":leaves:": "🍃", ":sweat_drops:": "💦", ":penguin:": "🐧", ":zzz:": "💤", ":walking:": "🚶", ":airplane:": "✈️", ":balloon:": "🎈", ":star:": "⭐", ":ribbon:": "🎀", ":worried:": "😟", ":underage:": "🔞", ":fearful:": "😨", ":hibiscus:": "🌺", ":microphone:": "🎤", ":open_hands:": "👐", ":ghost:": "👻", ":palm_tree:": "🌴", ":nail_care:": "💅", ":alien:": "👽", ":bow:": "🙇", ":cloud:": "☁", ":soccer:": "⚽", ":angel:": "👼", ":dancers:": "👯", ":snowflake:": "❄️", ":point_up:": "☝️", ":rainbow:": "🌈", ":gift_heart:": "💝", ":gift:": "🎁", ":beers:": "🍻", ":anguished:": "😧", ":earth_africa:": "🌍", ":movie_camera:": "🎥", ":anchor:": "⚓", ":zap:": "⚡", ":runner:": "🏃", ":sunflower:": "🌻", ":bouquet:": "💐", ":dog:": "🐶", ":moneybag:": "💰", ":herb:": "🌿", ":couple:": "👫", ":fallen_leaf:": "🍂", ":tulip:": "🌷", ":birthday:": "🎂", ":cat:": "🐱", ":coffee:": "☕", ":dizzy_face:": "😵", ":point_up_2:": "👆", ":open_mouth:": "😮", ":hushed:": "😯", ":basketball:": "🏀", ":ring:": "💍", ":astonished:": "😲", ":hear_no_evil:": "🙉", ":dash:": "💨", ":cactus:": "🌵", ":hotsprings:": "♨️", ":telephone:": "☎️", ":maple_leaf:": "🍁", ":princess:": "👸", ":massage:": "💆", ":love_letter:": "💌", ":trophy:": "🏆", ":blossom:": "🌼", ":lips:": "👄", ":fries:": "🍟", ":doughnut:": "🍩", ":frowning:": "😦", ":ocean:": "🌊", ":bomb:": "💣", ":cyclone:": "🌀", ":rocket:": "🚀", ":umbrella:": "☔", ":couplekiss:": "💏", ":lollipop:": "🍭", ":clapper:": "🎬", ":pig:": "🐷", ":smiling_imp:": "😈", ":imp:": "👿", ":bee:": "🐝", ":kissing_cat:": "😽", ":anger:": "💢", ":santa:": "🎅", ":earth_asia:": "🌏", ":football:": "🏈", ":guitar:": "🎸", ":panda_face:": "🐼", ":strawberry:": "🍓", ":smirk_cat:": "😼", ":banana:": "🍌", ":watermelon:": "🍉", ":snowman:": "⛄", ":smile_cat:": "😸", ":eggplant:": "🍆", ":crystal_ball:": "🔮", ":calling:": "📲", ":iphone:": "📱", ":partly_sunny:": "⛅", ":warning:": "⚠️", ":scream_cat:": "🙀", ":baby:": "👶", ":feet:": "🐾", ":footprints:": "👣", ":beer:": "🍺", ":wine_glass:": "🍷", ":video_camera:": "📹", ":rabbit:": "🐰", ":smoking:": "🚬", ":peach:": "🍑", ":snake:": "🐍", ":turtle:": "🐢", ":cherries:": "🍒", ":kissing:": "😗", ":frog:": "🐸", ":milky_way:": "🌌", ":closed_book:": "📕", ":candy:": "🍬", ":hamburger:": "🍔", ":bear:": "🐻", ":tiger:": "🐯", ":icecream:": "🍦", ":pineapple:": "🍍", ":ear_of_rice:": "🌾", ":syringe:": "💉", ":tv:": "📺", ":pill:": "💊", ":octopus:": "🐙", ":grapes:": "🍇", ":smiley_cat:": "😺", ":cd:": "💿", ":cocktail:": "🍸", ":cake:": "🍰", ":video_game:": "🎮", ":lipstick:": "💄", ":whale:": "🐳", ":cookie:": "🍪", ":dolphin:": "🐬", ":loud_sound:": "🔊", ":man:": "👨", ":monkey:": "🐒", ":books:": "📚", ":guardsman:": "💂", ":loudspeaker:": "📢", ":scissors:": "✂️", ":girl:": "👧", ":mortar_board:": "🎓", ":baseball:": "⚾️", ":woman:": "👩", ":fireworks:": "🎆", ":stars:": "🌠", ":mushroom:": "🍄", ":pouting_cat:": "😾", ":left_luggage:": "🛅", ":high_heel:": "👠", ":dart:": "🎯", ":swimmer:": "🏊", ":key:": "🔑", ":bikini:": "👙", ":family:": "👪", ":pencil2:": "✏", ":elephant:": "🐘", ":droplet:": "💧", ":seedling:": "🌱", ":apple:": "🍎", ":dollar:": "💵", ":book:": "📖", ":haircut:": "💇", ":computer:": "💻", ":bulb:": "💡", ":boy:": "👦", ":tangerine:": "🍊", ":sunrise:": "🌅", ":poultry_leg:": "🍗", ":shaved_ice:": "🍧", ":bird:": "🐦", ":eyeglasses:": "👓", ":goat:": "🐐", ":older_woman:": "👵", ":new_moon:": "🌑", ":customs:": "🛃", ":house:": "🏠", ":full_moon:": "🌕", ":lemon:": "🍋", ":baby_bottle:": "🍼", ":spaghetti:": "🍝", ":wind_chime:": "🎐", ":fish_cake:": "🍥", ":nose:": "👃", ":pig_nose:": "🐽", ":fish:": "🐟", ":koala:": "🐨", ":ear:": "👂", ":shower:": "🚿", ":bug:": "🐛", ":ramen:": "🍜", ":tophat:": "🎩", ":fuelpump:": "⛽", ":horse:": "🐴", ":watch:": "⌚", ":monkey_face:": "🐵", ":baby_symbol:": "🚼", ":sparkler:": "🎇", ":corn:": "🌽", ":tennis:": "🎾", ":battery:": "🔋", ":wolf:": "🐺", ":moyai:": "🗿", ":cow:": "🐮", ":mega:": "📣", ":older_man:": "👴", ":dress:": "👗", ":link:": "🔗", ":chicken:": "🐔", ":whale2:": "🐋", ":bento:": "🍱", ":pushpin:": "📌", ":dragon:": "🐉", ":hamster:": "🐹", ":golf:": "⛳", ":surfer:": "🏄", ":mouse:": "🐭", ":blue_car:": "🚙", ":bread:": "🍞", ":cop:": "👮", ":tea:": "🍵", ":bike:": "🚲", ":rice:": "🍚", ":radio:": "📻", ":baby_chick:": "🐤", ":sheep:": "🐑", ":lock:": "🔒", ":green_apple:": "🍏", ":racehorse:": "🐎", ":fried_shrimp:": "🍤", ":volcano:": "🌋", ":rooster:": "🐓", ":inbox_tray:": "📥", ":wedding:": "💒", ":sushi:": "🍣", ":ice_cream:": "🍨", ":tomato:": "🍅", ":rabbit2:": "🐇", ":beetle:": "🐞", ":bath:": "🛀", ":no_entry:": "⛔", ":crocodile:": "🐊", ":dog2:": "🐕", ":cat2:": "🐈", ":hammer:": "🔨", ":meat_on_bone:": "🍖", ":shell:": "🐚", ":poodle:": "🐩", ":stew:": "🍲", ":jeans:": "👖", ":honey_pot:": "🍯", ":unlock:": "🔓", ":black_nib:": "✒", ":snowboarder:": "🏂", ":white_flower:": "💮", ":necktie:": "👔", ":womens:": "🚺", ":ant:": "🐜", ":city_sunset:": "🌇", ":dragon_face:": "🐲", ":snail:": "🐌", ":dvd:": "📀", ":shirt:": "👕", ":game_die:": "🎲", ":dolls:": "🎎", ":8ball:": "🎱", ":bus:": "🚌", ":custard:": "🍮", ":camel:": "🐫", ":curry:": "🍛", ":hospital:": "🏥", ":bell:": "🔔", ":pear:": "🍐", ":door:": "🚪", ":saxophone:": "🎷", ":church:": "⛪", ":bicyclist:": "🚴", ":dango:": "🍡", ":office:": "🏢", ":rowboat:": "🚣", ":womans_hat:": "👒", ":mans_shoe:": "👞", ":love_hotel:": "🏩", ":mount_fuji:": "🗻", ":handbag:": "👜", ":hourglass:": "⌛", ":trumpet:": "🎺", ":school:": "🏫", ":cow2:": "🐄", ":toilet:": "🚽", ":pig2:": "🐖", ":violin:": "🎻", ":credit_card:": "💳", ":ferris_wheel:": "🎡", ":bowling:": "🎳", ":barber:": "💈", ":purse:": "👛", ":rat:": "🐀", ":date:": "📅", ":ram:": "🐏", ":tokyo_tower:": "🗼", ":kimono:": "👘", ":ship:": "🚢", ":mag_right:": "🔎", ":mag:": "🔍", ":fire_engine:": "🚒", ":police_car:": "🚓", ":black_joker:": "🃏", ":package:": "📦", ":calendar:": "📆", ":horse_racing:": "🏇", ":tiger2:": "🐅", ":boot:": "👢", ":ambulance:": "🚑", ":boar:": "🐗", ":pound:": "💷", ":ox:": "🐂", ":rice_ball:": "🍙", ":sandal:": "👡", ":tent:": "⛺", ":seat:": "💺", ":taxi:": "🚕", ":briefcase:": "💼", ":newspaper:": "📰", ":circus_tent:": "🎪", ":mens:": "🚹", ":flashlight:": "🔦", ":foggy:": "🌁", ":bamboo:": "🎍", ":ticket:": "🎫", ":helicopter:": "🚁", ":minidisc:": "💽", ":oncoming_bus:": "🚍", ":melon:": "🍈", ":notebook:": "📓", ":no_bell:": "🔕", ":oden:": "🍢", ":flags:": "🎏", ":blowfish:": "🐡", ":sweet_potato:": "🍠", ":ski:": "🎿", ":construction:": "🚧", ":satellite:": "📡", ":euro:": "💶", ":ledger:": "📒", ":leopard:": "🐆", ":truck:": "🚚", ":sake:": "🍶", ":railway_car:": "🚃", ":speedboat:": "🚤", ":vhs:": "📼", ":yen:": "💴", ":mute:": "🔇", ":wheelchair:": "♿", ":paperclip:": "📎", ":atm:": "🏧", ":telescope:": "🔭", ":rice_scene:": "🎑", ":blue_book:": "📘", ":postbox:": "📮", ":e-mail:": "📧", ":mouse2:": "🐁", ":nut_and_bolt:": "🔩", ":hotel:": "🏨", ":wc:": "🚾", ":green_book:": "📗", ":tractor:": "🚜", ":fountain:": "⛲", ":metro:": "🚇", ":clipboard:": "📋", ":no_smoking:": "🚭", ":slot_machine:": "🎰", ":bathtub:": "🛁", ":scroll:": "📜", ":station:": "🚉", ":rice_cracker:": "🍘", ":bank:": "🏦", ":wrench:": "🔧", ":bar_chart:": "📊", ":minibus:": "🚐", ":tram:": "🚊", ":microscope:": "🔬", ":bookmark:": "🔖", ":pouch:": "👝", ":fax:": "📠", ":sound:": "🔉", ":chart:": "💹", ":floppy_disk:": "💾", ":post_office:": "🏣", ":speaker:": "🔈", ":japan:": "🗾", ":mahjong:": "🀄", ":orange_book:": "📙", ":restroom:": "🚻", ":train:": "🚋", ":trolleybus:": "🚎", ":postal_horn:": "📯", ":factory:": "🏭", ":train2:": "🚆", ":pager:": "📟", ":outbox_tray:": "📤", ":mailbox:": "📫", ":light_rail:": "🚈", ":busstop:": "🚏", ":file_folder:": "📁", ":card_index:": "📇", ":monorail:": "🚝", ":no_bicycles:": "🚳", ":hugging:": "🤗", ":thinking:": "🤔", ":nerd:": "🤓", ":zipper_mouth:": "🤐", ":rolling_eyes:": "🙄", ":upside_down:": "🙃", ":slight_smile:": "🙂", ":writing_hand:": "✍", ":eye:": "👁", ":man_in_suit:": "🕴", ":golfer:": "🏌", ":golfer_woman:": "🏌‍♀", ":anger_right:": "🗯", ":coffin:": "⚰", ":gear:": "⚙", ":alembic:": "⚗", ":scales:": "⚖", ":keyboard:": "⌨", ":shield:": "🛡", ":bed:": "🛏", ":ballot_box:": "🗳", ":compression:": "🗜", ":wastebasket:": "🗑", ":file_cabinet:": "🗄", ":trackball:": "🖲", ":printer:": "🖨", ":joystick:": "🕹", ":hole:": "🕳", ":candle:": "🕯", ":prayer_beads:": "📿", ":amphora:": "🏺", ":label:": "🏷", ":film_frames:": "🎞", ":level_slider:": "🎚", ":thermometer:": "🌡", ":motorway:": "🛣", ":synagogue:": "🕍", ":mosque:": "🕌", ":kaaba:": "🕋", ":stadium:": "🏟", ":desert:": "🏜", ":cityscape:": "🏙", ":camping:": "🏕", ":rosette:": "🏵", ":volleyball:": "🏐", ":medal:": "🏅", ":popcorn:": "🍿", ":champagne:": "🍾", ":hot_pepper:": "🌶", ":burrito:": "🌯", ":taco:": "🌮", ":hotdog:": "🌭", ":shamrock:": "☘", ":comet:": "☄", ":turkey:": "🦃", ":scorpion:": "🦂", ":lion_face:": "🦁", ":crab:": "🦀", ":spider_web:": "🕸", ":spider:": "🕷", ":chipmunk:": "🐿", ":fog:": "🌫", ":chains:": "⛓", ":pick:": "⛏", ":stopwatch:": "⏱", ":ferry:": "⛴", ":mountain:": "⛰", ":ice_skate:": "⛸", ":skier:": "⛷", ":sad:": "😥", ":egg:": "🥚", ":drum:": "🥁" }; function convertShortcodes(string) { if (string.split(":").length > 2) { for (var i in emojiShortCodes) { if (string.includes(i)) { string = string.replaceAll(i, emojiShortCodes[i]); } } } return string; } function escapeHtml(unsafe) { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } var sanitizeChat = function (string, maxlength = 500) { var temp = document.createElement("div"); temp.innerText = string; temp.innerText = temp.innerHTML; temp = temp.textContent || temp.innerText || ""; temp = temp.substring(0, Math.min(temp.length, maxlength)); return temp.trim(); }; var sanitizeString = function (str) { str = str.replace(/[^a-z0-9áéíóúñü \.,_-]/gim, ""); return str.trim(); }; var sanitizeLabel = function (string) { let temp = document.createElement("div"); temp.innerText = string; temp.innerText = temp.innerHTML; temp = temp.textContent || temp.innerText || ""; temp = temp.substring(0, Math.min(temp.length, 100)); return temp.trim(); }; var sanitizeRoomName = function (roomid) { roomid = roomid.trim(); if (roomid === "") { return roomid; } else if (roomid === false) { return roomid; } var sanitized = roomid.replace(/[\W]+/g, "_"); if (roomid.replace(/ /g, "_") !== sanitized) { if (!session.cleanOutput) { warnUser("Info: Only AlphaNumeric characters should be used for the room name.\n\nThe offending characters have been replaced by an underscore"); } } if (sanitized.length > 30) { sanitized = sanitized.substring(0, 30); if (!session.cleanOutput) { warnUser("The Room name should be less than 31 alPhaNuMeric characters long.\n\nWe will trim it to length."); } } return sanitized; }; var sanitizePassword = function (passwrd) { if (passwrd === "") { return passwrd; } else if (passwrd === false) { return passwrd; } else if (passwrd === null) { return passwrd; } passwrd = passwrd.trim(); if (passwrd.length < 1) { if (!session.cleanOutput) { warnUser("The password provided was blank."); } } var sanitized = encodeURIComponent(passwrd); //.replace(/[\W]+/g, "_"); //if (sanitized !== passwrd) { // if (!(session.cleanOutput)) { // warnUser("Info: Only AlphaNumeric characters should be used in the password.\n\nThe offending characters have been replaced by an underscore"); // } //} return sanitized; }; function checkConnection() { if (session.ws === null) { return; } if (!session.cleanOutput) { if (document.getElementById("qos")) { // true or false; null might cause problems? getById("logoname").style.display = "unset"; if (session.ws && session.ws.readyState === WebSocket.OPEN) { getById("qos").style.color = "#FFF7"; } else { getById("qos").style.color = "red"; } } } } session.obsSceneSync = function () { if (session.layouts && session.obsSceneTriggers && session.obsState && session.obsState.details && session.obsState.details.currentScene.name && session.obsSceneTriggers.includes(session.obsState.details.currentScene.name)) { var idx = session.obsSceneTriggers.indexOf(session.obsState.details.currentScene.name); if (idx >= 0) { if (session.layouts[idx]) { var layout = combinedLayout(session.layouts[idx]); if (layout) { session.layout = layout; updateMixer(); } } } return true; } return false; }; session.sceneSync = function (UUID) { if (!session.rpcs[UUID]) { return; } else if (!session.rpcs[UUID].videoElement) { return; } // i'll want to consider other things, such as canvas at some point. var msg = {}; msg.sceneDisplay = session.rpcs[UUID].videoElement.style.display != "none"; msg.sceneMute = session.rpcs[UUID].mutedState; if (session.optimize !== false) { // if not visible in the scene anymore, lets lets optimize. This is outside the scope of OBS var bandwidth = parseInt(session.rpcs[UUID].targetBandwidth); // wtf is goign on here? if (msg.sceneDisplay === false) { if (bandwidth > session.optimize || bandwidth < 0) { // limit to optimized bitrate bandwidth = session.optimize; } } if (session.rpcs[UUID].bandwidth !== bandwidth) { // bandwidth already set correctly. don't resend. msg.bitrate = bandwidth; if (session.sendRequest(msg, UUID)) { session.rpcs[UUID].bandwidth = bandwidth; // this is letting the system know what the actual bandwidth is, even if it isn't the real target. } else { errorlog("Unable to set update OBS Visibility"); } } else { session.sendRequest(msg, UUID); } } else { session.sendRequest(msg, UUID); } }; var TriggerOnNewDetails = false; session.obsStateSync = function (data2send = false, uid = false) { if (session.disableOBS) { return; } if (!window.obsstudio) { return; } // this isn't OBS // they can disable remote control via OBS brower source drop-down itself. log(data2send); if (data2send && data2send == "sourceActive" && session.obsState.sourceActive) { TriggerOnNewDetails = true; } else if (data2send && data2send == "details" && session.obsState.sourceActive && TriggerOnNewDetails) { if (session.obsState.details && session.obsState.details.currentScene && session.obsState.details.currentScene.name) { session.obsState.details.thisScene = session.obsState.details.currentScene.name; TriggerOnNewDetails = false; } } var needOptimize = false; if (session.obsState.visibility !== null) { if (session.obsState.visibility === false) { /////////////////// I need to change tis to .state or whatever, anc catch/handle these events to update the buttons in the pop up menu needOptimize = true; } } session.obsSceneSync(); for (var UUID in session.rpcs) { if (uid && uid !== UUID) { continue; } // target just a single connection. var msg = {}; if (!data2send) { msg.obsState = Object.assign({}, session.obsState); // shallow copy to avoid mutating global state if (session.rpcs[UUID].obsControl === false) { msg.obsState.details = null; // we don't want to send needless data } } else if (data2send in session.obsState) { if (data2send == "details") { if (session.rpcs[UUID].obsControl === false) { continue; // we don't want to send needless data; this isn't a visibility update, so skip. } msg.obsState = {}; msg.obsState[data2send] = session.obsState[data2send]; } else { msg.obsState = {}; msg.obsState[data2send] = session.obsState[data2send]; } } if (session.filterOBSscenes && msg.obsState && msg.obsState.details && msg.obsState.details.scenes && msg.obsState.details.scenes.length) { var scenes = []; msg.obsState.details.scenes.forEach(scene => { if (session.filterOBSscenes && session.filterOBSscenes.length) { if (session.filterOBSscenes.includes(scene)) { scenes.push(scene); } } }); msg.obsState.details.scenes = scenes; } if (session.optimize !== false) { var bandwidth = parseInt(session.rpcs[UUID].targetBandwidth); if (needOptimize) { if (bandwidth > session.optimize || bandwidth < 0) { // limit to optimized bitrate bandwidth = session.optimize; } } if (session.rpcs[UUID].bandwidth !== bandwidth) { // bandwidth already set correctly. don't resend. msg.bitrate = bandwidth; warnlog("Message to be sent: "); warnlog(msg); if (session.sendRequest(msg, UUID)) { session.rpcs[UUID].bandwidth = bandwidth; // this is letting the system know what the actual bandwidth is, even if it isn't the real target. } else { errorlog("Unable to set update OBS Visibility"); } } else { warnlog("Message to be sent: "); warnlog(msg); session.sendRequest(msg, UUID); } } else { warnlog("Message to be sent: "); warnlog(msg); session.sendRequest(msg, UUID); } } }; session.getOBSOptimization = function (msg, UUID) { if (session.obsState) { msg.obsState = {}; var needOptimize = false; if (session.obsState.visibility !== null) { msg.obsState.visibility = session.obsState.visibility; if (session.obsState.visibility === false) { needOptimize = true; } } if (session.obsState.sourceActive !== null) { msg.obsState.sourceActive = session.obsState.sourceActive; //if (session.obsState.sourceActive===false){ // needOptimize=true; //} } if (session.obsState.recording !== null) { msg.obsState.recording = session.obsState.recording; } if (session.obsState.streaming !== null) { msg.obsState.streaming = session.obsState.streaming; } if (session.obsState.virtualcam !== null) { msg.obsState.virtualcam = session.obsState.virtualcam; } } if (session.optimize !== false) { msg.optimizedBitrate = parseInt(session.optimize) || 0; // not setting a bitrate; just letting them know what the optimized bitrate is. if (needOptimize) { session.rpcs[UUID].bandwidth = msg.optimizedBitrate; } } return msg; }; function getOBSDetails(callbackname = "details") { if (session.disableOBS) { return false; } if (!window.obsstudio) { return; } if (!("details" in session.obsState)) { session.obsState.details = {}; } var readOnlyFuncs = [ "getControlLevel", //"getStatus", "getCurrentScene", "getScenes" //"getTransitions", //"getCurrentTransition", //"pluginVersion" ]; var promises = {}; promises.main = true; Object.keys(window.obsstudio).forEach(async key => { try { if (typeof window.obsstudio[key] === "function") { if (readOnlyFuncs.includes(key)) { try { promises[key] = true; window.obsstudio[key](function (out) { var shortkey = key.replace("get", ""); shortkey = shortkey[0].toLowerCase() + shortkey.slice(1); session.obsState.details[shortkey] = out; delete promises[key]; if (!Object.keys(promises).length) { session.obsStateSync(callbackname); } }); } catch (e) { delete promises[key]; } } /* } else if (typeof window.obsstudio[key] === 'object'){ // none of these values I really need right now. var shortkey = key.replace("get",""); shortkey = shortkey[0].toLowerCase() + shortkey.slice(1); session.obsState.details[shortkey] = window.obsstudio[key]; } else { var shortkey = key.replace("get",""); shortkey = shortkey[0].toLowerCase() + shortkey.slice(1); session.obsState.details[shortkey] = window.obsstudio[key]; */ } } catch (e) { errorlog(e); } }); delete promises.main; if (!Object.keys(promises).length) { session.obsStateSync(callbackname); } } function toggleOBSControls() { toggle(getById("remoteOBSControl")); if (getById("remoteOBSControl").style.display == "none") { getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); } else { getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); var modalTemplate = `
`; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end document.getElementById("modalBackdrop").addEventListener("click", toggleOBSControls); } } function toggleOBSControlsLock(ele, disable = null) { const element = getById("remoteOBSControlContents"); // If disable is not specified, toggle based on current state // Otherwise, use the provided value const shouldDisable = disable !== null ? disable : element.style.pointerEvents !== 'none'; element.style.pointerEvents = shouldDisable ? 'none' : 'auto'; element.style.opacity = shouldDisable ? '0.6' : '1'; ele.textContent = shouldDisable ? '🔒' : '🔓'; return shouldDisable; // returns the new state } function requestOBSAction(ele) { if (session.disableOBS) { return false; } } function obsSceneChanged(event) { log(event.detail.name); getOBSDetails(); // contains obsStateSync } function obsVirtualcamStarted(event) { session.obsState.virtualcam = true; session.obsStateSync("virtualcam"); } function obsVirtualcamStopped(event) { session.obsState.virtualcam = false; session.obsStateSync("virtualcam"); } function obsStreamingStarted(event) { session.obsState.streaming = true; session.obsStateSync("streaming"); } function obsStreamingStopped(event) { session.obsState.streaming = false; session.obsStateSync("streaming"); } function obsRecordingStarted(event) { session.obsState.recording = true; session.obsStateSync("recording"); } function obsRecordingStopped(event) { session.obsState.recording = false; session.obsStateSync("recording"); } function obsSourceActiveChanged(event) { warnlog("obsSourceActiveChanged"); warnlog(event.detail); try { if (typeof event === "boolean") { var sourceActive = event; } else if (typeof event.detail === "boolean") { var sourceActive = event.detail; } else if (typeof event.detail.active === "boolean") { var sourceActive = event.detail.active; } else { var sourceActive = event.detail.active; } if (typeof sourceActive === "undefined") { return; } // Just fail. if (session.obsState.sourceActive !== sourceActive) { // only move forward if there is a change; the event likes to double fire you see. session.obsState.sourceActive = sourceActive; session.obsStateSync("sourceActive"); } } catch (e) { errorlog(e); } } function obsSourceVisibleChanged(event) { // accounts for visible in VDO.Ninja scene AND visible in OBS scene warnlog("obsSourceVisibleChanged"); warnlog(event.detail); try { if (typeof event === "boolean") { var visibility = event; } else if (typeof event.detail === "boolean") { var visibility = event.detail; } else if (typeof event.detail.visible === "boolean") { var visibility = event.detail.visible; } else { var visibility = event.detail.visible; } if (typeof visibility === "undefined") { // fall back if (typeof document.visibilityState !== "undefined") { visibility = document.visibilityState === "visible"; // modern } else if (typeof document.hidden !== "undefined") { visibility = !document.hidden; // legacy } else { return; // ... unknown input? fail. } } if (session.obsState.visibility !== visibility) { // only move forward if there is a change; the event likes to double fire you see. session.obsState.visibility = visibility; session.obsStateSync("visibility"); } } catch (e) { errorlog(e); } } function manageSceneState(data, UUID) { // incoming obs details if (session.disableOBS) { return; } var processNeeded = false; try { if ("sceneDisplay" in data) { processNeeded = true; session.pcs[UUID].sceneDisplay = data.sceneDisplay; } if ("sceneMute" in data) { processNeeded = true; session.pcs[UUID].sceneMute = data.sceneMute; } if (data.obsState) { if ("sourceActive" in data.obsState) { processNeeded = true; session.pcs[UUID].obsState.sourceActive = data.obsState.sourceActive; } if ("visibility" in data.obsState) { processNeeded = true; session.pcs[UUID].obsState.visibility = data.obsState.visibility; session.optimizeBitrate(UUID); // &optimize flag; sets video bitrate to target value if this flag == HIDDEN (if optimize=0, disables both audio and video) } if ("details" in data.obsState) { //if (Object.keys(data.obsState.details).length){ processNeeded = true; session.pcs[UUID].obsState.details = data.obsState.details; //} } if ("streaming" in data.obsState) { processNeeded = true; session.pcs[UUID].obsState.streaming = data.obsState.streaming; } if ("recording" in data.obsState) { processNeeded = true; session.pcs[UUID].obsState.recording = data.obsState.recording; } if ("virtualcam" in data.obsState) { processNeeded = true; session.pcs[UUID].obsState.virtualcam = data.obsState.virtualcam; } } } catch (e) { errorlog(e); } if (processNeeded) { log(data); applySceneState(); } else { return; } if (isIFrame) { pokeIframeAPI("obs-state", data.obsState, UUID); } if (session.obsControls === false) { return; } try { var control = 0; if (session.pcs[UUID].obsState && session.pcs[UUID].obsState.details) { control = parseInt(session.pcs[UUID].obsState.details.controlLevel) || 0; //0 for NONE, 1 for READ_OBS (OBS data), 2 for READ_USER (User data), 3 for BASIC, 4 for ADVANCED and 5 for ALL } if (control >= 4) { if (session.director || !session.roomid) { if (session.pcs[UUID].remote) { if (session.obsControls !== false) { getById("obscontrolbutton").classList.remove("hidden"); // so they get a tip. } } } } var multi = false; getById("obsControlButtons") .querySelectorAll("[data-system]") .forEach(ele => { if (ele.dataset.system in session.pcs) { if (ele.dataset.system !== UUID) { multi = true; } } else { // delete, since no longer active. ele.remove(); } }); getById("obsSceneNames") .querySelectorAll("[data-system]") .forEach(ele => { if (ele.dataset.system in session.pcs) { if (ele.dataset.system !== UUID) { multi = true; } } else { // delete, since no longer active. ele.remove(); } }); if (control == 0) { var obsControlButtonsBox = getById("obsControlButtons").querySelector("[data-system='" + UUID + "']"); if (obsControlButtonsBox) { obsControlButtonsBox.remove(); } var obsSceneNamesBox = getById("obsSceneNames").querySelector("[data-system='" + UUID + "']"); // this hides if less than 2, so hide it now. if (obsSceneNamesBox) { obsSceneNamesBox.remove(); } if (!multi) { getById("obsControlHelp").classList.remove("hidden"); } return; } getById("obsControlHelp").classList.add("hidden"); var obsControlButtonsBox = getById("obsControlButtons").querySelector("[data-system='" + UUID + "']"); if (!obsControlButtonsBox) { obsControlButtonsBox = document.createElement("div"); obsControlButtonsBox.dataset.system = UUID; getById("obsControlButtons").appendChild(obsControlButtonsBox); } else { obsControlButtonsBox.innerHTML = ""; } if (multi) { var h3 = document.createElement("h3"); h3.innerText = "OBS instance: " + (session.pcs[UUID].label || session.pcs[UUID].scene || UUID); obsControlButtonsBox.appendChild(h3); } if (session.pcs[UUID].obsState && "streaming" in session.pcs[UUID].obsState) { var controlButton = document.createElement("button"); controlButton.dataset.UUID = UUID; if (session.pcs[UUID].obsState.streaming) { controlButton.classList.add("pressed"); controlButton.ariaPressed = "true"; controlButton.dataset.obsAction = "stopStreaming"; controlButton.innerText = "📡 stop streaming"; controlButton.classList.remove("hidden"); } else if (session.pcs[UUID].obsState.streaming === false) { controlButton.classList.remove("hidden"); controlButton.dataset.obsAction = "startStreaming"; controlButton.innerText = "📡 start streaming"; } else { controlButton.dataset.obsAction = "startStreaming"; controlButton.innerText = "📡 start streaming"; controlButton.classList.remove("hidden"); } if (control < 5) { controlButton.disabled = true; controlButton.style.cursor = "not-allowed"; controlButton.title = "Source is lacking required permissions."; } else { controlButton.onclick = async function () { var msg = {}; msg.obsCommand = {}; msg.obsCommand.action = this.dataset.obsAction; msg.UUID = this.dataset.UUID; if (document.querySelector("#obsRemotePassword>input") && document.querySelector("#obsRemotePassword>input").value) { msg.remote = document.querySelector("#obsRemotePassword>input").value; } else { msg.remote = session.remote; } msg = await session.encodeRemote(msg); session.anysend(msg); // this is neat, but doesn't work with websocket. I need to add log("action request: " + this.dataset.obsAction); }; } obsControlButtonsBox.appendChild(controlButton); } if (session.pcs[UUID].obsState && "recording" in session.pcs[UUID].obsState) { var controlButton = document.createElement("button"); controlButton.dataset.UUID = UUID; if (session.pcs[UUID].obsState.recording) { controlButton.classList.add("pressed"); controlButton.ariaPressed = "true"; controlButton.dataset.obsAction = "stopRecording"; controlButton.innerText = "📽 stop recording"; controlButton.classList.remove("hidden"); } else if (session.pcs[UUID].obsState.recording === false) { controlButton.classList.remove("hidden"); controlButton.dataset.obsAction = "startRecording"; controlButton.innerText = "📽 start recording"; } else { controlButton.classList.remove("hidden"); controlButton.dataset.obsAction = "startRecording"; controlButton.innerText = "📽 start recording"; } if (control < 5) { controlButton.disabled = true; controlButton.style.cursor = "not-allowed"; controlButton.title = "Source is lacking required permissions."; } else { controlButton.onclick = async function () { var msg = {}; msg.obsCommand = {}; msg.obsCommand.action = this.dataset.obsAction; msg.UUID = this.dataset.UUID; if (document.querySelector("#obsRemotePassword>input").value) { msg.remote = document.querySelector("#obsRemotePassword>input").value; } else { msg.remote = session.remote; } msg = await session.encodeRemote(msg); session.anysend(msg); log("action request: " + this.dataset.obsAction); }; } obsControlButtonsBox.appendChild(controlButton); } if (session.pcs[UUID].obsState && "virtualcam" in session.pcs[UUID].obsState) { var controlButton = document.createElement("button"); controlButton.dataset.UUID = UUID; if (session.pcs[UUID].obsState.virtualcam) { controlButton.classList.add("pressed"); controlButton.ariaPressed = "true"; controlButton.dataset.obsAction = "stopVirtualcam"; controlButton.innerText = "💻 stop virtualcam"; controlButton.classList.remove("hidden"); } else if (session.pcs[UUID].obsState.virtualcam === false) { controlButton.classList.remove("hidden"); controlButton.dataset.obsAction = "startVirtualcam"; controlButton.innerText = "💻 start virtualcam"; } else { controlButton.classList.remove("hidden"); controlButton.dataset.obsAction = "startVirtualcam"; controlButton.innerText = "💻 start virtualcam"; } if (control < 5) { controlButton.disabled = true; controlButton.style.cursor = "not-allowed"; controlButton.title = "Source is lacking required permissions."; } else { controlButton.onclick = async function () { var msg = {}; msg.obsCommand = {}; msg.obsCommand.action = this.dataset.obsAction; msg.UUID = this.dataset.UUID; if (document.querySelector("#obsRemotePassword>input").value) { msg.remote = document.querySelector("#obsRemotePassword>input").value; } else { msg.remote = session.remote; } msg = await session.encodeRemote(msg); session.anysend(msg); log("action request: " + this.dataset.obsAction); }; } obsControlButtonsBox.appendChild(controlButton); } } catch (e) { errorlog(e); } // just in case the client has disconnected. if (control < 2) { var obsSceneNamesBox = getById("obsSceneNames").querySelector("[data-system='" + UUID + "']"); if (obsSceneNamesBox) { obsSceneNamesBox.remove(); } return; } var obsSceneNamesBox = getById("obsSceneNames").querySelectorAll("div[data-system='" + UUID + "']"); if (!obsSceneNamesBox.length) { obsSceneNamesBox = document.createElement("div"); obsSceneNamesBox.dataset.system = UUID; getById("obsSceneNames").appendChild(obsSceneNamesBox); } else { obsSceneNamesBox = obsSceneNamesBox[0]; obsSceneNamesBox.innerHTML = ""; } if (multi) { var h3 = document.createElement("h3"); h3.innerText = "OBS instance: " + (session.pcs[UUID].label || session.pcs[UUID].scene || UUID); obsSceneNamesBox.appendChild(h3); } if (session.pcs[UUID].obsState.details) { var details = session.pcs[UUID].obsState.details; if (details.scenes) { details.scenes.forEach(scene => { var sceneButton = document.createElement("button"); sceneButton.dataset.obsScene = scene; sceneButton.dataset.UUID = UUID; sceneButton.innerText = scene; if (details.currentScene && details.currentScene.name && details.currentScene.name === scene) { sceneButton.classList.add("pressed"); sceneButton.ariaPressed = "true"; } obsSceneNamesBox.appendChild(sceneButton); if (control < 4) { sceneButton.disabled = true; sceneButton.style.cursor = "not-allowed"; sceneButton.title = "Source is lacking required permissions."; } else { sceneButton.onclick = async function () { var msg = {}; msg.obsCommand = { action: "setCurrentScene", value: this.dataset.obsScene }; msg.UUID = this.dataset.UUID; if (document.querySelector("#obsRemotePassword>input").value) { msg.remote = document.querySelector("#obsRemotePassword>input").value; } else { msg.remote = session.remote; } msg = await session.encodeRemote(msg); session.anysend(msg); log("scene change request: " + this.dataset.obsScene); }; } }); } } getById("debugRemoteOBSControl").innerText = JSON.stringify(session.pcs[UUID].obsState); } function processOBSCommand(msg) { if (session.disableOBS) { return false; } else if (!window.obsstudio) { return false; } else if (typeof msg.obsCommand !== "object") { return false; } else if ("remote" in msg) { if ((msg.remote === session.remote && session.remote) || session.remote === true) { // approved } else { if (msg.UUID && msg.obsCommand.action) { var data = {}; data.rejected = "obsCommand"; //data.debug = msg.remote; session.sendRequest(data, msg.UUID); // this skips the server } warnlog("Denied access; remote does not match"); return false; } } else { if (msg.UUID && msg.obsCommand.action) { var data = {}; data.rejected = "obsCommand"; //data.debug = "no remote code provided"; session.sendRequest(data, msg.UUID); // this skips the server } return false; } try { // {changeScene: this.dataset.obsScene} if (msg.obsCommand.action && typeof msg.obsCommand.action == "string") { if (msg.obsCommand.action == "stopVirtualcam" || msg.obsCommand.action == "startVirtualcam") { if (session.obsState.virtualcam === false) { if (msg.UUID) { var data = {}; data.rejected = msg.obsCommand.action; session.sendRequest(data, msg.UUID); // this skips the server } return false; } } if (msg.obsCommand.action == "stopRecording" || msg.obsCommand.action == "startRecording") { if (session.obsState.recording === false) { if (msg.UUID) { var data = {}; data.rejected = msg.obsCommand.action; session.sendRequest(data, msg.UUID); // this skips the server } return false; } } if (msg.obsCommand.action == "stopStreaming" || msg.obsCommand.action == "startStreaming") { if (session.obsState.streaming === false) { if (msg.UUID) { var data = {}; data.rejected = msg.obsCommand.action; session.sendRequest(data, msg.UUID); // this skips the server } return false; } } if (msg.obsCommand.value && typeof msg.obsCommand.value == "string") { if (msg.obsCommand.action == "setCurrentScene" && session.filterOBSscenes && session.filterOBSscenes.length) { try { if (!session.filterOBSscenes.includes(msg.obsCommand.value)) { return false; } } catch (e) { errorlog(e); return false; } } window.obsstudio[msg.obsCommand.action](msg.obsCommand.value); } else { window.obsstudio[msg.obsCommand.action](); } } } catch (e) { errorlog(e); return false; } return true; } function applySceneState() { // guest side; tally light, etc. if (document.getElementById("videosource")) { var visibility = false; var ondeck = false; var recording = false; var tallyStyle = session.tallyStyle; if (!tallyStyle && session.tallyStyleDefault) { tallyStyle = session.tallyStyleDefault; } if (session.tallyOverride !== false) { if (session.tallyOverride == 1) { recording = true; } else if (session.tallyOverride == 2) { ondeck = true; visibility = false; recording = false; } else if (session.tallyOverride == 3) { visibility = true; recording = false; } else if (session.tallyOverride == 0) { ondeck = false; visibility = false; recording = false; } else { // maybe its a custom message or default? } if (!session.cleanOutput) { getById("obsState").classList.remove("hidden"); } } else if (!session.disableOBS) { for (var uid in session.pcs) { if (session.pcs[uid].obsState.sourceActive !== false && session.pcs[uid].obsState.visibility && session.pcs[uid].sceneDisplay !== false) { visibility = true; } else if (session.pcs[uid].obsState.visibility && session.pcs[uid].sceneDisplay !== false) { ondeck = true; } if ((session.pcs[uid].obsState.recording || session.pcs[uid].obsState.streaming) && session.pcs[uid].obsState.sourceActive !== false && session.pcs[uid].obsState.visibility && session.pcs[uid].sceneDisplay !== false) { // the scene that is recording must be visible also. recording = true; } } if (!session.cleanOutput) { getById("obsState").classList.remove("hidden"); } } else { return; } if (recording) { getById("obsState").classList.remove("ondeck"); getById("obsState").classList.add("recording"); // TODO: this needs to check all peers to make sure it's valid getById("obsState").innerHTML = "ON AIR"; if (tallyStyle) { getById("main").classList.remove("ondeck"); getById("main").classList.add("recording"); } } else if (visibility) { getById("obsState").classList.remove("recording"); getById("obsState").classList.remove("ondeck"); getById("obsState").innerHTML = "ACTIVE"; if (tallyStyle) { // only show active if its tally is enabled manually getById("main").classList.remove("recording"); getById("main").classList.remove("ondeck"); } else { getById("obsState").classList.add("hidden"); // most people don't care about being active } } else if (ondeck) { getById("obsState").classList.remove("recording"); getById("obsState").classList.add("ondeck"); // TODO: this needs to check all peers to make sure it's valid getById("obsState").innerHTML = "STAND BY"; if (tallyStyle) { getById("main").classList.remove("recording"); getById("main").classList.add("ondeck"); } } else { getById("obsState").classList.remove("recording"); getById("obsState").classList.remove("ondeck"); getById("obsState").innerHTML = "INACTIVE"; getById("obsState").classList.add("hidden"); // I don't think most people care to see inactive. if (tallyStyle) { getById("main").classList.remove("recording"); getById("main").classList.remove("ondeck"); } } //miniTranslate(getById("obsState")); if (visibility) { // BASIC TALLY LIGHT (on deck disabled) getById("obsState").classList.add("onair"); // LIVE if (tallyStyle) { getById("main").classList.add("onair"); } } else { getById("obsState").classList.remove("onair"); if (tallyStyle) { getById("main").classList.remove("onair"); } } if (session.automute) { if (!visibility) { session.micIsolatedAutoMute = []; if (session.automute !== "2") { for (var uid in session.pcs) { if (session.directorList.indexOf(uid) >= 0) { // allow validated directors to hear the guest session.micIsolatedAutoMute.push(uid); } } } } else { session.micIsolatedAutoMute = false; } session.applyIsolatedChat(); } } } function compare_vids(a, b) { var aa = a.order || 0; var bb = b.order || 0; if (aa < bb) { return 1; } if (aa > bb) { return -1; } return 0; } function compare_vids_sid(a, b) { var aa = a.dataset.sid || 0; var bb = b.dataset.sid || 0; if (aa > bb) { return 1; } if (aa < bb) { return -1; } return 0; } function compare_vids_label(a, b) { if (a.dataset.UUID && session.rpcs[a.dataset.UUID] && session.rpcs[a.dataset.UUID].label) { var aa = session.rpcs[a.dataset.UUID].label.toLowerCase(); } else { var aa = 0; } if (b.dataset.UUID && session.rpcs[b.dataset.UUID] && session.rpcs[b.dataset.UUID].label) { var bb = session.rpcs[b.dataset.UUID].label.toLowerCase(); } else { var bb = 0; } if (aa > bb) { return 1; } if (aa < bb) { return -1; } return 0; } function sortByZ(mediaPool, layout) { function sortABZ(a, b) { if (layout[a.dataset.sid]) { var aa = layout[a.dataset.sid].zIndex || layout[a.dataset.sid].z || 0; } else { var aa = 0; } if (layout[b.dataset.sid]) { var bb = layout[b.dataset.sid].zIndex || layout[b.dataset.sid].z || 0; } else { var bb = 0; } if (aa < bb) { return -1; } if (aa > bb) { return 1; } return 0; } mediaPool.sort(sortABZ); return mediaPool; } window.onpopstate = function () { if (session.firstPlayTriggered) { window.location.reload(true); // deprecated, but it seems to work, so w/e } }; var miniPerformerX = null; var miniPerformerY = null; function makeMiniDraggableElement(elmnt) { if (session.disableMouseEvents) { return; } try { elmnt.dragElement = false; // elmnt.style.bottom = "auto"; elmnt.style.cursor = "grab"; elmnt.stashonmouseup = null; elmnt.stashonmousemove = null; } catch (e) { errorlog(e); return; } var pos1 = 0; var pos2 = 0; var pos3 = 0; var pos4 = 0; var timestamp = false; function elementDrag(e) { // ON DRAG timestamp = false; if (session.infocus) { return; } try { e = e || window.event; if (e.type !== "touchmove") { if ("buttons" in e && e.buttons !== 1) { closeDragElement(e); return; } e.preventDefault(); } e.stopPropagation(); elmnt.dragElement = true; if (e.type === "touchmove") { pos1 = pos3 - e.touches[0].clientX; pos2 = pos4 - e.touches[0].clientY; pos3 = e.touches[0].clientX; pos4 = e.touches[0].clientY; } else { pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; } var topDrag = elmnt.offsetTop - pos2; if (topDrag > -3 + (window.innerHeight - elmnt.clientHeight)) { topDrag = -3 + (window.innerHeight - elmnt.clientHeight); } miniPerformerY = topDrag; miniPerformerX = elmnt.offsetLeft - pos1; if (miniPerformerY > window.innerHeight - elmnt.clientHeight) { miniPerformerY = window.innerHeight - elmnt.clientHeight; } if (miniPerformerX > window.innerWidth - elmnt.clientWidth) { miniPerformerX = window.innerWidth - elmnt.clientWidth; } miniPerformerX = (100 * miniPerformerX) / window.innerWidth; miniPerformerY = (100 * miniPerformerY) / window.innerHeight; if (session.widget && !session.leftMiniPreview) { if (miniPerformerX > 74) { miniPerformerX = 74; } } if (miniPerformerY < 0) { miniPerformerY = 0; } else if (miniPerformerY > 100) { miniPerformerY = 100; } if (miniPerformerX < 0) { miniPerformerX = 0; } else if (miniPerformerX > 100) { miniPerformerX = 100; } elmnt.style.right = "unset"; elmnt.style.top = miniPerformerY + "%"; elmnt.style.left = miniPerformerX + "%"; } catch (e) { errorlog(e); } } function closeDragElement(e) { // TOUCH END e = e || window.event; if (e.type !== "touchend") { if (e.button !== 0) { return; } document.onmouseup = elmnt.stashonmouseup; document.onmousemove = elmnt.stashonmousemove; elmnt.onmouseleave = null; } if (session.infocus) { return; } e.preventDefault(); if (timestamp && Date.now() - timestamp > 500) { // long hold, so this is a drag e.stopPropagation(); if (e.type === "touchend") { if (session.infocus === true) { session.infocus = false; } else { session.infocus = true; log("session: myself"); } setTimeout(() => updateMixer(), 10); } } else if (timestamp && e.type !== "touchend") { if (session.infocus === true) { session.infocus = false; } else { session.infocus = true; log("session: myself"); } setTimeout(() => updateMixer(), 10); } } function dragMouseDown(e) { ////// TOUCH START if (event.ctrlKey || event.metaKey) { return; } timestamp = Date.now(); e = e || window.event; if (session.infocus) { return; } e.preventDefault(); if (e.type === "touchstart") { pos3 = e.touches[0].clientX; pos4 = e.touches[0].clientY; elmnt.ontouchend = closeDragElement; elmnt.ontouchmove = elementDrag; } else { if (e.button !== 0) { return; } pos3 = e.clientX; pos4 = e.clientY; elmnt.stashonmouseup = document.onmouseup; // I don't want to interfere with other drag events. elmnt.stashonmousemove = document.onmousemove; document.onmouseup = closeDragElement; document.onmousemove = elementDrag; elmnt.onmouseleave = function (event) { closeDragElement(event); }; } } elmnt.onmousedown = dragMouseDown; elmnt.ontouchstart = dragMouseDown; } function makeDraggableElement(element) { if (session.disableMouseEvents) { return; } // this is here for a reason. :P if (!element) { return; } element.initialX; element.initialY; element.currentX; element.xOffset = 0; element.currentY; element.yOffset = 0; element.isDragging = false; element.dragElement = true; element.addEventListener("mousedown", dragStart); function dragStart(e) { element.initialX = e.clientX - element.xOffset; element.initialY = e.clientY - element.yOffset; document.addEventListener("mousemove", drag); document.addEventListener("mouseup", dragEnd); document.addEventListener("onmouseleave", dragEnd); document.addEventListener("onmouseenter", dragEnd); element.isDragging = true; } function dragEnd(e) { element.initialX = element.currentX; element.initialY = element.currentY; document.removeEventListener("mousemove", drag); document.removeEventListener("mouseup", dragEnd); document.removeEventListener("onmouseleave", dragEnd); document.removeEventListener("onmouseenter", dragEnd); element.isDragging = false; } function drag(e) { if (element.isDragging) { element.currentX = e.clientX - element.initialX; element.currentY = e.clientY - element.initialY; // Get the dimensions of the viewport let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); let vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); // Get the dimensions of the object let elementWidth = element.offsetWidth; let elementHeight = element.offsetHeight; // console.log('elementWidth:\n',elementWidth) // console.log('elementHeight:\n',elementHeight) // Calculate the boundaries let maxX = vw - elementWidth; let maxY = vh - elementHeight; let minX = 0; let minY = 0; // Calculate real boundaries (parent position: fixed issues) let topOffset = 0; let leftOffset = 0; let elementOffset = element; while (elementOffset) { topOffset += elementOffset.offsetTop; leftOffset += elementOffset.offsetLeft; elementOffset = elementOffset.offsetParent; } // Adjust the position if it's going beyond the boundaries let realX = element.currentX + leftOffset; let realY = element.currentY + topOffset; if (realX > maxX) { element.currentX = maxX - leftOffset; } else if (realX < minX) { element.currentX = minX - leftOffset; } if (realY > maxY) { element.currentY = maxY - topOffset; } else if (realY < minY) { element.currentY = minY - topOffset; } // Update the position and offset element.xOffset = element.currentX; element.yOffset = element.currentY; element.style.transform = `translate(${element.currentX}px, ${element.currentY}px)`; } } } function clearCacheForCurrentSite() { if ('caches' in window) { try { caches.keys().then(function (cacheNames) { cacheNames.forEach(function (cacheName) { caches.delete(cacheName); }); }); log("Cache cleared for current site"); } catch (e) { } } else { warnlog("Cache API not supported"); } } function removeStorage(cname) { localStorage.removeItem(cname); } function clearStorage() { localStorage.clear(); //clearCacheForCurrentSite(); // cache as well. if (!session.cleanOutput) { warnUser("The local storage and saved settings have been cleared", 1000); } } function setStorage(cname, cvalue, hours = 9999) { // not actually a cookie var now = new Date(); var item = { value: cvalue, expiry: now.getTime() + hours * 60 * 60 * 1000 }; try { localStorage.setItem(cname, JSON.stringify(item)); } catch (e) { errorlog(e); } } function getStorage(cname) { try { var itemStr = localStorage.getItem(cname); } catch (e) { errorlog(e); return; } if (!itemStr) { return ""; } var item = JSON.parse(itemStr); var now = new Date(); if (now.getTime() > item.expiry) { localStorage.removeItem(cname); return ""; } return item.value; } function play(streamid = null, UUID = false) { // play whatever is in the URL params; or filter by a streamID option log("play stream: " + session.view + " " + streamid); if (session.viewDirectorOnly) { if (!(UUID || streamid)) { warnlog("No UUID and StreamID"); return; } else if (session.directorList.indexOf(UUID) == -1) { warnlog("Not a director"); return; } } if (session.view_set) { var played = false; for (var j in session.view_set) { if (streamid === null) { // play what is in the view list ; not a group room probably session.watchStream(session.view_set[j]); played = true; } else if (streamid === session.view_set[j]) { // plays if the group room list matches the explicit list session.watchStream(session.view_set[j]); played = true; } } if (session.include) { session.include.forEach(sid => { if (session.view_set.includes(sid)) { // already played } else if (streamid === null) { // play what is in the view list ; not a group room probably session.watchStream(sid); } else if (streamid === sid) { // plays if the group room list matches the explicit list session.watchStream(sid); played = true; } }); } if (!played && streamid) { if (session.scene !== false) { if (!session.permaid) { if (!session.queue) { // I don't want to deal with queues. if (session.exclude === false || !session.exclude.includes(streamid)) { if (UUID) { if (session.directorList.indexOf(UUID) >= 0) { warnlog("stream ID added to badStreamList: " + streamid); session.badStreamList.push(streamid); // if I uncomment this, the director can mute the solo link. // session.watchStream(streamid); // changed June 4th 2024. We shouldn't be viewing the stream if on the bad list, no? } } } } } } } } else if (streamid && session.exclude !== false) { if (session.exclude.includes(streamid)) { // we don't play it at all. (if explicity listed as VIDEO, then OKay.) } else { session.watchStream(streamid); // I suppose we do play it. } } else if (streamid) { if (session.optimize === 0) { log("Running special optimize===logic logic for loading rtc connections"); try { // must be a scene and not an auto scene if (session.scene && session.activatedStreams.size) { if (session.activatedStreams.has(streamid)) { session.watchStream(streamid); return; } } if (UUID && streamid) { if (session.directorList.indexOf(UUID) >= 0) { session.watchStream(streamid); } } } catch (e) { errorlog(e); } } else { session.watchStream(streamid); } } else if (session.include.length) { session.include.forEach(sid => { session.watchStream(sid); }); } } function nextQueue() { if (!session.queue) { return; } if (!session.director) { return; } if (session.queueList.length == 0) { getById("queuebutton").classList.add("red"); setTimeout(function () { getById("queuebutton").classList.remove("red"); }, 50); return; } var nextStream = session.queueList.shift(); getById("queuebutton").classList.add("red"); setTimeout(function () { getById("queuebutton").classList.remove("red"); }, 200); updateQueue(); session.watchStream(nextStream); log("next stream loading: " + nextStream); } function updateQueue(adding = false) { if (!session.queue) { return; } if (!session.director) { return; } if (session.queueList.length) { if (session.queueList.length > 10) { getById("queueNotification").innerHTML = "‼"; } else { getById("queueNotification").innerHTML = session.queueList.length; } getById("queueNotification").classList.add("queueNotification"); } else { getById("queueNotification").innerHTML = ""; getById("queueNotification").classList.remove("queueNotification"); } // Keep the toolbar button visually hot while guests are waiting var queueButton = getById("queuebutton"); if (queueButton) { if (session.queueList.length) { queueButton.classList.add("queueAttention"); } else { queueButton.classList.remove("queueAttention"); } } var queueBadge = getById("queueNotification"); if (queueBadge) { if (session.queueList.length) { queueBadge.classList.add("queueNotificationPulse"); } else { queueBadge.classList.remove("queueNotificationPulse"); } } if (adding) { if (session.beepToNotify) { // Favor the louder knock tone when approvals are enabled playtone(false, session.knockToneEnabled ? "knocktone" : "testtone"); showNotification("someone joined the queue", "queue length: " + session.queueList.length); } getById("queuebutton").classList.remove("shake"); setTimeout(function () { getById("queuebutton").classList.add("shake"); }, 10); } } function hideStreamLowBandwidth(bandwidth, UUID) { if (session.lowBitrateCutoff === false) { // allow 0,but I should probably also do this if there is a disconnect. return; } if (session.directorList.includes(UUID) || session.rpcs[UUID].director) { if (session.showDirector || session.rpcs[UUID].showDirector) { // all good } else { return; // we don't include the director since not treated as a guest } } if (bandwidth <= session.lowBitrateCutoff) { //log("bandwidth <= session.lowBitrateCutoff"); log("actual bandwidth: " + bandwidth + " < bandwidth cut threshold: " + session.lowBitrateCutoff); // <= used so 0 can be used as a trigger if (session.lowBitrateSceneChange) { changeSceneLowBandwidth(true); } else if (!session.rpcs[UUID].bandwidthMuted) { session.rpcs[UUID].bandwidthMuted = true; updateMixer(); } } else if (session.lowBitrateSceneChange) { log("changeSceneLowBandwidth(false)"); changeSceneLowBandwidth(false); } else if (session.rpcs[UUID].bandwidthMuted) { session.rpcs[UUID].bandwidthMuted = false; if (session.rpcs[UUID].videoElement) { session.rpcs[UUID].videoElement.muted = checkMuteState(UUID); } updateMixer(); } } var changeSceneEnabled = false; var changeSceneLowBandwidthRevert = false; function changeSceneLowBandwidth(state) { if (!session.lowBitrateSceneChange) { return; } if (!session.obsState) { return; } try { if (session.obsState.sourceActive && session.obsState.details && session.obsState.details.currentScene) { changeSceneLowBandwidthRevert = session.obsState.details.currentScene.name || false; } else if ("sourceActive" in session.obsState && !session.obsState.sourceActive && session.obsState.details && session.obsState.details.currentScene) { if (session.obsState.details.currentScene.name !== session.lowBitrateSceneChange) { return; // not the FML scene, nor are we visible, so we're not going to switch back. Assume the user has overtaken the setup. } } if (!window.obsstudio || !window.obsstudio["setCurrentScene"]) { return; } if (state && changeSceneLowBandwidthRevert) { if (changeSceneEnabled) { // bitrate was higher , so we can now cut off. //log("2 changeSceneEnabled: "+session.lowBitrateSceneChange+" changeSceneEnabled:"+changeSceneEnabled); log("Changing to cut scene due to low bandwidth"); window.obsstudio["setCurrentScene"](session.lowBitrateSceneChange); } else { log("Low bandwidth, but not changing scenes because threshold not hit at least once yet"); } } else if (changeSceneLowBandwidthRevert) { changeSceneEnabled = true; //log("1 setCurrentScene: "+changeSceneLowBandwidthRevert+" changeSceneEnabled:"+changeSceneEnabled); log("Reverting to original scene due to good bandwidth"); window.obsstudio["setCurrentScene"](changeSceneLowBandwidthRevert); } } catch (e) { errorlog(e); } } function setupIncomingScreenTracking(v, UUID) { // SCREEN element. if (session.directorList.indexOf(UUID) >= 0) { v.muted = false; } v.addEventListener( "playing", e => { try { var bigPlayButton = document.getElementById("bigPlayButton"); if (bigPlayButton) { bigPlayButton.parentNode.removeChild(bigPlayButton); } } catch (e) { } resetupAudioOut(e.target, true); try { if (session.pip) { if (v.readyState >= 3) { if (!v.pip) { v.pip = true; toggleSystemPip(v, true); } } } } catch (e) { } }, { once: true } ); v.onpause = event => { // prevent things from pausing; human or other if (v.dataset.UUID && session.rpcs[v.dataset.UUID] && session.rpcs[v.dataset.UUID].manualBandwidth === 0) { return true; } if (!(event.ctrlKey || event.metaKey)) { warnlog("Video paused; force it to play again"); //return; //session.audioCtx.resume(); //log("ctx resume"); event.currentTarget .play() .then(_ => { log("playing 4"); }) .catch(error => { warnlog("didnt play 1"); }); if (Firefox) { unPauseVideo(v); } } return true; }; if (session.pip) { v.onloadedmetadata = function () { if (!v.paused) { if (!v.pip) { v.pip = true; toggleSystemPip(v, true); } } }; } v.addEventListener("resize", e => { // if the aspect ratio changes, then we might want to update the mixer. If audio only, then this doesn't matter. var v = e.target; var aspectRatio = parseFloat(v.videoWidth / v.videoHeight) || 0; log("resize event: " + aspectRatio); if (!aspectRatio) { v.resetAR = true; return; } // if Audio only, then we don't want to set or update any aspect ratio. if (v.resetAR) { log("ASPECT RATIO UNMUTED"); delete v.resetAR; v.dataset.aspectRatio = aspectRatio; pokeIframeAPI("aspect-ratio", v.dataset.aspectRatio, v.dataset.UUID, v.dataset.sid); setTimeout(function () { updateMixer(); }, 1); } else if (v.dataset.aspectRatio) { if (aspectRatio != parseFloat(v.dataset.aspectRatio)) { log("ASPECT RATIO CHANGED"); v.dataset.aspectRatio = aspectRatio; pokeIframeAPI("aspect-ratio", v.dataset.aspectRatio, v.dataset.UUID, v.dataset.sid); setTimeout(function () { updateMixer(); }, 1); // We don't want to run this on the first resize? just subsequent ones. } } else { log("NEW VIDEO ? ASPECT RATIO new"); v.dataset.aspectRatio = aspectRatio; pokeIframeAPI("aspect-ratio", v.dataset.aspectRatio, v.dataset.UUID, v.dataset.sid); setTimeout(function () { updateMixer(); }, 1); } }); if (typeof session.volume == "number") { v.volume = session.volume; } else { v.volume = 1.0; // play audio automatically } v.autoplay = true; v.controls = session.showControls || false; v.classList.add("tile"); v.setAttribute("playsinline", ""); v.controlTimer = null; v.dataset.menu = "context-menu-video"; if (!session.cleanOutput) { v.classList.add("task"); // this adds the right-click menu } if (document.getElementById("mainmenu")) { var m = getById("mainmenu"); m.remove(); document.querySelectorAll(".hidden2").forEach(ele2 => { ele2.classList.remove("hidden2"); }); } if (session.director) { if (session.showControls !== null) { v.controls = session.showControls; } else { v.controls = true; } var container = getById("screenContainer_" + UUID); v.container = container; v.disablePictureInPicture = false; v.setAttribute("controls", "controls"); container.appendChild(v); pokeIframeAPI("control-box-video-updated", v.id, UUID); session.requestRateLimit(session.directorViewBitrate, UUID); /// limit resolution for director v.title = "Hold CTRL or CMD (⌘) while clicking the video to open detailed stats"; if (session.beepToNotify) { playtone(); } } else if (session.scene !== false) { v.controls = session.showControls || false; if (session.view) { // specific video to be played v.style.display = "block"; } else if (session.scene === "0") { // auto plays, right? v.style.display = "block"; } else { // group scene I guess; needs to be added manually v.style.display = "none"; v.mutedStateScene = true; } setTimeout(function () { updateMixer(); }, 1); } else if (session.roomid !== false) { if (session.cleanOutput) { v.controls = session.showControls || false; } else if (session.studioSoftware) { v.controls = session.showControls || false; } else if (session.showControls !== null) { v.controls = session.showControls; } else { v.controls = true; } //if ((session.roomid==="") && (session.bitrate)){ // let's keep the default bitrates, since this isn't a real room and bitrates are specified. //} //else if (session.novideo !== false){ // if (session.novideo.includes(session.rpcs[UUID].streamID)){ // no video will have muted the video already anyways. // session.requestRateLimit(0,UUID, false);// optimizing audio here doesn't later get turned back on. let the automixer disable audio instead // } //} //else { // session.requestRateLimit(0,UUID, false);//// optimizing audio here doesn't later get turned back on. let the automixer disable audio instead //} setTimeout(function () { updateMixer(); }, 1); } else { v.style.display = "block"; setTimeout(function () { updateMixer(); }, 1); } v.addEventListener("click", function (e) { // show stats of video if double clicked log("clicked"); try { var uid = e.currentTarget.dataset.UUID; if (e.ctrlKey || e.metaKey) { e.preventDefault(); if (session.statsMenu !== false) { if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } e.stopPropagation(); return false; } else if ("prePausedBandwidth" in session.rpcs[uid]) { unPauseVideo(e.currentTarget); } } catch (e) { errorlog(e); } }); if (session.statsMenu) { if ("stats" in session.rpcs[UUID]) { if (getById("menuStatsBox")) { clearInterval(getById("menuStatsBox").interval); getById("menuStatsBox").remove(); } var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, UUID); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, UUID); } } v.touchTimeOut = null; v.touchLastTap = 0; v.touchCount = 0; v.addEventListener("touchend", function (event) { if (session.disableMouseEvents) { return; } log("touched"); //document.ontouchup = null; //document.onmouseup = null; document.onmousemove = null; document.ontouchmove = null; var currentTime = new Date().getTime(); var tapLength = currentTime - v.touchLastTap; clearTimeout(v.touchTimeOut); if (tapLength < 500 && tapLength > 0) { /// log("double touched"); v.touchCount += 1; event.preventDefault(); if (v.touchCount < 5) { v.touchLastTap = currentTime; return false; } v.touchLastTap = 0; v.touchCount = 0; log("double touched"); if (session.statsMenu !== false) { var uid = event.currentTarget.dataset.UUID; if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } event.stopPropagation(); return false; ////// } else { v.touchCount = 1; v.touchTimeOut = setTimeout( function (vv) { clearTimeout(vv.touchTimeOut); vv.touchLastTap = 0; vv.touchCount = 0; }, 5000, v ); v.touchLastTap = currentTime; } }); if (v.controls == false) { v.addEventListener("click", function () { if (v.paused) { log("PLAYING MANUALLY?"); v.play() .then(_ => { log("playing 5"); }) .catch(warnlog); } }); if (session.nocursor == false) { // we do not want to show the controls. This is because MacOS + OBS does not work; so electron app needs this. if (!session.cleanOutput) { if (session.studioSoftware) { } else if (session.showControls === false) { // explicitly disabled; default null. } else if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { } else { if (v.controlTimer) { clearInterval(v.controlTimer); } v.controlTimer = setTimeout(showControlBar.bind(null, v), 1000); //v.controlTimer = setTimeout(function (){v.controls=true;},3000); // 3 seconds before I enable the controls automatically. This way it doesn't auto appear during loading. 3s enough, right? } } } } //if (session.fadein){ v.addEventListener("animationend", function (e) { v.classList.remove("fadein"); // allows the video to fade in. if (v.holder) { v.holder.classList.remove("fadein"); } }); // v.classList.add("fadein"); // allows the video to fade in. // if (v.holder){ // v.holder.classList.add("fadein"); // } //} applyMuteState(UUID); // TODO; needs to be specific to screen video v.usermuted = false; v.addEventListener("volumechange", function (e) { var muteState = checkMuteState(UUID); if (this.muted && this.muted !== muteState) { this.usermuted = 1; } else if (!this.muted && this.muted !== muteState) { this.usermuted = 2; } else if (!this.muted) { this.usermuted = false; } }); if (session.screenShareStartPaused) { // we know this is a screen share already pauseVideo(v, false); } if (session.director) { /* var wss = ""; if (session.customWSS || session.wssSetViaUrl){ if (session.customWSS && (session.customWSS!==true)){ wss = "&pie="+session.customWSS; } else if (session.customWSS==true){ wss = "&wss=" + session.wss; } else { wss = "&wss2=" + session.wss; } } */ /* var codecGroupFlag=""; if (session.codecGroupFlag){ codecGroupFlag = session.codecGroupFlag; } */ /* var passAdd2=""; if (session.password){ if (session.defaultPassword===false){ passAdd2="&password="+session.password; } } */ if (session.customWSS && "isScene" in msg && msg.isScene !== false) { // this is a scene, so lets not show it. } else { var soloLink = soloLinkGenerator(session.rpcs[UUID].streamID); createControlBoxScreenshare(UUID, soloLink, session.rpcs[UUID].streamID); } } if (session.autorecord || session.autorecordremote) { log("AUTO RECORD START"); setTimeout( function (UUID, v) { var videoKbps = session.recordDefault; if (session.recordLocal !== false) { videoKbps = session.recordLocal; } if (session.director) { recordVideo(document.querySelector("[data-action-type='recorder-local'][data--u-u-i-d='" + UUID + "']"), null, videoKbps); } else if (v.stopWriter || v.recording) { } else if (v.startWriter) { v.startWriter(); } else { recordLocalVideo(null, videoKbps, v); } }, 2000, UUID, v ); } if (session.rpcs[UUID]) { clearTimeout(session.rpcs[UUID].getStatsTimeout); session.rpcs[UUID].getStatsTimeout = setTimeout(processStats, 100, UUID); } } function setupIncomingVideoTracking(v, UUID) { // video element. if (session.directorList.indexOf(UUID) >= 0) { v.muted = false; } v.onpause = event => { // prevent things from pausing; human or other if (v.dataset.UUID && session.rpcs[v.dataset.UUID] && session.rpcs[v.dataset.UUID].manualBandwidth === 0) { return true; } if (!CtrlPressed) { warnlog("Video paused; force it to play again"); //return; //session.audioCtx.resume(); //log("ctx resume"); event.currentTarget .play() .then(_ => { log("playing 6"); }) .catch(error => { warnlog("didnt play 1"); }); unPauseVideo(v); } else if (Firefox && CtrlPressed) { log("CLICK 351"); if (session.statsMenu !== false) { var uid = event.currentTarget.dataset.UUID; event.preventDefault(); if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } event.stopPropagation(); return false; } return true; }; /* v.onerror = function(event){ errorlog(event); try{ warnlog("Vidieo element threw an error; going to reconnect it"); session.rpcs[UUID].videoElement.stop(); session.rpcs[UUID].videoElement.srcObject = null; session.rpcs[UUID].videoElement.srcObject = session.rpcs[UUID].streamSrc; // replaecd with updateIncomingVideoElement these days session.rpcs[UUID].videoElement.play(); setTimeout(function(){updateMixer();},1); } catch(e){errorlog(e);} } */ if (session.pip) { v.onloadedmetadata = function () { if (!v.paused) { if (!v.pip) { v.pip = true; toggleSystemPip(v, true); } } }; } v.addEventListener("resize", e => { var v = e.target; var aspectRatio = parseFloat(v.videoWidth / v.videoHeight) || 0; log("resize event: " + aspectRatio); if (!aspectRatio) { v.resetAR = true; return; } // if Audio only, then we don't want to set or update any aspect ratio. if (typeof v.manualRotate == "number") { //v.rotated = v.manualRotate; // ((session.rotate || 0) + 90) % 360; } else if (session.keepIncomingVideosInLandscape) { if (aspectRatio < 1) { // session.keepIncomingVideosInLandscape v.rotated = session.keepIncomingVideosInLandscape; } else { v.rotated = 0; } } else if (session.keepIncomingVideosInPortrait) { if (aspectRatio > 1) { // session.keepIncomingVideosInLandscape v.rotated = session.keepIncomingVideosInPortrait; } else { v.rotated = 0; } } if (v.resetAR) { log("ASPECT RATIO UNMUTED"); delete v.resetAR; v.dataset.aspectRatio = aspectRatio; pokeIframeAPI("aspect-ratio", v.dataset.aspectRatio, v.dataset.UUID, v.dataset.sid); setTimeout(function () { updateMixer(); }, 1); } else if (v.dataset.aspectRatio) { if (aspectRatio != parseFloat(v.dataset.aspectRatio)) { log("ASPECT RATIO CHANGED"); v.dataset.aspectRatio = aspectRatio; pokeIframeAPI("aspect-ratio", v.dataset.aspectRatio, v.dataset.UUID, v.dataset.sid); setTimeout(function () { updateMixer(); }, 1); // We don't want to run this on the first resize? just subsequent ones. } } else { log("NEW VIDEO ? ASPECT RATIO new"); v.dataset.aspectRatio = aspectRatio; pokeIframeAPI("aspect-ratio", v.dataset.aspectRatio, v.dataset.UUID, v.dataset.sid); setTimeout(function () { updateMixer(); }, 1); } }); if (typeof session.volume == "number") { v.volume = session.volume; } else { v.volume = 1.0; // play audio automatically } v.autoplay = true; v.controls = session.showControls || false; v.classList.add("tile"); v.setAttribute("playsinline", ""); v.controlTimer = null; v.dataset.menu = "context-menu-video"; if (!session.cleanOutput) { v.classList.add("task"); // this adds the right-click menu } if (document.getElementById("mainmenu")) { var m = getById("mainmenu"); m.remove(); document.querySelectorAll(".hidden2").forEach(ele2 => { ele2.classList.remove("hidden2"); }); } if (session.director) { if (session.showControls === false) { v.controls = false; } else { v.controls = true; } var container = getById("videoContainer_" + UUID); v.container = container; v.disablePictureInPicture = false; v.setAttribute("controls", "controls"); container.appendChild(v); pokeIframeAPI("control-box-video-updated", v.id, UUID); container.classList.add("hasMedia"); session.requestRateLimit(session.directorViewBitrate, UUID); /// limit resolution for director v.title = "Hold CTRL or CMD (⌘) while clicking the video to open detailed stats"; if (session.beepToNotify) { playtone(); } } else if (session.scene !== false) { v.controls = session.showControls || false; if (session.view) { // specific video to be played v.style.display = "block"; } else if (session.scene === "0") { // auto plays, right? v.style.display = "block"; } else if (session.scene !== false && session.autoadd && session.rpcs[UUID].streamID && session.autoadd.includes(session.rpcs[UUID].streamID)) { /// session.autoadd v.style.display = "block"; // auto added because manually added. } else { // group scene I guess; needs to be added manually v.style.display = "none"; session.rpcs[UUID].mutedStateScene = true; } } else if (session.roomid !== false) { if (session.cleanOutput) { v.controls = session.showControls || false; } else if (session.studioSoftware) { v.controls = session.showControls || false; } else if (session.showControls !== null) { v.controls = session.showControls; } else { v.controls = true; } } else { v.style.display = "block"; } v.addEventListener("click", function (e) { // show stats of video if double clicked log("clicked"); try { var uid = e.currentTarget.dataset.UUID; if (e.ctrlKey || e.metaKey) { e.preventDefault(); if (session.statsMenu !== false) { if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } e.stopPropagation(); return false; } else if ("prePausedBandwidth" in session.rpcs[uid]) { unPauseVideo(e.currentTarget); } } catch (e) { errorlog(e); } }); if (session.statsMenu) { if ("stats" in session.rpcs[UUID]) { if (getById("menuStatsBox")) { clearInterval(getById("menuStatsBox").interval); getById("menuStatsBox").remove(); } var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, UUID); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, UUID); } } v.touchTimeOut = null; v.touchLastTap = 0; v.touchCount = 0; v.addEventListener("touchend", function (event) { if (session.disableMouseEvents) { return; } log("touched"); //document.ontouchup = null; //document.onmouseup = null; document.onmousemove = null; document.ontouchmove = null; var currentTime = new Date().getTime(); var tapLength = currentTime - v.touchLastTap; clearTimeout(v.touchTimeOut); if (tapLength < 500 && tapLength > 0) { /// log("double touched"); v.touchCount += 1; event.preventDefault(); if (v.touchCount < 5) { v.touchLastTap = currentTime; return false; } v.touchLastTap = 0; v.touchCount = 0; log("double touched"); if (session.statsMenu !== false) { var uid = event.currentTarget.dataset.UUID; if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } event.stopPropagation(); return false; ////// } else { v.touchCount = 1; v.touchTimeOut = setTimeout( function (vv) { clearTimeout(vv.touchTimeOut); vv.touchLastTap = 0; vv.touchCount = 0; }, 5000, v ); v.touchLastTap = currentTime; } }); if (session.rpcs[UUID].stats.info && "remote" in session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.remote) { v.addEventListener("wheel", remotePTZRequest); // v.addEventListener("wheel", remoteFocusZoomRequest); // just remote focus -- obsolete. } if (session.ptzSlider && (session.director || (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.remote))) { const ptzContainer = document.createElement('div'); ptzContainer.className = 'video-ptz-controls'; // Zoom slider const zoomSlider = document.createElement('div'); zoomSlider.className = 'video-zoom-slider'; const zoomLabel = document.createElement('label'); zoomLabel.innerText = "Zoom"; const zoomInput = document.createElement('input'); zoomInput.title = "Camera zoom control"; zoomInput.type = 'range'; zoomInput.min = '0'; zoomInput.max = '100'; zoomInput.value = '0'; let zoomUpdating = false; zoomInput.addEventListener('input', (e) => { if (zoomUpdating) return; const zoomValue = parseInt(e.target.value) / 100; // Normalize to 0-1 session.requestZoomChange(zoomValue, UUID, session.remote, true); }); // Pan slider const panSlider = document.createElement('div'); panSlider.className = 'video-pan-slider'; const panLabel = document.createElement('label'); panLabel.innerText = "Pan"; const panInput = document.createElement('input'); panInput.title = "Camera pan control (left/right)"; panInput.type = 'range'; panInput.min = '-100'; panInput.max = '100'; panInput.value = '0'; let panUpdating = false; panInput.addEventListener('input', (e) => { if (panUpdating) return; const panValue = parseInt(e.target.value) / 100; // Normalize to -1 to 1 session.requestPanChange(panValue, UUID, session.remote, true); }); // Tilt slider const tiltSlider = document.createElement('div'); tiltSlider.className = 'video-tilt-slider'; const tiltLabel = document.createElement('label'); tiltLabel.innerText = "Tilt"; const tiltInput = document.createElement('input'); tiltInput.title = "Camera tilt control (up/down)"; tiltInput.type = 'range'; tiltInput.min = '-100'; tiltInput.max = '100'; tiltInput.value = '0'; let tiltUpdating = false; tiltInput.addEventListener('input', (e) => { if (tiltUpdating) return; const tiltValue = parseInt(e.target.value) / 100; // Normalize to -1 to 1 session.requestTiltChange(tiltValue, UUID, session.remote, true); }); // Append elements to containers zoomSlider.appendChild(zoomLabel); zoomSlider.appendChild(zoomInput); panSlider.appendChild(panLabel); panSlider.appendChild(panInput); tiltSlider.appendChild(tiltLabel); tiltSlider.appendChild(tiltInput); ptzContainer.appendChild(zoomSlider); ptzContainer.appendChild(panSlider); ptzContainer.appendChild(tiltSlider); if (!v.container) { v.container = getById("videoContainer_" + UUID); } v.container.appendChild(ptzContainer); // Store references for external updates session.rpcs[UUID].zoomSlider = (value) => { zoomUpdating = true; zoomInput.value = Math.round(value * 100); // Convert 0-1 to 0-100 zoomUpdating = false; }; session.rpcs[UUID].panSlider = (value) => { panUpdating = true; panInput.value = Math.round(value * 100); // Convert -1 to 1 to -100 to 100 panUpdating = false; }; session.rpcs[UUID].tiltSlider = (value) => { tiltUpdating = true; tiltInput.value = Math.round(value * 100); // Convert -1 to 1 to -100 to 100 tiltUpdating = false; }; } else if (session.zoomSlider && (session.director || (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.remote))) { const slider = document.createElement('div'); slider.className = 'video-zoom-slider0'; const input = document.createElement('input'); input.title = "Hint: The remote camera's browser may needs to be visible for zoom to work in certain browsers"; input.type = 'range'; input.min = '0'; input.max = '255'; input.value = '0'; let updating = false; input.addEventListener('input', (e) => { if (updating) return; const zoomValue = parseInt(e.target.value) / 255; session.requestZoomChange(zoomValue, UUID, session.remote, true); }); // Update slider if remote changes occur const updateSlider = (value) => { updating = true; input.value = Math.round(value * 255); updating = false; input.title = input.value * 100 + ""; }; slider.appendChild(input); if (!v.container) { v.container = getById("videoContainer_" + UUID); } v.container.appendChild(slider); // Store reference for external updates session.rpcs[UUID].zoomSlider = updateSlider; } if (v.controls == false) { v.addEventListener("click", function () { log("click 33"); if (v.paused) { log("PLAYING MANUALLY?"); v.play() .then(_ => { log("playing 7"); }) .catch(warnlog); } }); if (session.nocursor == false) { // we do not want to show the controls. This is because MacOS + OBS does not work; so electron app needs this. if (!session.cleanOutput) { if (session.studioSoftware) { } else if (session.showControls === false) { // explicitly disabled; default null. } else if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { } else { if (v.controlTimer) { clearInterval(v.controlTimer); } v.controlTimer = setTimeout(showControlBar.bind(null, v), 1000); //v.controlTimer = setTimeout(function (){v.controls=true;},3000); // 3 seconds before I enable the controls automatically. This way it doesn't auto appear during loading. 3s enough, right? } } } } //if (session.fadein){ v.addEventListener("animationend", function (e) { v.classList.remove("fadein"); // allows the video to fade in. if (v.holder) { v.holder.classList.remove("fadein"); } }); //v.classList.add("fadein"); // allows the video to fade in. // if (v.holder){ //// v.holder.classList.add("fadein"); // } //} applyMuteState(UUID); v.usermuted = false; if (session.screenShareStartPaused && session.rpcs[UUID].screenShareState) { pauseVideo(v, false); } v.addEventListener("volumechange", function (e) { var muteState = checkMuteState(UUID); if (this.muted && this.muted !== muteState) { this.usermuted = 1; } else if (!this.muted && this.muted !== muteState) { this.usermuted = 2; } else if (!this.muted) { this.usermuted = false; } }); if (session.autorecord || session.autorecordremote) { log("AUTO RECORD START"); setTimeout( function (UUID, v) { var videoKbps = session.recordDefault; if (session.recordLocal !== false) { videoKbps = session.recordLocal; } if (session.director) { recordVideo(document.querySelector("[data-action-type='recorder-local'][data--u-u-i-d='" + UUID + "']"), null, videoKbps); } else if (v.stopWriter || v.recording) { } else if (v.startWriter) { v.startWriter(); } else { recordLocalVideo(null, videoKbps, v); } }, 2000, UUID, v ); } if (session.rpcs[UUID]) { clearTimeout(session.rpcs[UUID].getStatsTimeout); session.rpcs[UUID].getStatsTimeout = setTimeout(processStats, 100, UUID); } } session.requestPanChange = async function (pan, UUID, passwd = session.remote, absolute = false) { // pan is now expected to be a value between -1 and 1 log("request pan change: " + pan); var msg = {}; msg.pan = pan; // Normalized value -1 to 1 msg.remote = passwd; msg.abs = absolute; msg = await session.encodeRemote(msg); if (session.sendRequest(msg, UUID)) { log("pan success"); return true; } else { errorlog("failed to send pan change request"); return false; } }; session.requestTiltChange = async function (tilt, UUID, passwd = session.remote, absolute = false) { // tilt is now expected to be a value between -1 and 1 log("request tilt change: " + tilt); var msg = {}; msg.tilt = tilt; // Normalized value -1 to 1 msg.remote = passwd; msg.abs = absolute; msg = await session.encodeRemote(msg); if (session.sendRequest(msg, UUID)) { log("tilt success"); return true; } else { errorlog("failed to send tilt change request"); return false; } }; session.requestZoomChange = async function (zoom, UUID, passwd = session.remote, absolute = false) { // zoom is now expected to be a value between 0 and 1 log("request zoom change: " + zoom); var msg = {}; msg.zoom = zoom; // Normalized value 0 to 1 msg.abs = absolute; msg.remote = passwd; msg = await session.encodeRemote(msg); if (session.sendRequest(msg, UUID)) { log("zoom success"); return true; } else { errorlog("failed to send zoom change request"); return false; } }; session.requestFocusChange = async function (focal, UUID, passwd = session.remote, absolute = false) { log("request focus change: " + focal); var msg = {}; msg.focus = focal; msg.abs = absolute; msg.remote = passwd; msg = await session.encodeRemote(msg); if (session.sendRequest(msg, UUID)) { log("focus success"); } else { errorlog("failed to send focus change request"); } }; session.requestAutofocusChange = async function (enabled, UUID, passwd = session.remote) { log("request autofocus change: " + enabled); var msg = {}; msg.autofocus = enabled; msg.remote = passwd; msg = await session.encodeRemote(msg); if (session.sendRequest(msg, UUID)) { log("autofocus request success"); } else { errorlog("failed to send autofocus change request"); } }; function remotePTZRequest(event) { event.preventDefault(); var scale = event.deltaY > 0 ? -0.05 : 0.05; // Use larger normalized steps if (!event.altKey) { scale *= 2; // Double the scale when not holding Alt } if (event.ctrlKey || event.metaKey) { if (event.shiftKey) { // tilt: -1 to 1 session.requestTiltChange(scale, event.currentTarget.dataset.UUID); } else { // focus: -1 to 1 session.requestFocusChange(scale, event.currentTarget.dataset.UUID); } } else if (event.shiftKey) { // pan: -1 to 1 session.requestPanChange(scale, event.currentTarget.dataset.UUID); } else { // zoom: 0 to 1 (relative) session.requestZoomChange(scale, event.currentTarget.dataset.UUID); } } function remoteFocusZoomRequest(event) { // obsolete. event.preventDefault(); var scale = event.deltaY > 0 ? -0.004 : 0.004; log(event.currentTarget); log(event.deltaY); if (!event.altKey) { scale *= 10; } if (event.ctrlKey || event.metaKey) { // focus session.requestFocusChange(scale, event.currentTarget.dataset.UUID); } else { // zoom session.requestZoomChange(scale, event.currentTarget.dataset.UUID); } } function mediaAudioTrackUpdated(UUID, streamID) { pokeIframeAPI("new-audio-track-added", true, UUID, streamID); // videoTrack is whether video. audio will be false I guess. } function mediaVideoTrackUpdated(UUID, streamID) { pokeIframeAPI("new-video-track-added", true, UUID, streamID); // videoTrack is whether video. audio will be false I guess. } function mediaSourceUpdated(UUID, streamID) { pokeIframeAPI("new-stream-added", true, UUID, streamID); // videoTrack is whether video. audio will be false I guess. pokeAPI("streamAdded", streamID); } function showControlBar(vel) { try { vel.controls = true; } catch (e) { errorlog(e); } } function createRichVideoElement(UUID) { // this function is used to check and generate a rich video element if needed if (!session.rpcs[UUID].videoElement) { log("video element is being created and any media tracks added"); session.rpcs[UUID].videoElement = createVideoElement(); session.rpcs[UUID].videoElement.dataset.UUID = UUID; session.rpcs[UUID].videoElement.id = "videosource_" + UUID; // could be set to UUID in the future if (session.rpcs[UUID].streamID) { session.rpcs[UUID].videoElement.dataset.sid = session.rpcs[UUID].streamID; } if (session.rpcs[UUID].rotate !== false) { session.rpcs[UUID].videoElement.rotated = session.rpcs[UUID].rotate; session.rpcs[UUID].videoElement.dataset.rotated = session.rpcs[UUID].rotate; updateVideoTransform(session.rpcs[UUID].videoElement); } session.rpcs[UUID].videoElement.addEventListener( "playing", e => { try { var bigPlayButton = document.getElementById("bigPlayButton"); if (bigPlayButton) { bigPlayButton.parentNode.removeChild(bigPlayButton); } } catch (e) { } resetupAudioOut(e.target, true); try { if (session.pip) { if (v.readyState >= 3) { if (!v.pip) { v.pip = true; toggleSystemPip(v, true); } } } } catch (e) { } }, { once: true } ); if (session.rpcs[UUID].mirrorState !== null || session.rpcs[UUID].flipState !== null) { applyMirrorGuest( !!session.rpcs[UUID].mirrorState, session.rpcs[UUID].videoElement, session.rpcs[UUID].flipState !== null ? !!session.rpcs[UUID].flipState : undefined ); } if (session.posterImage) { session.rpcs[UUID].videoElement.poster = session.posterImage; } setupIncomingVideoTracking(session.rpcs[UUID].videoElement, UUID); pokeIframeAPI("video-element-created", "videosource_" + UUID, UUID); } return session.rpcs[UUID].videoElement; } function updateVolume(update = false) { if (session.audioGain !== false) { if (update) { if (session.roomid) { var pswd = session.password || ""; generateHash(session.streamID + session.roomid + pswd + session.salt, 6).then(function (hash) { setStorage("micVolume_" + hash, session.audioGain, (hours = 6)); }); } } if (session.audioGain === 0) { getById("header").classList.add("orange"); getById("head7").classList.remove("hidden"); } else { getById("header").classList.remove("orange"); getById("head7").classList.add("hidden"); } } else { var pswd = session.password || ""; generateHash(session.streamID + session.roomid + pswd + session.salt, 6).then(function (hash) { var volume = getStorage("micVolume_" + hash); if (volume !== "") { if (parseInt(volume) === 0) { getById("header").classList.add("orange"); getById("head7").classList.remove("hidden"); } else if (parseInt(volume)) { getById("header").classList.remove("orange"); getById("head7").classList.add("hidden"); } else { return; } session.audioGain = parseInt(volume); var vol = parseFloat(session.audioGain / 100) || 0; for (var waid in session.webAudios) { // TODO: EXCLUDE CURRENT TRACK IF ALREADY EXISTS ... if (trackid === wa.id){.. log("Adjusting Gain; only track 0 in all likely hood, unless more than track 0 support is added."); session.webAudios[waid].gainNode.gain.setValueAtTime(vol, session.webAudios[waid].audioContext.currentTime); } } }); } } function hideHomeCheck() { if (session.hidehome) { getById("logoname").classList.add("permahide"); getById("container-1").classList.add("permahide"); getById("container-4").classList.add("permahide"); getById("dropButton").classList.add("permahide"); getById("head1").classList.add("permahide"); if (session.permaid === false && session.roomid == false && !session.webcamonly && !session.screenshare) { getById("mainmenu").classList.add("permahide"); } else { getById("mainmenu").classList.remove("permahide"); } getById("audioScreenCaptureDocs").classList.add("permahide"); getById("audioScreenCaptureDocs2").classList.add("permahide"); getById("translateButton").classList.add("permahide"); // getById("legal").classList.add("permahide"); getById("calendarButton").classList.add("permahide"); getById("info").classList.add("permahide"); getById("helpbutton").classList.add("permahide"); } if (urlParams.has("headertitle")) { let pageTitle = urlParams.get("headertitle") || ""; pageTitle = decodeURIComponent(pageTitle) || ""; document.title = pageTitle; getById("metaTitle").content = pageTitle; } if (urlParams.has("favicon")) { let favicon = ""; if (urlParams.get("favicon")) { favicon = decodeURIComponent(urlParams.get("favicon")) || ""; } getById("favicon1").href = favicon; getById("favicon2").href = favicon; getById("favicon3").href = favicon; } } function stashRoomSession(broadcastFlag = null) { try { let settings = {}; settings.roomid = session.roomid; settings.password = session.password; settings.label = session.label; settings.trb = session.totalRoomBitrate; settings.widget = session.widget; settings.codecGroupFlag = session.codecGroupFlag; settings.showDirector = session.showDirector; if (broadcastFlag !== null) { settings.broadcast = broadcastFlag; } setStorage("directorOtherSettings", settings); } catch (e) { errorlog(e); } } // toggleQualityDirector(1200, this.dataset.UUID, this) function switchModes(state = null) { if (state === null) { session.switchMode = !session.switchMode; } else { session.switchMode = state; } if (session.switchMode) { getById("directorlayout").classList.add("hidden"); getById("gridlayout").classList.remove("hidden"); updateMixer(); } else { getById("directorlayout").classList.remove("hidden"); getById("gridlayout").classList.add("hidden"); for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement) { session.rpcs[UUID].videoElement.style = ""; session.rpcs[UUID].videoElement.alreadyAdded = false; var target = document.querySelector("#container_" + UUID + " .controlVideoBox"); if (target) { target.prepend(session.rpcs[UUID].videoElement); if (session.signalMeter) { if (session.rpcs[UUID].signalMeter) { target.appendChild(session.rpcs[UUID].signalMeter); } } if (session.batteryMeter) { if (session.rpcs[UUID].batteryMeter) { target.appendChild(session.rpcs[UUID].batteryMeter); } } if (session.rpcs[UUID].voiceMeter) { target.appendChild(session.rpcs[UUID].voiceMeter); } if (session.rpcs[UUID].remoteMuteElement) { target.appendChild(session.rpcs[UUID].remoteMuteElement); } } } } if (session.videoElement) { session.videoElement.style = ""; session.videoElement.alreadyAdded = false; if (session.showDirector == true) { var target = document.querySelector("#videoContainer_director"); if (target && session.videoElement) { target.prepend(session.videoElement); } } else if ((session.videoElement.srcObject && session.videoElement.srcObject.getTracks().length) || getById("press2talk").dataset.enabled == true) { getById("miniPerformer").prepend(session.videoElement); } } if (session.screenShareElement) { session.screenShareElement.style = ""; session.screenShareElement.alreadyAdded = false; if (session.showDirector == true) { var target = document.querySelector("#videoScreenContainer_director"); if (target && session.screenShareElement && session.screenShareElement.srcObject && session.screenShareElement.srcObject.getTracks().length) { target.prepend(session.screenShareElement); } } else if ((session.screenShareElement.srcObject && session.screenShareElement.srcObject.getTracks().length) || getById("press2talk").dataset.enabled == true) { getById("miniPerformer").prepend(session.videoElement); } } applyQualityDirector(); } } var updateMixerTimer = null; var updateMixerActive = false; function updateMixer(e = false) { var controlBar = document.getElementById("subControlButtons"); if (controlBar && controlBar.dragElement && !controlBar.isDragging) { let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); let vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); // Calculate real boundaries (parent position: fixed issues) let topOffset = 0; let leftOffset = 0; let elementOffset = controlBar; while (elementOffset) { topOffset += elementOffset.offsetTop; leftOffset += elementOffset.offsetLeft; elementOffset = elementOffset.offsetParent; } let realX = controlBar.xOffset + leftOffset; let realY = controlBar.yOffset + topOffset; let maxX = vw - controlBar.offsetWidth; let maxY = vh - controlBar.offsetHeight; if (realX > maxX) { controlBar.xOffset = maxX - leftOffset; } else if (realX < 0) { controlBar.xOffset = 0 - leftOffset; } if (realY > maxY) { controlBar.yOffset = maxY - topOffset; } else if (realY < 0) { controlBar.yOffset = 0 - topOffset; } controlBar.style.transform = `translate(${controlBar.xOffset}px, ${controlBar.yOffset}px)`; } if (session.manual === true) { return; } else if (!session.switchMode && session.director) { return; } else if (session.windowed) { return; } clearInterval(updateMixerTimer); if (updateMixerActive) { if (session.mobile) { updateMixerTimer = setTimeout(function () { updateMixer(); }, 200); } else { updateMixerTimer = setTimeout(function () { updateMixer(); }, 50); } return; } updateMixerActive = true; log("updating mixer"); try { updateMixerRun(e); } catch (e) { } if (session.mobile) { setTimeout(function () { updateMixerActive = false; }, 500); } else { setTimeout(function () { updateMixerActive = false; }, 100); } } function updateMixerRun(e = false) { // this is the main auto-mixing code. It's a giant function that runs when there are changes to screensize, video track statuses, etc. try { if (session.switchMode) { } else if (session.director) { return; } else if (session.manual === true) { return; } var header = getById("header"); var playarea = getById("gridlayout"); if (session.pipWindow) { var hi = 0; var h = session.pipWindow.clientHeight || session.pipWindow.innerHeight || session.pipWindow.outerHeight; var w = session.pipWindow.clientWidth || session.pipWindow.innerWidth || session.pipWindow.outerWidth; } else if (document.body.dataset.rotated) { var hi = header.offsetHeight; var w = document.body.clientHeight; if (session.widget && session.iFramesAllowed) { w *= (100 - session.widgetwidth) / 100; try { let widget = document.getElementById("widget"); if (!widget) { widget = document.createElement("iframe"); widget.id = "widget"; widget = loadIframe(parseURL4Iframe(session.widget), widget); if (widget) { document.body.appendChild(widget); if (session.widgetleft) { widget.classList.add("left"); playarea.style.left = session.widgetwidth + "%"; playarea.style.width = (100 - session.widgetwidth) + "%"; } else { playarea.style.left = "0"; playarea.style.width = (100 - session.widgetwidth) + "%"; } } } if (widget) { widget.style.height = "calc(100% - " + hi + "px)"; widget.style.top = hi; } } catch (e) { errorlog(e); } } else if (!session.widget && session.widgetleft) { playarea.style.left = "0"; } var h = document.body.clientWidth - hi; if (session.dedicatedControlBarSpace || document.body.clientWidth <= 700) { // # This needs to be reviewed. if (session.dedicatedControlBarSpace !== false) { let controlBar = document.getElementById("subControlButtons"); if (controlBar && !session.overlayControls) { if (!controlBar.yOffset || controlBar.yOffset > -10) { h = document.body.clientWidth - hi - controlBar.offsetHeight; } } } } } else { var hi = header.offsetHeight; var w = window.innerWidth; if (session.widget && session.iFramesAllowed) { w *= (100 - session.widgetwidth) / 100; try { let widget = document.getElementById("widget"); if (!widget) { widget = document.createElement("iframe"); widget.id = "widget"; widget = loadIframe(parseURL4Iframe(session.widget), widget); if (widget) { document.body.appendChild(widget); if (session.widgetleft) { widget.classList.add("left"); playarea.style.left = session.widgetwidth + "%"; playarea.style.width = (100 - session.widgetwidth) + "%"; playarea.style.position = "absolute"; } else { playarea.style.left = "0"; playarea.style.width = (100 - session.widgetwidth) + "%"; } } } if (widget) { widget.style.height = "calc(100% - " + hi + "px)"; widget.style.top = hi; } } catch (e) { errorlog(e); } } else if (!session.widget && session.widgetleft) { playarea.style.left = "0"; } var h = window.innerHeight - hi; if (session.dedicatedControlBarSpace || window.innerHeight <= 700) { // # This needs to be reviewed. if (session.dedicatedControlBarSpace !== false) { let controlBar = document.getElementById("subControlButtons"); if (controlBar && !session.overlayControls) { if (!controlBar.yOffset || controlBar.yOffset > -10) { h = window.innerHeight - hi - controlBar.offsetHeight; } } } } } if (session.locked) { var w123 = w; var h123 = h; if (w > h * session.locked) { w = h * session.locked; } else if (h > w / session.locked) { h = w / session.locked; } playarea.style.left = (w123 - w) / 2 + "px"; playarea.style.top = (h123 - h) / 2 + "px"; playarea.style.width = w + "px"; playarea.style.height = h + "px"; playarea.style.position = "absolute"; playarea.style.display = "block"; } var arW = 16.0; var arH = 9.0; if (session.aspectRatio) { if (session.aspectRatio == 1) { arW = 9.0; arH = 16.0; } else if (session.aspectRatio == 2) { arW = 12.0; // square root; cause why not. arH = 12.0; } else if (session.aspectRatio == 3) { arW = 12.0; // square root; cause why not. arH = 9.0; } } var groups = [...session.group]; if (session.groupView.length) { groups.push(...session.groupView); } var sssid = false; var soloVideo = false; if (session.infocus === true) { soloVideo = true; } else if (session.infocus && session.infocus in session.rpcs) { // if the infocus stream is connected if (groups.length || session.allowNoGroup) { try { if (groups.some(item => session.rpcs[session.infocus].group.includes(item))) { soloVideo = session.infocus; } } catch (e) { errorlog(e); } } else { soloVideo = session.infocus; } } else if (session.infocus2 === true) { sssid = session.streamID; } else if (session.infocus2 && session.infocus2 in session.rpcs) { // if the infocus2 stream is connected if (groups.length || session.allowNoGroup) { try { if (groups.some(item => session.rpcs[session.infocus2].group.includes(item))) { sssid = session.rpcs[session.infocus2].streamID; } } catch (e) { errorlog(e); } } else { sssid = session.rpcs[session.infocus2].streamID; } } var ww = w / arW; var hh = h / arH; var mediaPool = []; var mediaPool_invisible = []; var miniPreview = session.minipreview; if (miniPreview && (!session.activeSpeaker && session.layout) && session.streamID && session.streamID in session.layout) { miniPreview = false; if (session.videoElement.container && session.videoElement.container.id == "minipreview") { delete session.videoElement.container; } if (document.getElementById("minipreview")) { document.getElementById("minipreview").remove(); } } if (session.iframeEle && session.iframeEle.style.display !== "none") { // local feed if (session.order !== false) { session.iframeEle.order = session.order; } else { session.iframeEle.order = 0; } if (session.activeSpeaker && !session.activelySpeaking) { mediaPool_invisible.push(session.iframeEle); } else { mediaPool.push(session.iframeEle); } } if (session.videoElement && (session.videoElement.src || session.videoElement.srcObject)) { // I, myself, exist if (session.videoElement.style.display !== "none") { // local feed if (miniPreview && soloVideo !== true) { } else { if (session.order !== false) { session.videoElement.order = session.order; } else { session.videoElement.order = 0; } if (session.activeSpeaker && !session.activelySpeaking) { //mediaPool_invisible.push(session.videoElement); //} else if (session.videoElement && session.videoElement.srcObject && (session.videoElement.srcObject.getTracks().length === 0)){ // do not show a video element if its completely empty. } else if (session.videoElement && session.videoElement.srcObject && session.videoElement.srcObject.getVideoTracks().length === 0) { // do not show a video element if its completely empty. } else if (soloVideo && soloVideo !== true) { // } else if (session.videoMuted && session.style === 1) { // i'm too tired to try to get this working. } else { mediaPool.push(session.videoElement); } } } } if (session.screenShareState && session.screenShareElement) { // I, myself, exist if (!session.screenShareElementHidden) { if (session.order !== false) { session.screenShareElement.order = session.order; } else { session.screenShareElement.order = 0; } if (soloVideo !== false) { //session.screenShareElement.style.display="none"; } else if (session.activeSpeaker && !session.activelySpeaking) { //session.screenShareElement.style.display="none"; } else if (!session.noScreenShare) { mediaPool.push(session.screenShareElement); } else { session.screenShareElement.style.display = "none"; } } } var delayedRequestList = {}; function delayedRequestRate(bandwidth, UUID, optimizeAudio = false, lock = null) { delayedRequestList[UUID] = [bandwidth, UUID, optimizeAudio, lock]; } if (soloVideo && soloVideo in session.rpcs) { // this technically can be a scene or guest // remote guest being full screened; infocus == UUID mediaPool = []; // remove myself from fullscreen if (iOS || iPad) { if (!miniPreview) { miniPreview = 1; } } for (var j in session.rpcs) { if (groups.length || session.allowNoGroup) { try { if (!groups.some(item => session.rpcs[j].group.includes(item))) { continue; } } catch (e) { errorlog(e); } } if (j != soloVideo) { // this remote guest is NOT in focus try { if (session.rpcs[j].iframeEle) { mediaPool_invisible.push(session.rpcs[j].iframeEle); } if (session.rpcs[j].videoElement && session.rpcs[j].videoElement.style.display !== "none") { // Add it if not hidden if (session.scene !== false) { //delayedRequestRate(session.hiddenSceneViewBitrate, j); } else { if (document.pictureInPictureElement && document.pictureInPictureElement.id && document.pictureInPictureElement.id == session.rpcs[j].videoElement.id) { var bitratePIP = parseInt(session.zoomedBitrate / 4); //warnUser("GOOD"); delayedRequestRate(bitratePIP, j); } else { delayedRequestRate(0, j); // disable the video of non-fullscreen videos } } } else if (session.rpcs[j].videoElement) { delayedRequestRate(0, j, true); // disable the video of non-fullscreen videos } } catch (e) { errorlog(e); } } else { // remote guest is in-focus video //////// try { if (session.rpcs[j].iframeEle) { mediaPool_invisible.push(session.rpcs[j].iframeEle); } if (session.rpcs[j].videoElement) { mediaPool.push(session.rpcs[j].videoElement); // active speaker session.rpcs[j].videoElement.style.visibility = "visible"; if (session.rpcs[j].order !== false) { session.rpcs[j].videoElement.order = session.rpcs[j].order; } else { session.rpcs[j].videoElement.order = 0; } if (session.scene !== false) { } else { var totalRoomBitrate = session.totalRoomBitrate; if (session.controlRoomBitrate !== false && session.controlRoomBitrate !== true) { totalRoomBitrate = Math.min(session.controlRoomBitrate, totalRoomBitrate); } var targetBitrate = session.zoomedBitrate; if (totalRoomBitrate > session.zoomedBitrate) { targetBitrate = totalRoomBitrate; } delayedRequestRate(targetBitrate, j); // 1.2-Mbps is decent, no? in-focus, so higher bitrate } } } catch (e) { errorlog(e); } } } } else if (soloVideo && soloVideo === true) { // this cannot be a scene, as you can't have yourself in a scene. // well, fullscreen myself. "true" represents me. UUID would be for others. // already added myself to this as fullscreen for (var j in session.rpcs) { if (groups.length || session.allowNoGroup) { try { if (!groups.some(item => session.rpcs[j].group.includes(item))) { continue; } } catch (e) { errorlog(e); } } try { if (session.rpcs[j].videoElement && session.rpcs[j].videoElement.style.display !== "none") { // Add it if not hidden if (document.pictureInPictureElement && document.pictureInPictureElement.id && document.pictureInPictureElement.id == session.rpcs[j].videoElement.id) { var bitratePIP = parseInt(session.zoomedBitrate / 4); delayedRequestRate(bitratePIP, j); //warnUser("GOOD"); } else { delayedRequestRate(0, j); // disable the video of non-fullscreen videos } // mediaPool_invisible.push(session.rpcs[j].videoElement); } else if (session.rpcs[j].videoElement) { delayedRequestRate(0, j, true); // other videos are disabled when previewing yourself, but audio retained } } catch (e) { errorlog(e); } } } else { var roomQuality = 0; var screenShareTotal = 0; for (var i in session.rpcs) { if (session.rpcs[i] === null) { continue; } if (groups.length || session.allowNoGroup) { try { if (!groups.some(item => session.rpcs[i].group.includes(item))) { continue; } } catch (e) { errorlog(e); } } if (session.rpcs[i].videoElement) { // remote feeds if (session.rpcs[i].videoElement.style.display !== "none") { if (session.rpcs[i].videoElement.srcObject && session.rpcs[i].videoElement.srcObject.getVideoTracks().length) { // only count videos with actual video tracks; audio-only excluded if (session.rpcs[i].videoMuted) { // it's video muted // mediaPool_invisible.push(session.rpcs[i].videoElement); // skipped later on } else if (session.rpcs[i].directorVideoMuted) { // it's muted by the director, so likely disabled. // mediaPool_invisible.push(session.rpcs[i].videoElement); // skipped later on } else if (session.rpcs[i].virtualHangup) { } else if (session.rpcs[i].bandwidthMuted) { } else if (session.rpcs[i].videoElement.style.opacity === "0") { // mediaPool_invisible.push(session.rpcs[i].videoElement); // skipped later on } else { roomQuality += 1; if (session.rpcs[i].screenShareState) { screenShareTotal += 1; } } } } } } if (session.broadcast !== false) { if ((!session.activeSpeaker && session.layout) && session.streamID in session.layout) { // skip } else { if (roomQuality > 0) { if (session.nopreview !== false) { for (var i = 0; i < mediaPool.length; i++) { if (mediaPool[i].nodeName && mediaPool[i].nodeName == "IFRAME") { mediaPool[i].style.display = "none"; } } mediaPool = []; // we don't want to show our self-preview if in broadcast mode and there is a director. } } } } if (roomQuality === 0) { roomQuality = 1; } var totalRoomBitrate = session.totalRoomBitrate; if (session.controlRoomBitrate !== false && session.controlRoomBitrate !== true) { totalRoomBitrate = Math.min(session.controlRoomBitrate, totalRoomBitrate); } var roomBitrate = totalRoomBitrate; var sceneBitrate = false; var screenShareBitrate = false; if (session.bitrate && !roomBitrate && !session.totalSceneBitrate) { roomBitrate = session.bitrate; } else if (session.screenShareBitrate !== false) { screenShareBitrate = session.screenShareBitrate; if (roomQuality - screenShareTotal > 0) { roomBitrate = parseInt(totalRoomBitrate / (roomQuality - screenShareTotal)); if (session.totalSceneBitrate) { sceneBitrate = parseInt(session.totalSceneBitrate / (roomQuality - screenShareTotal)); if (session.bitrate !== false) { sceneBitrate = Math.min(session.bitrate, sceneBitrate); } } } } else if (screenShareTotal) { try { if (session.roomid !== false && session.scene === false) { if (roomQuality - screenShareTotal <= 0) { roomBitrate = totalRoomBitrate; screenShareBitrate = totalRoomBitrate; } else { screenShareBitrate = totalRoomBitrate / (1.5 * screenShareTotal); roomBitrate = parseInt((totalRoomBitrate - screenShareBitrate) / (roomQuality - screenShareTotal)); } } else if (session.totalSceneBitrate !== false) { if (roomQuality - screenShareTotal <= 0) { sceneBitrate = session.totalSceneBitrate; if (session.bitrate !== false) { sceneBitrate = Math.min(session.bitrate, sceneBitrate); } screenShareBitrate = sceneBitrate; } else { screenShareBitrate = parseInt(totalRoomBitrate / (1.5 * screenShareTotal)); sceneBitrate = parseInt((totalRoomBitrate - screenShareBitrate) / (roomQuality - screenShareTotal)); if (session.bitrate !== false) { sceneBitrate = Math.min(session.bitrate, sceneBitrate); screenShareBitrate = Math.min(session.bitrate, screenShareBitrate); } } } else { screenShareBitrate = false; } } catch (e) { errorlog(e); } } else { roomBitrate = parseInt(totalRoomBitrate / roomQuality); if (session.totalSceneBitrate) { sceneBitrate = parseInt(session.totalSceneBitrate / roomQuality); if (session.bitrate !== false) { sceneBitrate = Math.min(session.bitrate, sceneBitrate); } } } if (session.minimumRoomBitrate) { if (session.totalRoomBitrate && roomBitrate < session.minimumRoomBitrate) { roomBitrate = session.minimumRoomBitrate; if (roomBitrate > session.totalRoomBitrate) { roomBitrate = session.totalRoomBitrate; } } if (session.totalSceneBitrate && sceneBitrate < session.minimumRoomBitrate) { sceneBitrate = session.minimumRoomBitrate; if (sceneBitrate > session.totalSceneBitrate) { sceneBitrate = session.totalSceneBitrate; } } } var i = null; var countOrder = 0; try { var RPCSkeys = Object.keys(session.rpcs); // default sorting type: time added; //RPCSkeys.sort(); } catch (e) { return; } for (var keyIndex = 0; keyIndex < RPCSkeys.length; keyIndex++) { i = RPCSkeys[keyIndex]; if (session.rpcs[i] === null) { continue; } session.rpcs[i].mutedStateMixer = false; if (groups.length || session.allowNoGroup) { // The MAIN and LAST group filter. try { if (!groups.some(item => session.rpcs[i].group.includes(item))) { if (session.scene !== false) { if (session.groupAudio) { delayedRequestRate(session.hiddenSceneViewBitrate, i, false); } else { delayedRequestRate(session.hiddenSceneViewBitrate, i, true); // hidden. I dont want it to be super low, for video quality reasons. session.rpcs[i].mutedStateMixer = true; } if (!session.hiddenSceneViewBitrate) { session.rpcs[i].videoElement.nogb = 2; } } else { if (session.groupAudio) { delayedRequestRate(0, i, false); } else { delayedRequestRate(0, i, true); // w/e This is not in OBS, so we just set it as low as possible. Shoudln't exist really unless loading? session.rpcs[i].mutedStateMixer = true; } } applyMuteState(i); continue; } } catch (e) { } } applyMuteState(i); var doNotPush = false; var isScreenShareFeed = false; try { if ("realUUID" in session.rpcs[i]) { isScreenShareFeed = true; } else if (session.rpcs[i].videoElement && session.rpcs[i].videoElement.dataset && session.rpcs[i].videoElement.dataset.sid) { isScreenShareFeed = session.rpcs[i].videoElement.dataset.sid.endsWith(":s"); } } catch (e) { } if (session.noScreenShare && isScreenShareFeed) { doNotPush = true; if (session.rpcs[i].videoElement) { session.rpcs[i].videoElement.style.display = "none"; } } if (session.rpcs[i].iframeEle) { if (session.rpcs[i].iframeEle.style.display == "none") { // pass } else if (session.rpcs[i].iframeEle.style.opacity === "0") { // pass } else { session.rpcs[i].iframeEle.style.visibility = "visible"; if (session.rpcs[i].order !== false) { session.rpcs[i].iframeEle.order = session.rpcs[i].order; } else { session.rpcs[i].iframeEle.order = 0; } try { if (session.activeSpeaker && !session.rpcs[i].defaultSpeaker) { mediaPool_invisible.push(session.rpcs[i].iframeEle); // TODO: this needs validation; will the iframe be maintained if activer speaker is going? do we even want this? /* } else if (session.rpcs[i].iframeEle.dataset.meshcast){ //////// MESH CAST ONLY LOGIC if (session.rpcs[i].iframeEle.contentDocument && session.rpcs[i].iframeEle.contentDocument.querySelectorAll("video").length){ if (session.rpcs[i].iframeVideo){ mediaPool.push(session.rpcs[i].iframeVideo); } else if (session.rpcs[i].iframeEle.contentDocument.querySelectorAll("video").length){ session.rpcs[i].iframeVideo = session.rpcs[i].iframeEle.contentDocument.querySelectorAll("video")[0]; session.rpcs[i].iframeVideo.id="meshcast_"+i; //errorlog("THIS IS GOOD"); mediaPool.push(session.rpcs[i].iframeVideo); } else { //errorlog("No video yet"); } } else { // this is a problem is not on the same domain. if (!document.getElementById("iframe_"+i)){ if (document.getElementById("hiddenElements")){ document.getElementById("hiddenElements").append(session.rpcs[i].iframeEle); } else { document.body.append(session.rpcs[i].iframeEle); } if (session.rpcs[i].iframeVideo){ mediaPool.push(session.rpcs[i].iframeVideo); } else if (session.rpcs[i].iframeEle.contentDocument.querySelectorAll("video").length){ session.rpcs[i].iframeVideo = session.rpcs[i].iframeEle.contentDocument.querySelectorAll("video")[0]; session.rpcs[i].iframeVideo.id="meshcast_"+i; mediaPool.push(session.rpcs[i].iframeVideo); } else { //errorlog("No video yet"); } } else { if (session.rpcs[i].iframeVideo){ mediaPool.push(session.rpcs[i].iframeVideo); } else { //errorlog("Does not support contentDocument or something"); } } } */ } else { ///////// MESH CAST LOGIC ENDS HERE //errorlog("not meshcast"); mediaPool.push(session.rpcs[i].iframeEle); } } catch (e) { errorlog(e); } } } if (session.rpcs[i].imageElement) { //if (session.rpcs[i].videoElement && session.rpcs[i].videoElement.srcObject.getAudioTracks().length) { // is there audio? // mediaPool_invisible.push(session.rpcs[i].videoElement); // include audio as hidden track; //} if (session.rpcs[i].videoMuted || session.rpcs[i].directorVideoMuted || session.rpcs[i].virtualHangup || session.rpcs[i].bandwidthMuted) { continue; } if (session.rpcs[i].videoElement && session.rpcs[i].videoElement.style.display == "none") { // currently this is considered the state of scenes. pertty dumb on my part. continue; } if (session.rpcs[i].order !== false) { session.rpcs[i].imageElement.order = session.rpcs[i].order; } else { session.rpcs[i].imageElement.order = 0; } if (session.activeSpeaker && !session.rpcs[i].defaultSpeaker) { // mediaPool_invisible.push(session.rpcs[i].imageElement); } else { mediaPool.push(session.rpcs[i].imageElement); } doNotPush = true; } if (session.rpcs[i].videoElement) { // remote feeds //session.rpcs[i].targetBandwidth = -1; if (session.rpcs[i].videoElement.style.opacity === "0") { continue; } try { session.rpcs[i].videoElement.style.visibility = "visible"; } catch (e) { errorlog(e); } if (session.rpcs[i].virtualHangup || session.rpcs[i].bandwidthMuted || session.rpcs[i].directorVideoMuted) { continue; } if (session.style && session.style >= 2) { if (session.rpcs[i].videoElement.srcObject && (session.rpcs[i].videoElement.srcObject.getVideoTracks().length == 0 || session.rpcs[i].videoMuted)) { if (session.rpcs[i].videoElement.style.display == "none") { // currently this is considered the state of scenes. pertty dumb on my part. continue; } if (createStyleCanvas(i)) { applyStyleEffect(i); } if (session.rpcs[i].order !== false) { session.rpcs[i].canvas.order = session.rpcs[i].order; } else { session.rpcs[i].canvas.order = 0; } if (session.activeSpeaker && !session.rpcs[i].defaultSpeaker) { // mediaPool_invisible.push(session.rpcs[i].canvas); } else { mediaPool.push(session.rpcs[i].canvas); } doNotPush = true; //continue; } } else if (session.style == 1) { if (session.rpcs[i].videoElement.srcObject && (session.rpcs[i].videoElement.srcObject.getVideoTracks().length == 0 || session.rpcs[i].videoMuted)) { //if (session.style==1){ // avatars and waveforms might be better done elsewhere? as a canvas effect even? doNotPush = true; //} } } else if (session.rpcs[i].videoElement.srcObject && (session.rpcs[i].videoElement.srcObject.getVideoTracks().length == 0 || session.rpcs[i].videoMuted)) { if (session.rpcs[i].screenShareState) { doNotPush = true; } } //} else if (!session.directorList.indexOf(i)>=0){ // director is never audio-only. Video if need, yes, but not visualized-audio. // if (session.rpcs[i].videoElement.srcObject && ((session.rpcs[i].videoElement.srcObject.getVideoTracks().length==0) || (session.rpcs[i].videoMuted)) && !session.rpcs[i].directorVideoMuted){ // continue; // } //} session.rpcs[i].opacityMuted = "1"; if (session.rpcs[i].opacityDisconnect == "1") { if (session.rpcs[i].videoElement) { session.rpcs[i].videoElement.style.opacity = "1"; } } if (session.rpcs[i].videoMuted) { if (session.rpcs[i].videoElement.srcObject.getAudioTracks().length == 0) { // if no audio track, no point in removing the video track, since it will just stall out then. continue; // easiest is to just not show anything if no video and no audio track. } if (session.rpcs[i].videoElement.srcObject) { session.rpcs[i].videoElement.srcObject.getVideoTracks().forEach(track => { log("remove track3"); session.rpcs[i].videoElement.srcObject.removeTrack(track); session.rpcs[i].videoElement.load(); }); } //continue; // currently disabling this, since we want to show it. } else if (session.rpcs[i].virtualHangup || session.rpcs[i].bandwidthMuted || session.rpcs[i].directorVideoMuted) { continue; } if (session.scene !== false) { if (session.sceneType === 3) { // order countOrder += 1; if (session.order === false) { if (countOrder == 1) { session.rpcs[i].videoElement.style.display = "block"; } else { session.rpcs[i].videoElement.style.display = "none"; } } else if (session.order === countOrder) { session.rpcs[i].videoElement.style.display = "block"; } else { session.rpcs[i].videoElement.style.display = "none"; } } } if (session.rpcs[i].videoElement.style.display == "none") { // Video is disabled; run at lowest if (session.scene !== false) { delayedRequestRate(session.hiddenSceneViewBitrate, i, true); // hidden. I dont want it to be super low, for video quality reasons. if (!session.hiddenSceneViewBitrate) { session.rpcs[i].videoElement.nogb = 2; } } else { delayedRequestRate(0, i, true); // w/e This is not in OBS, so we just set it as low as possible. Shoudln't exist really unless loading? } } else if (session.scene !== false) { // max // if (sceneBitrate !== false) { if (screenShareBitrate !== false && session.rpcs[i].screenShareState) { delayedRequestRate(screenShareBitrate, i); // well, screw that. Setting it to room quality. } else { delayedRequestRate(sceneBitrate, i); // well, screw that. Setting it to room quality. } } else { if (screenShareBitrate !== false && session.rpcs[i].screenShareState) { delayedRequestRate(screenShareBitrate, i); // well, screw that. Setting it to room quality. } else { delayedRequestRate(-1, i); // unlock. } } if (session.rpcs[i].order !== false) { session.rpcs[i].videoElement.order = session.rpcs[i].order; } else { session.rpcs[i].videoElement.order = 0; } if (session.activeSpeaker && !session.rpcs[i].defaultSpeaker) { if (!(session.rpcs[i].videoElement in mediaPool_invisible)) { // mediaPool_invisible.push(session.rpcs[i].videoElement); } else { errorlog("THIS SHOULD NOT HAPPEN; 650"); } } else if (!doNotPush) { mediaPool.push(session.rpcs[i].videoElement); } } else if (session.roomid !== false) { // guests should see video at low bitrate, ie: 100kbps (not 35kbps like if disabled) if (session.rpcs[i].order !== false) { session.rpcs[i].videoElement.order = session.rpcs[i].order; } else { session.rpcs[i].videoElement.order = 0; } if (session.activeSpeaker && !session.rpcs[i].defaultSpeaker) { if (!(session.rpcs[i].videoElement in mediaPool_invisible)) { // mediaPool_invisible.push(session.rpcs[i].videoElement); } else { errorlog("THIS SHOULD NOT HAPPEN; 665"); } } else if (!doNotPush) { mediaPool.push(session.rpcs[i].videoElement); } if (session.roomid === "" && session.bitrate) { // we will let the URL specified bitrate hold, since this isn't a real room. delayedRequestRate(-1, i); } else { if (screenShareBitrate !== false && session.rpcs[i].screenShareState) { delayedRequestRate(screenShareBitrate, i); // well, screw that. Setting it to room quality. } else { delayedRequestRate(roomBitrate, i); // well, screw that. Setting it to room quality. } } } else { // view=xx,yy or whatever. This should be highest quality. if (session.rpcs[i].order !== false) { session.rpcs[i].videoElement.order = session.rpcs[i].order; } else { session.rpcs[i].videoElement.order = 0; } if (session.activeSpeaker && !session.rpcs[i].defaultSpeaker) { if (!(session.rpcs[i].videoElement in mediaPool_invisible)) { // mediaPool_invisible.push(session.rpcs[i].videoElement); } else { errorlog("THIS SHOULD NOT HAPPEN; 684"); } } else if (!doNotPush) { mediaPool.push(session.rpcs[i].videoElement); } if (sceneBitrate) { delayedRequestRate(sceneBitrate, i); } else if (session.screenShareBitrate !== false && session.rpcs[i].screenShareState) { // session.screenShareBitrate is non-room delayedRequestRate(session.screenShareBitrate, i); // well, screw that. Setting it to room quality. } else { delayedRequestRate(-1, i); } } if (session.rpcs[i].videoElement.nogb == 2) { session.rpcs[i].videoElement.nogb = 1; session.rpcs[i].videoElement.classList.add("nogb"); } else if (session.rpcs[i].videoElement.nogb == 1) { session.rpcs[i].videoElement.nogb = 0; session.rpcs[i].videoElement.classList.remove("nogb"); } } } } if (session.broadcastIFrame && session.broadcastIFrame.src) { // keep alive iframes whennot visible. i think if (!mediaPool.length) { mediaPool.push(session.broadcastIFrame); } } if (document.fullscreenElement) { try { if (document.fullscreenElement.tagName === "VIDEO") { // if its HTML, than we assume its the full canvas for (var i = 0; i < mediaPool.length; i++) { // if its your local camera, it shouldn't be a problem, so we can focus on remote cameras only if (mediaPool[i].id !== document.fullscreenElement.id) { // if its selected camera, we want to exclude it if (mediaPool[i].dataset && mediaPool[i].dataset.UUID && mediaPool[i].tagName && mediaPool[i].tagName == "VIDEO") { delayedRequestRate(session.hiddenSceneViewBitrate, mediaPool[i].dataset.UUID, null); // null implies don't change the current audio setting mediaPool_invisible.push(mediaPool[i]); // move visible elements to the invisible list, since something is full screen mediaPool.splice(i, 1); } } } } } catch (e) { errorlog(e); } } var sscount = 0; var skip = false; for (var m = 0; m < mediaPool.length; m++) { mediaPool[m].alreadyAdded = false; } if (!session.layout || session.activeSpeaker) { if (session.orderby) { if (session.orderby == "id") { mediaPool.sort(compare_vids_sid); } else if (session.orderby == "label") { mediaPool.sort(compare_vids_label); } else { mediaPool.sort(compare_vids_sid); } } mediaPool.sort(compare_vids); } else if (session.exclusiveLayoutAudio) { [...mediaPool].forEach(ele => { if (ele.dataset.sid) { if (session.layout[ele.dataset.sid]) { return; } else if (session.layout[""]) { let matched = false; session.layout[""].forEach(i => { if (i.defaultStreamID && i.defaultStreamID == ele.dataset.sid) { matched = true; } }); if (matched) { return; } } const index = mediaPool.indexOf(ele); if (index > -1) { if (ele.dataset.UUID && session.scene !== false) { delayedRequestRate(session.hiddenSceneViewBitrate, ele.dataset.UUID, false); // it's added already, so we know it needs sound. But lets d } mediaPool.splice(index, 1); // 2nd parameter means remove one item only } } }); if (RPCSkeys) { for (var keyIndex = 0; keyIndex < RPCSkeys.length; keyIndex++) { i = RPCSkeys[keyIndex]; if (session.rpcs[i] === null) { continue; } if (session.rpcs[i].streamID) { let matched = false; mediaPool.forEach(ele => { if (ele.dataset.sid == session.rpcs[i].streamID) { matched = true; } }); if (!matched) { if (session.rpcs[i].mutedStateMixer === false) { session.rpcs[i].mutedStateMixer = true; applyMuteState(i); } } } } } } if (session.fakeFeeds && session.fakeFeeds.length && mediaPool.length < session.fakeFeeds.length) { for (let i = 0; i < session.fakeFeeds.length; i++) { if (mediaPool.length < session.fakeFeeds.length) { mediaPool.push(session.fakeFeeds[i]); } else { try { session.fakeFeeds[i].remove(); } catch (e) { errorlog(e); } } } } if (session.slotsList && session.slotsList.length > 0) { // Filter mediaPool to only include videos in the slotsList const filteredMediaPool = []; for (let i = 0; i < mediaPool.length; i++) { if (session.slotsList.includes(i + 1)) { // +1 for 1-indexed slotsList filteredMediaPool.push(mediaPool[i]); } } mediaPool = filteredMediaPool; } var mpl = session.slots || mediaPool.length; if (!sssid) { if (mpl > 1) { var BB = 0; var rw = 1; var rh = 1; var NW; var NH; var current; for (NW = 1; NW <= mpl; NW++) { NH = Math.ceil(mpl / NW); var www = ww / NW; var hhh = hh / NH; if (www > hhh) { current = Math.round(hhh * hhh * (mpl / (NW * NH))); } else { current = Math.round(www * www * (mpl / (NW * NH))); } if (current >= BB) { BB = current; rw = NW; rh = NH; } if (mediaPool[NW - 1]) { //if (mediaPool[NW-1].tagName == "VIDEO"){ if (mediaPool[NW - 1].dataset.UUID) { if (mediaPool[NW - 1].dataset.UUID in session.rpcs) { const rpc = session.rpcs[mediaPool[NW - 1].dataset.UUID]; const currentUUID = mediaPool[NW - 1].dataset.UUID; // Skip parent UUID if a _screen counterpart exists (avoid double-counting) if (!currentUUID.endsWith("_screen") && session.rpcs[currentUUID + "_screen"]) { // This is a parent with a _screen entry; let the _screen entry be counted instead continue; } // Check for screen share indicators: flag, UUID suffix, or stream ID suffix const isScreen = rpc.screenShareState || currentUUID.endsWith("_screen") || (mediaPool[NW - 1].dataset.sid && mediaPool[NW - 1].dataset.sid.endsWith(":s")); if (isScreen && !rpc.smallScreen) { let hasLiveScreenVideo = false; try { // Check streamSrc first (most reliable for local/direct) if (rpc.streamSrc && rpc.streamSrc.getVideoTracks) { hasLiveScreenVideo = rpc.streamSrc.getVideoTracks().some(trk => trk.readyState === "live"); } // Fallback: check the video element's srcObject if streamSrc is missing/empty if (!hasLiveScreenVideo && mediaPool[NW - 1].srcObject && mediaPool[NW - 1].srcObject.getVideoTracks) { hasLiveScreenVideo = mediaPool[NW - 1].srcObject.getVideoTracks().some(trk => trk.readyState === "live"); } } catch (e) { } console.log("updateMixer check:", { uuid: mediaPool[NW - 1].dataset.UUID, isScreen, hasLiveScreenVideo, screenShareState: rpc.screenShareState }); if (hasLiveScreenVideo) { sscount += 1; sssid = mediaPool[NW - 1].dataset.sid; } } } } else if ("id" in mediaPool[NW - 1] && mediaPool[NW - 1].id == "screensharesource" && session.notifyScreenShare) { sscount += 1; sssid = mediaPool[NW - 1].dataset.sid; } } } } else { var rw = 1; var rh = 1; } if (sscount > 1) { sssid = false; // lets not maximize if more than one screen share. } } } catch (e) { errorlog(e); sssid = false; } // Add screen share status classes to the gridlayout element if (sscount > 0) { playarea.classList.add("has-screenshare"); playarea.classList.remove("no-screenshare"); } else { playarea.classList.add("no-screenshare"); playarea.classList.remove("has-screenshare"); } var customLayout = false; var allowScreenshareAutoLayout = !session.layout || session.activeSpeaker || session.alignRight; var effectiveScreenshareStyle = session.screenshareStyle; if (!effectiveScreenshareStyle && session.alignRight) { effectiveScreenshareStyle = 2; } if (!session.notifyScreenShare && session.scene !== false) { // this is a scene, so lets assume &smallshare will disable larger screen shares since there is no one to screen share. } else if (sssid && effectiveScreenshareStyle && allowScreenshareAutoLayout) { customLayout = {}; let spotlightLayout; if (mediaPool.length >= 12) { spotlightLayout = { x: 10, y: 10, w: 90, h: 90, c: false }; } else if (mediaPool.length >= 10) { spotlightLayout = { x: 0, y: 10, w: 100, h: 90, c: false }; } else if (mediaPool.length >= 8) { spotlightLayout = { x: 20, y: 20, w: 80, h: 80, c: false }; } else if (mediaPool.length == 7) { spotlightLayout = { x: 16.66667, y: 0, w: 83.33333, h: 100, c: false }; } else if (mediaPool.length == 5 || mediaPool.length == 6) { spotlightLayout = { x: 20, y: 0, w: 80, h: 100, c: false }; } else { spotlightLayout = { x: 20, y: 0, w: 80, h: 100, c: false }; } if (effectiveScreenshareStyle === 2) { if (spotlightLayout.x < 20) { spotlightLayout.x = 20; } if (spotlightLayout.w > 80) { spotlightLayout.w = 80; } } customLayout[sssid] = spotlightLayout; if (effectiveScreenshareStyle === 2 && mediaPool.length > 6) { var columnSlot = 0; var totalOthers = mediaPool.length - 1; var slotHeight = totalOthers > 0 ? 100 / totalOthers : 100; for (var i = 0; i < mediaPool.length; i++) { if (mediaPool[i].dataset.sid === sssid) { continue; } customLayout[mediaPool[i].dataset.sid] = { x: 0, y: slotHeight * columnSlot, w: 20, h: slotHeight, c: true }; columnSlot += 1; } } else { var posCount = 0; for (var i = 0; i < mediaPool.length; i++) { if (mediaPool[i].dataset.sid === sssid) { continue; } if (mediaPool.length == 2) { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: 0, w: 20, h: 100, c: session.cover }; } else if (mediaPool.length == 3) { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 41 + 9, w: 20, h: 41, c: session.cover }; } else if (mediaPool.length == 4) { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 33.3333, w: 20, h: 33.3333, c: session.cover }; } else if (mediaPool.length == 5) { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 25, w: 20, h: 25, c: session.cover }; } else if (mediaPool.length == 6) { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 20, w: 20, h: 20, c: session.cover }; } else if (mediaPool.length == 7) { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 16.66667, w: 16.66667, h: 16.66667, c: session.cover }; } else if (mediaPool.length == 8) { if (posCount == 0 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 70, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 1 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 50, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 2 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 30, y: 0, w: 20, h: 20, c: true }; } else { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 25, w: 20, h: 25, c: true }; } } else if (mediaPool.length == 9) { if (posCount == 0 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 80, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 1 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 60, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 2 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 40, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 3 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 20, y: 0, w: 20, h: 20, c: true }; } else { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 25, w: 20, h: 25, c: true }; } } else if (mediaPool.length >= 10) { if (posCount < 10) { customLayout[mediaPool[i].dataset.sid] = { x: 90 - 10 * posCount, y: 0, w: 10, h: 10, c: true }; } else { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: (posCount - 9) * 10, w: 10, h: 10, c: true }; } } else { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: posCount * 20, w: 20, h: 20, c: true }; } posCount += 1; } } } else if (sssid && allowScreenshareAutoLayout) { customLayout = {}; if (mediaPool.length >= 12) { customLayout[sssid] = { x: 0, y: 10, w: 90, h: 90, c: false }; } else if (mediaPool.length >= 10) { customLayout[sssid] = { x: 0, y: 10, w: 100, h: 90, c: false }; } else if (mediaPool.length >= 8) { customLayout[sssid] = { x: 0, y: 20, w: 80, h: 80, c: false }; } else if (mediaPool.length == 7) { customLayout[sssid] = { x: 0, y: 0, w: 83.33333, h: 100, c: false }; } else if (mediaPool.length == 5) { customLayout[sssid] = { x: 0, y: 0, w: 80, h: 100, c: false }; } else if (mediaPool.length == 6) { customLayout[sssid] = { x: 0, y: 0, w: 80, h: 100, c: false }; } else if (mediaPool.length == 1) { customLayout[sssid] = { x: 0, y: 0, w: 100, h: 100, c: false }; } else { customLayout[sssid] = { x: 0, y: 0, w: 80, h: 100, c: false }; } var posCount = 0; for (var i = 0; i < mediaPool.length; i++) { if (mediaPool[i].dataset.sid === sssid) { continue; } if (mediaPool.length == 2) { // customLayout[mediaPool[i].dataset.sid] = { x: 80, y: 0, w: 20, h: 100, c: session.cover }; } else if (mediaPool.length == 3) { // customLayout[mediaPool[i].dataset.sid] = { x: 80, y: posCount * 41 + 9, w: 20, h: 41, c: session.cover }; } else if (mediaPool.length == 4) { // customLayout[mediaPool[i].dataset.sid] = { x: 80, y: posCount * 33.3333, w: 20, h: 33.3333, c: session.cover }; } else if (mediaPool.length == 5) { customLayout[mediaPool[i].dataset.sid] = { x: 80, y: posCount * 25, w: 20, h: 25, c: session.cover }; } else if (mediaPool.length == 6) { customLayout[mediaPool[i].dataset.sid] = { x: 80, y: posCount * 20, w: 20, h: 20, c: session.cover }; } else if (mediaPool.length == 7) { customLayout[mediaPool[i].dataset.sid] = { x: 83.33333, y: posCount * 16.66667, w: 16.66667, h: 16.66667, c: session.cover }; } else if (mediaPool.length == 8) { if (posCount == 0 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 10, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 1 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 30, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 2 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 50, y: 0, w: 20, h: 20, c: true }; } else { customLayout[mediaPool[i].dataset.sid] = { x: 80, y: posCount * 25, w: 20, h: 25, c: true }; } } else if (mediaPool.length == 9) { if (posCount == 0 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 0, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 1 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 20, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 2 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 40, y: 0, w: 20, h: 20, c: true }; } else if (posCount == 3 + 4) { customLayout[mediaPool[i].dataset.sid] = { x: 60, y: 0, w: 20, h: 20, c: true }; } else { customLayout[mediaPool[i].dataset.sid] = { x: 80, y: posCount * 25, w: 20, h: 25, c: true }; } } else if (mediaPool.length >= 10) { if (posCount < 10) { customLayout[mediaPool[i].dataset.sid] = { x: 10 * posCount, y: 0, w: 10, h: 10, c: true }; } else { customLayout[mediaPool[i].dataset.sid] = { x: 90, y: (posCount - 9) * 10, w: 10, h: 10, c: true }; } } else { customLayout[mediaPool[i].dataset.sid] = { x: 80, y: posCount * 20, w: 20, h: 20, c: true }; // } posCount += 1; } } else if (session.rows && mediaPool.length) { try { customLayout = {}; let n = mediaPool.length; if (session.slots && n < session.slots) { n = session.slots; // If we have fewer videos than slots, still use the full slots count } let rows = 1; if (session.rows.length >= n) { rows = parseInt(session.rows[n - 1]) || 1; } else { rows = parseInt(session.rows[session.rows.length - 1]) || 1; } if (rows < 0) { rows = Math.abs(rows); let cols = Math.ceil(n / rows) || 1; for (var i = 0; i < n; i++) { let col = i % cols; let row = parseInt(i / cols) % rows; if (row === Math.floor((n - 1) / cols) && n % cols !== 0) { // Last row logic let itemsInLastRow = n % cols || cols; let offset = (cols - itemsInLastRow) * (100 / cols) / 2; customLayout[mediaPool[i].dataset.sid] = { y: (100 / rows) * row, h: 100 / rows, x: offset + (100 / cols) * col, w: 100 / cols, c: session.cover }; } else { customLayout[mediaPool[i].dataset.sid] = { y: (100 / rows) * row, h: 100 / rows, x: (100 / cols) * col, w: 100 / cols, c: session.cover }; } } } else { let cols; if (session.slots) { cols = session.slots / rows; } else { cols = Math.ceil(n / rows) || 1; } for (var i = 0; i < n; i++) { let col = i % cols; let row = parseInt(i / cols) % rows; //console.log(row,col, rows,cols,i,n) if (cols >= 2 && rows >= 2 && n < (row + 1) * cols) { let delta = (row + 1) * cols - n; customLayout[mediaPool[i].dataset.sid] = { y: (100 / rows) * row, h: 100 / rows, x: (100 / cols) * (col + delta), w: 100 / cols, c: session.cover }; } else { customLayout[mediaPool[i].dataset.sid] = { y: (100 / rows) * row, h: 100 / rows, x: (100 / cols) * col, w: 100 / cols, c: session.cover }; } } } } catch (e) { errorlog(e); } } try { if (!skip) { var childNodes = playarea.childNodes; for (var n = 0; n < childNodes.length; n++) { if (childNodes[n].querySelector("video")) { var vidtemp = childNodes[n].querySelector("video"); var matched = false; for (var m = 0; m < mediaPool.length; m++) { if (vidtemp.id === mediaPool[m].id) { vidtemp.alreadyAdded = true; mediaPool[m] = vidtemp; matched = true; childNodes[n].matched = true; break; } } if (!matched && vidtemp.isInvisible) { vidtemp.isInvisible = false; if (session.pauseInvisible && vidtemp.dataset.UUID) { applyMuteState(vidtemp.dataset.UUID); } } } else if (childNodes[n].querySelector("iframe")) { var iftemp = childNodes[n].querySelector("iframe"); if (iftemp.framesource) { if ((!session.activeSpeaker && session.layout) && session.layout[""]) { for (var i = 0; i < session.layout[""].length; i++) { if (session.layout[""][i].iframeSrc && session.layout[""][i].iframeSrc == iftemp.framesource) { childNodes[n].matched = true; if (iftemp.isConnected) { iftemp.alreadyAdded = true; } } } } continue; } for (var m = 0; m < mediaPool.length; m++) { if (mediaPool[m].nodeName === "IFRAME" && mediaPool[m].src && iftemp.src === mediaPool[m].src) { iftemp.alreadyAdded = true; iftemp.id = mediaPool[m].id; if (session.directorList.indexOf(iftemp.dataset.UUID) == -1) { iftemp.dataset.UUID = mediaPool[m].dataset.UUID; iftemp.dataset.sid = mediaPool[m].dataset.sid; } mediaPool[m] = iftemp; childNodes[n].matched = true; break; } } for (var m = 0; m < mediaPool_invisible.length; m++) { if (mediaPool_invisible[m].nodeName === "IFRAME" && mediaPool_invisible[m].src && iftemp.src === mediaPool_invisible[m].src) { iftemp.alreadyAdded = true; iftemp.id = mediaPool_invisible[m].id; if (session.directorList.indexOf(iftemp.dataset.UUID) == -1) { iftemp.dataset.UUID = mediaPool_invisible[m].dataset.UUID; iftemp.dataset.sid = mediaPool_invisible[m].dataset.sid; } mediaPool_invisible[m] = iftemp; childNodes[n].matched = true; break; } } } } for (var n = 0; n < childNodes.length; n++) { if (!childNodes[n].matched) { playarea.removeChild(childNodes[n]); n--; } else { childNodes[n].matched = null; } } } } catch (e) { errorlog(e); } if (session.videoElement && (session.videoElement.src || session.videoElement.srcObject)) { // fileshare or stream if ("playlist" in session.videoElement) { playarea.appendChild(session.videoElement); // fileshare. } else if (session.videoElement.style.display !== "none") { if (session.videoElement && session.videoElement.srcObject && session.videoElement.srcObject.getVideoTracks().length) { if (miniPreview) { var container = null; if (mpl === 0 && miniPreview === 2) { if (soloVideo !== true) { // since miniPreview==2, we want to full screen our preview. Deleting the old mini preview container will ensure it loads right. if (!session.layout || session.activeSpeaker) { if (session.videoElement.container && session.videoElement.container.id == "minipreview") { delete session.videoElement.container; } if (document.getElementById("minipreview")) { document.getElementById("minipreview").remove(); } } // mediaPool.push(session.videoElement); mpl = 1; } } else if (miniPreview === 3) { if (soloVideo !== true) { container = document.createElement("div"); session.videoElement.container = container; container.style.top = "-500px"; container.style.left = "-500px"; container.style.width = "1px"; container.style.height = "1px"; //container.style.display = "flex"; container.style.zIndex = "0"; container.style.margin = "0"; container.style.position = "absolute"; container.style.cursor = "pointer"; container.style.border = "0"; container.appendChild(session.videoElement); playarea.appendChild(container); } } else if (soloVideo !== true) { if (document.getElementById("minipreview")) { container = document.getElementById("minipreview"); } else { container = document.createElement("div"); var togglePreview = document.createElement("div"); togglePreview.className = "togglePreview"; try { container.style.top = "calc(" + hi + "px + 2vh)"; container.style.maxHeight = parseInt(playarea.offsetHeight) + "px"; togglePreview.style.top = "calc(" + hi + "px + 2vh)"; togglePreview.style.maxHeight = parseInt(playarea.offsetHeight) + "px"; } catch (e) { errorlog(e); container.style.top = hi + "px"; togglePreview.style.top = hi + "px"; } // if (miniPerformerY !== null) { container.style.top = miniPerformerY + "%"; } if (miniPerformerX !== null) { if (session.widget && !session.leftMiniPreview) { if (miniPerformerX > (99 - session.widgetwidth)) { miniPerformerX = 99 - session.widgetwidth; } } container.style.left = miniPerformerX + "%"; } else if (session.leftMiniPreview !== false) { container.style.left = session.leftMiniPreview + "%"; togglePreview.style.left = session.leftMiniPreview + "%"; } else if (session.widget) { container.style.right = session.widgetwidth + "%"; togglePreview.style.right = session.widgetwidth + "%"; } else { container.style.right = "2vw"; togglePreview.style.right = "2vw"; } container.appendChild(session.videoElement); session.videoElement.container = container; playarea.appendChild(container); togglePreview.innerHTML = ''; if (!session.previewToggleState) { container.classList.toggle("hidden"); togglePreview.classList.toggle("blinded"); } if (!(iOS || iPad)) { playarea.appendChild(togglePreview); } togglePreview.onclick = function (event) { event.preventDefault(); event.stopPropagation(); getById("minipreview").classList.toggle("hidden"); this.classList.toggle("blinded"); session.previewToggleState = !session.previewToggleState; return false; }; makeMiniDraggableElement(container); container.id = "minipreview"; } container.style.width = "18%"; //container.style.display = "flex"; container.style.zIndex = "3"; container.style.margin = "0"; container.style.position = "absolute"; container.style.cursor = "pointer"; container.style.border = "2px #BBB solid"; container.style.height = "block"; applyMirror(session.mirrorExclude); } else if (soloVideo === true) { if (document.getElementById("minipreview")) { container = document.getElementById("minipreview"); container.style.height = "100%"; //container.style.transform = "block"; //container.style.transformOrigin = "unset"; } } if (session.ruleOfThirds) { if (container && container.id == "minipreview" && !container.svg) { var svg = document.createElement("img"); svg.src = session.ruleOfThirds; svg.style.width = "100%"; svg.style.height = "100%"; svg.style.position = "absolute"; svg.style.left = "0"; svg.style.top = "0"; container.svg = svg; container.appendChild(svg); } } container = null; // clear reference } } else if (session.streamSrc && !session.videoElement.srcObject) { warnlog("THIS SHOULD NOT HAPPEN; 2067"); } } } try { if (session.slots) { var slotArray = []; mediaPool.forEach(vid => { if (vid.slotBlank) { vid.slotBlank = false; vid.slot = 0; } if ("slot" in vid && vid.slot) { if (!slotArray.includes(parseInt(vid.slot))) { slotArray.push(parseInt(vid.slot)); } else { vid.slot = 0; //mediaPool_invisible.push(vid); //var index = mediaPool.indexOf(vid); //if (index > -1) { // mediaPool.splice(index, 1); //} } } }); var slotCounter = 1; mediaPool.reverse(); var j = mediaPool.length; while (j--) { if (!("slot" in mediaPool[j]) || mediaPool[j].slot == "0" || !mediaPool[j].slot) { while (slotArray.includes(slotCounter)) { slotCounter += 1; } slotArray.push(slotCounter); mediaPool[j].slot = slotCounter; mediaPool[j].slotBlank = true; } if (!("slot" in mediaPool[j]) || !parseInt(mediaPool[j].slot) || mediaPool[j].slot == "0" || !mediaPool[j].slot || session.slots < parseInt(mediaPool[j].slot)) { //if ((!("slot" in mediaPool[j]) || !parseInt(mediaPool[j].slot) || mediaPool[j].slot == "0" || !mediaPool[j].slot || session.slots < parseInt(mediaPool[j].slot)) && !customLayout) { mediaPool_invisible.push(mediaPool[j]); mediaPool.splice(j, 1); } } mediaPool.reverse(); } if (session.pauseInvisible) { mediaPool_invisible.forEach(vid => { // This is an experimental version, with improvements? Maybe its better? if (vid) { try { vid.style.width = ""; vid.style.height = ""; if (!vid.isInvisible) { vid.isInvisible = true; if (session.pauseInvisible && vid.dataset.UUID) { session.requestRateLimit(session.hiddenSceneViewBitrate, vid.dataset.UUID, true); vid.muted = true; } } if (vid.dataset.doNotMove) { return; } vid.style.top = "0px"; vid.style.left = "0px"; if (vid.isConnected) { console.warn("Video is invisible, yet connected to the DOM?"); } } catch (e) { errorlog(e); } } }); } else { // this is the old version; a fail safe. mediaPool_invisible.forEach(vid => { if (vid) { try { vid.style.width = "0px"; vid.style.height = "0px"; vid.style.top = "0px"; vid.style.left = "0px"; vid.isInvisible = true; if (vid.alreadyAdded && vid.alreadyAdded == true) { vid.alreadyAdded = false; return; } else if (vid.dataset.doNotMove) { return; } if (!(vid.nodeName == "IFRAME" && vid.isConnected)) { playarea.appendChild(vid); } } catch (e) { errorlog(e); } } }); } } catch (e) { errorlog(e); } var layout = false; if (customLayout || (!session.activeSpeaker && session.layout)) { layout = session.layout || customLayout; layout = { ...layout }; if (layout[""]) { for (var i = 0; i < layout[""].length; i++) { if (layout[""][i].defaultStreamID && !layout[layout[""][i].defaultStreamID]) { var found = false; for (var ell in mediaPool) { if (mediaPool[ell].dataset.sid && mediaPool[ell].dataset.sid === layout[""][i].defaultStreamID) { layout[layout[""][i].defaultStreamID] = layout[""][i]; found = true; } } if (found) { continue; } } layout["#" + i] = layout[""][i]; if (layout["#" + i].iframeSrc) { if (session.iframeSrcs[layout["#" + i].iframeSrc]) { var ele = session.iframeSrcs[layout["#" + i].iframeSrc]; ele.id = "#" + i; } else { var ele = loadIframe(parseURL4Iframe(layout["#" + i].iframeSrc), "#" + i); ele.framesource = layout["#" + i].iframeSrc; session.iframeSrcs[layout["#" + i].iframeSrc] = ele; } //ele.alreadyAdded = true; //ele.matched = true; } else if (layout["#" + i].backgroundMedia || layout["#" + i].text || layout["#" + i].foregroundMedia) { var ele = document.createElement("div"); ele.dataset.sid = "#" + i; } else { continue; } ele.dataset.sid = "#" + i; mediaPool.push(ele); } } try { mediaPool = sortByZ(mediaPool, layout); } catch (e) { // layout = false; errorlog(e); } } var i = 0; var offset = 0; if (session.waitImage && !(mediaPool_invisible.length || mediaPool.length)) { if (!session.waitImageTimeoutObject) { session.waitImageTimeoutObject = setTimeout(function () { session.waitImageTimeoutObject = true; if (!document.getElementById("retryimage")) { playarea.innerHTML += ''; getById("retryimage").src = decodeURIComponent(session.waitImage); getById("retryimage").onerror = function () { this.style.display = "none"; }; if (session.cover) { getById("retryimage").style.objectFit = "cover"; } } getById("retryimage").style.display = "block"; }, session.waitImageTimeout); } } else if (session.waitImage) { try { clearTimeout(session.waitImageTimeoutObject); session.waitImageTimeoutObject = false; getById("retryimage").style.display = "none"; } catch (e) { } } mediaPool.forEach(vid => { try { if (!vid || !("id" in vid)) { errorlog(vid); return; } if (vid.needsLoading) { try { vid.load(); } catch (e) { errorlog(e); } } if (session.slots) { if ("slot" in vid && parseInt(vid.slot)) { i = parseInt(vid.slot) - 1; if (i < 0) { return; } } else { return; } } var offsetx = 0; if (i !== 0) { if (Math.ceil((i + 0.01) / rw) == rh) { if (mpl % rw) { offsetx = Math.max(((rw - (mpl % rw)) * (w / rw)) / 2, 0); } } } var cover = session.cover; var borderOffset = session.border || 0; var videoMargin = session.videoMargin || 0; var borderRadius = session.borderRadius || 0; var borderColor = session.borderColor || "#000"; var fadein = session.fadein || false; var backgroundMedia = session.defaultMedia || false; var foregroundMedia = session.defaultOverlayMedia || false; var animated = session.animatedMoves || 0; var textOverlay = false; if (!borderOffset) { borderColor = "#0000"; } if (layout) { if (!(vid.dataset.sid && vid.dataset.sid in layout)) { if (vid.container) { vid.container.style.display = "none"; } vid.isInvisible = true; if (session.pauseInvisible && vid.dataset.UUID) { session.requestRateLimit(session.hiddenSceneViewBitrate, vid.dataset.UUID, true); vid.muted = true; return; } if (vid.dataset.UUID) { delayedRequestRate(session.hiddenSceneViewBitrate, vid.dataset.UUID, false); // it's added already, so we know it needs sound. But lets d } return; } if ("borderThickness" in layout[vid.dataset.sid]) { borderOffset = layout[vid.dataset.sid].borderThickness || 0; } if ("animated" in layout[vid.dataset.sid]) { animated = layout[vid.dataset.sid].animated || 0; if (animated === true) { animated = session.animatedMoves || 50; } } if ("margin" in layout[vid.dataset.sid]) { videoMargin = layout[vid.dataset.sid].margin || 0; } if ("rounded" in layout[vid.dataset.sid]) { borderRadius = layout[vid.dataset.sid].rounded || 0; } if (layout[vid.dataset.sid].borderColor) { borderColor = layout[vid.dataset.sid].borderColor; } if (layout[vid.dataset.sid].fadeIn) { fadein = layout[vid.dataset.sid].fadeIn; } if ("backgroundMedia" in layout[vid.dataset.sid]) { backgroundMedia = layout[vid.dataset.sid].backgroundMedia || false; } if ("foregroundMedia" in layout[vid.dataset.sid]) { foregroundMedia = layout[vid.dataset.sid].foregroundMedia || false; } if (layout[vid.dataset.sid].text) { if (!vid.container || !vid.container.textOverlay) { textOverlay = document.createElement("div"); textOverlay.className = "textOverlay"; //vid.container.appendChild(vid.container.textOverlay); } else { textOverlay = vid.container.textOverlay; } textOverlay.innerText = layout[vid.dataset.sid].text; textOverlay.style.color = layout[vid.dataset.sid].textColor || "#ffffff"; textOverlay.style.fontSize = layout[vid.dataset.sid].fontSize || "24px"; textOverlay.style.fontFamily = layout[vid.dataset.sid].fontFamily || "Arial, sans-serif"; textOverlay.style.position = "absolute"; textOverlay.style.width = "100%"; textOverlay.style.textAlign = "center"; textOverlay.style.zIndex = "10"; // Position the text const textPosition = layout[vid.dataset.sid].textPosition || "50%"; textOverlay.style.top = textPosition; textOverlay.style.transform = "translateY(-50%)"; // Add background if specified if (layout[vid.dataset.sid].textBackground) { textOverlay.style.backgroundColor = layout[vid.dataset.sid].textBackground; textOverlay.style.padding = "10px"; } else { textOverlay.style.backgroundColor = "transparent"; textOverlay.style.textShadow = "1px 1px 2px rgba(0,0,0,0.8)"; } } else if (vid.container && vid.container.textOverlay) { vid.container.textOverlay.remove(); delete vid.container.textOverlay; } if (vid.container) { if (!(vid.nodeName == "IFRAME" && vid.isConnected)) { // moving an iframe will break it. if (!vid.alreadyAdded || vid.nodeName == "IFRAME") { playarea.appendChild(vid.container); } } } } var skipAnimation = false; if (vid.isInvisible) { vid.isInvisible = false; if (session.pauseInvisible && vid.dataset.UUID) { applyMuteState(vid.dataset.UUID); } skipAnimation = true; if (fadein) { vid.classList.add("fadein"); if (vid.holder) { vid.holder.classList.add("fadein"); } } } offsety = Math.max((h - Math.ceil(mpl / rw) * Math.ceil(h / rh)) / 2, 0); if (vid.container) { var container = vid.container; if (container.move) { clearInterval(container.move); container.move = null; } } else { var container = document.createElement("div"); vid.container = container; } container.style.position = "absolute"; container.style.display = "block"; container.classList.add("container_holder_video"); // Add screen share class to individual containers var isScreenShare = false; var vidUUID = vid.dataset.UUID; var vidSid = vid.dataset.sid; if (vidUUID && session.rpcs[vidUUID] && !vidSid && session.rpcs[vidUUID].streamID) { vidSid = session.rpcs[vidUUID].streamID; } if (vid.id === "screensharesource" || (vidUUID && vidUUID.endsWith("_screen")) || (vidSid && vidSid.endsWith(":s"))) { isScreenShare = true; } else if (vidUUID && session.rpcs[vidUUID] && session.rpcs[vidUUID].screenShareState) { if (!session.rpcs[vidUUID + "_screen"]) { isScreenShare = true; } } if (isScreenShare) { container.classList.add("is-screenshare"); container.classList.remove("is-not-screenshare"); } else { container.classList.add("is-not-screenshare"); container.classList.remove("is-screenshare"); } // ANIMATED - CONTAINER ; width/height/z-index/cover/////////////// if (layout) { try { var left = (w / 100) * layout[vid.dataset.sid].x || layout[vid.dataset.sid].xp || 0; var top = (h / 100) * layout[vid.dataset.sid].y || layout[vid.dataset.sid].yp || 0; top += hi; var width = (w / 100) * layout[vid.dataset.sid].w || layout[vid.dataset.sid].wp || 0; var height = (h / 100) * layout[vid.dataset.sid].h || layout[vid.dataset.sid].hp || 0; if (layout[vid.dataset.sid].cover || layout[vid.dataset.sid].c) { // this should be true/false //vid.style.objectFit = "cover"; cover = layout[vid.dataset.sid].cover || layout[vid.dataset.sid].c; } else { //vid.style.objectFit = "contain"; // this should fall back to sessio.cover if no layout supplied cover = false; } //container.style.zindex = 0; container.style.zIndex = layout[vid.dataset.sid].zIndex || layout[vid.dataset.sid].z || 0; } catch (e) { errorlog(e); } } else { var left = Math.max(offsetx + Math.floor((((i % rw) + 0) * w) / rw), 0); var top = Math.max(offsety + Math.floor(((Math.floor(i / rw) + 0) * h) / rh + hi), 0); var width = Math.ceil(w / rw); var height = Math.ceil(h / rh); //container.style.zIndex = 0; } var computed = getComputedStyle(vid); if (animated && !skipAnimation) { container.style.transition = "width " + animated + "ms ease-in-out 0s, height " + animated + "ms ease-in-out 0s, background-color " + animated + "ms ease-in-out 0s, transform " + animated + "ms ease-in-out 0s, top " + animated + "ms ease-in-out 0s, left " + animated + "ms ease-in-out 0s"; } else { container.style.transition = ""; } if (layout) { ////////////////// NOT ANIMATED - CONTAINER ; width/height/z-index/cover/////////////// container.style.left = left + "px"; container.style.top = top + "px"; container.style.width = width + "px"; container.style.height = height + "px"; container.twidth = width; container.theight = height; } else { container.style.left = offsetx + Math.floor((((i % rw) + 0) * w) / rw) + "px"; container.style.top = offsety + Math.floor(((Math.floor(i / rw) + 0) * h) / rh + hi) + "px"; container.twidth = Math.ceil(w / rw); container.theight = Math.ceil(h / rh); container.style.width = container.twidth + "px"; container.style.height = container.theight + "px"; } var maxWidth = 0; if (parseInt(computed.width) > parseInt(container.style.width)) { maxWidth = computed.width; } else { maxWidth = container.style.width; } var maxHeight = 0; if (parseInt(computed.height) > parseInt(container.style.height)) { maxHeight = computed.height; } else { maxHeight = container.style.height; } if (cover === true) { vid.style.maxWidth = maxWidth; vid.style.maxHeight = maxHeight; vid.style.objectFit = "cover"; } else if (cover == 2) { // For session.cover == 2, determine whether to use cover or contain // based on aspect ratio comparison vid.style.maxWidth = maxWidth; vid.style.maxHeight = maxHeight; const vw = vid.naturalWidth || vid.videoWidth || 0; const vh = vid.naturalHeight || vid.videoHeight || 0; if (vw && vh) { // Calculate aspect ratios const videoAspect = vw / vh; // Use container dimensions for comparison const containerWidth = parseFloat(maxWidth); const containerHeight = parseFloat(maxHeight); const containerAspect = containerWidth / containerHeight; // If video is wider than container proportionally (width being squished), // use cover. Otherwise use contain. if (videoAspect > containerAspect) { vid.style.objectFit = "cover"; } else { vid.style.objectFit = "contain"; } } else { // Default to contain if we can't determine dimensions vid.style.objectFit = "contain"; } } else { vid.style.objectFit = "contain"; vid.style.maxWidth = maxWidth; vid.style.maxHeight = maxHeight; } //try { if (vid.alreadyAdded && vid.alreadyAdded == true) { if (!container.holder) { var holder = document.createElement("div"); container.holder = holder; holder.className = "holder"; holder.dataset.holder = true; container.appendChild(holder); holder.appendChild(vid); } else { var holder = container.holder; } } else if (vid.dataset.doNotMove) { vid.style.position = "absolute"; vid.style.left = left + "px"; vid.style.top = top + "px"; vid.style.width = width + "px"; vid.style.height = height + "px"; vid.style.display = "flex"; i += 1; return; } else { if (!container.holder) { var holder = document.createElement("div"); container.holder = holder; holder.className = "holder"; holder.dataset.holder = true; holder.appendChild(vid); container.appendChild(holder); } else { var holder = container.holder; holder.prepend(vid); } playarea.appendChild(container); vid.style.maxWidth = "100%"; vid.style.maxHeight = "100%"; } if (layout) { var wrw = (w / 100) * layout[vid.dataset.sid].w || 0; var hrh = (h / 100) * layout[vid.dataset.sid].h || 0; } else { var wrw = w / rw; var hrh = h / rh; } if (backgroundMedia) { container.style.backgroundImage = "url(" + backgroundMedia + ")"; if (cover) { container.style.backgroundSize = "cover"; } else { container.style.backgroundSize = "contain"; } container.style.backgroundPosition = "center"; container.style.backgroundRepeat = "no-repeat"; } else if (container.style.backgroundImage) { container.style.backgroundImage = "unset"; } if (foregroundMedia) { if (!container.foregroundMedia) { container.foregroundMedia = document.createElement("img"); container.foregroundMedia.className = "foregroundMedia"; container.appendChild(container.foregroundMedia); } container.foregroundMedia.src = foregroundMedia; } else if (container.foregroundMedia) { try { container.foregroundMedia.remove(); delete container.foregroundMedia; } catch (e) { errorlog(e); } } if (textOverlay && !container.textOverlay) { container.appendChild(textOverlay); } if ("rotated" in vid && vid.rotated !== false) { if (vid.dataset) { vid.dataset.rotated = vid.rotated ? vid.rotated : "0"; } updateVideoTransform(vid); } else if (vid.dataset && vid.dataset.rotated) { vid.dataset.rotated = "0"; updateVideoTransform(vid); } vid.style.width = "100%"; vid.style.height = "100%"; holder.style.position = "absolute"; if (vid.classList.contains("paused")) { if (holder.paused) { holder.paused.className = "playButton"; } else { var paused = document.createElement("span"); paused.id = "paused_" + vid.dataset.UUID; paused.className = "playButton"; paused.dataset.UUID = vid.dataset.UUID; paused.onclick = function () { unPauseVideo(vid); }; holder.paused = paused; holder.appendChild(paused); } } else if (holder.paused) { holder.paused.className = "hidden"; } var vw = vid.naturalWidth || vid.videoWidth; // naturalWidth is for images I guess var vh = vid.naturalHeight || vid.videoHeight; // log(vw + " : "+vh); if (cover && !session.structure) { ////// if ("rotated" in vid && (vid.rotated == 90 || vid.rotated == 270)) { holder.style.left = borderOffset + "px"; holder.style.top = borderOffset + "px"; holder.style.height = "calc(100% - " + videoMargin * 2 + "px)"; holder.style.width = "calc(100% - " + videoMargin * 2 + "px)"; vid.style.width = height - (borderOffset + videoMargin) * 2 + "px"; vid.style.height = width - (borderOffset + videoMargin) * 2 + "px"; vid.style.left = 0; vid.style.top = 0; } else { holder.style.left = videoMargin + "px"; holder.style.top = videoMargin + "px"; holder.style.height = "calc(100% - " + videoMargin * 2 + "px)"; holder.style.width = "calc(100% - " + videoMargin * 2 + "px)"; vid.style.width = "100%"; vid.style.height = "100%"; vid.style.left = 0; vid.style.top = 0; } ////////// COVER VERSION if (session.sharperScreen && sssid && vid.dataset.sid && vid.dataset.sid === sssid) { // do not dynamically scale the screen share feed. } else if (session.dynamicScale) { if (vid.dataset.UUID) { let targetWidth = wrw; let targetHeight = hrh; targetWidth -= (borderOffset + videoMargin) * 2; targetHeight -= (borderOffset + videoMargin) * 2; if (targetWidth < 0) { targetWidth = 0; } if (targetHeight < 0) { targetHeight = 0; } if (session.devicePixelRatio) { session.requestResolution(vid.dataset.UUID, targetWidth * session.devicePixelRatio, targetHeight * session.devicePixelRatio, true, false, cover); // snap=true; if resolution close to 100%, send 100%. screenshare only } else if (window.devicePixelRatio && parseInt(window.devicePixelRatio) > 1) { session.requestResolution(vid.dataset.UUID, targetWidth * window.devicePixelRatio, targetHeight * window.devicePixelRatio, true, false, cover); } else { session.requestResolution(vid.dataset.UUID, targetWidth, targetHeight, true, false, cover); } } } vid.style.borderColor = borderColor; vid.style.borderWidth = borderOffset + "px"; vid.style.borderRadius = borderRadius + "px"; holder.style.borderColor = borderColor; holder.style.borderWidth = "0px"; holder.style.borderRadius = borderRadius + "px"; } else if ((vw && vh) || (vid.width && vid.height) || vid.dataset.aspectRatio) { if ("rotated" in vid && (vid.rotated == 90 || vid.rotated == 270)) { if (vw && vh) { var vvw = parseInt(vh); var vvh = parseInt(vw); } else if (vid.width && vid.height) { var vvw = parseInt(vid.height); var vvh = parseInt(vid.width); } else { // video disabled; fall back to aspect Ratio var vvw = 1; var vvh = vid.dataset.aspectRatio; } vid.style.objectFit = "cover"; //contain; vid.style.overflow = "unset"; //contain; //vid.style.maxWidth = "unset"; //vid.style.maxHeight = "unset"; } else { if (vw && vh) { var vvw = parseInt(vw); var vvh = parseInt(vh); } else if (vid.width && vid.height) { var vvw = parseInt(vid.width); var vvh = parseInt(vid.height); } else { var vvw = vid.dataset.aspectRatio; var vvh = 1; } } var asw = (wrw - videoMargin * 2 - borderOffset * 2) / vvw; // (window.innerWidth/ N) / vid.videoHeight; var ash = (hrh - videoMargin * 2 - borderOffset * 2) / vvh; if (session.structure) { // wrw x hrh var arx = (wrw - videoMargin * 2 - borderOffset * 2) / (hrh - videoMargin * 2 - borderOffset * 2); var tarx = arW / arH; //var arW = 16.0; //var arH = 9.0; if (arx > tarx) { // width is too long var hsw = hrh * tarx - videoMargin * 2 * tarx - borderOffset * 2; var hsl = (wrw - hsw) / 2; var hst = videoMargin; var hsh = hrh - videoMargin * 2; } else { var hsh = (wrw - videoMargin * 2 + borderOffset * 2) / tarx; var hst = (hrh - hsh) / 2; var hsl = videoMargin; var hsw = wrw - videoMargin * 2; } } else if (asw > ash) { var hsh = hrh - videoMargin * 2; var hst = videoMargin; var hsw = (hsh - borderOffset * 2) * (vvw / vvh) + borderOffset * 2; var hsl = (wrw - hsw) / 2; } else { var hsw = wrw - videoMargin * 2; var hsl = videoMargin; var hsh = (hsw - borderOffset * 2) / (vvw / vvh) + borderOffset * 2; var hst = (hrh - hsh) / 2; } holder.style.left = Math.floor(hsl) + "px"; // this needs to be replaced with padding. This means testing with rotation = 90 holder.style.top = Math.floor(hst) + "px"; holder.style.width = Math.ceil(hsw) + "px"; holder.style.height = Math.ceil(hsh) + "px"; //holder.style.padding = videoMargin + "px"; holder.style.borderColor = borderColor; holder.style.borderWidth = borderOffset + "px"; holder.style.borderRadius = borderRadius + "px"; vid.style.borderWidth = "0px"; if ("rotated" in vid && (vid.rotated == 90 || vid.rotated == 270)) { vid.style.width = Math.ceil(wrw - borderOffset * 2) + "px"; vid.style.height = Math.ceil(hsw - borderOffset * 2) + "px"; vid.style.left = 0; vid.style.maxWidth = "100vh"; vid.style.maxHeight = "100vw"; if (ChromiumVersion && ChromiumVersion < 77) { if (!animated && parseInt(container.style.width) > parseInt(holder.style.height)) { vid.style.position = "relative"; vid.style.objectFit = "contain"; //contain; } else if (animated && container.twidth && parseInt(container.twidth) > parseInt(holder.style.height)) { vid.style.position = "relative"; vid.style.objectFit = "contain"; //contain; } } else { vid.style.position = "relative"; } } else if (session.blurBackground !== false && vid.nodeName == "VIDEO" && !container.blurred && vid.srcObject && ((asw > 1 && ash > 1) || asw >= 1 || ash >= 1)) { vid.srcObject.getVideoTracks().forEach(trk => { if (!container.blurred) { container.blurred = document.createElement("video"); container.blurred.controls = false; container.blurred.style = "z-index:-10000;position:absolute;left:0;width:100%;height:100%;top:0;object-fit:fill;-webkit-filter: blur(" + session.blurBackground + "px)"; container.blurred.srcObject = createMediaStream(); container.blurred.srcObject.addTrack(trk); container.blurred.play(); holder.appendChild(container.blurred); } else { if (container.blurred.paused) { container.blurred.play(); } } }); } else if (session.blurBackground !== false && vid.nodeName == "VIDEO" && vid.srcObject && ((asw > 1 && ash > 1) || asw >= 1 || ash >= 1)) { if (container.blurred.paused) { container.blurred.play(); } //container.blurred.style = "z-index:-10000;position:absolute;left:0;width:100%;height:100%;top:0;object-fit:fill;-webkit-filter: blur("+session.blurBackground+"px)"; } else if (container.blurred) { try { container.blurred.remove(); } catch (e) { } try { delete container.blurred; } catch (e) { } } ////////// NON-COVER VERSION (based on holder) if (session.sharperScreen && sssid && vid.dataset.sid && vid.dataset.sid === sssid) { // do not dynamically scale the screen share feed. } else if (session.dynamicScale) { if (vid.dataset.UUID) { let targetWidth = wrw; let targetHeight = hrh; targetWidth -= (borderOffset + videoMargin) * 2; targetHeight -= (borderOffset + videoMargin) * 2; if (targetWidth < 0) { targetWidth = 0; } if (targetHeight < 0) { targetHeight = 0; } if (session.devicePixelRatio) { session.requestResolution(vid.dataset.UUID, targetWidth * session.devicePixelRatio, targetHeight * session.devicePixelRatio, true, false, cover); // snap=true; if resolution close to 100%, send 100%. screenshare only } else if (window.devicePixelRatio && parseInt(window.devicePixelRatio) > 1) { session.requestResolution(vid.dataset.UUID, targetWidth * window.devicePixelRatio, targetHeight * window.devicePixelRatio, true, false, cover); } else { session.requestResolution(vid.dataset.UUID, targetWidth, targetHeight, true, false, cover); } } } } else { holder.style.left = borderOffset + videoMargin + "px"; holder.style.top = borderOffset + videoMargin + "px"; holder.style.height = "calc(100% - " + (borderOffset + videoMargin * 2) + "px)"; holder.style.width = "calc(100% - " + (borderOffset + videoMargin * 2) + "px)"; ////////// UNKNOWN VERSION if (session.sharperScreen && sssid && vid.dataset.sid && vid.dataset.sid === sssid) { // do not dynamically scale the screen share feed. } else if (session.dynamicScale) { if (vid.dataset.UUID) { let targetWidth = wrw; let targetHeight = hrh; targetWidth -= (borderOffset + videoMargin) * 2; targetHeight -= (borderOffset + videoMargin) * 2; if (targetWidth < 0) { targetWidth = 0; } if (targetHeight < 0) { targetHeight = 0; } if (session.devicePixelRatio) { session.requestResolution(vid.dataset.UUID, targetWidth * session.devicePixelRatio, targetHeight * session.devicePixelRatio, true, false, cover); // snap=true; if resolution close to 100%, send 100%. screenshare only } else if (window.devicePixelRatio && parseInt(window.devicePixelRatio) > 1) { session.requestResolution(vid.dataset.UUID, targetWidth * window.devicePixelRatio, targetHeight * window.devicePixelRatio, true, false, cover); } else { session.requestResolution(vid.dataset.UUID, targetWidth, targetHeight, true, false, cover); } } } /////////////// holder.style.borderColor = borderColor; holder.style.borderWidth = borderOffset + "px"; holder.style.borderRadius = borderRadius + "px"; vid.style.borderWidth = "0px"; } if (session.colorVideosBackground) { vid.style.backgroundColor = session.colorVideosBackground; } else { vid.style.backgroundColor = "unset"; } if ((vid.dataset.UUID && session.rpcs && session.rpcs[vid.dataset.UUID] && ( ("label" in session.rpcs[vid.dataset.UUID] && session.rpcs[vid.dataset.UUID].label !== false && session.showlabels === true) || (session.showmeta === true && session.rpcs[vid.dataset.UUID] && session.rpcs[vid.dataset.UUID].meta) )) || ((session.showlabels === true || session.showmeta === true) && (((vid.id === "videosource") && session.label) || vid.labelText || session.meta) )) { if (animated && container.twidth && container.theight) { var vidwidth = container.twidth; var vidheight = container.theight; } else { var vidwidth = vid.offsetWidth; var vidheight = vid.offsetHeight; } var fontsize = (vidwidth + vidheight) * 0.03; if (vidwidth / 16 >= vidheight / 9) { var voar = vidwidth / 16 / (vidheight / 9); } else { var voar = vidheight / 9 / (vidwidth / 16); } voar = Math.pow(voar, 0.5); fontsize = fontsize / voar; if (holder.label) { var label = holder.label; } else { var label = document.createElement("span"); holder.label = label; if (session.labelstyle) { label.className = "video-label " + session.labelstyle; } else { label.className = "video-label"; } holder.appendChild(label); } if (fontsize) { if (session.labelsize) { fontsize = (fontsize * session.labelsize) / 100; } label.style.fontSize = parseInt(fontsize) + "px"; } if (( vid.dataset.UUID && session.rpcs && session.rpcs[vid.dataset.UUID] && ( // Label check ( "label" in session.rpcs[vid.dataset.UUID] && session.rpcs[vid.dataset.UUID].label !== false && session.showlabels === true ) || // Meta check with explicit undefined checks ( session.showmeta === true && session.rpcs[vid.dataset.UUID] && session.rpcs[vid.dataset.UUID].meta ) ) ) || ( (session.showlabels === true || session.showmeta === true) && ( ((vid.id === "videosource") && session.label) || vid.labelText || session.meta ) ) ) { let labelContent = []; let imageElements = []; if (session.showlabels === true) { if (vid.dataset.UUID && session.rpcs[vid.dataset.UUID] && session.rpcs[vid.dataset.UUID].label) { session.rpcs[vid.dataset.UUID].label.split("\\n").forEach(label => { labelContent.push({ type: 'text', value: label }); }); } else if ((vid.id === "videosource") && session.label || vid.labelText) { (vid.labelText || session.label).split("\\n").forEach(label => { labelContent.push({ type: 'text', value: label }); }); } } if (session.showmeta === true) { let metaData = []; if (vid.dataset.UUID && session.rpcs[vid.dataset.UUID] && session.rpcs[vid.dataset.UUID].meta) { metaData = Object.entries(session.rpcs[vid.dataset.UUID].meta); } else if (session.meta) { metaData = Object.entries(session.meta); } metaData.forEach(([key, value]) => { if (value && typeof value === 'object') { if (value.type && value.type == 'file') { // console.log(value); if (value.filetype) { imageElements.push(value); } } else { labelContent.push({ type: 'text', value: value.value || JSON.stringify(value) }); } } else if (value) { labelContent.push({ type: 'text', value: value }); } }); } // Create or get label container if (!holder.labelContainer) { holder.labelContainer = document.createElement("div"); holder.labelContainer.className = "video-label-container"; holder.appendChild(holder.labelContainer); } // Handle text content if (labelContent.length > 0) { if (!holder.label) { holder.label = document.createElement("div"); if (session.labelstyle) { holder.label.className = "video-label " + session.labelstyle; } else { holder.label.className = "video-label"; } holder.labelContainer.appendChild(holder.label); } // Apply font size adjustments as before if (fontsize) { if (session.labelsize) { fontsize = (fontsize * session.labelsize) / 100; } holder.label.style.fontSize = parseInt(fontsize) + "px"; } holder.label.innerHTML = labelContent .map(item => `${escapeHtml(String(item.value))}`) .join(""); } // Handle image content if (imageElements.length > 0) { if (!holder.imageContainer) { holder.imageContainer = document.createElement("div"); holder.imageContainer.className = "video-image-container"; holder.imageContainer.style.position = "absolute"; holder.imageContainer.style.top = "10px"; holder.imageContainer.style.right = "10px"; holder.imageContainer.style.display = "flex"; holder.imageContainer.style.alignItems = "flex-start"; holder.imageContainer.style.justifyContent = "flex-end"; holder.appendChild(holder.imageContainer); } holder.imageContainer.style.maxWidth = "min(20vw, calc(" + holder.style.width + " * 0.25))"; holder.imageContainer.style.maxHeight = "min(20vh, calc(" + holder.style.height + " * 0.25))"; holder.imageContainer.style.minWidth = "max(7vw, calc(" + holder.style.width + " * 0.22), 30px)"; holder.imageContainer.style.minHeight = "max(7vw, calc(" + holder.style.height + " * 0.22), 30px)"; // Clear existing images holder.imageContainer.innerHTML = ''; // Add new images imageElements.forEach(meta => { if (meta.filetype.startsWith("image/")) { const imgElement = document.createElement("img"); imgElement.src = meta.value; imgElement.className = `meta-image ${meta.templateName || ''}`; imgElement.style.width = "auto"; // Fill container width imgElement.style.height = "auto"; // Fill container height imgElement.style.objectFit = "contain"; // Maintain aspect ratio imgElement.style.position = "absolute"; imgElement.style.maxWidth = "100%"; imgElement.style.maxHeight = "100%"; holder.imageContainer.appendChild(imgElement); } }); } } } else if (holder.label) { holder.label.remove(); delete holder.label; } if (vid.dataset.UUID && session.rpcs[vid.dataset.UUID]) { if (session.rpcs[vid.dataset.UUID].voiceMeter) { holder.appendChild(session.rpcs[vid.dataset.UUID].voiceMeter); } if (session.rpcs[vid.dataset.UUID].remoteMuteElement) { holder.appendChild(session.rpcs[vid.dataset.UUID].remoteMuteElement); } if (session.signalMeter) { if (vid.dataset.UUID && !session.rpcs[vid.dataset.UUID].signalMeter) { session.rpcs[vid.dataset.UUID].signalMeter = getById("signalMeterTemplate").cloneNode(true); session.rpcs[vid.dataset.UUID].signalMeter.classList.remove("hidden"); session.rpcs[vid.dataset.UUID].signalMeter.id = "signalMeter_" + vid.dataset.UUID; session.rpcs[vid.dataset.UUID].signalMeter.dataset.level = 0; session.rpcs[vid.dataset.UUID].signalMeter.title = getTranslation("signal-meter"); holder.appendChild(session.rpcs[vid.dataset.UUID].signalMeter); holder.signalMeter = session.rpcs[vid.dataset.UUID].signalMeter; } else if (vid.dataset.UUID && session.rpcs[vid.dataset.UUID].signalMeter) { if (!holder.signalMeter) { holder.appendChild(session.rpcs[vid.dataset.UUID].signalMeter); holder.signalMeter = session.rpcs[vid.dataset.UUID].signalMeter; } } } if (session.batteryMeter) { if (vid.dataset.UUID && !session.rpcs[vid.dataset.UUID].batteryMeter) { session.rpcs[vid.dataset.UUID].batteryMeter = getById("batteryMeterTemplate").cloneNode(true); session.rpcs[vid.dataset.UUID].batteryMeter.classList.remove("hidden"); session.rpcs[vid.dataset.UUID].batteryMeter.id = "batteryMeter_" + vid.dataset.UUID; session.rpcs[vid.dataset.UUID].batteryMeter.dataset.level = 0; session.rpcs[vid.dataset.UUID].batteryMeter.title = getTranslation("battery-meter"); holder.appendChild(session.rpcs[vid.dataset.UUID].batteryMeter); holder.batteryMeter = session.rpcs[vid.dataset.UUID].batteryMeter; } else if (vid.dataset.UUID && session.rpcs[vid.dataset.UUID].batteryMeter) { if (!holder.batteryMeter) { holder.appendChild(session.rpcs[vid.dataset.UUID].batteryMeter); holder.batteryMeter = session.rpcs[vid.dataset.UUID].batteryMeter; } } } if (session.volumeControl && session.rpcs[vid.dataset.UUID].videoElement && vid.tagName != "VIDEO") { if (vid.dataset.UUID && !session.rpcs[vid.dataset.UUID].volumeControl) { session.rpcs[vid.dataset.UUID].volumeControl = getById("volumeControlTemplate").cloneNode(true); session.rpcs[vid.dataset.UUID].volumeControl.classList.remove("hidden"); session.rpcs[vid.dataset.UUID].volumeControl.id = "volumeControl_" + vid.dataset.UUID; session.rpcs[vid.dataset.UUID].volumeControl.value = parseInt(session.rpcs[vid.dataset.UUID].videoElement.volume * 100); session.rpcs[vid.dataset.UUID].volumeControl.dataset.UUID = vid.dataset.UUID; session.rpcs[vid.dataset.UUID].volumeControl.title = getTranslation("volume-control"); session.rpcs[vid.dataset.UUID].volumeControl.oninput = function () { if (this.dataset.UUID && session.rpcs[this.dataset.UUID] && session.rpcs[this.dataset.UUID].videoElement) { session.rpcs[this.dataset.UUID].videoElement.volume = parseFloat(this.value / 100); } }; holder.appendChild(session.rpcs[vid.dataset.UUID].volumeControl); holder.volumeControl = session.rpcs[vid.dataset.UUID].volumeControl; } else if (vid.dataset.UUID && session.rpcs[vid.dataset.UUID].volumeControl) { if (!holder.volumeControl && session.rpcs[vid.dataset.UUID].videoElement) { holder.appendChild(session.rpcs[vid.dataset.UUID].volumeControl); holder.volumeControl = session.rpcs[vid.dataset.UUID].volumeControl; session.rpcs[vid.dataset.UUID].volumeControl.value = parseInt(session.rpcs[vid.dataset.UUID].videoElement.volume * 100); } } } if (session.showConnections) { if (!session.rpcs[vid.dataset.UUID].connectionDetails) { createConnectionDetailsEle(vid.dataset.UUID); } holder.appendChild(session.rpcs[vid.dataset.UUID].connectionDetails); } } if (session.ruleOfThirds) { if (vid.id == "videosource") { if (!holder.svg) { var svg = document.createElement("img"); svg.src = session.ruleOfThirds; svg.style.width = "100%"; svg.style.height = "100%"; svg.style.position = "absolute"; svg.style.left = "0"; svg.style.top = "0"; svg.style.pointerEvents = "none"; holder.svg = svg; holder.appendChild(svg); } } } try { if (!(session.cleanOutput && session.cleanish == false)) { if (session.firstPlayTriggered === false) { // don't play unless needed; might cause clicking or who knows what else. if (vid.tagName.toLowerCase() == "video") { // we don't want to try playing an Iframe or Canvas. if (vid.paused) { warnlog("VIDEO IS NOT PLAYING"); var playPromise = vid.play(); if (playPromise !== undefined) { playPromise .then(_ => { // playing //session.firstPlayTriggered=true; // global tracking. "user gesture obtained", so no longer needed if playing. }) .catch(err => { var bigPlayButton = document.getElementById("bigPlayButton"); if (bigPlayButton) { bigPlayButton.innerHTML = ''; bigPlayButton.style.display = "block"; } }); } else { session.firstPlayTriggered = true; // well, I don't know if it's playing, and so whatever. fail gracefully. } } } } } } catch (e) { errorlog(e); var bigPlayButton = document.getElementById("bigPlayButton"); if (bigPlayButton) { bigPlayButton.parentNode.removeChild(bigPlayButton); } } if (vid.nodeName == "IFRAME") { // I need to add this back in at some point. i += 1; return; } if (!session.cleanOutput && !session.nocursor && !session.nofullwindowbutton) { if (session.roomid !== false && session.scene === false) { if (!(vid.id === "videosource" && miniPreview) && !session.infocusForceMode) { if (!holder.button) { var button = document.createElement("div"); holder.button = button; holder.appendChild(button); } else { var button = holder.button; } button.id = "button_" + vid.id; button.dataset.button = true; if (soloVideo) { button.innerHTML = ""; button.title = "Show all active videos togethers"; button.style.visibility = "visible"; } else if (mpl > 1 || session.fullscreenButton) { // with session.fullscreenButton we hide the actuall full screen button, so this replaces it button.innerHTML = ""; button.title = "Enlarge video and increase its clarity"; button.style.visibility = "visible"; } else { button.style.visibility = "hidden"; } button.classList.add("fullwindowButton"); if (vid.id == "videosource") { button.onclick = function (event) { if (session.infocus === true) { session.infocus = false; } else { session.infocus = true; } setTimeout(() => updateMixer(), 10); if (session.fullscreenButton) { if (session.infocus) { fullscreenPageToggle(true); } else { fullscreenPageToggle(false); } } }; } else { button.dataset.UUID = vid.dataset.UUID; button.onclick = function (event) { var target = event.currentTarget; if (session.infocus === target.dataset.UUID) { //target.childNodes[0].className = 'las la-arrows-alt'; session.infocus = false; } else { //target.childNodes[0].className = 'las la-compress'; session.infocus = target.dataset.UUID; //log("session:"+target.dataset.UUID); } if (session.fullscreenButton) { if (session.infocus) { fullscreenPageToggle(true); } else { fullscreenPageToggle(false); } } setTimeout(() => updateMixer(), 10); }; } vid.onclick = function (event) { if (session.disableMouseEvents) { return; } button.style.display = "block"; container.style.backgroundColor = "#4444"; button.style.opacity = "100%"; }; button.onmouseenter = function (event) { if (session.disableMouseEvents) { return; } button.style.display = "block"; container.style.backgroundColor = "#4444"; setTimeout( function (button) { button.style.opacity = "100%"; }, 1, button ); }; button.onmousemove = function (event) { if (session.disableMouseEvents) { return; } button.style.display = "block"; container.style.backgroundColor = "#4444"; button.style.opacity = "100%"; }; container.onmousemove = function (event) { if (session.disableMouseEvents) { return; } button.style.display = "block"; container.style.backgroundColor = "#4444"; button.style.opacity = "100%"; }; container.onmouseenter = function (event) { if (session.disableMouseEvents) { return; } button.style.display = "block"; container.style.backgroundColor = "#4444"; setTimeout( function (button) { button.style.opacity = "100%"; }, 1, button ); }; container.onmouseleave = function (event) { if (session.disableMouseEvents) { return; } button.style.display = "none"; container.style.backgroundColor = null; button.style.opacity = "10%"; }; } else if (vid.id === "videosource" && miniPreview && soloVideo == true && !session.infocusForceMode) { if (!holder.button) { var button = document.createElement("div"); holder.button = button; holder.appendChild(button); } else { var button = holder.button; } button.id = "button_videosource"; button.dataset.button = true; if (soloVideo) { button.innerHTML = ""; button.title = "Show all active videos togethers"; button.style.display = "unset"; } else { button.style.visibility = "hidden"; button.style.display = "none"; } button.classList.add("fullwindowButton"); button.onclick = function (event) { event.stopPropagation(); event.preventDefault(); if (session.infocus === true) { session.infocus = false; setTimeout(() => updateMixer(), 10); } if (session.fullscreenButton) { if (session.infocus) { fullscreenPageToggle(true); } else { fullscreenPageToggle(false); } } }; } else if (session.infocusForceMode && holder.button) { try { holder.button.remove(); } catch (e) { errorlog(e); } } } } i += 1; } catch (err) { errorlog(err); } }); for (var uid in delayedRequestList) { session.requestRateLimit(...delayedRequestList[uid]); } updateUserList(); } var translationBacklog = []; function miniTranslate(ele, ident = false, direct = false) { if (!translation) { translation = {}; } if (ident) { if (translation.innerHTML && ident in translation.innerHTML) { if (ele.querySelector("[data-translate]")) { ele.querySelector("[data-translate]").innerHTML = translation.innerHTML[ident]; ele.querySelector("[data-translate]").dataset.translate = ident; } else { ele.innerHTML = translation.innerHTML[ident]; ele.dataset.translate = ident; } return; } else if (direct) { if (ele.querySelector("[data-translate]")) { ele.querySelector("[data-translate]").innerHTML = direct; ele.querySelector("[data-translate]").dataset.translate = ident; } else { ele.dataset.translate = ident; ele.innerHTML = direct; } return; } else { if (!(ident in miscTranslations)) { var value = ident.replaceAll("-", " "); // lets use the key as the translation } else { var value = miscTranslations[ident]; // lets use a miscellaneous translation as backup? } if (ele.querySelector("[data-translate]")) { ele.querySelector("[data-translate]").innerHTML = value; ele.querySelector("[data-translate]").dataset.translate = ident; } else { ele.innerHTML = value; ele.dataset.translate = ident; } return; } } var allItems = ele.querySelectorAll("[data-translate]"); allItems.forEach(function (ele2) { if (translation.innerHTML && ele2.dataset.translate in translation.innerHTML) { ele2.innerHTML = translation.innerHTML[ele2.dataset.translate]; } else if (translation.miscellaneous && ele2.dataset.translate in translation.miscellaneous) { ele2.innerHTML = translation.miscellaneous[ele2.dataset.translate]; } }); if (ele.dataset) { if (translation.innerHTML && ele.dataset.translate in translation.innerHTML) { ele.innerHTML = translation.innerHTML[ele.dataset.translate]; } else if (translation.miscellaneous && ele.dataset.translate in translation.miscellaneous) { ele.innerHTML = translation.miscellaneous[ele.dataset.translate]; } } if (translation.titles) { var allTitles = ele.querySelectorAll("[title]"); allTitles.forEach(function (ele2) { if (ele.dataset.key) { var key = ele2.dataset.key; } else { var key = ele2.title.replace(/[\W]+/g, "-").toLowerCase(); ele2.dataset.key = key; } if (key in translation.titles) { ele2.title = translation.titles[key]; } else if (ele2.dataset.translate && ele2.dataset.translate in translation.titles) { ele2.title = translation.titles[ele2.dataset.translate]; } }); if (ele.title) { if (ele.dataset.key) { var key = ele.dataset.key; } else { var key = ele.title.replace(/[\W]+/g, "-").toLowerCase(); ele.dataset.key = key; } if (key in translation.titles) { ele.title = translation.titles[key]; } else if (ele.dataset.translate && ele.dataset.translate in translation.titles) { ele.title = translation.titles[ele.dataset.translate]; } } } if (translation.placeholders) { var allPlaceholders = ele.querySelectorAll("[placeholder]"); allPlaceholders.forEach(function (ele2) { var key = ele2.placeholder.replace(/[\W]+/g, "-").toLowerCase(); if (key in translation.placeholders) { ele2.placeholder = translation.placeholders[key]; } }); if (ele.placeholder) { var key = ele.placeholder.replace(/[\W]+/g, "-").toLowerCase(); if (key in translation.placeholders) { ele.placeholder = translation.placeholders[key]; } } } } async function changeLg(lang, rtl = false, save = false) { log("changeLg: " + lang); var retry = false; if (lang == "auto") { const navlang = navigator.language || navigator.userLanguage || ""; const userLang = navlang.toLowerCase(); // Convert to lower case lang = userLang.replace("_", "-"); // Replace dash with underscore if present retry = true; } await fetchWithTimeout("./translations/" + lang + ".json", 2000) .then(async response => { try { if (response.status !== 200) { getById("mainmenu").style.opacity = 1; if (retry) { console.warn("Couldn't find the exact language file for '" + lang + "'; trying a more generic option instead"); lang = lang.split("-")[0]; if (lang && lang !== "auto") { await changeLg(lang, rtl); // Retry with a more generic language code. } } return; } await response .json() .then(async function (data) { translation = data; // translation.innerHTML[ele.dataset.translate] document.documentElement.dir = rtl ? "rtl" : "ltr"; document.documentElement.setAttribute('lang', lang); document.body.classList.remove('rtl'); document.body.classList.remove('ltr'); document.body.classList.add(rtl ? 'rtl' : 'ltr'); document.documentElement.style.setProperty('--rtl-or-ltr', rtl ? 'right' : 'left'); if (save) { try { localStorage.setItem("vdo_ninja_language", lang); } catch (e) { console.warn("Could not save language to localStorage", e); } } if (translation.miscellaneous) { Object.keys(translation.miscellaneous).forEach(key => { miscTranslations[key] = translation.miscellaneous[key]; }); } translation.miscellaneous = miscTranslations; var allItems = document.querySelectorAll("[data-translate]"); allItems.forEach(function (ele) { if (ele.dataset.translate in translation.innerHTML) { ele.innerHTML = translation.innerHTML[ele.dataset.translate]; } else if (translation.miscellaneous && ele.dataset.translate in translation.miscellaneous) { ele.innerHTML = translation.miscellaneous[ele.dataset.translate]; // use the misc translation if no main one is found } }); var allTitles = document.querySelectorAll("[title]"); allTitles.forEach(function (ele) { if (ele.dataset.key) { var key = ele.dataset.key; } else { var key = ele.title.replace(/[\W]+/g, "-").toLowerCase(); ele.dataset.key = key; } if (key in translation.titles) { ele.title = translation.titles[key]; } else if (ele.dataset.translate && translation.titles && ele.dataset.translate in translation.titles) { ele.title = translation.titles[ele.dataset.translate]; } }); var allPlaceholders = document.querySelectorAll("[placeholder]"); allPlaceholders.forEach(function (ele) { var key = ele.placeholder.replace(/[\W]+/g, "-").toLowerCase(); if (key in translation.placeholders) { ele.placeholder = translation.placeholders[key]; } }); if (translationBacklog.length) { for (var i = 0; i < translationBacklog.length; i++) { try { miniTranslate(translationBacklog[i][0], translationBacklog[i][1]); } catch (e) { } } translationBacklog = []; } getById("mainmenu").style.opacity = 1; }) .catch(async err2 => { if (retry) { console.warn("Couldn't find the exact language file for '" + lang + "'; trying a more generic option instead"); lang = lang.split("-")[0]; if (lang && lang !== "auto") { await changeLg(lang, rtl); // won't retry I'd hope. } } else { errorlog(err2); } }); } catch (e) { getById("mainmenu").style.opacity = 1; } }) .catch(async err => { if (retry) { console.warn("Couldn't find exact language; trying a more generic option instead"); lang = lang.split("-")[0]; if (lang && lang !== "auto") { await changeLg(lang, rtl); // won't retry I'd hope. } } else { errorlog(err); } }); } var controlBarTimeout = false; function showControl(e) { if (controlBarTimeout) { clearTimeout(controlBarTimeout); } if (session.mobile) { getById("controlButtons").classList.remove("partialFadeout"); } else { getById("controlButtons").classList.remove("fadeout"); } controlBarTimeout = setTimeout(function () { if (session.mobile) { getById("controlButtons").classList.add("partialFadeout"); } else { getById("controlButtons").classList.add("fadeout"); } }, 5000); } var loadedQRCode = false; function loadQR(callback = false, value = false) { if (loadedQRCode === false) { loadedQRCode = true; var script = document.createElement("script"); if (callback) { script.onload = function () { callback(value); }; } script.src = "./thirdparty/qrcode.min.js"; // dynamically load this only if its needed. Keeps loading time down. document.head.appendChild(script); } else if (callback) { callback(value); } } function showInviteQR() { var inviteURL = getById("inviteLinkURL").href; warnUser("Loading QR Code..."); loadQR(function(url) { getById("alertModalMessage").innerHTML = ""; var qrcode = new QRCode(getById("alertModalMessage"), { width: 300, height: 300, colorDark: "#000000", colorLight: "#FFFFFF", useSVG: false }); qrcode.makeCode(url); getById("alertModalMessage").title = ""; setTimeout(function() { getById("alertModalMessage").title = ""; if (getById("alertModalMessage").getElementsByTagName("img").length) { getById("alertModalMessage").getElementsByTagName("img")[0].style.cursor = "none"; } }, 100); }, inviteURL); } if (typeof session.pendingFramegrabAudioSettings === "undefined") { session.pendingFramegrabAudioSettings = null; } if (typeof session.framegrabAudioPending === "undefined") { session.framegrabAudioPending = false; } if (typeof session.framegrabAudioRetryTimer === "undefined") { session.framegrabAudioRetryTimer = null; } if (typeof session.framegrabAudioRetryAttempts === "undefined") { session.framegrabAudioRetryAttempts = 0; } if (typeof session.pendingMicRefreshTimeout === "undefined") { session.pendingMicRefreshTimeout = null; } session.updateFramegrabAudioUI = function (enable) { try { const controls = getById("controlButtons"); const micButton = getById("mutebutton"); const speakerButton = getById("mutespeakerbutton"); if (enable) { if (controls) { controls.classList.remove("hidden"); } if (micButton) { micButton.classList.remove("hidden"); micButton.style.removeProperty("display"); } if (speakerButton) { speakerButton.classList.remove("hidden"); speakerButton.style.removeProperty("display"); } } else if (speakerButton) { speakerButton.classList.add("hidden"); } } catch (err) { errorlog(err); } }; session.clearFramegrabAudioTracks = function (stopTracks = true) { const removeTracks = stream => { if (!stream) { return; } try { stream.getAudioTracks().forEach(track => { try { stream.removeTrack(track); } catch (err) { } if (stopTracks) { try { track.stop(); } catch (stopErr) { } } }); } catch (err) { } }; removeTracks(session.streamSrc); removeTracks(session.streamSrcClone); if (session.videoElement && session.videoElement.srcObject) { removeTracks(session.videoElement.srcObject); } session.framegrabAudioInitialized = false; session.framegrabAudioAutoSelectionApplied = false; session.framegrabAudioEnabling = false; session.framegrabAudioPending = false; session.framegrabAudioRetryAttempts = 0; if (session.framegrabAudioRetryTimer) { clearTimeout(session.framegrabAudioRetryTimer); session.framegrabAudioRetryTimer = null; } }; session.prepareFramegrabAudioPreference = function (settings = {}) { if (!settings) { return; } if (Object.prototype.hasOwnProperty.call(settings, "deviceId")) { const deviceId = settings.deviceId; if (deviceId === null || deviceId === false || deviceId === "") { session.audioDevice = 0; } else if (deviceId === 1 || deviceId === "1" || deviceId === "default") { session.audioDevice = 1; } else if (deviceId === "communications") { session.audioDevice = "communications"; } else if (Array.isArray(deviceId)) { session.audioDevice = deviceId.filter(Boolean); } else { session.audioDevice = String(deviceId); } } else if (!session.audioDevice || session.audioDevice === 0) { session.audioDevice = 1; } }; session.autoSelectFramegrabAudioDevice = function () { if (!session.framegrabAudio) { return false; } const audioMenu = getById("audioSource3"); if (!audioMenu) { return false; } const inputs = Array.from(audioMenu.querySelectorAll("input[type='checkbox']")); if (!inputs.length) { return false; } const hasActiveMic = inputs.some(input => input.id !== "multiselect1" && input.checked); if (hasActiveMic) { session.framegrabAudioAutoSelectionApplied = true; return true; } const noAudioOption = inputs.find(input => input.id === "multiselect1"); const findByValue = value => inputs.find(input => input.value === value); let candidate = null; const preferList = []; if (Array.isArray(session.audioDevice) && session.audioDevice.length) { preferList.push(...session.audioDevice); } else if (session.audioDevice === 0) { return false; } else if (session.audioDevice === 1 || session.audioDevice === "1") { preferList.push("default", "communications"); } else if (session.audioDevice) { preferList.push(session.audioDevice); } for (let i = 0; i < preferList.length; i++) { const value = preferList[i]; if (typeof value !== "string") { continue; } const directMatch = findByValue(value); if (directMatch) { candidate = directMatch; break; } const desired = normalizeDeviceLabel(value); const labelMatch = inputs.find(input => { const label = input.dataset && input.dataset.label ? input.dataset.label : (input.getAttribute ? input.getAttribute("data-label") : ""); return label && normalizeDeviceLabel(label) === desired; }); if (labelMatch) { candidate = labelMatch; break; } } if (!candidate) { candidate = findByValue("default") || findByValue("communications"); } if (!candidate) { candidate = inputs.find(input => input.id !== "multiselect1"); } if (!candidate) { return false; } try { candidate.checked = true; candidate.dispatchEvent(new Event("change", { bubbles: true })); } catch (err) { } if (typeof SelectedAudioInputDevices !== "undefined") { if (!Array.isArray(SelectedAudioInputDevices)) { SelectedAudioInputDevices = []; } SelectedAudioInputDevices = SelectedAudioInputDevices.filter(value => value && value !== "ZZZ"); if (!SelectedAudioInputDevices.includes(candidate.value)) { SelectedAudioInputDevices.push(candidate.value); } } if (noAudioOption) { noAudioOption.checked = false; } session.framegrabAudioAutoSelectionApplied = true; return true; }; session.applyFramegrabAudioSettings = async function (settings = {}) { const enable = !(settings && settings.enable === false); const clearRetry = () => { if (session.framegrabAudioRetryTimer) { clearTimeout(session.framegrabAudioRetryTimer); session.framegrabAudioRetryTimer = null; } session.framegrabAudioRetryAttempts = 0; }; const scheduleRetry = () => { if (!session.framegrabAudio || !session.framegrabAudioRequested) { return; } session.pendingFramegrabAudioSettings = settings || { enable: true }; session.framegrabAudioPending = true; const MAX_RETRIES = 6; const RETRY_DELAY_MS = 600; if (session.framegrabAudioRetryAttempts >= MAX_RETRIES) { log('[FRAMEGRAB AUDIO] Retry limit reached; disabling audio'); session.framegrabAudio = false; session.framegrabAudioRequested = false; session.pendingFramegrabAudioSettings = null; session.updateFramegrabAudioUI(false); session.clearFramegrabAudioTracks(); session.framegrabAudioPending = false; if (!session.cleanOutput) { warnUser('Unable to attach an audio input for the framegrab. Please confirm microphone access and try again.', 6000); } return; } session.framegrabAudioRetryAttempts += 1; if (session.framegrabAudioRetryTimer) { return; } session.framegrabAudioRetryTimer = setTimeout(() => { session.framegrabAudioRetryTimer = null; if (!session.framegrabAudio || !session.framegrabAudioRequested) { return; } session.applyFramegrabAudioSettings(session.pendingFramegrabAudioSettings || { enable: true }).catch(errorlog); }, RETRY_DELAY_MS); }; if (!enable) { session.pendingFramegrabAudioSettings = null; session.framegrabAudio = false; session.framegrabAudioRequested = false; session.framegrabAudioPending = false; session.updateFramegrabAudioUI(false); clearRetry(); session.clearFramegrabAudioTracks(); return; } session.framegrabAudio = true; session.framegrabAudioRequested = true; session.framegrabAudioPending = false; session.pendingFramegrabAudioSettings = settings || { enable: true }; session.updateFramegrabAudioUI(true); session.framegrabAudioAutoSelectionApplied = false; session.prepareFramegrabAudioPreference(settings || {}); const overrideConstraints = (() => { if (!settings || typeof settings !== "object") { return false; } if (!Object.prototype.hasOwnProperty.call(settings, "deviceId")) { return false; } let desiredId = settings.deviceId; if (Array.isArray(desiredId)) { desiredId = desiredId.find(Boolean) || null; } if ( desiredId === null || desiredId === false || desiredId === "" || desiredId === 0 || desiredId === "0" || desiredId === 1 || desiredId === "1" || desiredId === "default" || desiredId === "communications" ) { return false; } desiredId = String(desiredId); return { audio: { deviceId: desiredId } }; })(); if (!session.streamSrc) { session.framegrabAudioPending = true; return; } let deviceInfos = null; try { deviceInfos = await enumerateDevices(); gotDevices(deviceInfos); } catch (err) { errorlog(err); } const audioInputsAvailable = Array.isArray(deviceInfos) && deviceInfos.some(info => info && info.kind === 'audioinput'); let autoSelectOk = false; try { autoSelectOk = session.autoSelectFramegrabAudioDevice() === true; } catch (err) { errorlog(err); } if (!autoSelectOk) { if (!audioInputsAvailable) { log('[FRAMEGRAB AUDIO] No audio inputs detected; retrying'); } scheduleRetry(); return; } try { await grabAudio("#audioSource3", null, overrideConstraints); session.framegrabAudioInitialized = true; const trackCount = session.streamSrc && typeof session.streamSrc.getAudioTracks === "function" ? session.streamSrc.getAudioTracks().length : 0; if (!trackCount) { session.framegrabAudioPending = true; scheduleRetry(); return; } session.framegrabAudioPending = false; if (trackCount) { try { session.seedStream(); } catch (err) { errorlog(err); } } clearRetry(); session.pendingFramegrabAudioSettings = null; } catch (err) { errorlog(err); scheduleRetry(); } }; session.startFramegrabAudio = async function (overrideSettings = null) { if (!session.framegrab) { return false; } const pendingSettings = overrideSettings || session.pendingFramegrabAudioSettings || { enable: true }; if (pendingSettings && pendingSettings.enable === false) { return session.applyFramegrabAudioSettings(pendingSettings); } session.framegrabAudioRequested = true; session.framegrabAudio = true; const settings = Object.assign({ enable: true }, pendingSettings || {}); session.pendingFramegrabAudioSettings = settings; session.updateFramegrabAudioUI(true); if (!session.streamSrc) { session.framegrabAudioPending = true; return true; } if (session.framegrabAudioEnabling) { return session.streamSrc.getAudioTracks && session.streamSrc.getAudioTracks().length > 0; } session.framegrabAudioEnabling = true; activatedPreview = false; let success = false; try { await session.applyFramegrabAudioSettings(settings); const hasAudio = session.streamSrc && session.streamSrc.getAudioTracks && session.streamSrc.getAudioTracks().length > 0; if (hasAudio) { session.muted = false; const muteToggle = getById("mutetoggle"); if (muteToggle) { muteToggle.className = "las la-microphone toggleSize"; } const muteButton = getById("mutebutton"); if (muteButton) { muteButton.classList.remove("red", "pulsate"); muteButton.ariaPressed = "false"; } if (!session.cleanOutput) { try { getById("header").classList.remove("red"); } catch (err) { } } success = true; } } catch (err) { errorlog(err); warnUser("Unable to access the microphone. Please check browser permissions.", 6000); session.framegrabAudio = false; session.framegrabAudioRequested = false; session.updateFramegrabAudioUI(false); } finally { session.framegrabAudioEnabling = false; activatedPreview = false; } const result = success || session.framegrabAudioPending; if (!result) { session.framegrabAudio = false; session.framegrabAudioRequested = false; session.updateFramegrabAudioUI(false); } return result; }; var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent"; var eventer = window[eventMethod]; var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message"; eventer(messageEvent, function (e) { // this listens for child IFRAMES. try { if (e.origin == "https://www.youtube.com") { processYoutubeEvent(e); } else if (e.data && typeof e.data == "object" && "action" in e.data) { if (e.data.action == "screen-share-state" && !e.data.value) { //pokeIframeAPI("screen-share-state", false); if (session.screenShareElement && session.screenShareElement.contentWindow) { if (e.source == session.screenShareElement.contentWindow) { // reject messages send from other iframes warnlog(e); postMessageIframe(session.screenShareElement, { close: true }); session.screenShareElement.parentNode.removeChild(session.screenShareElement); session.screenShareElement = false; updateMixer(); } } } else if (e.data.action === "framegrab-audio-settings") { if (!session.framegrab || typeof session.startFramegrabAudio !== "function" || typeof session.applyFramegrabAudioSettings !== "function") { return; } const enable = !(typeof e.data.enable !== "undefined" && e.data.enable === false); const payload = { enable }; if (enable && typeof e.data.deviceId !== "undefined" && e.data.deviceId !== null) { payload.deviceId = e.data.deviceId; } try { const handler = enable ? session.startFramegrabAudio : session.applyFramegrabAudioSettings; const maybePromise = handler(payload); if (maybePromise && typeof maybePromise.then === "function") { maybePromise.catch(errorlog); } } catch (err) { errorlog(err); } } else if (e.data.action == "video-loaded") { // TODO: if (e.source == session...iframeEle.contentWindow) { warnlog(e); toggleSpeakerMute(true); updateMixer(); // harmless to let run. (not so harmless if updateMixer reloads meshcast actually) TODO; Do I need this? } } } catch (e) { errorlog(e); } }); function requestRotateGuest(ele) { var UUID = ele.dataset.UUID; var data = {}; //data.mirrorGuestTarget = UUID; //session.sendPeers(data, false, UUID); ele.classList.add("pressed"); ele.ariaPressed = "true"; data.rotate = true; session.sendRequest(data, UUID); setTimeout( function (el) { el.value = 0; el.classList.remove("pressed"); el.ariaPressed = "false"; }, 500, ele ); } function requestMirrorGuest(ele) { var UUID = ele.dataset.UUID; if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; applyMirrorGuest(false, session.rpcs[UUID].videoElement, session.rpcs[UUID].flipState); var data = {}; data.mirrorGuestState = false; data.mirrorGuestTarget = UUID; session.sendPeers(data, false, UUID); data.mirrorGuestTarget = true; session.sendPeers(data, UUID); } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; applyMirrorGuest(true, session.rpcs[UUID].videoElement, session.rpcs[UUID].flipState); var data = {}; data.mirrorGuestState = true; data.mirrorGuestTarget = UUID; session.sendPeers(data, false, UUID); data.mirrorGuestTarget = true; session.sendPeers(data, UUID); } } function requestKeyframeScene(ele) { var UUID = ele.dataset.UUID; if (ele.value == 1) { } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; session.requestKeyframe(UUID, true); setTimeout( function (el) { el.value = 0; el.classList.remove("pressed"); el.ariaPressed = "false"; }, 1000, ele ); } } function pokeIframeAPI(action, value = null, UUID = null, SID = null, CID = null) { if (!isIFrame) { return; } try { var data = {}; data.action = action; if (value !== null) { data.value = value; } if (UUID !== null) { data.UUID = UUID; } if (!SID) { if (UUID && UUID in session.rpcs) { if (session.rpcs[UUID].streamID) { SID = session.rpcs[UUID].streamID; } } } if (!SID) { if (UUID && UUID in session.pcs) { if (session.pcs[UUID].streamID) { SID = session.pcs[UUID].streamID; } } } if (SID) { data.streamID = SID; } if (CID) { data.cib = CID; } if (isIFrame) { parent.postMessage(data, session.iframetarget); } } catch (e) { errorlog(e); } } async function jumptoroom2() { var arr = window.location.href.split("?"); var roomname = getById("videoname1").value; roomname = sanitizeRoomName(roomname); if (roomname.length) { var pass = getById("passwordRoom").value; pass = sanitizePassword(pass); var passStr = ""; if (pass && pass.length) { passStr = "&password=" + pass; } if (arr.length > 1 && arr[1] !== "") { window.location += "&room=" + roomname + passStr + "&host"; } else { window.location += "?room=" + roomname + passStr + "&host"; } } else { getById("videoname1").focus(); getById("videoname1").classList.remove("shake"); setTimeout(function () { getById("videoname1").classList.add("shake"); }, 10); } } async function jumptoroom(event = null) { if (event) { if (event.which !== 13) { return; } } var arr = window.location.href.split("?"); var roomname = getById("joinroomID").value; roomname = sanitizeRoomName(roomname); if (roomname.length) { var passStr = ""; window.focus(); var pass = await promptAlt(getTranslation("enter-password-if-desired"), false, true); //sanitizePassword(session.password); if (pass && pass.length) { session.password = sanitizePassword(pass); passStr = "&password=" + session.password; } else { session.password = false; } sessionStorage.setItem("jvi", "1"); // joined via input - for showing invite link header if (arr.length > 1 && arr[1] !== "") { window.location += "&room=" + roomname + passStr; } else { window.location += "?room=" + roomname + passStr; } } else { getById("joinroomID").focus(); getById("joinroomID").classList.remove("shake"); setTimeout(function () { getById("joinroomID").classList.add("shake"); }, 10); } } async function jumptoURL(event = null) { // this is for the native app var url = getById("joinbyURL").value; if (url.length) { if (url.startsWith("?")) { url = "./" + url; } if (url.startsWith("&")) { url = "./?" + url; } if (!url.startsWith("http") && !url.startsWith(".")) { url = "./?" + url; } setStorage("jumptoURL", url, 1008); // should be really only used by the native app; 6 months window.location = url; } else { getById("joinbyURL").focus(); getById("joinbyURL").classList.remove("shake"); setTimeout(function () { getById("joinbyURL").classList.add("shake"); }, 10); } } function sleep(ms = 0) { return new Promise(r => setTimeout(r, ms)); // LOLz! } function sleepCancellable(ms = 0) { let resolve; const promise = new Promise(r => { resolve = r; setTimeout(resolve, ms); }); return { promise, resolve }; } async function changeAvatarImage(ev, ele, set = false) { log("changeAvatarImage() triggered"); if (session.avatar && session.avatar.timer) { clearInterval(session.avatar.timer); session.avatar.timer = null; } if (!session.streamSrc) { checkBasicStreamsExist(); } if (ele.files && ele.files.length) { session.avatar = document.querySelector("img"); session.avatar.ready = false; session.avatar.onload = () => { URL.revokeObjectURL(session.avatar.src); // no longer needed, free memory session.avatar.ready = true; getById("noAvatarSelected3").classList.remove("selected"); getById("noAvatarSelected").classList.remove("selected"); getById("defaultAvatar1").classList.add("selected"); getById("defaultAvatar2").classList.add("selected"); var tracks = session.streamSrc.getVideoTracks(); if (!tracks.length || session.videoMuted) { updateRenderOutpipe(); } }; session.avatar.src = URL.createObjectURL(ele.files[0]); // set src to blob url return; } else if (ele.tagName.toLowerCase() == "img") { session.avatar = ele; session.avatar.ready = true; getById("noAvatarSelected3").classList.remove("selected"); getById("noAvatarSelected").classList.remove("selected"); getById("defaultAvatar1").classList.add("selected"); getById("defaultAvatar2").classList.add("selected"); var tracks = session.streamSrc.getVideoTracks(); if (!tracks.length || session.videoMuted) { updateRenderOutpipe(); } } else { session.avatar = false; var tracks = session.streamSrc.getVideoTracks(); if (!tracks.length || session.videoMuted) { var msg = {}; msg.videoMuted = true; session.sendMessage(msg); if (document.getElementById("videosource")) { document.getElementById("videosource").load(); } else if (document.getElementById("previewWebcam")) { document.getElementById("previewWebcam").load(); } updateRenderOutpipe(); } getById("noAvatarSelected3").classList.add("selected"); getById("noAvatarSelected").classList.add("selected"); getById("defaultAvatar1").classList.remove("selected"); getById("defaultAvatar2").classList.remove("selected"); return; } } session.autoSyncCallback = function (UUID = null) { // session.autoSyncObject has been updated. You can overwrite this with your own function log(session.autoSyncObject); pokeIframeAPI("auto-sync-updated", session.autoSyncObject, UUID); }; session.autoSync = function (alternative = null) { // Update session.autoSyncObject and then run session.autoSync() var msg = {}; if (alternative === null) { msg.autoSync = session.autoSyncObject; } else { session.autoSyncObject = alternative; msg.autoSync = session.autoSyncObject; } session.sendPeers(msg); }; //function updateRemotePTZControls(videoOptions, UUID){ // console.log(videoOptions); // console.log(UUID); //} function isolateIncomingChannel(channel, UUID) { if (!session.rpcs[UUID]) return; if (channel === 0 || channel === false) { delete session.rpcs[UUID].isolatedChannel; } else { session.rpcs[UUID].isolatedChannel = channel || session.rpcs[UUID].isolatedChannel; } updateIncomingAudioElement(UUID); } function isolateChannel(source, channel) { if (!channel || channel === 0) { return source; // No isolation, return the original source } const splitter = session.audioCtx.createChannelSplitter(6); // Assuming max 6 channels const merger = session.audioCtx.createChannelMerger(1); // Mono output source.connect(splitter); splitter.connect(merger, channel - 1, 0); // Connect the specified channel to the mono output return merger; } function directIsolateChannel(UUID, channel = null) { // isolateChannel() try { if (UUID) { var targets = document.querySelectorAll("[data--u-u-i-d='" + UUID + "'][data-action-type='isolate-channel']"); var add = false; if (channel) { add = true; } targets.forEach(ele => { if (channel && parseInt(ele.dataset.channel) && (parseInt(ele.dataset.channel) == channel)) { if (ele.classList.contains("pressed")) { add = false; ele.classList.remove("pressed"); ele.ariaPressed = "false"; } else { ele.classList.add("pressed"); ele.ariaPressed = "true"; } } else { ele.classList.remove("pressed"); ele.ariaPressed = "false"; } }); var msg = {}; if (add) { msg.isolateChannel = channel } else { msg.isolateChannel = false; } return session.sendMessage(msg, UUID); } } catch (e) { errorlog(e); } return false; } function uploadImageSnapshot(PostURL) { if (!session.videoElement) { return; } const video = session.videoElement; const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext("2d").drawImage(video, 0, 0, video.videoWidth, video.videoHeight); // for drawing the video element on the canvas const playImage = new Image(); canvas.getContext("2d").drawImage(playImage, 0, 0, playImage.width, playImage.height); canvas.toBlob( function (blob) { var request = new XMLHttpRequest(); if (PostURL.includes("?")) { request.open("POST", PostURL + "&name=" + session.streamID); } else { request.open("POST", PostURL + "?name=" + session.streamID); } request.setRequestHeader("Content-Type", "image/jpeg"); request.send(blob); log("Posted image"); }, "image/jpeg", 0.9 ); } var slideshowImage = false; var slideshowCanvas = false; var slideshowActive = false; function slideshowHack() { // just shows an animated IMAGE of the first video. audio plays, but no "video" if (!session.manual) { session.manual = session.manual === null ? true : session.manual; } if (slideshowActive) { return; } slideshowActive = true; try { if (!slideshowImage) { slideshowImage = document.createElement("img"); slideshowImage.style.width = "100%"; slideshowImage.style.height = "100%"; slideshowImage.style.objectFit = "contain"; slideshowImage.style.border = "0"; slideshowImage.style.padding = "0"; slideshowImage.style.margin = "0"; getById("gridlayout").innerHTML = ""; getById("gridlayout").appendChild(slideshowImage); } var keys = Object.keys(session.rpcs); if (!keys.length) { slideshowActive = false; return; } var video = false; var key = false; for (var i = 0; i < keys.length; i++) { if (session.rpcs[keys[i]].videoElement) { key = keys[i]; video = session.rpcs[keys[i]].videoElement; break; } } if (!video) { slideshowCanvas = false; slideshowActive = false; return; } if (!slideshowCanvas) { slideshowCanvas = document.createElement("canvas"); slideshowCanvas.getContext("2d").imageSmoothingEnabled = false; session.requestResolution(key, session.viewwidth || 480, session.viewheight || 480); } slideshowCanvas.width = video.videoWidth; slideshowCanvas.height = video.videoHeight; slideshowCanvas.getContext("2d").drawImage(video, 0, 0, video.videoWidth, video.videoHeight); // for drawing the video element on the canvas var image = slideshowCanvas.toDataURL("image/png").replace("image/png", "image/octet-stream"); // here is the most important part because if you dont replace you will get a DOM 18 exception. slideshowImage.src = image; } catch (e) { errorlog(e); } slideshowActive = false; } function setAvatarImage(tracks) { if (session.avatar && session.avatar.ready) { if (session.avatar && session.avatar.timer) { clearInterval(session.avatar.timer); session.avatar.timer = null; } setupCanvas(); var width = 512; var height = 288; var maxW = 1280; var maxH = 720; if (session.quality == 0) { maxW = 1920; maxH = 1080; } else if (session.quality == 2) { maxW = 640; maxH = 360; } if (session.width) { maxW = session.width; } if (session.height) { maxH = session.height; } if (session.avatar.naturalHeight && session.avatar.naturalHeight > maxH) { width = parseInt((maxH / session.avatar.naturalHeight) * session.avatar.naturalWidth); height = maxH; if (width > maxW) { width = maxW; height = parseInt((maxW / width) * height); } } else if (session.avatar.naturalWidth && session.avatar.naturalWidth > maxW) { width = maxW; height = parseInt((maxW / session.avatar.naturalWidth) * session.avatar.naturalHeight); } else { width = session.avatar.naturalWidth; height = session.avatar.naturalHeight; } session.canvasSource.width = width; session.canvasSource.height = height; session.canvas.height = 2 * parseInt(height / 2); session.canvas.width = 2 * parseInt(width / 2); session.canvasCtx.drawImage(session.avatar, 0, 0, session.canvas.width, session.canvas.height); session.avatar.timer = setInterval(function () { log("drawing"); session.canvasCtx.drawImage(session.avatar, 0, 0, session.canvas.width, session.canvas.height); }, 200); // too slow and it takes way too long for the video to udpate when a new guest joins applyMirror(true); session.avatar.tracks = session.canvas.captureStream().getVideoTracks(); return session.avatar.tracks; } applyMirror(session.mirrorExclude); return tracks; } var drawOnScreenObject = null; function drawOnScreen() { var canvas = document.getElementById("drawOnScreen"); if (!canvas) { canvas = document.createElement("canvas"); document.getElementById("gridlayout").appendChild(canvas); document.getElementById("gridlayout").style.position = "relative"; } else { return; } var ctx = canvas.getContext("2d"); canvas.width = parseInt(document.getElementById("gridlayout").clientWidth / 2); canvas.height = parseInt(document.getElementById("gridlayout").clientHeight / 2); canvas.style.width = "100%"; canvas.style.height = "100%"; canvas.style.display = "block"; canvas.style.position = "absolute"; canvas.style.bottom = "0"; canvas.style.left = "0"; var flag = false, prevX = 0, currX = 0, prevY = 0, currY = 0, dot_flag = false; var x = "black", y = 2; var object = {}; function findxy(res, e) { if (res == "down") { prevX = currX; prevY = currY; currX = e.clientX - canvas.offsetLeft; currY = e.clientY - canvas.offsetTop; flag = true; dot_flag = true; if (dot_flag) { ctx.beginPath(); ctx.fillStyle = x; ctx.fillRect(currX, currY, 2, 2); ctx.closePath(); dot_flag = false; } } if (res == "up" || res == "out") { flag = false; } if (res == "move") { if (flag) { prevX = currX; prevY = currY; currX = e.clientX - canvas.offsetLeft; currY = e.clientY - canvas.offsetTop; draw(); } } } function draw() { ctx.beginPath(); var mx = canvas.width / parseInt(document.getElementById("gridlayout").clientWidth); var my = canvas.height / parseInt(document.getElementById("gridlayout").clientHeight); var mo = parseInt(document.getElementById("header").clientHeight); ctx.moveTo(prevX * mx, prevY * my - mo * my); ctx.lineTo(currX * mx, currY * my - mo * my); ctx.strokeStyle = x; ctx.lineWidth = y; ctx.stroke(); ctx.closePath(); } function onMouseMove(e) { findxy("move", e); } function onMouseDown(e) { findxy("down", e); } function onMouseUp(e) { findxy("up", e); } function onMouseOut(e) { findxy("out", e); } object.stop = function stop() { canvas.removeEventListener("mousemove", onMouseMove, false); canvas.removeEventListener("mousedown", onMouseDown, false); canvas.removeEventListener("mouseup", onMouseUp, false); canvas.removeEventListener("mouseout", onMouseOut, false); canvas.remove(); document.getElementById("startDrawScreen").classList.remove("hidden"); document.querySelectorAll(".drawActive").forEach(ele => { ele.classList.add("hidden"); }); drawOnScreenObject = null; }; object.init = function init() { canvas.addEventListener("mousemove", onMouseMove, false); canvas.addEventListener("mousedown", onMouseDown, false); canvas.addEventListener("mouseup", onMouseUp, false); canvas.addEventListener("mouseout", onMouseOut, false); document.getElementById("startDrawScreen").classList.add("hidden"); document.querySelectorAll(".drawActive").forEach(ele => { ele.classList.remove("hidden"); }); }; object.color = function color(obj) { switch (obj.dataset.color) { case "green": x = "green"; break; case "blue": x = "blue"; break; case "red": x = "red"; break; case "yellow": x = "yellow"; break; case "orange": x = "orange"; break; case "black": x = "black"; break; case "white": x = "white"; break; } if (x == "white") y = 14; else y = 2; }; object.erase = function erase() { ctx.clearRect(0, 0, canvas.width, canvas.height); }; object.save = function save() { var dataURL = canvas.toDataURL(); }; object.init(); drawOnScreenObject = object; return object; } // SENDER DRAWERS LOGIC PORTION START function fitCurve(points) { if (points.length <= 1) return points; if (points.length === 2) return [{ t: 'l', p: [points[0], points[1]] }]; if (points.length === 3) return [{ t: 'q', p: points }]; let result = []; for (let i = 0; i < points.length - 1; i += 3) { let p0 = points[i]; let p1 = points[i + 1] || p0; let p2 = points[i + 2] || p1; let p3 = points[i + 3] || p2; result.push({ t: 'b', p: [p0, p1, p2, p3] }); } return result; } function isSharpTurn(points) { if (points.length < 3) return false; let angle1 = Math.atan2(points[1].y - points[0].y, points[1].x - points[0].x); let angle2 = Math.atan2(points[2].y - points[1].y, points[2].x - points[1].x); let angleDiff = Math.abs(angle2 - angle1); return angleDiff > Math.PI / 4; } function drawOnThis(video) { try { if (!video || !video.container) { warnlog("no video holder; not compatible"); return; } var container = video.container || video.parentNode; var holder = container.holder || null; var canvas = document.createElement('canvas'); if (!holder) { holder = document.createElement("div"); container.holder = holder; holder.className = "holder"; holder.dataset.holder = true; container.style = "display: flex;\ align-items: center;\ justify-content: center;"; container.appendChild(holder); holder.appendChild(video); video.style.setProperty('top', '0', 'important'); video.style.setProperty('left', '0', 'important'); session.windowed = false; applyMirror(); canvas.style.position = "fixed"; holder.style = "position: relative;\ width: 800px;\ height: 450px; \ display: flex;\ align-items: center;\ justify-content: center;" } else { canvas.className = "drawingCanvas"; } canvas.style.pointerEvents = "none"; holder.appendChild(canvas); video.canvas = canvas; const ctx = canvas.getContext('2d'); ctx.lineWidth = 3; const buttonContainer = document.createElement('div'); const enableDrawingBtn = document.createElement('button'); const clearDrawingBtn = document.createElement('button'); const undoDrawingBtn = document.createElement('button'); // Undo button enableDrawingBtn.textContent = "Enable Drawing"; clearDrawingBtn.textContent = "Clear"; undoDrawingBtn.textContent = "Undo"; // Undo button text buttonContainer.className = "buttonContainer"; buttonContainer.appendChild(enableDrawingBtn); buttonContainer.appendChild(clearDrawingBtn); buttonContainer.appendChild(undoDrawingBtn); // Add undo button to container holder.appendChild(buttonContainer); let isDrawing = false; let drawingEnabled = false; let drawingData = []; let lastPoint = null; let lastSentTime = 0; const sendInterval = 1000; // 1 second let lastPoints = []; function startDrawing(e) { if (!drawingEnabled) return; isDrawing = true; draw(e); } function draw(e) { if (!isDrawing || !drawingEnabled) return; const rect = canvas.getBoundingClientRect(); let x = (e.clientX - rect.left) / rect.width; let y = (e.clientY - rect.top) / rect.height; // Check if the mouse is within bounds if (x < 0 || x > 1 || y < 0 || y > 1) { stopDrawing(); return; } ctx.lineCap = 'round'; ctx.strokeStyle = 'red'; const canvasX = x * canvas.width; const canvasY = y * canvas.height; if (lastPoint) { ctx.beginPath(); ctx.moveTo(lastPoint.x * canvas.width, lastPoint.y * canvas.height); ctx.lineTo(canvasX, canvasY); ctx.stroke(); } x = Math.round(x * 4000) / 4000; y = Math.round(y * 4000) / 4000; lastPoint = { x, y }; lastPoints.push({ x, y }); // Send data more frequently if (lastPoints.length >= 5 || Date.now() - lastSentTime >= 50) { sendDrawingData(); } } function redrawCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = "red"; let isNewPath = true; for (let i = 0; i < drawingData.length; i++) { let segment = drawingData[i]; if (!segment) { // End of a path ctx.stroke(); ctx.beginPath(); isNewPath = true; continue; } if (segment.t) { // This is a complex segment (line or bezier) switch (segment.t) { case 'b': let [p0, p1, p2, p3] = segment.p; if (isNewPath) { ctx.moveTo(p0.x * canvas.width, p0.y * canvas.height); isNewPath = false; } ctx.bezierCurveTo( p1.x * canvas.width, p1.y * canvas.height, p2.x * canvas.width, p2.y * canvas.height, p3.x * canvas.width, p3.y * canvas.height ); break; case 'q': let [q0, q1, q2] = segment.p; if (isNewPath) { ctx.moveTo(q0.x * canvas.width, q0.y * canvas.height); isNewPath = false; } ctx.quadraticCurveTo( q1.x * canvas.width, q1.y * canvas.height, q2.x * canvas.width, q2.y * canvas.height ); break; case 'l': let [l0, l1] = segment.p; if (isNewPath) { ctx.moveTo(l0.x * canvas.width, l0.y * canvas.height); isNewPath = false; } ctx.lineTo(l1.x * canvas.width, l1.y * canvas.height); break; default: warnlog(segment); } } else if (segment.x !== undefined && segment.y !== undefined) { // This is a simple point const canvasX = segment.x * canvas.width; const canvasY = segment.y * canvas.height; if (isNewPath) { ctx.moveTo(canvasX, canvasY); isNewPath = false; } else { ctx.lineTo(canvasX, canvasY); } } } ctx.stroke(); } function processPoints(points) { let processedPoints = []; let currentSegment = []; for (let i = 0; i < points.length; i++) { if (points[i] === null) { if (currentSegment.length > 0) { if (currentSegment.length === 2) { processedPoints.push({ t: 'l', p: currentSegment }); } else { processedPoints.push(...fitCurve(currentSegment)); } currentSegment = []; } processedPoints.push(null); } else if (i > 0 && isSignificantBreak(points[i - 1], points[i])) { if (currentSegment.length > 0) { if (currentSegment.length === 2) { processedPoints.push({ t: 'l', p: currentSegment }); } else { processedPoints.push(...fitCurve(currentSegment)); } currentSegment = []; } currentSegment.push(points[i]); } else { currentSegment.push(points[i]); if (currentSegment.length >= 4) { processedPoints.push(...fitCurve(currentSegment)); currentSegment = [currentSegment[currentSegment.length - 1]]; } } } if (currentSegment.length > 0) { if (currentSegment.length === 2) { processedPoints.push({ t: 'l', p: currentSegment }); } else { processedPoints.push(...fitCurve(currentSegment)); } } return processedPoints; } function isSignificantBreak(point1, point2) { const distance = Math.sqrt(Math.pow(point2.x - point1.x, 2) + Math.pow(point2.y - point1.y, 2)); return distance > 0.05; // Increased threshold } function processCurrentSegment(segment) { if (segment.length < 3) return segment; if (isSharpTurn(segment)) { return segment; } else { return fitCurve(segment); } } function drawBezierCurve(points) { const [start, control1, control2, end] = points; ctx.moveTo(start.x * canvas.width, start.y * canvas.height); ctx.bezierCurveTo( control1.x * canvas.width, control1.y * canvas.height, control2.x * canvas.width, control2.y * canvas.height, end.x * canvas.width, end.y * canvas.height ); } function sendDrawingData(alt = false) { if (alt === "clear") { drawingData = []; if (video.id === "videosource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing) { session.sendMessage({ draw: "clear" }, UUID); } } } else if (video.id === "screensharesource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing && !session.pcs[UUID].realUUID) { session.sendMessage({ draw: "clear", altUUID: true }, UUID); } } } else if (session.rpcs[video.dataset.UUID] && session.rpcs[video.dataset.UUID].allowDrawing) { session.sendRequest({ draw: "clear" }, video.dataset.UUID); } return; } if (alt === "cleanup") { drawingData = []; if (video.id === "videosource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing) { session.sendMessage({ draw: "cleanup" }, UUID); } } } else if (video.id === "screensharesource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing && !session.pcs[UUID].realUUID) { session.sendMessage({ draw: "cleanup", altUUID: true }, UUID); } } } else if (session.rpcs[video.dataset.UUID] && session.rpcs[video.dataset.UUID].allowDrawing) { session.sendRequest({ draw: "cleanup" }, video.dataset.UUID); } return; } if (alt === "undo") { if (drawingData.length > 0) { // Find the last segment to remove let found = false; for (let i = drawingData.length - 1; i >= 0; i--) { if (drawingData[i] === null) { drawingData = drawingData.slice(0, i); if (found) { drawingData.push(null); break; } } else { found = true; } if (i === 0) { // Handle case when there's no null in drawingData drawingData = []; } } redrawCanvas(); // Send the undo command if (video.id === "videosource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing) { session.sendMessage({ draw: "undo" }, UUID); } } } else if (video.id === "screensharesource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing && !session.pcs[UUID].realUUID) { session.sendMessage({ draw: "undo", altUUID: true }, UUID); } } } else if (session.rpcs[video.dataset.UUID] && session.rpcs[video.dataset.UUID].allowDrawing) { session.sendRequest({ draw: "undo" }, video.dataset.UUID); } } return; } if (alt === "sync") { if (!drawingData.length) { return; } // Send the processed points if (video.id === "videosource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing) { if (!session.pcs[UUID].initialDrawing) { session.pcs[UUID].initialDrawing = true; session.sendMessage({ draw: { p: drawingData } }, UUID); } } } } else if (video.id === "screensharesource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing && !session.pcs[UUID].realUUID) { if (!session.pcs[UUID].initialDrawing2) { session.pcs[UUID].initialDrawing2 = true; session.sendMessage({ draw: { p: drawingData }, altUUID: true }, UUID); } } } } else if (session.rpcs[video.dataset.UUID] && session.rpcs[video.dataset.UUID].allowDrawing) { if (!session.rpcs[video.dataset.UUID].initialDrawing) { session.rpcs[video.dataset.UUID].initialDrawing = true; session.sendRequest({ draw: { p: drawingData } }, video.dataset.UUID); } } return; } if (lastPoints.length > 0) { var processedPoints = processPoints(lastPoints); lastPoints = []; lastSentTime = Date.now(); var dataToSend = { p: processedPoints }; drawingData.push(...processedPoints); // Store only points in drawingData // Send the processed points with timestamp if (video.id === "videosource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing) { if (session.pcs[UUID].initialDrawing) { session.sendMessage({ draw: dataToSend }, UUID); } else { session.pcs[UUID].initialDrawing = true; session.sendMessage({ draw: { p: drawingData } }, UUID); } } } } else if (video.id === "screensharesource") { for (var UUID in session.pcs) { if (session.pcs[UUID].allowDrawing && !session.pcs[UUID].realUUID) { if (session.pcs[UUID].initialDrawing2) { session.sendMessage({ draw: dataToSend, altUUID: true }, UUID); } else { session.pcs[UUID].initialDrawing2 = true; session.sendMessage({ draw: { p: drawingData }, altUUID: true }, UUID); } } } } else if (session.rpcs[video.dataset.UUID] && session.rpcs[video.dataset.UUID].allowDrawing) { if (session.rpcs[video.dataset.UUID].initialDrawing) { session.sendRequest({ draw: dataToSend }, video.dataset.UUID); } else { session.rpcs[video.dataset.UUID].initialDrawing = true; session.sendRequest({ draw: { p: drawingData } }, video.dataset.UUID); } } } } function resizeCanvas() { canvas.width = video.offsetWidth; canvas.height = video.offsetHeight; redrawCanvas(); } resizeCanvas(); window.addEventListener('resize', resizeCanvas); video.addEventListener('resize', resizeCanvas); enableDrawingBtn.addEventListener('click', () => { drawingEnabled = !drawingEnabled; enableDrawingBtn.textContent = drawingEnabled ? 'Disable Drawing' : 'Enable Drawing'; canvas.style.pointerEvents = drawingEnabled ? "auto" : "none"; }); clearDrawingBtn.addEventListener('click', () => { ctx.clearRect(0, 0, canvas.width, canvas.height); drawingData = []; lastPoints = []; sendDrawingData("clear"); }); undoDrawingBtn.addEventListener('click', () => { sendDrawingData("undo"); }); function stopDrawing() { if (isDrawing) { isDrawing = false; ctx.beginPath(); lastPoint = null; lastPoints.push(null); // Add null to mark end of path sendDrawingData(); } } canvas.addEventListener('mousedown', startDrawing); canvas.addEventListener('mousemove', draw); canvas.addEventListener('mouseup', stopDrawing); canvas.addEventListener('mouseout', stopDrawing); canvas.addEventListener('mouseleave', stopDrawing); canvas.addEventListener('mouseenter', (e) => { if (e.buttons !== 1) { // If left mouse button is not pressed stopDrawing(); } }); function createCleanupFunction() { return function cleanup() { sendDrawingData("cleanup"); window.removeEventListener('resize', resizeCanvas); video.removeEventListener('resize', resizeCanvas); if (canvas) { canvas.removeEventListener('mousedown', startDrawing); canvas.removeEventListener('mousemove', draw); canvas.removeEventListener('mouseup', stopDrawing); canvas.removeEventListener('mouseout', stopDrawing); canvas.removeEventListener('mouseleave', stopDrawing); canvas.removeEventListener('mouseenter', stopDrawing); if (canvas.parentNode) { canvas.parentNode.removeChild(canvas); } } if (buttonContainer && buttonContainer.parentNode) { buttonContainer.parentNode.removeChild(buttonContainer); } }; } function syncNewConnections() { return function syncDrawing() { setTimeout(() => { sendDrawingData("sync") }, 4000); } } video.syncDrawOnVideo = syncNewConnections(); video.clearDrawOnVideo = createCleanupFunction(); return video.clearDrawOnVideo; } catch (e) { errorlog(e); } } // END SENDING LOGIC // var cleanUp = drawOnThis(document.getElementById('videoElement')); // cleanUp(); // START RECEIVING LOGIC function receiveDrawingOnVideo(video, UUID = false) { try { if (!video || !video.container) { warnlog("no video holder; not compatible"); return; } const canvas = document.createElement('canvas'); canvas.className = "drawingCanvas"; canvas.style.pointerEvents = "none"; var receivedDrawingData = []; var container = video.parentNode; if (!container) { return; } container.appendChild(canvas); var color = 'red'; if (UUID) { color = getColorFromName(UUID); } function positionCanvas() { const videoRect = video.getBoundingClientRect(); const computedStyle = getComputedStyle(video); canvas.style.width = computedStyle.width; canvas.style.height = computedStyle.height; canvas.style.top = `${videoRect.top + window.scrollY}px`; canvas.style.left = `${videoRect.left + window.scrollX}px`; canvas.width = video.clientWidth; canvas.height = video.clientHeight; if (video.dataset.transform) { canvas.style.transform = video.dataset.transform; } redrawCanvas(); } const ctx = canvas.getContext('2d'); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.lineWidth = 3; function resizeCanvas() { canvas.width = video.offsetWidth; canvas.height = video.offsetHeight; if (video.dataset.transform) { canvas.style.transform = video.dataset.transform; } redrawCanvas(); } let observer = null; if (!(video && video.container && video.container.holder)) { positionCanvas(); window.addEventListener('resize', positionCanvas); video.addEventListener('resize', positionCanvas); observer = new ResizeObserver(positionCanvas); observer.observe(video); } else { resizeCanvas(); window.addEventListener('resize', resizeCanvas); video.addEventListener('resize', resizeCanvas); } function drawBezierCurve(points) { const [start, control1, control2, end] = points; ctx.moveTo(start.x * canvas.width, start.y * canvas.height); ctx.bezierCurveTo( control1.x * canvas.width, control1.y * canvas.height, control2.x * canvas.width, control2.y * canvas.height, end.x * canvas.width, end.y * canvas.height ); } function redrawCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = color; let isNewPath = true; for (let i = 0; i < receivedDrawingData.length; i++) { let segment = receivedDrawingData[i]; if (!segment) { // End of a path ctx.stroke(); ctx.beginPath(); isNewPath = true; continue; } switch (segment.t) { case 'b': let [p0, p1, p2, p3] = segment.p; if (isNewPath) { ctx.moveTo(p0.x * canvas.width, p0.y * canvas.height); isNewPath = false; } ctx.bezierCurveTo( p1.x * canvas.width, p1.y * canvas.height, p2.x * canvas.width, p2.y * canvas.height, p3.x * canvas.width, p3.y * canvas.height ); break; case 'q': let [q0, q1, q2] = segment.p; if (isNewPath) { ctx.moveTo(q0.x * canvas.width, q0.y * canvas.height); isNewPath = false; } ctx.quadraticCurveTo( q1.x * canvas.width, q1.y * canvas.height, q2.x * canvas.width, q2.y * canvas.height ); break; case 'l': let [l0, l1] = segment.p; if (isNewPath) { ctx.moveTo(l0.x * canvas.width, l0.y * canvas.height); isNewPath = false; } ctx.lineTo(l1.x * canvas.width, l1.y * canvas.height); break; default: // Fallback for non-curve points if (isNewPath) { ctx.moveTo(segment.x * canvas.width, segment.y * canvas.height); isNewPath = false; } else { ctx.lineTo(segment.x * canvas.width, segment.y * canvas.height); } } } ctx.stroke(); } function updateDrawing(newData) { if (newData === "clear") { receivedDrawingData = []; ctx.clearRect(0, 0, canvas.width, canvas.height); } else if (newData === "undo") { if (receivedDrawingData.length > 0) { // Find the last segment to remove let found = false; for (let i = receivedDrawingData.length - 1; i >= 0; i--) { if (receivedDrawingData[i] === null) { receivedDrawingData = receivedDrawingData.slice(0, i); if (found) { receivedDrawingData.push(null); break; } } else { found = true; } if (i === 0) { // Handle case when there's no null in receivedDrawingData receivedDrawingData = []; } } redrawCanvas(); } } else { // Handle both new and old data formats if (newData.p) { // New format receivedDrawingData.push(...newData.p); } else if (Array.isArray(newData)) { // Old format or array of points receivedDrawingData.push(...newData); } else if (typeof newData === 'object' && newData.x !== undefined && newData.y !== undefined) { // Single point receivedDrawingData.push(newData); } else { console.error("Unexpected data format:", newData); return; } redrawCanvas(); } } function clearDrawing() { receivedDrawingData = []; ctx.clearRect(0, 0, canvas.width, canvas.height); } function cleanup() { try { if (observer) { window.removeEventListener('resize', positionCanvas); video.removeEventListener('resize', positionCanvas); observer.disconnect(); } else { window.removeEventListener('resize', resizeCanvas); video.removeEventListener('resize', resizeCanvas); } clearDrawing(); } catch (e) { errorlog(e); } container.removeChild(canvas); } return { updateDrawing, clearDrawing, cleanup }; } catch (e) { errorlog(e); } } // END REMOTE DRAWING LOGIC ////////// Canvas Effects /////////////// var drawFrameMirroredActive = false; function drawFrameMirrored(mirror = true, flip = false) { if (drawFrameMirroredActive) return; drawFrameMirroredActive = true; if (session.effect == "2") { mirror = true; flip = false; } else if (session.effect == "-2") { mirror = true; flip = true; } else if (session.effect == "-1") { mirror = false; flip = true; } try { session.canvasCtx.save(); if (flip) { session.canvasCtx.scale(mirror ? -1 : 1, -1); session.canvasCtx.drawImage(session.canvasSource, 0, 0, session.canvas.width * (mirror ? -1 : 1), session.canvas.height * -1); } else { session.canvasCtx.scale(mirror ? -1 : 1, 1); session.canvasCtx.drawImage(session.canvasSource, 0, 0, session.canvas.width * (mirror ? -1 : 1), session.canvas.height); } session.canvasCtx.restore(); } catch (e) { errorlog(e); } drawFrameMirroredActive = false; } function motionDetection(video, threshold = 15, sensitivity = 75) { var targetSize = 16; if (!video.motionDetector) { video.motionDetector = {}; video.motionDetector.canvas = document.createElement("canvas"); video.motionDetector.canvas.width = targetSize; video.motionDetector.canvas.height = targetSize; try { video.motionDetector.ctx = video.motionDetector.canvas.getContext("2d", { willReadFrequently: true }); } catch (e) { video.motionDetector.ctx = video.motionDetector.canvas.getContext("2d"); } video.motionDetector.previous = []; for (var y = 0; y < targetSize; y++) { for (var x = 0; x < targetSize; x++) { video.motionDetector.previous.push(0); } } } var motionDetector = video.motionDetector; motionDetector.ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, 0, 0, targetSize, targetSize); var data = motionDetector.ctx.getImageData(0, 0, targetSize, targetSize).data; var matches = 0; for (var y = 0; y < targetSize; y++) { for (var x = 0; x < targetSize; x++) { var pos = y * targetSize + x; var pos2 = pos * 3; var value = data[pos2] + data[pos2 + 1] + data[pos2 + 2]; // convert to to greyscale if (motionDetector.previous[pos] && Math.abs(motionDetector.previous[pos] - value) > sensitivity) { matches += 1; } motionDetector.previous[pos] = value; } } if (matches >= threshold) { log("MOTION DETECTED: " + matches); if (session.motionSwitch && window.obsstudio && window.obsstudio["setCurrentScene"]) { if (!changeSceneEnabled) { // the bit cut scene change is already active. if (session.obsState && session.obsState.details && session.obsState.details.thisScene && session.obsState.details.currentScene) { if (session.obsState.details.thisScene !== session.obsState.details.currentScene.name) { // don't trigger it multiple times; makes it hard to prep next scene window.obsstudio["setCurrentScene"](session.obsState.details.thisScene); } } } } pokeIframeAPI("motion-detected", true, video.dataset.UUID || true); if (session.infocus !== (video.dataset.UUID || true)) { if (!session.layout) { session.infocus = video.dataset.UUID || true; updateMixer(); } } if (session.motionRecord) { if (!session.motionRecordTimeout) { session.motionRecordTimeout = setTimeout(function () { session.motionRecordTimeout = null; }, 1000); saveVideoFrameToDisk(video); } } } } let currentOscillatorId = 0; function clearOscillator() { const thisOscillatorId = ++currentOscillatorId; if (session.canvasOscillator) { session.canvasOscillator.stop(); session.canvasOscillator.disconnect(); session.canvasOscillator = null; } if (session.canvasSilence) { session.canvasSilence.disconnect(); session.canvasSilence = null; } if (session.stats) { delete session.stats.canvas_draw_rate; } } function setupOscillator(callbackFunction, frameRate = 30, timeOne = null, thisOscillatorId = null) { if (!thisOscillatorId) { thisOscillatorId = ++currentOscillatorId; } else if (currentOscillatorId !== thisOscillatorId) { return false; } if (session.canvasOscillator) { session.canvasOscillator.stop(); session.canvasOscillator.disconnect(); session.canvasOscillator = null; } if (session.canvasSilence) { session.canvasSilence.disconnect(); session.canvasSilence = null; } if (!session.audioCtx) { session.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } let oscillator = session.audioCtx.createOscillator(); session.canvasOscillator = oscillator; let silence = session.audioCtx.createGain(); session.canvasSilence = silence; silence.gain.value = 0; oscillator.connect(silence); silence.connect(session.audioCtx.destination); if (!timeOne) { timeOne = session.audioCtx.currentTime; } oscillator.onended = () => { oscillator.disconnect(); silence.disconnect(); if (currentOscillatorId === thisOscillatorId) { let timeTwo = session.audioCtx.currentTime; if (typeof callbackFunction === "function") { callbackFunction(); } if (session.stats) { let actualRate = 1 / (timeTwo - timeOne); // Calculate the actual FPS session.stats.canvas_draw_rate = parseInt(actualRate * 10) / 10; } setupOscillator(callbackFunction, frameRate, timeTwo, thisOscillatorId); } }; oscillator.start(timeOne); oscillator.stop(timeOne + 1 / frameRate); return function (check = false) { if (check && currentOscillatorId !== thisOscillatorId) { clearOscillator(); return true; } else if (check) { return false; } if (currentOscillatorId === thisOscillatorId) { clearOscillator(); // clear only if needs to be cleared } return false; }; } function setupCanvas() { clearOscillator(); log("SETUP CANVAS"); if (session.canvas === null) { session.canvas = document.createElement("canvas"); session.canvas.width = 512; session.canvas.height = 288; try { session.canvasCtx = session.canvas.getContext("2d", { alpha: true, willReadFrequently: true }); } catch (e) { errorlog(e); session.canvasCtx = session.canvas.getContext("2d"); } session.canvasCtx.fillStyle = "black"; session.canvasCtx.fillRect(0, 0, 512, 288); session.canvasSource = createVideoElement(); session.canvasSource.autoplay = true; session.canvasSource.srcObject = createMediaStream(); session.canvasSource.id = "effectsVideoSource"; if (session.canvasSource.srcObject.getVideoTracks().length) { session.canvasSource.width = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().width || 1280; session.canvasSource.height = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().height || 720; } if (iOS || iPad) { session.canvasSource.style.position = "absolute"; session.canvasSource.style.left = "0"; session.canvasSource.style.top = "0"; session.canvasSource.controls = session.showControls || false; session.canvasSource.style.maxWidth = "1px"; session.canvasSource.style.maxHeight = "1px"; session.canvasSource.setAttribute("playsinline", ""); document.body.appendChild(session.canvasSource); //session.canvasSource.play(); } } else { session.canvasSource.srcObject.getVideoTracks().forEach(function (trk) { session.canvasSource.srcObject.removeTrack(trk); }); } } function applyEffects(track) { // video only please. do not touch audio. Run update Render Outpipe () instead of this directly. log("applyEffects()"); if (session.effect == "0" || !session.effect) { // auto align face return track; } else if (session.effect == "1") { // auto align face setupCanvas(); session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); drawFace(); } else if (session.effect == "7") { // manual zoom setupCanvas(); session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); digitalZoom(true); } else if (["8", "overlay"].includes(session.effect)) { // manual zoom setupCanvas(); session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); simpleDraw(); } else if (["-2", "-1", "2"].includes(session.effect)) { // mirror video at a canvas level setupCanvas(); session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); setupOscillator(drawFrameMirrored, track.getSettings().frameRate || 30); } else if (session.effect == "3" || session.effect == "4" || session.effect == "5") { // blur & greenscreen (low and high) setupCanvas(); session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); TFLiteWorker(); } else if (session.effect == "6") { setupCanvas(); session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); if (session.canvasSource.readyState >= 3) { mainMeshMask(); } else { session.canvasSource.onloadeddata = mainMeshMask; } } else if (session.effect == "13") { // ha, no way to turn it off once it's started, except to change cameras? not sure. try { track .applyConstraints({ backgroundBlur: true }) .then(() => { const settings = track.getSettings(); log(`Background blur is ${settings.backgroundBlur ? "ON" : "OFF"}`); }) .catch(errorlog); } catch (e) { errorlog(e); } return track; } else if (session.effect == "14" || session.effect == "15") { // chroma key effects setupCanvas(); session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); chromaKey(); } else { if (session.canvasource) { session.canvasSource.srcObject.getVideoTracks().forEach(function (trk) { session.canvasSource.srcObject.removeTrack(trk); }); } else { session.canvasSource = createVideoElement(); session.canvasSource.srcObject = createMediaStream(); } session.canvasSource.autoplay = true; session.canvasSource.id = "effectsVideoSource"; session.canvasSource.srcObject.addTrack(track); session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; session.canvas.width = 512; session.canvas.height = 288; if (iOS || iPad) { session.canvasSource.style.position = "absolute"; session.canvasSource.style.left = "0"; session.canvasSource.style.top = "0"; session.canvasSource.style.maxWidth = "1px"; session.canvasSource.style.maxHeight = "1px"; session.canvasSource.controls = session.showControls || false; session.canvasSource.setAttribute("playsinline", ""); document.body.appendChild(session.canvasSource); //session.canvasSource.play(); } try { JEELIZFACEFILTER.destroy(); } catch (e) { } if (session.canvasWebGL) { session.canvasWebGL.remove(); session.canvasWebGL = null; } session.canvasWebGL = document.createElement("canvas"); session.canvasWebGL.width = track.getSettings().width || 1280; session.canvasWebGL.height = track.getSettings().height || 720; session.canvasWebGL.id = "effectsCanvasTarget"; session.canvasWebGL.style.position = "fixed"; session.canvasWebGL.style.top = "-9999px"; session.canvasWebGL.style.left = "-9999px"; document.body.appendChild(session.canvasWebGL); loadEffect(session.effect); return session.canvasWebGL.captureStream().getVideoTracks()[0]; } try { return session.canvas.captureStream().getVideoTracks()[0]; } catch (e) { if (!session.cleanOutput) { warnUser(getTranslation("not-clean-session"), false, false); } return track; } } function dataURItoArraybuffer(dataURI) { var byteString = atob(dataURI.split(",")[1]); var mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0]; var ab = new ArrayBuffer(byteString.length); var ia = new Uint8Array(ab); for (var i = 0; i < byteString.length; i++) { ia[i] = byteString.charCodeAt(i); } return ab; } var makeImagesActive = null; async function makeImages(startup = false) { if (!session.webp) { return; } else if (makeImagesActive === true) { return; } else if (!session.videoElement) { return; } else if (session.videoMuted) { return; } var stream = session.getLocalStream(); if (!stream || !stream.getVideoTracks().length) { errorlog("No video element; can't make images for webp mode"); if (makeImagesActive) { var exit = true; for (var i in session.pcs) { if (session.pcs[i].allowWebp) { // just for safety, to avoid a race condition, double check that it's still not active. exit = false; } } if (exit) { makeImagesActive = false; return; } if (session.webPcanvas.makeImagesTimeout) { session.webPcanvas.makeImagesTimeout.onended = null; session.webPcanvas.makeImagesTimeout = null; } var osc = session.webPcanvas.aCtx.createOscillator(); osc.connect(session.webPcanvas.silence); session.webPcanvas.makeImagesTimeout = osc; osc.start(0); osc.onended = function () { this.disconnect(); makeImages(); }; osc.stop(session.webPcanvas.aCtx.currentTime + 0.5); } return; } if (makeImagesActive === null) { makeImagesActive = true; session.webPcanvas = document.createElement("canvas"); session.webPcanvas.makeImagesTimeout = null; session.webPcanvas.aCtx = new AudioContext(); session.webPcanvas.silence = session.webPcanvas.aCtx.createGain(); session.webPcanvas.silence.gain.value = 0; session.webPcanvas.silence.connect(session.webPcanvas.aCtx.destination); session.webPcanvas.nowTime = new Date().getTime(); session.webPcanvasCtx = session.webPcanvas.getContext("2d", { alpha: session.alpha }); } else { if (session.webPcanvas.makeImagesTimeout) { session.webPcanvas.makeImagesTimeout.onended = null; } makeImagesActive = true; } if (startup) { var exit = true; for (var i in session.pcs) { if (session.pcs[i].allowWebp) { // just for safety, to avoid a race condition, double check that it's still not active. exit = false; } } if (exit) { makeImagesActive = false; return; } log("MAKE IMAGES STARTING?"); } var track = stream.getVideoTracks()[0]; var settings = track.getSettings(); try { var broadcasting = false; var arrayBuffer = false; var width = 480; var height = 270; var timeout = settings.frameRate > 24 ? 1000 / 24 : 1000 / settings.frameRate; // the answer to everything. var quality = 0.66; if (session.webPquality === 0) { width = 1920; height = 1080; timeout = settings.frameRate > 30 ? 1000 / 30 : 1000 / settings.frameRate; } else if (session.webPquality === 1) { width = 1280; height = 720; timeout = settings.frameRate > 30 ? 1000 / 30 : 1000 / settings.frameRate; } else if (session.webPquality === 2) { width = 960; height = 540; timeout = settings.frameRate > 30 ? 1000 / 30 : 1000 / settings.frameRate; } else if (session.webPquality === 3) { width = 853; height = 480; timeout = settings.frameRate > 30 ? 1000 / 30 : 1000 / settings.frameRate; } else if (session.webPquality === 4) { width = 640; height = 360; timeout = settings.frameRate > 30 ? 1000 / 30 : 1000 / settings.frameRate; } else if (session.webPquality === 5) { width = 480; height = 270; timeout = settings.frameRate > 30 ? 1000 / 30 : 1000 / settings.frameRate; } else if (session.webPquality === 6) { width = 480; height = 270; timeout = 1000 / 15; } else if (session.webPquality === 7) { width = 480; height = 270; timeout = 1000 / 5; } else if (session.webPquality === 8) { width = 480; height = 270; timeout = 1000 / 3; } else if (session.webPquality === 9) { width = 640; height = 360; timeout = 1000; } session.webPcanvas.timeout = timeout; session.webPcanvas.quality = quality; if (settings.width < width) { session.webPcanvas.width = settings.width; session.webPcanvasCtx.width = settings.width; } else { session.webPcanvas.width = width; session.webPcanvasCtx.width = width; } if (settings.height < height) { session.webPcanvas.height = settings.height; session.webPcanvasCtx.height = settings.height; } else { session.webPcanvas.height = height; session.webPcanvasCtx.height = height; } var ar = session.webPcanvas.width / session.webPcanvas.height; if (session.forceAspectRatio && session.forceAspectRatio > ar) { session.webPcanvas.width = session.webPcanvas.height * session.forceAspectRatio; } else if (session.forceAspectRatio && session.forceAspectRatio <= ar) { session.webPcanvas.height = session.webPcanvas.width * session.forceAspectRatio; } for (var i in session.pcs) { try { if (session.pcs[i].allowWebp) { // only publish to those seeking this stream broadcasting = true; if (!session.pcs[i].sendChannel.bufferedAmount) { try { if (!arrayBuffer) { session.webPcanvasCtx.drawImage(session.videoElement, 0, 0, session.webPcanvas.width, session.webPcanvas.height); arrayBuffer = dataURItoArraybuffer(session.webPcanvas.toDataURL("image/" + session.webp, session.webPcanvas.quality)); } session.pcs[i].sendChannel.send(arrayBuffer); } catch (e) { errorlog(e); } } } } catch (e) { } } } catch (e) { errorlog(e); makeImagesActive = false; return; } makeImagesActive = false; session.webPcanvas.lastTime = session.webPcanvas.nowTime; session.webPcanvas.nowTime = new Date().getTime(); if (broadcasting) { // wait a bit of time, now that we sent a frame out. var time = session.webPcanvas.timeout - (session.webPcanvas.nowTime - session.webPcanvas.lastTime); if (time <= 0) { var osc = session.webPcanvas.aCtx.createOscillator(); osc.connect(session.webPcanvas.silence); session.webPcanvas.makeImagesTimeout = osc; osc.start(0); osc.onended = function () { this.disconnect(); makeImages(); }; osc.stop(session.webPcanvas.aCtx.currentTime); //session.webPcanvas.makeImagesTimeout = setTimeout(function(){makeImages();},0); } else { var osc = session.webPcanvas.aCtx.createOscillator(); osc.connect(session.webPcanvas.silence); session.webPcanvas.makeImagesTimeout = osc; osc.start(0); osc.onended = function () { this.disconnect(); makeImages(); }; osc.stop(session.webPcanvas.aCtx.currentTime + time / 1000); //session.webPcanvas.makeImagesTimeout = setTimeout(function(){makeImages();},time); } } else { // just double check that we shoulnd't be broadcasting. for (var i in session.pcs) { if (session.pcs[i].allowWebp) { var osc = session.webPcanvas.aCtx.createOscillator(); osc.connect(session.webPcanvas.silence); session.webPcanvas.makeImagesTimeout = osc; osc.start(0); osc.onended = function () { this.disconnect(); makeImages(); }; osc.stop(session.webPcanvas.aCtx.currentTime + time / 1000); //session.webPcanvas.makeImagesTimeout = setTimeout(function(){makeImages();},0); return; } } log("Stopping webP broadcast."); } } var updateUserListTimeout = null; var updateUserListActive = false; function updateUserList() { if (session.showList === true) { // continue } else if (session.showList !== true && (session.cleanOutput || session.scene !== false || !session.roomid || session.director || session.showList === false)) { return; } clearInterval(updateUserListTimeout); updateUserListTimeout = setTimeout(function () { if (updateUserListActive) { return; } updateUserListActive = true; try { var added = false; getById("userList").innerHTML = ""; for (var UUID in session.rpcs) { if ((session.rpcs[UUID].videoElement && session.rpcs[UUID].streamSrc && session.rpcs[UUID].streamSrc.getTracks().length) || session.rpcs[UUID].canvas || session.rpcs[UUID].imageElement) { if (session.rpcs[UUID].videoElement && document.body.contains(session.rpcs[UUID].videoElement)) { continue; } else if (session.rpcs[UUID].canvas && document.body.contains(session.rpcs[UUID].canvas)) { continue; } else if (session.rpcs[UUID].imageElement && document.body.contains(session.rpcs[UUID].imageElement)) { continue; } } if (session.rpcs[UUID].virtualHangup) { // end of screen share / director ? continue; } if (session.rpcs[UUID].videoMuted || (!session.rpcs[UUID].imageElement && !session.rpcs[UUID].canvas) || (session.infocus && session.infocus !== UUID) || (!session.rpcs[UUID].defaultSpeaker && session.activeSpeaker)) { if (session.directorList.indexOf(UUID) >= 0) { if (!session.rpcs[UUID].streamSrc) { // director not active yet, so we won't bother showing it. continue; } } var insert = document.createElement("div"); if (session.rpcs[UUID].label) { insert.innerText = session.rpcs[UUID].label.split("\\n")[0] + ""; } else if (session.directorList.indexOf(UUID) >= 0) { miniTranslate(insert, "director"); //insert.innerHTML = getTranslation("director"); } else { miniTranslate(insert, "unknown-user"); //insert.innerHTML = getTranslation("unknown-user"); } try { insert.dataset.UUID = UUID; insert.dataset.sid = session.rpcs[UUID].streamID; insert.title = "Stream ID: " + session.rpcs[UUID].streamID; insert.addEventListener("click", function (e) { // show stats of video if double clicked log("clicked"); try { if (session.statsMenu !== false) { var uid = e.currentTarget.dataset.UUID; if (e.ctrlKey || e.metaKey) { e.preventDefault(); if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } e.stopPropagation(); return false; } } } catch (e) { errorlog(e); } }); if (session.statsMenu) { if ("stats" in session.rpcs[UUID]) { if (getById("menuStatsBox")) { clearInterval(getById("menuStatsBox").interval); getById("menuStatsBox").remove(); } var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, UUID); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, UUID); } } } catch (e) { } getById("userList").appendChild(insert); if (session.rpcs[UUID].videoElement) { var volumeBarInsert = document.createElement("input"); volumeBarInsert.type = "range"; volumeBarInsert.className = "hidden"; volumeBarInsert.setAttribute("orient", "vertical"); volumeBarInsert.max = 100; volumeBarInsert.min = 0; volumeBarInsert.step = 1; volumeBarInsert.dataset.UUID = UUID; volumeBarInsert.setAttribute("value", parseFloat(session.rpcs[UUID].videoElement.volume) * 100); volumeBarInsert.title = volumeBarInsert.value + "%"; volumeBarInsert.oninput = function (e) { session.rpcs[this.dataset.UUID].videoElement.volume = parseInt(this.value) / 100 || 0; if (!session.rpcs[this.dataset.UUID].videoElement.volume) { this.parentNode.classList.add("red"); } else { this.parentNode.classList.remove("red"); } }; volumeBarInsert.onchange = function (e) { this.parentNode.querySelector("input").classList.toggle("hidden"); }; var volumeButton = document.createElement("i"); volumeButton.className = "las la-volume-up"; volumeButton.onclick = function () { this.parentNode.querySelector("input").classList.toggle("hidden"); this.parentNode.volumeBarInsert.focus(); }; var volumeInsert = document.createElement("div"); volumeInsert.className = "volume-control-userlist"; volumeInsert.volumeBarInsert = volumeBarInsert; insert.appendChild(volumeInsert); volumeInsert.appendChild(volumeBarInsert); volumeInsert.appendChild(volumeButton); } if (session.rpcs[UUID].remoteMuteState || !session.rpcs[UUID].streamSrc) { var muteInsert = document.createElement("div"); muteInsert.className = "video-mute-state-userlist"; muteInsert.innerHTML = ''; insert.appendChild(muteInsert); } else if (session.rpcs[UUID].voiceMeter) { insert.appendChild(session.rpcs[UUID].voiceMeter); } //getById("userList").innerHTML += "
"; added = true; } } if (!added) { getById("connectUsers").style.display = "none"; } else { getById("connectUsers").style.display = "block"; } } catch (e) { } updateUserListActive = false; }, 200); } function resetCanvas() { log("resetCanvas();"); if (!session.streamSrc) { checkBasicStreamsExist(); return; } session.streamSrc.getVideoTracks().forEach(track => { session.canvasSource.width = track.getSettings().width || 1280; session.canvasSource.height = track.getSettings().height || 720; }); } function initEffectsImage() { if (!session.effectsImage) { if (!session.selectedImage_contents) { session.selectedImage_contents = getById("selectImage_contents"); } if (session.selectedImage_contents.querySelector("img")) { session.effectsImage = session.selectedImage_contents.querySelector("img"); session.effectsImage.classList.add("selectedContentEffectsImage"); } else if (session.defaultBackgroundImages && session.defaultBackgroundImages.length) { session.effectsImage = document.createElement("img"); session.effectsImage.onload = function () { URL.revokeObjectURL(session.effectsImage.src); // no longer needed, free memory }; session.effectsImage.src = session.defaultBackgroundImages[0]; session.effectsImage.classList.add("selectedContentEffectsImage"); } else { session.effectsImage = document.createElement("img"); session.effectsImage.onload = function () { URL.revokeObjectURL(session.effectsImage.src); // no longer needed, free memory }; session.effectsImage.src = "./media/bg_sample.webp"; } } } var LaunchTFWorkerCallback = false; function TFLiteWorker() { if (session.tfliteModule == false) { LaunchTFWorkerCallback = true; return; } if (TFLITELOADING) { LaunchTFWorkerCallback = true; return; } LaunchTFWorkerCallback = false; log("TFLiteWorker() called"); initEffectsImage(); //if (session.tfliteModule.looping){return;} const segmentationWidth = 256; const segmentationHeight = 144; const segmentationPixelCount = segmentationWidth * segmentationHeight; const inputMemoryOffset = session.tfliteModule._getInputMemoryOffset() / 4; const outputMemoryOffset = session.tfliteModule._getOutputMemoryOffset() / 4; const segmentationMask = new ImageData(segmentationWidth, segmentationHeight); const segmentationMaskCanvas = document.createElement("canvas"); segmentationMaskCanvas.width = segmentationWidth; segmentationMaskCanvas.height = segmentationHeight; const segmentationMaskCtx = segmentationMaskCanvas.getContext("2d", { alpha: true, willReadFrequently: true }); session.tfliteModule.nowTime = new Date().getTime(); session.tfliteModule.offsetTime = 0; var slow = 0; var slower = false; async function process() { if (!(session.effect == "3" || session.effect == "4" || session.effect == "5")) { //session.tfliteModule.looping=false; errorlog("shouldn't happen"); return; } if (session.tfliteModule.activelyProcessing) { return; } session.tfliteModule.activelyProcessing = true; if (session.mobile) { if (screenWidth !== window.innerWidth) { screenWidth = window.innerWidth; //session.tfliteModule.looping=false; session.tfliteModule.activelyProcessing = false; setTimeout(function () { updateRenderOutpipe(); }, 200); return; } } try { segmentationMaskCtx.filter = "none"; segmentationMaskCtx.drawImage(session.canvasSource, 0, 0, session.canvasSource.width, session.canvasSource.height, 0, 0, segmentationWidth, segmentationHeight); const imageData = segmentationMaskCtx.getImageData(0, 0, segmentationWidth, segmentationHeight); for (let i = 0; i < segmentationPixelCount; i++) { session.tfliteModule.HEAPF32[inputMemoryOffset + i * 3] = imageData.data[i * 4] / 255; session.tfliteModule.HEAPF32[inputMemoryOffset + i * 3 + 1] = imageData.data[i * 4 + 1] / 255; session.tfliteModule.HEAPF32[inputMemoryOffset + i * 3 + 2] = imageData.data[i * 4 + 2] / 255; } session.tfliteModule._runInference(); for (let i = 0; i < segmentationPixelCount; i++) { const background = session.tfliteModule.HEAPF32[outputMemoryOffset + i * 2]; const person = session.tfliteModule.HEAPF32[outputMemoryOffset + i * 2 + 1]; const shift = Math.max(background, person); const backgroundExp = Math.exp(background - shift); const personExp = Math.exp(person - shift); segmentationMask.data[i * 4 + 3] = Math.min(Math.pow((255 * personExp) / (backgroundExp + personExp), 1.5) - 10, 255); // softmax } segmentationMaskCtx.putImageData(segmentationMask, 0, 0); session.canvasCtx.globalCompositeOperation = "copy"; if ((session.mobile && !session.flagship) || slower) { session.canvasCtx.filter = "blur(4px)"; } else { session.canvasCtx.filter = "blur(8px)"; } session.canvasCtx.drawImage(segmentationMaskCanvas, 0, 0, segmentationWidth, segmentationHeight, 0, 0, session.canvasSource.width, session.canvasSource.height); session.canvasCtx.globalCompositeOperation = "source-in"; session.canvasCtx.filter = "none"; session.canvasCtx.drawImage(session.canvasSource, 0, 0); session.canvasCtx.globalCompositeOperation = "destination-over"; if (session.effect == "4") { // greenscreen session.canvasCtx.filter = "none"; session.canvasCtx.fillStyle = "#0F0"; session.canvasCtx.fillRect(0, 0, session.canvas.width, session.canvas.height); } else if (session.effect == "5") { session.canvasCtx.filter = "none"; if (session.effectsImage.complete) { try { session.canvasCtx.drawImage(session.effectsImage, 0, 0, session.canvas.width, session.canvas.height); } catch (e) { } } } else if (session.effect == "3") { // BLUR if (session.effectValue) { session.canvasCtx.filter = "blur(" + parseInt(session.effectValue) * 2 + "px)"; } else { session.canvasCtx.filter = "blur(4px)"; // Does not work on Safari } session.canvasCtx.drawImage(session.canvasSource, 0, 0); session.canvasCtx.filter = "none"; } else { session.tfliteModule.activelyProcessing = false; //session.tfliteModule.looping=false; return; } ////// } catch (e) { errorlog(e); session.tfliteModule.activelyProcessing = false; //session.tfliteModule.looping=false; return; } session.tfliteModule.lastTime = session.tfliteModule.nowTime; session.tfliteModule.nowTime = new Date().getTime(); var time = 30 - (session.tfliteModule.nowTime - session.tfliteModule.lastTime || 0); time = time + (session.tfliteModule.offsetTime || 0); session.tfliteModule.activelyProcessing = false; slow -= 1; if (time <= 0) { if (time < -40) { slow += 1; if (slow > 100) { slower = true; } } session.tfliteModule.offsetTime = 0; } else { slow -= 2; session.tfliteModule.offsetTime = time || 0; } } async function processiOS() { if (!(session.effect == "3" || session.effect == "4" || session.effect == "5")) { errorlog("shouldn't happen"); //session.tfliteModule.looping=false; return; } if (session.tfliteModule.activelyProcessing) { return; } session.tfliteModule.activelyProcessing = true; if (screenWidth !== window.innerWidth) { screenWidth = window.innerWidth; setTimeout(function () { updateRenderOutpipe(); }, 200); //session.tfliteModule.looping=false; session.tfliteModule.activelyProcessing = false; return; } try { segmentationMaskCtx.drawImage(session.canvasSource, 0, 0, session.canvasSource.width, session.canvasSource.height, 0, 0, segmentationWidth, segmentationHeight); var imageData = segmentationMaskCtx.getImageData(0, 0, segmentationWidth, segmentationHeight); for (let i = 0; i < segmentationPixelCount; i++) { session.tfliteModule.HEAPF32[inputMemoryOffset + i * 3] = imageData.data[i * 4] / 255; session.tfliteModule.HEAPF32[inputMemoryOffset + i * 3 + 1] = imageData.data[i * 4 + 1] / 255; session.tfliteModule.HEAPF32[inputMemoryOffset + i * 3 + 2] = imageData.data[i * 4 + 2] / 255; } session.tfliteModule._runInference(); for (let i = 0; i < segmentationPixelCount; i++) { const background = session.tfliteModule.HEAPF32[outputMemoryOffset + i * 2]; const person = session.tfliteModule.HEAPF32[outputMemoryOffset + i * 2 + 1]; const shift = Math.max(background, person); const backgroundExp = Math.exp(background - shift); const personExp = Math.exp(person - shift); segmentationMask.data[i * 4 + 3] = 255 - (255 * personExp) / (backgroundExp + personExp); // softmax } segmentationMaskCtx.putImageData(segmentationMask, 0, 0); session.canvasCtx.globalCompositeOperation = "copy"; session.canvasCtx.drawImage(session.canvasSource, 0, 0); session.canvasCtx.globalCompositeOperation = "destination-out"; session.canvasCtx.drawImage(segmentationMaskCanvas, 0, 0, segmentationWidth, segmentationHeight, 0, 0, session.canvasSource.width, session.canvasSource.height); session.canvasCtx.globalCompositeOperation = "destination-over"; if (session.effect == "4") { // greenscreen session.canvasCtx.fillStyle = "#0F0"; session.canvasCtx.fillRect(0, 0, session.canvas.width, session.canvas.height); } else if (session.effect == "5") { if (session.effectsImage.complete) { try { session.canvasCtx.drawImage(session.effectsImage, 0, 0, session.canvas.width, session.canvas.height); } catch (e) { } } } else if (session.effect == "3") { // BLUR const width = canvasBG.width; const height = canvasBG.height; ctxBG.drawImage(session.canvasSource, 0, 0, width, height); imageData = ctxBG.getImageData(0, 0, width, height); const { data } = imageData; // THE BELOW BLUR CODE polyfil is by David Enke // MIT License: Copyright (c) 2019 // https://github.com/steveseguin/context-filter-polyfill/blob/master/src/filters/blur.filter.ts const wm = width - 1; const hm = height - 1; const rad1 = amount + 1; const r = []; const g = []; const b = []; //const a = []; const vmin = []; const vmax = []; let iterations = 3; // 1 - 3 let p, p1, p2; while (iterations-- > 0) { let yw = 0; let yi = 0; for (let y = 0; y < height; y++) { let rsum = data[yw] * rad1; let gsum = data[yw + 1] * rad1; let bsum = data[yw + 2] * rad1; for (let i = 1; i <= amount; i++) { p = yw + ((i > wm ? wm : i) << 2); rsum += data[p++]; gsum += data[p++]; bsum += data[p++]; } for (let x = 0; x < width; x++) { r[yi] = rsum; g[yi] = gsum; b[yi] = bsum; if (y === 0) { vmin[x] = ((p = x + rad1) < wm ? p : wm) << 2; vmax[x] = (p = x - amount) > 0 ? p << 2 : 0; } p1 = yw + vmin[x]; p2 = yw + vmax[x]; rsum += data[p1++] - data[p2++]; gsum += data[p1++] - data[p2++]; bsum += data[p1++] - data[p2++]; yi++; } yw += width << 2; } for (let x = 0; x < width; x++) { let yp = x; let rsum = r[yp] * rad1; let gsum = g[yp] * rad1; let bsum = b[yp] * rad1; for (let i = 1; i <= amount; i++) { yp += i > hm ? 0 : width; rsum += r[yp]; gsum += g[yp]; bsum += b[yp]; } yi = x << 2; for (let y = 0; y < height; y++) { data[yi] = (rsum * mulSum) >>> shgSum; data[yi + 1] = (gsum * mulSum) >>> shgSum; data[yi + 2] = (bsum * mulSum) >>> shgSum; if (x === 0) { vmin[y] = ((p = y + rad1) < hm ? p : hm) * width; vmax[y] = (p = y - amount) > 0 ? p * width : 0; } p1 = x + vmin[y]; p2 = x + vmax[y]; rsum += r[p1] - r[p2]; gsum += g[p1] - g[p2]; bsum += b[p1] - b[p2]; yi += width << 2; } } } ////////////// END OF BLUR CODE - MIT LICENCED. ctxBG.putImageData(imageData, 0, 0); session.canvasCtx.drawImage(canvasBG, 0, 0, width, height, 0, 0, session.canvas.width, session.canvas.height); } else { session.tfliteModule.activelyProcessing = false; //session.tfliteModule.looping=false; return; } } catch (e) { session.tfliteModule.activelyProcessing = false; //session.tfliteModule.looping=false; errorlog(e); return; } session.tfliteModule.lastTime = session.tfliteModule.nowTime; session.tfliteModule.nowTime = new Date().getTime(); var time = 30 - (session.tfliteModule.nowTime - session.tfliteModule.lastTime || 0); time = time + (session.tfliteModule.offsetTime || 0); slow -= 1; if (time <= 0) { if (time < -40) { slow += 1; if (slow > 100) { slower = true; } } session.tfliteModule.offsetTime = 0; } else { slow -= 2; session.tfliteModule.offsetTime = time || 0; } session.tfliteModule.activelyProcessing = false; } //session.tfliteModule.looping=true; var screenWidth = window.innerWidth; if (iOS || iPad || SafariVersion) { var canvasBG = document.createElement("canvas"); var ctxBG = canvasBG.getContext("2d", { alpha: false }); var amount = 1.0; var mulTable = [1, 57, 41, 21, 203, 34, 97, 73, 227, 91, 149, 62, 105, 45, 39, 137, 241, 107, 3, 173, 39, 71, 65, 238, 219, 101, 187, 87, 81, 151, 141, 133, 249, 117, 221, 209, 197, 187, 177, 169, 5, 153, 73, 139, 133, 127, 243, 233, 223, 107, 103, 99, 191, 23, 177, 171, 165, 159, 77, 149, 9, 139, 135, 131, 253, 245, 119, 231, 224, 109, 211, 103, 25, 195, 189, 23, 45, 175, 171, 83, 81, 79, 155, 151, 147, 9, 141, 137, 67, 131, 129, 251, 123, 30, 235, 115, 113, 221, 217, 53, 13, 51, 50, 49, 193, 189, 185, 91, 179, 175, 43, 169, 83, 163, 5, 79, 155, 19, 75, 147, 145, 143, 35, 69, 17, 67, 33, 65, 255, 251, 247, 243, 239, 59, 29, 229, 113, 111, 219, 27, 213, 105, 207, 51, 201, 199, 49, 193, 191, 47, 93, 183, 181, 179, 11, 87, 43, 85, 167, 165, 163, 161, 159, 157, 155, 77, 19, 75, 37, 73, 145, 143, 141, 35, 138, 137, 135, 67, 33, 131, 129, 255, 63, 250, 247, 61, 121, 239, 237, 117, 29, 229, 227, 225, 111, 55, 109, 216, 213, 211, 209, 207, 205, 203, 201, 199, 197, 195, 193, 48, 190, 47, 93, 185, 183, 181, 179, 178, 176, 175, 173, 171, 85, 21, 167, 165, 41, 163, 161, 5, 79, 157, 78, 154, 153, 19, 75, 149, 74, 147, 73, 144, 143, 71, 141, 140, 139, 137, 17, 135, 134, 133, 66, 131, 65, 129, 1]; var mulSum = mulTable[amount]; var shgTable = [0, 9, 10, 10, 14, 12, 14, 14, 16, 15, 16, 15, 16, 15, 15, 17, 18, 17, 12, 18, 16, 17, 17, 19, 19, 18, 19, 18, 18, 19, 19, 19, 20, 19, 20, 20, 20, 20, 20, 20, 15, 20, 19, 20, 20, 20, 21, 21, 21, 20, 20, 20, 21, 18, 21, 21, 21, 21, 20, 21, 17, 21, 21, 21, 22, 22, 21, 22, 22, 21, 22, 21, 19, 22, 22, 19, 20, 22, 22, 21, 21, 21, 22, 22, 22, 18, 22, 22, 21, 22, 22, 23, 22, 20, 23, 22, 22, 23, 23, 21, 19, 21, 21, 21, 23, 23, 23, 22, 23, 23, 21, 23, 22, 23, 18, 22, 23, 20, 22, 23, 23, 23, 21, 22, 20, 22, 21, 22, 24, 24, 24, 24, 24, 22, 21, 24, 23, 23, 24, 21, 24, 23, 24, 22, 24, 24, 22, 24, 24, 22, 23, 24, 24, 24, 20, 23, 22, 23, 24, 24, 24, 24, 24, 24, 24, 23, 21, 23, 22, 23, 24, 24, 24, 22, 24, 24, 24, 23, 22, 24, 24, 25, 23, 25, 25, 23, 24, 25, 25, 24, 22, 25, 25, 25, 24, 23, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 23, 25, 23, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, 24, 22, 25, 25, 23, 25, 25, 20, 24, 25, 24, 25, 25, 22, 24, 25, 24, 25, 24, 25, 25, 24, 25, 25, 25, 25, 22, 25, 25, 25, 24, 25, 24, 25, 18]; var shgSum = shgTable[amount]; log("session.canvas: " + session.canvas.width + "x" + session.canvas.height); canvasBG.width = parseInt(session.canvas.width / 12); canvasBG.height = parseInt(session.canvas.height / 12); ctxBG.width = canvasBG.width; ctxBG.height = canvasBG.height; try { session.tfliteModule.stopOscillator = setupOscillator(processiOS, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { errorlog(e); session.tfliteModule.stopOscillator = setupOscillator(processiOS, 30); } } else { try { session.tfliteModule.stopOscillator = setupOscillator(process, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { errorlog(e); session.tfliteModule.stopOscillator = setupOscillator(process, 30); } } } var insertableStreamWorker = null; function setupSenderTransform(sender, UUID = false) { if (!insertableStreamWorker) { insertableStreamWorker = new Worker("./insertableStreamWorker.js", { name: "Insertable Stream worker" }); insertableStreamWorker.onmessage = event => { if (event.data === "insertableStreamWorkerLoaded") { if (session.encodedInsertableStreams == "e2ee") { if (session.password) { insertableStreamWorker.postMessage({ cryptoPhrase: session.password + session.salt + "aDdedSaLt123" }); // salt ⚔️ rainbow } else { insertableStreamWorker.postMessage({ cryptoKey: "aabbccddeeff00112233445566778899" }); } } else if (session.encodedInsertableStreams == "lyra") { insertableStreamWorker.postMessage({ lyraCodecModule: session.lyraCodecModule }); } } else { console.log(event.data); } }; } try { const senderStreams = sender.createEncodedStreams(); const { readable, writable } = senderStreams; let operation = "pass"; if (session.encodedInsertableStreams == "e2ee") { operation = "encode"; } else if (session.encodedInsertableStreams == "red") { operation = "redencode"; } else if (session.encodedInsertableStreams == "lyra" && sender.track && sender.track.kind === "audio") { operation = "lyraencode"; } else if (UUID && session.pcs[UUID] && session.pcs[UUID].preferAudioCodec == "lyra" && sender.track && sender.track.kind === "audio") { operation = "lyraencode"; } insertableStreamWorker.postMessage( { operation: operation, readable, writable }, [readable, writable] ); } catch (e) { errorlog(e); } } function setupReceiverTransform(receiver, UUID = false) { if (!insertableStreamWorker) { insertableStreamWorker = new Worker("./insertableStreamWorker.js", { name: "Insertable Stream worker" }); insertableStreamWorker.onmessage = event => { if (event.data === "insertableStreamWorkerLoaded") { if (session.encodedInsertableStreams == "e2ee") { if (session.password) { insertableStreamWorker.postMessage({ cryptoPhrase: session.password + session.salt + "aDdedSaLt123" }); // salt ⚔️ rainbow } else { insertableStreamWorker.postMessage({ cryptoKey: "aabbccddeeff00112233445566778899" }); } } else if (session.encodedInsertableStreams == "lyra") { insertableStreamWorker.postMessage({ lyraCodecModule: session.lyraCodecModule }); } } else { console.log(event.data); } }; } try { let operation = "pass"; if (session.encodedInsertableStreams == "e2ee") { operation = "decode"; } else if (session.encodedInsertableStreams == "red") { operation = "reddecode"; } else if (session.encodedInsertableStreams == "lyra" && receiver.track && receiver.track.kind === "audio") { operation = "lyradecode"; } const receiverStreams = receiver.createEncodedStreams(); const { readable, writable } = receiverStreams; insertableStreamWorker.postMessage( { operation: operation, readable, writable }, [readable, writable] ); } catch (e) { errorlog(e); } } function mainMeshMask() { if (session.TFJSModel === null || session.TFJSModel === true) { setTimeout(function () { mainMeshMask(); }, 1000); return; } function heatMapColorforValue(value) { var h = parseInt((1.0 - value) * 240); if (h < 0) { h = 0; } if (h > 240) { h = 240; } return "hsl(" + h + ", 100%, 50%)"; } async function process() { if (session.TFJSModel.activelyProcessing) { return; } session.TFJSModel.activelyProcessing = true; if (session.effect !== "6") { if (session.TFJSModel.timeoutDraw) { session.TFJSModel.timeoutDraw(); session.TFJSModel.timeoutDraw = null; } session.TFJSModel.activelyProcessing = false; return; } const predictions = await session.TFJSModel.estimateFaces({ input: session.canvasSource }); var output = []; if (predictions.length > 0) { for (let j = 0; j < predictions.length; j++) { const fp = predictions[j].annotations; session.canvasCtx.fillStyle = "#000000"; session.canvasCtx.fillRect(0, 0, session.canvas.width, session.canvas.height); const keypoints = predictions[j].scaledMesh; for (let i = 0; i < keypoints.length; i++) { var [x, y, z] = keypoints[i]; x = parseInt(x); y = parseInt(y); z = parseInt(z); if (session.pushEffectsData) { output.push(x); output.push(y); } session.canvasCtx.fillStyle = heatMapColorforValue((z + 40) / 60); session.canvasCtx.fillRect(x, y, 5, 5); } } } if (session.pushEffectsData) { //output = FastIntegerCompression.compress(output); //log(output); if (isIFrame) { parent.postMessage( { effectsData: output, eID: session.pushEffectsData }, session.iframetarget ); } else { for (var i in session.pcs) { if (!session.pcs[i].sendChannel.bufferedAmount) { // don't overload things. session.sendMessage({ effectsData: output, eID: session.effect }, i); } } } } if (!session.TFJSModel.timeoutDraw) { try { session.TFJSModel.timeoutDraw = setupOscillator(process, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { session.TFJSModel.timeoutDraw = setupOscillator(process, 30); // setTimeout(function(){draw();},33); } } session.TFJSModel.activelyProcessing = false; } process(); } var faceDetector = false; var faceAlignment = false; var activeDetection = false; function drawFace() { if (session.effect !== "1") { return; } if (faceAlignment) { faceAlignment(); return; } else if (faceAlignment === null) { return; } faceAlignment = null; var timers = {}; timers.activelyProcessingDraw = false; var ctx = session.canvasCtx; function fde1() { warnlog("LOADED drawFace()"); var lastFace = {}; //session.canvasSource.width = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().width || 1280; //session.canvasSource.height = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().height || 720; lastFace.x = session.canvasSource.width / 2; lastFace.y = session.canvasSource.height / 2; lastFace.w = session.canvasSource.width; lastFace.h = session.canvasSource.height; session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); function detectFace() { if (activeDetection) { return; } activeDetection = true; if (session.effect !== "1") { return; } try { faceDetector .detect(session.canvasSource) .then(faces => { if (faces.length) { for (let face of faces) { lastFace.x = face.boundingBox.x; lastFace.y = face.boundingBox.y; lastFace.w = face.boundingBox.width; lastFace.h = face.boundingBox.height; break; } } //setTimeout(function(){draw();},0); }) .catch(e => { errorlog("Boo, Face Detection failed: " + e); }); } catch (e) { } setTimeout(function () { detectFace(); }, 200); activeDetection = false; } var wh = null; var xa = null; var ya = null; function draw() { if (timers.activelyProcessingDraw) { return; } timers.activelyProcessingDraw = true; if (session.effect !== "1") { timers.activelyProcessingDraw = false; if (timers.timeoutDraw) { timers.timeoutDraw(); timers.timeoutDraw = null; } return; } try { if (!session.canvasSource.width) { timers.activelyProcessingDraw = false; return; } if (wh === null && session.canvasSource.width) { wh = Math.pow((session.canvasSource.width * session.canvasSource.width) / 36, 0.5); xa = 0; ya = 0; } session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); if (lastFace.w) { wh = wh * 0.999 + Math.pow(lastFace.w * lastFace.h, 0.5) * 0.001; var w = wh * 6; if (w > session.canvasSource.width) { w = session.canvasSource.width; } if (session.canvasSource.height > session.canvasSource.width) { if (w > session.canvasSource.height && session.canvasSource.height > session.canvasSource.width) { w = session.canvasSource.height; } } else if (w > session.canvasSource.width) { w = session.canvasSource.width; } var h = (w / session.canvasSource.width) * session.canvasSource.height; xa = xa * 0.998 + 0.002 * (lastFace.x + lastFace.w / 2); ya = ya * 0.998 + 0.002 * (lastFace.y + lastFace.h / 2); var x = xa - w / 2; var y = ya - h / 2; if (x < 0) { x = 0; } if (y < 0) { y = 0; } if (x > session.canvasSource.width - w) { x = session.canvasSource.width - w; } if (y > session.canvasSource.height - h) { y = session.canvasSource.height - h; } if (x < 0) { x = 0; } if (y < 0) { y = 0; } } //console.log(x, y, w, h, session.canvasSource.width, session.canvasSource.height); ctx.drawImage(session.canvasSource, x, y, w, h, 0, 0, session.canvasSource.width, session.canvasSource.height); //ctx.beginPath(); //ctx.rect(lastFace.x, lastFace.y, lastFace.w, lastFace.h); // ctx.stroke(); } catch (e) { } if (!timers.timeoutDraw) { try { timers.timeoutDraw = setupOscillator(draw, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { timers.timeoutDraw = setupOscillator(draw, 40); // setTimeout(function(){draw();},33); } } else { var res = timers.timeoutDraw("check"); if (res) { try { timers.timeoutDraw = setupOscillator(draw, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { timers.timeoutDraw = setupOscillator(draw, 40); // setTimeout(function(){draw();},33); } } } timers.activelyProcessingDraw = false; } if (window.FaceDetector == undefined) { if (!session.cleanOutput) { warnUser("Face Detection API not detected.\n\nYou may be able to enable it here: chrome://flags/#enable-experimental-web-platform-features"); } faceDetector = false; } else { faceDetector = new FaceDetector(); } function fde2() { if (!timers.activelyProcessingDraw) { draw(); } if (!activeDetection) { detectFace(); } } fde2(); return fde2; } faceAlignment = fde1(); } //////// END CANVAS EFFECTS /////////////////// var getFacesActive = false; async function getFaces() { if (getFacesActive) { return; } getFacesActive = true; if (session.grabFaceData) { if (!faceDetector) { if (window.FaceDetector == undefined) { if (!session.cleanOutput) { warnUser("Face Detection API not detected.\n\nYou may be able to enable it here: chrome://flags/#enable-experimental-web-platform-features"); } session.grabFaceData = false; faceDetector = false; getFacesActive = false; return; } else { session.grabFaceData = 1; faceDetector = new FaceDetector(); } } try { var videos = {}; for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement) { await faceDetector .detect(session.rpcs[UUID].videoElement) .then(faces => { videos[session.rpcs[UUID].streamID] = {}; videos[session.rpcs[UUID].streamID].videoWidth = session.rpcs[UUID].videoElement.videoWidth; videos[session.rpcs[UUID].streamID].videoHeight = session.rpcs[UUID].videoElement.videoHeight; videos[session.rpcs[UUID].streamID].faces = faces; }) .catch(e => { //errorlog("Boo, Face Detection failed: " + e); }); } } log(videos); } catch (e) { } pokeIframeAPI("face-tracking-data", videos); setTimeout(function () { getFaces(); }, 200); } getFacesActive = false; } ////// var simpleDrawMain = false; function simpleDraw(reinit = false) { let supported = ["8", "overlay"]; if (!supported.includes(session.effect)) { errorlog("not a valid effeect?"); return; } if (simpleDrawMain) { simpleDrawMain(reinit); return; } else if (simpleDrawMain === null) { return; } simpleDrawMain = null; var timers = {}; timers.activelyProcessingDraw = false; function fde1() { try { log("LOADED simpleDraw()"); session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); function draw() { if (timers.activelyProcessingDraw) { return; } timers.activelyProcessingDraw = true; if (!supported.includes(session.effect)) { if (timers.timeoutDraw) { timers.timeoutDraw(); timers.timeoutDraw = null; } timers.activelyProcessingDraw = false; return; } try { if (!session.canvasSource.width) { timers.activelyProcessingDraw = false; return; } session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); session.canvasCtx.drawImage(session.canvasSource, 0, 0, session.canvasSource.width, session.canvasSource.height, 0, 0, session.canvasSource.width, session.canvasSource.height); if (session.effect === "overlay" && session.foregroundImg && session.foregroundImg.complete) { session.canvasCtx.drawImage(session.foregroundImg, 0, 0, session.canvas.width, session.canvas.height); } } catch (e) { errorlog(e); } if (!timers.timeoutDraw) { try { timers.timeoutDraw = setupOscillator(draw, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { timers.timeoutDraw = setupOscillator(draw, 40); // setTimeout(function(){draw();},33); } } else { var res = timers.timeoutDraw("check"); if (res) { try { timers.timeoutDraw = setupOscillator(draw, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { timers.timeoutDraw = setupOscillator(draw, 40); // setTimeout(function(){draw();},33); } } } timers.activelyProcessingDraw = false; } } catch (e) { errorlog(e); timers.activelyProcessingDraw = false; } function fde2(reinit = false) { if (reinit) { if (session.canvasSource && session.canvasSource.srcObject && session.canvasSource.srcObject.getVideoTracks().length) { session.canvasSource.width = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().width || 1280; session.canvasSource.height = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().height || 720; } } if (!timers.activelyProcessingDraw) { draw(); } } fde2(); return fde2; } simpleDrawMain = fde1(); } //////// END CANVAS EFFECTS /////////////////// ////// function changeZoomPosition(event, ele) { const value = parseFloat(ele.value); // Determine which slider was moved and update its pair if (ele.id === "zoomPositionX1" || ele.id === "zoomPositionX") { xPosition = value; // Update other horizontal slider const otherSlider = ele.id === "zoomPositionX1" ? getById("zoomPositionX") : getById("zoomPositionX1"); if (otherSlider) { otherSlider.value = value; } } else if (ele.id === "zoomPositionY1" || ele.id === "zoomPositionY") { yPosition = value; // Update other vertical slider const otherSlider = ele.id === "zoomPositionY1" ? getById("zoomPositionY") : getById("zoomPositionY1"); if (otherSlider) { otherSlider.value = value; } } } var xPosition = 0.5; // Center position horizontally (0 to 1) var yPosition = 0.5; // Center position vertically (0 to 1) var digitalZoomMain = false; function digitalZoom(resetZoom = false) { if (session.effect !== "7") { return; } if (digitalZoomMain) { digitalZoomMain(resetZoom); return; } else if (digitalZoomMain === null) { return; } digitalZoomMain = null; var activelyProcessingDraw = false; function fde1() { try { warnlog("LOADED digitalZoom()"); session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); var xa = null; var ya = null; var zz = 1; // Modify the draw function to use the position values function draw() { if (activelyProcessingDraw) { return; } activelyProcessingDraw = true; if (session.effect !== "7") { zz = 1.0; xa = 0; ya = 0; activelyProcessingDraw = false; return; } try { if (!session.canvasSource) { activelyProcessingDraw = false; return; } // Use videoWidth/videoHeight for actual current dimensions const srcWidth = session.canvasSource.videoWidth || session.canvasSource.width; const srcHeight = session.canvasSource.videoHeight || session.canvasSource.height; if (!srcWidth) { activelyProcessingDraw = false; return; } session.canvas.height = 2 * parseInt(srcHeight / 2); session.canvas.width = 2 * parseInt(srcWidth / 2); if (session.effectValue) { // Smooth out the zoom factor zz = 0.9 * zz + session.effectValue * 0.1; // Calculate the scaled dimensions const scaledWidth = srcWidth / zz; const scaledHeight = srcHeight / zz; // Calculate the offset based on position sliders // This centers the zoom on the selected position xa = (srcWidth - scaledWidth) * xPosition; ya = (srcHeight - scaledHeight) * yPosition; // Draw the zoomed region session.canvasCtx.drawImage( session.canvasSource, xa, ya, scaledWidth, scaledHeight, 0, 0, srcWidth, srcHeight ); } else { // If no zoom, draw the full image session.canvasCtx.drawImage( session.canvasSource, 0, 0, srcWidth, srcHeight, 0, 0, srcWidth, srcHeight ); } } catch (e) { errorlog(e); } activelyProcessingDraw = false; } } catch (e) { errorlog(e); activelyProcessingDraw = false; } function fde2(resetZoom = false) { if (session.canvasSource && session.canvasSource.srcObject && session.canvasSource.srcObject.getVideoTracks().length) { session.canvasSource.width = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().width || 1280; session.canvasSource.height = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().height || 720; } if (resetZoom) { xa = null; ya = null; zz = 1; } try { setupOscillator(draw, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { setupOscillator(draw, 40); // setTimeout(function(){draw();},33); } draw(); } fde2(); return fde2; } digitalZoomMain = fde1(); digitalZoomMain(resetZoom); } function rgbToHsv(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h, s, v = max; const d = max - min; s = max === 0 ? 0 : d / max; if (max === min) { h = 0; } else { switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return [h, s, v]; } // real green scree filter var chromaKeyMain = false; function chromaKey(reinit = false) { let supported = ["14", "15"]; if (!supported.includes(session.effect)) { warnlog("not a valid effect"); return; } if (chromaKeyMain) { chromaKeyMain(reinit); return; } else if (chromaKeyMain === null) { return; } chromaKeyMain = null; var timers = {}; timers.activelyProcessingDraw = false; function fde1() { try { log("LOADED chromaKey()"); session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); function processChromaKey() { if (timers.activelyProcessingDraw) return; timers.activelyProcessingDraw = true; if (!supported.includes(session.effect)) { if (timers.timeoutDraw) { timers.timeoutDraw(); timers.timeoutDraw = null; } timers.activelyProcessingDraw = false; return; } try { if (!session.canvasSource.width) { timers.activelyProcessingDraw = false; return; } session.canvas.height = 2 * parseInt(session.canvasSource.height / 2); session.canvas.width = 2 * parseInt(session.canvasSource.width / 2); session.canvasCtx.drawImage(session.canvasSource, 0, 0); const imageData = session.canvasCtx.getImageData(0, 0, session.canvas.width, session.canvas.height); const data = imageData.data; const threshold = parseInt(session.effectValue) || 80; const smoothing = 15; // Green range in HSV (normalized to 0-1) const targetHue = 0.33; // Pure green const hueRange = 0.15; // Range around pure green for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; // Quick pre-check for obvious non-green pixels if (g <= r || g <= b) { continue; } const [h, s, v] = rgbToHsv(r, g, b); // Calculate how "green" the pixel is const hueDiff = Math.abs(h - targetHue); const isInGreenRange = hueDiff <= hueRange || hueDiff >= (1 - hueRange); if (!isInGreenRange) { continue; } // Green intensity calculation const greenDominance = (g - Math.max(r, b)) / 255; const saturationBoost = s * 0.7; // Reduce impact of washed-out greens const keyValue = (greenDominance * 0.6 + saturationBoost * 0.4) * 100; let alpha = 255; if (keyValue > threshold) { const smoothFactor = Math.min((keyValue - threshold) / smoothing, 1); alpha = (1 - smoothFactor) * 255; } data[i + 3] = alpha; } session.canvasCtx.putImageData(imageData, 0, 0); if (session.effect === "15" && session.effectsImage && session.effectsImage.complete) { session.canvasCtx.globalCompositeOperation = 'destination-over'; session.canvasCtx.drawImage(session.effectsImage, 0, 0, session.canvas.width, session.canvas.height); session.canvasCtx.globalCompositeOperation = 'source-over'; } } catch (e) { errorlog(e); } if (!timers.timeoutDraw) { try { timers.timeoutDraw = setupOscillator(processChromaKey, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { timers.timeoutDraw = setupOscillator(processChromaKey, 40); } } else { var res = timers.timeoutDraw("check"); if (res) { try { timers.timeoutDraw = setupOscillator(processChromaKey, session.canvasSource.srcObject.getVideoTracks()[0].getSettings().frameRate || 30); } catch (e) { timers.timeoutDraw = setupOscillator(processChromaKey, 40); } } } timers.activelyProcessingDraw = false; } } catch (e) { errorlog(e); timers.activelyProcessingDraw = false; } function fde2(reinit = false) { if (reinit) { if (session.canvasSource && session.canvasSource.srcObject && session.canvasSource.srcObject.getVideoTracks().length) { session.canvasSource.width = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().width || 1280; session.canvasSource.height = session.canvasSource.srcObject.getVideoTracks()[0].getSettings().height || 720; } } if (!timers.activelyProcessingDraw) { processChromaKey(); } } fde2(); return fde2; } chromaKeyMain = fde1(); } //////// END CANVAS EFFECTS /////////////////// function getNativeOutputResolution() { try { if (session.videoElement && session.videoElement.srcObject) { var tracks = session.videoElement.srcObject.getVideoTracks(); if (tracks.length && tracks[0].getSettings) { return tracks[0].getSettings(); } else { return false; } } else if (session.videoElement && session.videoElement.videoWidth && session.videoElement.videoHeight) { return { width: session.videoElement.videoWidth, height: session.videoElement.videoHeight }; } } catch (e) { return false; } } function toggleSceneStats(button) { var UUID = button.dataset.UUID; if (button.value == 1) { button.value = 0; button.classList.remove("pressed"); button.ariaPressed = "false"; if (UUID) { session.rpcs[UUID].allowGraphs = false; } else { session.allowDirectorGraph = false; } } else { button.value = 1; button.classList.add("pressed"); button.ariaPressed = "true"; if (UUID) { session.rpcs[UUID].allowGraphs = true; } else { session.allowDirectorGraph = true; } } if (UUID) { var controls = getById("container_" + UUID); } else { var controls = getById("container_director"); } if (button.value == 1) { controls.querySelectorAll("[data-no-scenes]").forEach(ele => { ele.classList.remove("hidden"); if (ele.dataset.message) { ele.innerHTML = "Requesting data .."; } }); if (controls.querySelector('[data-action-type="stats-graphs-bitrate"]')) { controls.querySelector('[data-action-type="stats-graphs-bitrate"]').classList.remove("hidden"); } if (controls.querySelector('[data-action-type="stats-graphs-details"]')) { controls.querySelector('[data-action-type="stats-graphs-details"]').classList.remove("hidden"); } if (UUID) { session.sendRequest({ requestStatsContinuous: true }, UUID); } } else { if (UUID) { session.sendRequest({ requestStatsContinuous: false }, UUID); } if (controls.querySelector('[data-action-type="stats-graphs-bitrate"]')) { controls.querySelector('[data-action-type="stats-graphs-bitrate"]').classList.add("hidden"); } if (controls.querySelector('[data-action-type="stats-graphs-details"]')) { controls.querySelector('[data-action-type="stats-graphs-details"]').classList.add("hidden"); } } } function getColor(value) { var hue = (value * 120).toString(10); return ["hsl(", hue, ",100%,50%)"].join(""); } function plotData(info, UUID, uuid) { // type = "bitrate" or "nacks" log("plot data"); var container = getById("container_" + UUID).querySelector('[data-action-type="stats-graphs-bitrate"]'); if (!container) { log("container not found"); return; } var canvas = getById("container_" + UUID).querySelector('canvas[data-uid="' + uuid + '"]'); var canvasNew = false; if (!canvas) { canvasNew = true; canvas = document.createElement("canvas"); canvas.height = 50; canvas.width = 124; canvas.className = "canvasStats"; canvas.history_nacks = []; canvas.history_bitrate = []; canvas.target = 4000; if (info.scene) { canvas.title = "Scene: " + info.scene + ". Red/orange implies packet loss. Y-axis is marked with 2500-kbps increments."; } else if (info.label) { canvas.title = "Label: " + info.label + ". Red/orange implies packet loss. Y-axis is marked with 2500-kbps increments"; } else { canvas.title = "Red/orange implies packet loss. Y-axis is marked with 2500-kbps increments"; } canvas.dataset.uid = uuid; container.appendChild(canvas); } selfDestructElement(UUID, uuid); var context = canvas.getContext("2d"); var bitrate = 0; if ("video_bitrate_kbps" in info) { bitrate = info.video_bitrate_kbps; } if (isNaN(bitrate)) { bitrate = 0; } if (bitrate < 0) { bitrate = 0; } var nacks = 0; if ("nacks_per_second" in info) { nacks = info.nacks_per_second; } if (isNaN(nacks)) { nacks = 0; } if (nacks < 0) { nacks = 0; } var height = context.canvas.height; var width = context.canvas.width; canvas.history_nacks.push(nacks); canvas.history_bitrate.push(bitrate); canvas.history_nacks = canvas.history_nacks.slice(-125); canvas.history_bitrate = canvas.history_bitrate.slice(-125); var maxBitrate = Math.max(...canvas.history_bitrate); var target = canvas.target || 4000; if (target && maxBitrate > target) { canvas.target = maxBitrate * 1.5; // set it higher than it needs to be, so it doens't jump around a lot var yScale = height / canvas.target; context.clearRect(0, 0, width, height); var x = width - 1; var w = 1; for (var i = 0; i < canvas.history_bitrate.length; i++) { var nacks = canvas.history_nacks[i]; var bitrate = canvas.history_bitrate[i]; var val = (10 - nacks) / 10; if (val > 1) { val = 1; } else if (val < 0) { val = 0; } var color = getColor(val); var y = height - bitrate * yScale; context.fillStyle = color; context.fillRect(x, y, w, height); context.fillStyle = "#DDD5"; context.fillRect(x, y - 2, w, 4); if (y - 5 > 0) { context.fillStyle = "#FFF3"; context.fillRect(x, y + 2, w, 1); } var imageData = context.getImageData(1, 0, width - 1, height); context.putImageData(imageData, 0, 0); context.clearRect(width - 1, 0, 1, height); } for (var tt = 2500; tt < canvas.target; tt += 2500) { var y = parseInt(height - tt * yScale); context.fillStyle = "#0555"; context.fillRect(0, y, width, 1); } log("finished plotting a new y-axis"); return; } //if (info.available_outgoing_bitrate_kbps){ // limit target, but requires a history //} var val = (10 - nacks) / 10; if (val > 1) { val = 1; } else if (val < 0) { val = 0; } var color = getColor(val); var yScale = height / target; var x = width - 1; var y = height - bitrate * yScale; var w = 1; context.fillStyle = color; context.fillRect(x, y, w, height); context.fillStyle = "#DDD5"; context.fillRect(x, y - 2, w, 4); if (y - 5 > 0) { context.fillStyle = "#FFF3"; context.fillRect(x, y + 2, w, 1); } context.fillStyle = "#0555"; if (canvasNew) { for (var tt = 2500; tt < target; tt += 2500) { var y = parseInt(height - tt * yScale); context.fillRect(0, y, width, 1); } } else { for (var tt = 2500; tt < target; tt += 2500) { var y = parseInt(height - tt * yScale); context.fillRect(x, y, 1, 1); } } var imageData = context.getImageData(1, 0, width - 1, height); context.putImageData(imageData, 0, 0); context.clearRect(width - 1, 0, 1, height); log("finished plotting"); } function selfDestructElement(UUID, uid) { getById("container_" + UUID) .querySelectorAll('[data-uid="' + uid + '"]') .forEach(ele => { ele.classList.remove("greyout"); clearTimeout(ele.selfFadeout); ele.selfFadeout = setTimeout( function (ele) { ele.classList.add("greyout"); }, 4000, ele ); clearTimeout(ele.selfDestruct); ele.selfDestruct = setTimeout( function (ele) { ele.remove(); }, 10000, ele ); }); } function directorGraphStats() { if (!(session.allowDirectorGraph || session.allowGraphs)) { return; } if (session.director) { var UUID = "director"; var maincon = getById("container_director"); var sceneStats = {}; for (var uuid in session.pcs) { if (session.pcs[uuid].scene !== false) { sceneStats[uuid] = {}; sceneStats[uuid].label = session.pcs[uuid].label; sceneStats[uuid].scene = session.pcs[uuid].scene; sceneStats[uuid].resolution = session.pcs[uuid].stats.resolution; sceneStats[uuid].video_bitrate_kbps = session.pcs[uuid].stats.video_bitrate_kbps; sceneStats[uuid].video_encoder = session.pcs[uuid].stats.video_encoder; } } if (!Object.keys(sceneStats).length) { maincon.querySelectorAll("[data-no-scenes]").forEach(ele => { ele.classList.remove("hidden"); if (ele.dataset.message) { ele.innerHTML = "No scenes active"; } }); log("zero size"); return; } maincon.querySelectorAll("[data-no-scenes]").forEach(ele => { ele.classList.add("hidden"); }); for (var uuid in sceneStats) { var container = maincon.querySelector('[data-action-type="stats-graphs-details-container"][data-uid="' + uuid + '"]'); if (!container) { container = maincon.querySelector('[data-action-type="stats-graphs-details-container"]').cloneNode(true); container.dataset.uid = uuid; container.classList.remove("hidden"); maincon.querySelector('[data-action-type="stats-graphs-details"]').appendChild(container); } plotData(sceneStats[uuid], UUID, uuid); if ("video_bitrate_kbps" in sceneStats[uuid] && sceneStats[uuid].video_bitrate_kbps !== "video_bitrate_kbps") { var span = container.querySelector("[data-bitrate]"); if (span) { span.classList.remove("hidden"); span.innerHTML = "video bitrate: " + parseInt(sceneStats[uuid].video_bitrate_kbps) + " (kbps)"; span.style.cursor = "pointer"; span.title = "Click to adjust bitrate"; span.onclick = async function (e) { e.preventDefault(); e.stopPropagation(); var currentUUID = this.closest('[data-action-type="stats-graphs-details-container"]').dataset.uid; const result = await promptAlt("Select target bitrate (kbps)", false, false, false, false, false, false, { type: 'select', options: ['50', '500', '1000', '2000', '5000', '10000', '20000', '[Custom]'], placeholder: 'Enter custom bitrate in kbps' }); if (result) { var msg = { targetBitrate: parseInt(result), UUID: currentUUID, requestAs: uuid }; if (isIFrame) { parent.postMessage(msg, session.iframetarget); } session.sendRequest(msg); } }; } } var span = container.querySelector("[data-scene-name]"); if (span && "label" in sceneStats[uuid] && sceneStats[uuid].label) { span.classList.remove("hidden"); span.innerHTML = "stats for viewer: " + sceneStats[uuid].label; } else if (span && "scene" in sceneStats[uuid] && sceneStats[uuid].scene !== false) { span.classList.remove("hidden"); span.innerHTML = "stats for scene: " + sceneStats[uuid].scene; } else if (uuid === "meshcast") { span.classList.remove("hidden"); span.innerHTML = "stats for meshcast ingest"; span.title = "You can use &label=xxxx to give your view links a unique label"; } else { span.classList.remove("hidden"); span.innerHTML = "stats for some viewer"; span.title = "You can use &label=xxxx to give your view links a unique label"; } if ("resolution" in sceneStats[uuid]) { var span = container.querySelector("[data-resolution]"); if (span) { span.classList.remove("hidden"); span.innerHTML = sceneStats[uuid].resolution; span.style.cursor = "pointer"; span.title = "Click to adjust resolution"; span.onclick = async function (e) { e.preventDefault(); e.stopPropagation(); var currentUUID = this.closest('[data-action-type="stats-graphs-details-container"]').dataset.uid; const result = await promptAlt("Select target resolution", false, false, false, false, false, false, { type: 'select', options: ['360', '720', '1080', '[Custom]'], placeholder: 'Enter custom height in pixels' }); if (result) { session.requestResolution(currentUUID, 4096, result || 2160, false, uuid); } }; } } if ("video_encoder" in sceneStats[uuid]) { var span = container.querySelector("[data-video-codec]"); if (span) { span.classList.remove("hidden"); span.innerHTML = "video codec: " + sceneStats[uuid].video_encoder; } } } } } function remoteStats(msg, UUID) { var rpc = session.rpcs && session.rpcs[UUID] ? session.rpcs[UUID] : null; if (isIFrame && rpc) { parent.postMessage({ remoteStats: msg.remoteStats, streamID: rpc.streamID, UUID: UUID }, session.iframetarget); } if (!rpc) { return; } var allowUI = rpc.allowGraphs || session.allowGraphs; if (allowUI && session.director) { var size = 0; for (var key in msg.remoteStats) { if (msg.remoteStats.hasOwnProperty(key)) { size++; } } if (!size) { getById("container_" + UUID) .querySelectorAll("[data-no-scenes]") .forEach(ele => { ele.classList.remove("hidden"); if (ele.dataset.message) { ele.innerHTML = "No scenes active"; } }); log("zero size"); } else { getById("container_" + UUID) .querySelectorAll("[data-no-scenes]") .forEach(ele => { ele.classList.add("hidden"); }); for (var uuid in msg.remoteStats) { var container = getById("container_" + UUID).querySelector('[data-action-type="stats-graphs-details-container"][data-uid="' + uuid + '"]'); if (!container) { container = getById("container_" + UUID) .querySelector('[data-action-type="stats-graphs-details-container"]') .cloneNode(true); container.dataset.uid = uuid; container.classList.remove("hidden"); getById("container_" + UUID) .querySelector('[data-action-type="stats-graphs-details"]') .appendChild(container); } plotData(msg.remoteStats[uuid], UUID, uuid); if ("video_bitrate_kbps" in msg.remoteStats[uuid] && msg.remoteStats[uuid].video_bitrate_kbps !== "video_bitrate_kbps") { var span = container.querySelector("[data-bitrate]"); if (span) { span.classList.remove("hidden"); span.innerHTML = "video bitrate: " + parseInt(msg.remoteStats[uuid].video_bitrate_kbps) + " (kbps)"; span.style.cursor = "pointer"; span.title = "Click to adjust bitrate"; span.onclick = async function (e) { e.preventDefault(); e.stopPropagation(); const result = await promptAlt("Select target bitrate (kbps)", false, false, false, false, false, false, { type: 'select', options: ['50', '500', '1000', '2000', '5000', '10000', '20000', '[Custom]'], placeholder: 'Enter custom bitrate in kbps' }); if (result) { var msg = { targetBitrate: parseInt(result), UUID: UUID, requestAs: uuid }; if (isIFrame) { parent.postMessage(msg, session.iframetarget); } session.sendRequest(msg); } }; } } var span = container.querySelector("[data-scene-name]"); if (span && "label" in msg.remoteStats[uuid] && msg.remoteStats[uuid].label) { span.classList.remove("hidden"); span.innerHTML = "stats for viewer: " + msg.remoteStats[uuid].label; } else if (span && "scene" in msg.remoteStats[uuid] && msg.remoteStats[uuid].scene !== false) { span.classList.remove("hidden"); span.innerHTML = "stats for scene: " + msg.remoteStats[uuid].scene; } else if (uuid === "meshcast") { span.classList.remove("hidden"); span.innerHTML = "stats for meshcast ingest"; span.title = "You can use &label=xxxx to give your view links a unique label"; } else { span.classList.remove("hidden"); span.innerHTML = "stats for some viewer"; span.title = "You can use &label=xxxx to give your view links a unique label"; } if ("resolution" in msg.remoteStats[uuid]) { var span = container.querySelector("[data-resolution]"); if (span) { span.classList.remove("hidden"); span.innerHTML = msg.remoteStats[uuid].resolution; span.style.cursor = "pointer"; span.title = "Click to adjust resolution"; span.onclick = async function (e) { e.preventDefault(); e.stopPropagation(); const result = await promptAlt("Select target resolution", false, false, false, false, false, false, { type: 'select', options: ['360', '720', '1080', '1440', '2160', '[Custom]'], placeholder: 'Enter custom height in pixels' }); if (result) { session.requestResolution(UUID, 4096, result || 2160, false, uuid); } }; } } if ("video_encoder" in msg.remoteStats[uuid]) { var span = container.querySelector("[data-video-codec]"); if (span) { span.classList.remove("hidden"); span.innerHTML = "video codec: " + msg.remoteStats[uuid].video_encoder; } } } } } } function processStats(UUID) { // for (pc in session.pcs){session.pcs[pc].getStats().then(function(stats) {stats.forEach(stat=>{if (stat.id.includes("RTCIce")){console.log(stat)}})})}; if (!session.rpcs || !(UUID in session.rpcs)) { return; } try { if (session.rpcs[UUID].videoElement.paused) { if (session.firstPlayTriggered) { if (session.audioCtx.state == "suspended") { // added oct 9th 2022 try { session.audioCtx.resume(); } catch (e) { warnlog(e); } } if (session.audioCtx.state == "running") { // NOTE: I Don't know why this was log("trying to play"); session.rpcs[UUID].videoElement .play() .then(_ => { log("playing 8"); //if ((session.audioEffects===true) || session.pushLoudness){ // updateIncomingAudioElement(UUID); //} }) .catch(warnlog); } } } } catch (e) { } try { if (session.rpcs[UUID].realUUID && session.rpcs[session.rpcs[UUID].realUUID]) { var node = session.rpcs[session.rpcs[UUID].realUUID]; } else { var node = session.rpcs[UUID]; } var validTrackIds = []; if (session.rpcs[UUID].streamSrc) { session.rpcs[UUID].streamSrc.getTracks().forEach(trk => { validTrackIds.push(trk.id); }); } if (session.rpcs[UUID].whep) { processMeshcastStats(UUID); if (!node.getStats) { clearTimeout(session.rpcs[UUID].getStatsTimeout); session.rpcs[UUID].getStatsTimeout = setTimeout(processStats, session.statsInterval, UUID); //setTimeout(processStats, session.statsInterval, UUID); // no p2p, so lets do WHEP again manually. } } if (node.getStats) { node.getStats().then(function (stats) { if (!(UUID in session.rpcs)) { return; } clearTimeout(session.rpcs[UUID].getStatsTimeout); session.rpcs[UUID].getStatsTimeout = setTimeout(processStats, session.statsInterval, UUID); if (!session.rpcs[UUID].stats["Peer-to-Peer_Connection"]) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"] = {}; } var nominatedCandidate = false; var candidates = {}; var ipleakingAllowedRemote = false; var ipleakingAllowedLocal = false; stats.forEach(stat => { try { if (stat.id && stat.id.startsWith("DEPRECATED_")) { return; } var trackID = stat.trackIdentifier || stat.id || false; if (stat.type == "track" && stat.remoteSource) { if (stat.trackIdentifier && !validTrackIds.includes(stat.trackIdentifier)) { return; } if (stat.id in session.rpcs[UUID].stats) { session.rpcs[UUID].stats[stat.id]._trackID = stat.trackIdentifier; session.rpcs[UUID].stats[stat.id].Jitter_Buffer_ms = parseInt((1000 * (parseFloat(stat.jitterBufferDelay) - session.rpcs[UUID].stats[stat.id]._jitter_delay)) / (parseInt(stat.jitterBufferEmittedCount) - session.rpcs[UUID].stats[stat.id]._jitter_count)) || 0; session.rpcs[UUID].stats[stat.id]._jitter_delay = parseFloat(stat.jitterBufferDelay) || 0; session.rpcs[UUID].stats[stat.id]._jitter_count = parseInt(stat.jitterBufferEmittedCount) || 0; if ("frameWidth" in stat) { if ("frameHeight" in stat) { session.rpcs[UUID].stats[stat.id].Resolution = stat.frameWidth + " x " + stat.frameHeight; session.rpcs[UUID].stats[stat.id]._frameWidth = stat.frameWidth; session.rpcs[UUID].stats[stat.id]._frameHeight = stat.frameHeight; } } } else { var media = {}; media._jitter_delay = parseFloat(stat.jitterBufferDelay) || 0; media._jitter_count = parseInt(stat.jitterBufferEmittedCount) || 0; media.Jitter_Buffer_ms = 0; media._trackID = stat.trackIdentifier; session.rpcs[UUID].stats[stat.id] = media; if (stat.kind && stat.kind == "audio") { session.rpcs[UUID].stats[stat.id].type = "Audio Track"; session.rpcs[UUID].stats[stat.id]._type = "audio"; } else if (stat.kind && stat.kind == "video") { session.rpcs[UUID].stats[stat.id].type = "Video Track"; session.rpcs[UUID].stats[stat.id]._type = "video"; } } } else if (stat.type == "remote-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedRemote = true; } } else if (stat.type == "local-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedLocal = true; } } else if (stat.type == "candidate-pair" && stat.nominated) { if (!nominatedCandidate) { nominatedCandidate = stat; } else if (nominatedCandidate.priority < stat.priority) { nominatedCandidate = stat; } } else if (stat.type == "transport") { if ("bytesReceived" in stat) { if ("_bytesReceived" in session.rpcs[UUID].stats["Peer-to-Peer_Connection"]) { if (session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._timestamp) { if (stat.timestamp) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].total_recv_bitrate_kbps = parseInt((8 * (stat.bytesReceived - session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._bytesReceived)) / (stat.timestamp - session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._timestamp)); hideStreamLowBandwidth(session.rpcs[UUID].stats["Peer-to-Peer_Connection"].total_recv_bitrate_kbps, UUID); //changeSceneLowBandwidth(session.rpcs[UUID].stats['Peer-to-Peer_Connection'].total_recv_bitrate_kbps, UUID); } } } session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._bytesReceived = stat.bytesReceived; } if ("timestamp" in stat) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._timestamp = stat.timestamp; if (!session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._timestampStart) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._timestampStart = stat.timestamp; } else { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].time_active_minutes = parseInt((stat.timestamp - session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._timestampStart) / 600) / 100; } } } else if (stat.type == "inbound-rtp" && trackID) { if (stat.trackIdentifier && !validTrackIds.includes(stat.trackIdentifier)) { return; } session.rpcs[UUID].stats[trackID] = session.rpcs[UUID].stats[trackID] || {}; if (stat.trackIdentifier) { session.rpcs[UUID].stats[trackID]._trackID = stat.trackIdentifier; } if ("jitterBufferDelay" in stat) { session.rpcs[UUID].stats[trackID].Jitter_Buffer_ms = parseInt((1000 * (parseFloat(stat.jitterBufferDelay) - session.rpcs[UUID].stats[trackID]._jitter_delay_2)) / (parseInt(stat.jitterBufferEmittedCount) - session.rpcs[UUID].stats[trackID]._jitter_count_2)) || 0; session.rpcs[UUID].stats[trackID]._jitter_delay_2 = parseFloat(stat.jitterBufferDelay) || 0; session.rpcs[UUID].stats[trackID]._jitter_count_2 = parseInt(stat.jitterBufferEmittedCount) || 0; } if ("frameWidth" in stat) { if ("frameHeight" in stat) { session.rpcs[UUID].stats[trackID].Resolution = stat.frameWidth + " x " + stat.frameHeight; session.rpcs[UUID].stats[trackID]._frameWidth = stat.frameWidth; session.rpcs[UUID].stats[trackID]._frameHeight = stat.frameHeight; } } session.rpcs[UUID].stats[trackID].Bitrate_in_kbps = parseInt((8 * (stat.bytesReceived - (session.rpcs[UUID].stats[trackID]._last_bytes || 0))) / (stat.timestamp - session.rpcs[UUID].stats[trackID]._last_time)); session.rpcs[UUID].stats[trackID]._last_bytes = stat.bytesReceived || session.rpcs[UUID].stats[trackID]._last_bytes; session.rpcs[UUID].stats[trackID]._last_time = stat.timestamp || session.rpcs[UUID].stats[trackID]._last_time; if (stat.mediaType == "video") { session.rpcs[UUID].stats._codecId = stat.codecId; session.rpcs[UUID].stats._codecIdTrackId = trackID; session.rpcs[UUID].stats[trackID].type = "Video Stream"; session.rpcs[UUID].stats[trackID]._type = "video"; if (session.obsfix && "codec" in session.rpcs[UUID].stats && session.rpcs[UUID].stats.codec == "video/VP8") { session.rpcs[UUID].stats[trackID].pliDelta = stat.pliCount - session.rpcs[UUID].stats[trackID].keyFramesRequested_pli || 0; session.rpcs[UUID].stats[trackID].nackTrigger = stat.nackCount - session.rpcs[UUID].stats[trackID].streamErrors_nackCount + session.rpcs[UUID].stats[trackID].nackTrigger || 0; log("OBS PLI FIX MODE ON"); if (session.rpcs[UUID].stats[trackID].pliDelta === 0 && session.rpcs[UUID].stats[trackID].nackTrigger >= session.obsfix) { // heavy packet loss with no pliCount? session.requestKeyframe(UUID); session.rpcs[UUID].stats[trackID].nackTrigger = 0; log("TRYING KEYFRAME"); } else if (session.rpcs[UUID].stats[trackID].pliDelta > 0) { session.rpcs[UUID].stats[trackID].nackTrigger = 0; } } else if (session.obsfix && "codec" in session.rpcs[UUID].stats && session.rpcs[UUID].stats.codec == "video/VP9") { session.rpcs[UUID].stats[trackID].pliDelta = stat.pliCount - session.rpcs[UUID].stats[trackID].keyFramesRequested_pli || 0; session.rpcs[UUID].stats[trackID].nackTrigger = stat.nackCount - session.rpcs[UUID].stats[trackID].streamErrors_nackCount + session.rpcs[UUID].stats[trackID].nackTrigger || 0; log("OBS PLI FIX MODE ON"); if (session.rpcs[UUID].stats[trackID].pliDelta === 0 && session.rpcs[UUID].stats[trackID].nackTrigger >= session.obsfix * 4) { // heavy packet loss with no pliCount? well, VP9 will trigger hopefully not as often. session.requestKeyframe(UUID); session.rpcs[UUID].stats[trackID].nackTrigger = 0; log("TRYING KEYFRAME"); } else if (session.rpcs[UUID].stats[trackID].pliDelta > 0) { session.rpcs[UUID].stats[trackID].nackTrigger = 0; } } session.rpcs[UUID].stats[trackID].keyFramesRequested_pli = stat.pliCount || 0; session.rpcs[UUID].stats[trackID].streamErrors_nackCount = stat.nackCount || 0; //warnlog(stat); if ("framesPerSecond" in stat) { session.rpcs[UUID].stats[trackID].FPS = parseInt(stat.framesPerSecond); } else if ("framesDecoded" in stat && stat.timestamp) { var lastFramesDecoded = 0; var lastTimestamp = 0; try { lastFramesDecoded = session.rpcs[UUID].stats[trackID]._framesDecoded; lastTimestamp = session.rpcs[UUID].stats[trackID]._timestamp; } catch (e) { } session.rpcs[UUID].stats[trackID].FPS = parseInt((10 * (stat.framesDecoded - lastFramesDecoded)) / (stat.timestamp / 1000 - lastTimestamp)) / 10; //session.rpcs[UUID].stats[trackID].FPS = parseInt((stat.framesDecoded - lastFramesDecoded)/(stat.timestamp/1000 - lastTimestamp)); session.rpcs[UUID].stats[trackID]._framesDecoded = stat.framesDecoded; session.rpcs[UUID].stats[trackID]._timestamp = stat.timestamp / 1000; } } else if (stat.mediaType == "audio") { //log("AUDIO LEVEL: "+stat.audioLevel); session.rpcs[UUID].stats._audioCodecId = stat.codecId; session.rpcs[UUID].stats._audioCodecIdTrackId = trackID; session.rpcs[UUID].stats[trackID].type = "Audio Stream"; session.rpcs[UUID].stats[trackID]._type = "audio"; if ("audioLevel" in stat) { session.rpcs[UUID].stats[trackID].audio_level = parseInt(parseFloat(stat.audioLevel) * 10000) / 10000.0; } } if ("packetsLost" in stat && "packetsReceived" in stat) { if (!("_packetsLost" in session.rpcs[UUID].stats[trackID])) { session.rpcs[UUID].stats[trackID]._packetsLost = stat.packetsLost; } if (!("_packetsReceived" in session.rpcs[UUID].stats[trackID])) { session.rpcs[UUID].stats[trackID]._packetsReceived = stat.packetsReceived; } if (!("packetLoss_in_percentage" in session.rpcs[UUID].stats[trackID])) { session.rpcs[UUID].stats[trackID].packetLoss_in_percentage = 0; } let packetLoss = ((stat.packetsLost - session.rpcs[UUID].stats[trackID]._packetsLost) * 100.0) / (stat.packetsReceived - session.rpcs[UUID].stats[trackID]._packetsReceived + (stat.packetsLost - session.rpcs[UUID].stats[trackID]._packetsLost)) || 0; /* if (session.rpcs[UUID].stats[trackID]._type && (session.rpcs[UUID].stats[trackID]._type =="video")){ if (packetLoss>1){ var data = {}; data.bitrate = parseInt(session.rpcs[UUID].stats[trackID].Bitrate_in_kbps*0.8); session.sendRequest(data,UUID); } else { var data = {}; data.bitrate = parseInt(session.rpcs[UUID].stats[trackID].Bitrate_in_kbps*1.1); session.sendRequest(data,UUID); } } */ session.rpcs[UUID].stats[trackID].packetLoss_in_percentage = session.rpcs[UUID].stats[trackID].packetLoss_in_percentage * 0.35 + 0.65 * packetLoss; if (session.rpcs[UUID].signalMeter && session.rpcs[UUID].stats[trackID]._type === "video") { if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 0.01) { if (session.rpcs[UUID].stats[trackID].Bitrate_in_kbps == 0) { session.rpcs[UUID].signalMeter.dataset.level = 0; } else { session.rpcs[UUID].signalMeter.dataset.level = 5; } } else if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 0.3) { session.rpcs[UUID].signalMeter.dataset.level = 4; } else if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 1.0) { session.rpcs[UUID].signalMeter.dataset.level = 3; } else if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 3.5) { session.rpcs[UUID].signalMeter.dataset.level = 2; } else { session.rpcs[UUID].signalMeter.dataset.level = 1; } } session.rpcs[UUID].stats[trackID]._packetsReceived = stat.packetsReceived; session.rpcs[UUID].stats[trackID]._packetsLost = stat.packetsLost; } } else if ("_codecId" in session.rpcs[UUID].stats && stat.id == session.rpcs[UUID].stats._codecId) { if ("mimeType" in stat) { if (session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId]) { session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId].codec = stat.mimeType; } else { session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId] = {}; session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId].codec = stat.mimeType; } } if ("frameHeight" in stat) { if ("frameWidth" in stat) { session.rpcs[UUID].stats.Resolution = parseInt(stat.frameWidth) + " x " + parseInt(stat.frameHeight); } } } else if ("_audioCodecId" in session.rpcs[UUID].stats && stat.id == session.rpcs[UUID].stats._audioCodecId) { if ("mimeType" in stat) { var addOnDescription = stat.mimeType; addOnDescription = addOnDescription.replace("audio/", ""); if ("sdpFmtpLine" in stat) { if (stat.sdpFmtpLine.includes("useinbandfec=1")) { addOnDescription += ", /w fec"; } } if (session.rpcs[UUID].stats[session.rpcs[UUID].stats._audioCodecIdTrackId]) { } else { session.rpcs[UUID].stats[session.rpcs[UUID].stats._audioCodecIdTrackId] = {}; } session.rpcs[UUID].stats[session.rpcs[UUID].stats._audioCodecIdTrackId].codec = addOnDescription; if (stat.clockRate) { session.rpcs[UUID].stats[session.rpcs[UUID].stats._audioCodecIdTrackId].clockRate = stat.clockRate; if (stat.channels) { session.rpcs[UUID].stats[session.rpcs[UUID].stats._audioCodecIdTrackId].clockRate += " / " + stat.channels; } } } } else if (Firefox) { if ("frameWidth" in stat) { session.rpcs[UUID].stats.resolution = stat.frameWidth + " x " + stat.frameHeight; if ("framesPerSecond" in stat) { session.rpcs[UUID].stats.resolution += " @ " + stat.framesPerSecond; } } if ("mimeType" in stat && "type" in stat && "id" in stat && stat.type == "codec") { if (stat.mimeType.includes("video")) { session.rpcs[UUID].stats.video_codec = stat.mimeType.split("video/")[1]; } else if (stat.mimeType.includes("audio")) { session.rpcs[UUID].stats.audio_codec = stat.mimeType.split("audio/")[1]; if (stat.clockRate) { session.rpcs[UUID].stats.audio_clockRate = stat.clockRate; if (stat.channels) { session.rpcs[UUID].stats.audio_clockRate += " / " + stat.channels; } } else if (stat.sdpFmtpLine) { session.rpcs[UUID].stats.fmtp = stat.sdpFmtpLine; } } } /* if ("jitter" in stat){ if (("kind" in stat) && (stat.kind=="video")){ session.rpcs[UUID].stats.video_jitter_ms = parseInt(stat.jitter*1000); } else if (("kind" in stat) && (stat.kind=="audio")){ session.rpcs[UUID].stats.audio_jitter_ms = parseInt(stat.jitter*1000); } } */ if ("bytesReceived" in stat) { if ("kind" in stat && stat.kind == "video") { if ("_bytesReceived_video" in session.rpcs[UUID].stats) { session.rpcs[UUID].stats.videoBitrate_kbps = parseInt((stat.bytesReceived - session.rpcs[UUID].stats._bytesReceived_video) / ((1024 * session.statsInterval) / 8000)); } session.rpcs[UUID].stats._bytesReceived_video = stat.bytesReceived; } else if ("kind" in stat && stat.kind == "audio") { if ("_bytesReceived_audio" in session.rpcs[UUID].stats) { session.rpcs[UUID].stats.audioBitrate_kbps = parseInt((stat.bytesReceived - session.rpcs[UUID].stats._bytesReceived_audio) / ((1024 * session.statsInterval) / 8000)); } session.rpcs[UUID].stats._bytesReceived_audio = stat.bytesReceived; } } } } catch (e) { errorlog(e); } }); ////////// if (nominatedCandidate) { if (nominatedCandidate.localCandidateId && session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._local_ice_id && session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._local_ice_id !== nominatedCandidate.localCandidateId) { if ("candidateType" in nominatedCandidate) { try { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].candidateType_local; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_IP; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_protocol; } catch (e) { } } } if (nominatedCandidate.remoteCandidateId && session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._remote_ice_id && session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._remote_ice_id !== nominatedCandidate.remoteCandidateId) { if ("candidateType" in nominatedCandidate) { try { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].candidateType_remote; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_IP; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_protocol; } catch (e) { } } } if (nominatedCandidate.localCandidateId) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._local_ice_id = nominatedCandidate.localCandidateId; } if (nominatedCandidate.remoteCandidateId) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"]._remote_ice_id = nominatedCandidate.remoteCandidateId; } if ("currentRoundTripTime" in nominatedCandidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].Round_Trip_Time_ms = nominatedCandidate.currentRoundTripTime * 1000; } } if (nominatedCandidate && nominatedCandidate.localCandidateId) { if (candidates[nominatedCandidate.localCandidateId]) { var candidate = candidates[nominatedCandidate.localCandidateId]; if ("candidateType" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].candidateType_local = candidate.candidateType; if (candidate.candidateType === "relay") { if ("relayProtocol" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_protocol = candidate.relayProtocol; } else { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_protocol; } if ("ip" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_IP = candidate.ip; } else { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_IP; } // Store URL for QoS hostname extraction (may not exist in all browsers) if ("url" in candidate && candidate.url) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_url = candidate.url; } else { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_url; } session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_ip_blocking = !ipleakingAllowedLocal; } else { try { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_IP; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_protocol; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_relay_url; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_ip_blocking; } catch (e) { } } } if ("networkType" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].local_networkType = candidate.networkType; } } } if (nominatedCandidate && nominatedCandidate.remoteCandidateId) { if (candidates[nominatedCandidate.remoteCandidateId]) { var candidate = candidates[nominatedCandidate.remoteCandidateId]; if ("candidateType" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].candidateType_remote = candidate.candidateType; if (candidate.candidateType === "relay") { if ("ip" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_IP = candidate.ip; } else { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_IP; } if ("relayProtocol" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_protocol = candidate.relayProtocol; } else { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_protocol; } session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_ip_blocking = !ipleakingAllowedRemote; } else { try { delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_IP; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_relay_protocol; delete session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_ip_blocking; } catch (e) { } } } if ("networkType" in candidate) { session.rpcs[UUID].stats["Peer-to-Peer_Connection"].remote_networkType = candidate.networkType; } } } // QoS data accumulation if (session.qosEnabled && session.qosData && !session.qosData.sent) { try { var qd = session.qosData; var peerStats = session.rpcs[UUID].stats["Peer-to-Peer_Connection"]; // Accumulate RTT samples if (peerStats && peerStats.Round_Trip_Time_ms) { qd.rttSamples.push(peerStats.Round_Trip_Time_ms); if (qd.rttSamples.length > 500) qd.rttSamples.shift(); } // Track transport type and TURN servers if (peerStats && peerStats.candidateType_local) { if (peerStats.candidateType_local === "relay") { qd.transportType = "turn"; // Use URL-based hostname instead of IP (graceful degradation if unavailable) if (peerStats.local_relay_url && session.qosTurnAllowlist && session.qosTurnAllowlist.length) { var host = extractTurnHostnameFromUrl(peerStats.local_relay_url); if (host && session.qosTurnAllowlist.includes(host) && !qd.turnServersUsed.includes(host)) { qd.turnServersUsed.push(host); } } // Safari/Firefox may not have url - we still get transportType="turn" } else if (!qd.transportType) { qd.transportType = "p2p"; } if (!qd.candidateTypesLocal.includes(peerStats.candidateType_local)) { qd.candidateTypesLocal.push(peerStats.candidateType_local); } } if (peerStats && peerStats.candidateType_remote && !qd.candidateTypesRemote.includes(peerStats.candidateType_remote)) { qd.candidateTypesRemote.push(peerStats.candidateType_remote); } // Accumulate packet loss and jitter from tracks for (var tid in session.rpcs[UUID].stats) { var trackStat = session.rpcs[UUID].stats[tid]; if (trackStat && typeof trackStat === "object") { if (trackStat._type === "video") { if (trackStat.packetLoss_in_percentage !== undefined) { qd.packetLossVideoSamples.push(trackStat.packetLoss_in_percentage); if (qd.packetLossVideoSamples.length > 500) qd.packetLossVideoSamples.shift(); } if (trackStat.Bitrate_in_kbps) { qd.bitrateSamples.push(trackStat.Bitrate_in_kbps); if (qd.bitrateSamples.length > 500) qd.bitrateSamples.shift(); } if (trackStat.Jitter_Buffer_ms) { qd.jitterSamples.push(trackStat.Jitter_Buffer_ms); if (qd.jitterSamples.length > 500) qd.jitterSamples.shift(); } if (trackStat.codec) qd.lastVideoCodec = trackStat.codec; if (trackStat.Resolution) qd.lastResolution = trackStat.Resolution; } else if (trackStat._type === "audio") { if (trackStat.packetLoss_in_percentage !== undefined) { qd.packetLossAudioSamples.push(trackStat.packetLoss_in_percentage); if (qd.packetLossAudioSamples.length > 500) qd.packetLossAudioSamples.shift(); } if (trackStat.codec) qd.lastAudioCodec = trackStat.codec; } } } } catch (e) { warnlog("QoS accumulation error: " + e); } } playoutdelay(UUID); setTimeout(function () { session.directorSpeakerMute(); session.directorDisplayMute(); }, 0); }); } } catch (e) { errorlog(e); } pokeIframeAPI("view-stats-updated", true, UUID); } // QoS stats collection for publisher outbound connections (session.pcs) // This runs periodically when QoS is enabled to gather stats from publisher connections function processPcsQosStats(UUID) { if (!session.qosEnabled || !session.qosData || session.qosData.sent) return; if (!session.pcs || !(UUID in session.pcs)) return; try { session.pcs[UUID].getStats().then(function(stats) { if (!(UUID in session.pcs)) return; if (!session.qosEnabled || !session.qosData || session.qosData.sent) return; var qd = session.qosData; var nominatedCandidate = null; var candidates = {}; stats.forEach(function(stat) { try { if (stat.id && stat.id.startsWith("DEPRECATED_")) return; if (stat.type === "outbound-rtp") { if (stat.kind === "video") { if (stat.qualityLimitationReason) { session.pcs[UUID].stats.quality_limitation_reason = stat.qualityLimitationReason; } if (stat.frameWidth && stat.frameHeight) { var res = stat.frameWidth + " x " + stat.frameHeight; if (stat.framesPerSecond) { res += " @ " + stat.framesPerSecond; } session.pcs[UUID].stats.resolution = res; if (!qd.lastResolution) qd.lastResolution = res; } if (stat.encoderImplementation) { session.pcs[UUID].stats.encoder = stat.encoderImplementation; if (!qd.lastVideoCodec) qd.lastVideoCodec = stat.encoderImplementation; } // Track bitrate for publishers if (stat.bytesSent !== undefined && stat.timestamp) { if (session.pcs[UUID].stats._lastBytesSent !== undefined) { var timeDiff = stat.timestamp - session.pcs[UUID].stats._lastTimestamp; if (timeDiff > 0) { var bitrate = parseInt((8 * (stat.bytesSent - session.pcs[UUID].stats._lastBytesSent)) / timeDiff); if (bitrate > 0) { qd.bitrateSamples.push(bitrate); if (qd.bitrateSamples.length > 500) qd.bitrateSamples.shift(); } } } session.pcs[UUID].stats._lastBytesSent = stat.bytesSent; session.pcs[UUID].stats._lastTimestamp = stat.timestamp; } } } else if (stat.type === "remote-candidate") { candidates[stat.id] = stat; if (stat.relayProtocol && stat.ip) { session.pcs[UUID].stats.remote_relay_IP = stat.ip; session.pcs[UUID].stats.remote_relayProtocol = stat.relayProtocol; } if (stat.candidateType) { session.pcs[UUID].stats.candidateType_remote = stat.candidateType; } } else if (stat.type === "local-candidate") { candidates[stat.id] = stat; if (stat.relayProtocol && stat.ip) { session.pcs[UUID].stats.local_relayIP = stat.ip; session.pcs[UUID].stats.local_relayProtocol = stat.relayProtocol; } if (stat.candidateType) { session.pcs[UUID].stats.candidateType_local = stat.candidateType; } } else if (stat.type === "candidate-pair" && stat.nominated) { if (!nominatedCandidate || nominatedCandidate.priority < stat.priority) { nominatedCandidate = stat; } } else if (stat.type === "remote-inbound-rtp") { // Packet loss from remote peer if (stat.packetsLost !== undefined && stat.packetsReceived !== undefined) { var total = stat.packetsLost + stat.packetsReceived; if (total > 0) { var lossPercent = (stat.packetsLost / total) * 100; if (stat.kind === "video") { qd.packetLossVideoSamples.push(lossPercent); if (qd.packetLossVideoSamples.length > 500) qd.packetLossVideoSamples.shift(); } else if (stat.kind === "audio") { qd.packetLossAudioSamples.push(lossPercent); if (qd.packetLossAudioSamples.length > 500) qd.packetLossAudioSamples.shift(); } } } // Jitter from remote peer if (stat.jitter !== undefined) { var jitterMs = stat.jitter * 1000; qd.jitterSamples.push(jitterMs); if (qd.jitterSamples.length > 500) qd.jitterSamples.shift(); } } } catch (e) { } }); // Process nominated candidate for RTT and transport type if (nominatedCandidate) { // RTT if (nominatedCandidate.totalRoundTripTime && nominatedCandidate.responsesReceived) { var rtt = parseInt((nominatedCandidate.totalRoundTripTime / nominatedCandidate.responsesReceived) * 1000); session.pcs[UUID].stats.average_roundTripTime_ms = rtt; qd.rttSamples.push(rtt); if (qd.rttSamples.length > 500) qd.rttSamples.shift(); } if (nominatedCandidate.currentRoundTripTime) { var currentRtt = parseInt(nominatedCandidate.currentRoundTripTime * 1000); qd.rttSamples.push(currentRtt); if (qd.rttSamples.length > 500) qd.rttSamples.shift(); } // Transport type from nominated candidate var localCandidate = candidates[nominatedCandidate.localCandidateId]; var remoteCandidate = candidates[nominatedCandidate.remoteCandidateId]; if (localCandidate) { if (localCandidate.candidateType === "relay") { qd.transportType = "turn"; // Use stat.url to get TURN hostname (official servers only) // Note: stat.url may not be available in all browsers (graceful degradation) if (localCandidate.url && session.qosTurnAllowlist && session.qosTurnAllowlist.length) { var host = extractTurnHostnameFromUrl(localCandidate.url); if (host && session.qosTurnAllowlist.includes(host) && !qd.turnServersUsed.includes(host)) { qd.turnServersUsed.push(host); } } // If url unavailable (Safari/Firefox), we still track transportType="turn" // but skip hostname - better no data than wrong/private data } else if (!qd.transportType || qd.transportType === "unknown") { qd.transportType = "p2p"; } if (!qd.candidateTypesLocal.includes(localCandidate.candidateType)) { qd.candidateTypesLocal.push(localCandidate.candidateType); } } if (remoteCandidate && !qd.candidateTypesRemote.includes(remoteCandidate.candidateType)) { qd.candidateTypesRemote.push(remoteCandidate.candidateType); } } // Schedule next stats collection (every 5 seconds) if (UUID in session.pcs && session.qosEnabled && session.qosData && !session.qosData.sent) { clearTimeout(session.pcs[UUID].qosStatsTimeout); session.pcs[UUID].qosStatsTimeout = setTimeout(processPcsQosStats, 5000, UUID); } }).catch(function(e) { warnlog("QoS pcs stats error: " + e); }); } catch (e) { warnlog("QoS pcs stats error: " + e); } } function createConnectionDetailsEle(UUID) { if (!session.rpcs[UUID]) { return false; } session.rpcs[UUID].connectionDetails = document.createElement("div"); session.rpcs[UUID].connectionDetails.id = "remoteConnections_" + UUID; if (session.rpcs[UUID].stats.info && "total_outbound_p2p_connections" in session.rpcs[UUID].stats.info) { session.rpcs[UUID].connectionDetails.innerText = "🔗" + session.rpcs[UUID].stats.info.total_outbound_p2p_connections; session.rpcs[UUID].connectionDetails.dataset.value = session.rpcs[UUID].stats.info.total_outbound_p2p_connections; } session.rpcs[UUID].connectionDetails.dataset.UUID = UUID; session.rpcs[UUID].connectionDetails.title = getTranslation("viewer-count"); session.rpcs[UUID].connectionDetails.className = "rem-con-count"; session.rpcs[UUID].connectionDetails.addEventListener("click", function (e) { // show stats of video if double clicked log("clicked connectionDetails icon "); try { e.preventDefault(); if (session.statsMenu !== false) { var uid = e.currentTarget.dataset.UUID; if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } e.stopPropagation(); return false; } catch (e) { errorlog(e); } }); return true; } function playoutdelay(UUID) { // applies a delay to all videos try { if ((session.rpcs[UUID].buffer !== false) || (session.buffer != false) || (session.audioBuffer !== false)) { // if buffer is set, then session.sync will be set; at least to 0. var receivers = getReceivers2(UUID).reverse() || []; //session.rpcs[UUID].getReceivers().reverse(); if (session.rpcs[UUID].whep) { receivers = receivers.concat(getReceiversMC(UUID).reverse()); // if I try to reuse getReceivers2, I get some confused stats (not able to tell tracks apart) TODO: see if this issue is a problem else where, esp with screen shares. sstype==3 } receivers.forEach(function (receiver) { try { for (var tid in session.rpcs[UUID].stats) { if (typeof session.rpcs[UUID].stats[tid] == "object" && "_trackID" in session.rpcs[UUID].stats[tid] && session.rpcs[UUID].stats[tid]._trackID === receiver.track.id && session.rpcs[UUID].stats[tid]._type == receiver.track.kind && "Jitter_Buffer_ms" in session.rpcs[UUID].stats[tid]) { //if (ChromiumVersion<=103){ // I don't know the exact version, except I know OBS Studio is 103 and it uses the old way still.netwqor var sync_offset = 0.0; if (session.rpcs[UUID].stats[tid]._sync_offset) { sync_offset = session.rpcs[UUID].stats[tid]._sync_offset || 0; } else { session.rpcs[UUID].stats[tid]._sync_offset = 0; } var target_buffer = 0; if (session.rpcs[UUID].stats[tid]._type == "audio") { if (session.audioBuffer !== false) { target_buffer = parseFloat(session.audioBuffer || 0); } else { target_buffer = parseFloat(session.buffer || 0); } } else { target_buffer = parseFloat(session.buffer || 0); } if (session.rpcs[UUID].buffer !== false) { target_buffer = parseFloat(session.rpcs[UUID].buffer); } sync_offset += target_buffer; sync_offset -= session.rpcs[UUID].stats[tid].Jitter_Buffer_ms || 0; if (session.includeRTT && session.rpcs[UUID].stats["Peer-to-Peer_Connection"]) { sync_offset -= parseInt(session.rpcs[UUID].stats["Peer-to-Peer_Connection"].Round_Trip_Time_ms / 2) || 0; // I can't be sure what the actual one-way delay is } if (sync_offset > target_buffer) { sync_offset = target_buffer || 0; } if (sync_offset < 0) { sync_offset = 0; } session.rpcs[UUID].stats[tid].Added_Buffer_Delay_ms = sync_offset || 0; if (session.rpcs[UUID].stats["Peer-to-Peer_Connection"]) { session.rpcs[UUID].stats[tid].Total_Playout_Delay_ms = sync_offset + parseInt(session.rpcs[UUID].stats["Peer-to-Peer_Connection"].Round_Trip_Time_ms / 2) + session.rpcs[UUID].stats[tid].Jitter_Buffer_ms || 0; } if (session.rpcs[UUID].stats[tid]._type == "audio") { session.rpcs[UUID].stats[tid]._sync_offset = sync_offset || 0; if ("jitterBufferTarget" in receiver) { receiver.jitterBufferTarget = parseFloat(sync_offset) || 0; } else { receiver.playoutDelayHint = parseFloat(sync_offset / 1000) || 0; } // receiver.jitterBufferDelayhint = parseFloat(sync_offset/1000); // This is deprecated I believe if (session.sync !== false) { var audio_delay = session.sync || 0; // video is typically showing greater delay than audio. audio_delay += target_buffer - session.rpcs[UUID].stats[tid].Jitter_Buffer_ms || 0; if (receiver.track.kind == "audio" && receiver.track.id in session.rpcs[UUID].inboundAudioPipeline) { if (session.rpcs[UUID].inboundAudioPipeline[receiver.track.id] && session.rpcs[UUID].inboundAudioPipeline[receiver.track.id].delayNode) { if (audio_delay < 0) { audio_delay = 0; } try { session.rpcs[UUID].inboundAudioPipeline[receiver.track.id].delayNode.delayTime.linearRampToValueAtTime(parseFloat(audio_delay / 1000.0), session.audioCtx.currentTime + parseFloat(session.statsInterval / 9000)); } catch (e) { session.rpcs[UUID].inboundAudioPipeline[receiver.track.id].delayNode.delayTime.setValueAtTime(parseFloat(audio_delay / 1000.0), session.audioCtx.currentTime + 1); } session.rpcs[UUID].stats[tid].Audio_Sync_Delay_ms = audio_delay || 0; } } } } else if (session.rpcs[UUID].stats[tid]._type == "video") { session.rpcs[UUID].stats[tid]._sync_offset = sync_offset || 0; if ("jitterBufferTarget" in receiver) { receiver.jitterBufferTarget = parseFloat(sync_offset) || 0; } else { receiver.playoutDelayHint = parseFloat(sync_offset / 1000) || 0; } // receiver.jitterBufferDelayhint = parseFloat(sync_offset/1000); // This is deprecated I believe } } } } catch (e) { errorlog(e); } }); } } catch (e) { errorlog(e); warnlog("device does not support playout delay"); } } function printViewStats(menu, UUID) { // Stats for viewing a remote video if (session.statsMenu === false) { return false; } if (!session.rpcs[UUID]) { menu.innerHTML = "


Remote Publisher Disconnected"; return false; } var statsObj = session.rpcs[UUID].stats; var streamID = session.rpcs[UUID].streamID; var scrollLeft = menu.scrollLeft; var scrollTop = menu.scrollTop; menu.innerHTML = "StreamID: " + streamID + "
"; if (statsObj.chunked_mode_video && typeof statsObj.chunked_mode_video.buffer_buffer !== "undefined") { var chunkVideo = statsObj.chunked_mode_video; var chunkSummary = "Video Buffer: " + parseInt(chunkVideo.buffer_buffer || 0) + " ms / Δ " + parseInt(chunkVideo.buffer_delta || 0) + " ms"; if (chunkVideo.rebuffering) { chunkSummary += " (rebuffering)"; } menu.innerHTML += chunkSummary + "
"; if (typeof chunkVideo.fec_repairs !== "undefined" || typeof chunkVideo.nacks_sent !== "undefined") { var repairSummary = []; if (typeof chunkVideo.fec_repairs !== "undefined") { repairSummary.push("FEC " + parseInt(chunkVideo.fec_repairs || 0)); } if (typeof chunkVideo.nacks_sent !== "undefined") { repairSummary.push("NACK " + parseInt(chunkVideo.nacks_sent || 0)); } if (repairSummary.length) { menu.innerHTML += "Video Repairs: " + repairSummary.join(" / ") + "
"; } } } if (statsObj.chunked_mode_audio && typeof statsObj.chunked_mode_audio.buffer_buffer !== "undefined") { var chunkAudio = statsObj.chunked_mode_audio; var audioSummary = "Audio Buffer: " + parseInt(chunkAudio.buffer_buffer || 0) + " ms / Δ " + parseInt(chunkAudio.buffer_delta || 0) + " ms"; if (chunkAudio.rebuffering) { audioSummary += " (rebuffering)"; } menu.innerHTML += audioSummary + "
"; } //// doesn't work on viewer side. //if (session.rpcs && session.rpcs[UUID] && session.rpcs[UUID] && session.rpcs[UUID].restartIce){ // only show if available // menu.innerHTML += ""; //} menu.innerHTML += printValues(statsObj); menu.scrollTop = scrollTop; menu.scrollLeft = scrollLeft; return true; } function plotDataSimple(canvas, bitrate, nacks = 0) { canvas.height = 50; canvas.width = 124; canvas.className = "canvasStats"; var context = canvas.getContext("2d"); if (isNaN(bitrate)) { bitrate = 0; } if (isNaN(nacks)) { nacks = 0; } var height = context.canvas.height; var width = context.canvas.width; var val = (10 - nacks) / 10; if (val > 1) { val = 1; } else if (val < 0) { val = 0; } var yScale = height / 4000; var x = width - 1; var y = height - bitrate * yScale; var w = 1; context.fillStyle = getColor(val); context.fillRect(x, y, w, height); context.fillStyle = "#FFFFFF55"; context.fillRect(x, y - 2, w, 4); if (y - 5 > 0) { context.fillStyle = "#FFFFFF44"; context.fillRect(x, y + 2, w, 1); } context.putImageData(context.getImageData(1, 0, width - 1, height), 0, 0); context.clearRect(width - 1, 0, 1, height); } function printValues(obj, sort = false) { // see: printViewStats var out = ""; var keys = Object.keys(obj); if (sort) { keys.sort(); } var lat = false; var lon = false; keys.forEach(key => { if (key.startsWith("_")) return; if (typeof obj[key] === "object" && obj[key] !== null) { let tmp = sanitizeChat(key); out += `
  • ${tmp}

  • `; if (key == "info") { out += printValues(obj[key]); } else if (key == "meta") { out += `
  • `; Object.entries(obj[key]).forEach(([category, data]) => { if (data.type === "file" && data.filetype && data.filetype.startsWith("image/")) { out += `
    ${data.label || category}
    ${category} ${formatFileSize(data.size)}
    `; } else if (data.type === "url") { out += ``; } else { out += `
    ${category}: ${data.value || ''}
    `; } }); out += `
  • `; } else { out += printValues(obj[key], true); } } else { try { var unit = ""; var value = obj[key]; var stat = sanitizeChat(key); stat = stat.charAt(0).toUpperCase() + stat.slice(1); var hint = ""; if (typeof obj[key] == "string") { value = sanitizeChat(value); } if (key == "useragent") { value = "" + value + ""; } if (key == "Bitrate_in_kbps") { var unit = " kbps"; stat = "Bitrate"; hint = "You can refer to the documentation for ways to increase the target bitrate"; } else if (key == "type") { var unit = ""; stat = "Type"; if (value == "Audio Track") { value = "🔊 " + value; //out += ""; } if (value == "Video Track") { value = "📺 " + value; } } else if (key == "packetLoss_in_percentage") { var unit = " %"; stat = "Packet Loss 📶"; value = parseInt(parseFloat(value) * 10000) / 10000.0; hint = "A high packet loss will lower quality of the media"; } else if (key == "local_relay_IP") { value = "" + value + ""; } else if (key == "remote_relay_IP") { value = "" + value + ""; } else if (key == "local_ip_blocking" && value) { console.warn("Your system or connection is blocking p2p traffic"); value = "⚠️ You're blocking"; hint = "no direct p2p connection made because of YOUR browser or system setting"; } else if (key == "remote_ip_blocking" && value) { console.warn("A remote client is blocking p2p traffic"); value = "⚠️ They're blocking"; hint = "no direct p2p connection made because of THEIR browser or system setting"; } else if (key == "candidateType_local" && value == "relay") { value = "💸 relay server"; hint = "no direct p2p connection made; using the TURN relay servers."; stat = "Candidate type - Local"; } else if (key == "candidateType_remote" && value == "relay") { value = "💸 relay server"; hint = "no direct p2p connection made; using the TURN relay servers."; stat = "Candidate type - Remote"; } else if (key == "candidateType_local" && value == "host") { hint = "No NAT firewall, typical of LAN to LAN"; stat = "Candidate type - Local"; } else if (key == "candidateType_remote" && value == "host") { hint = "No NAT firewall, typical of LAN to LAN"; stat = "Candidate type - Remote"; } else if (key == "candidateType_local" && value == "srflx") { hint = "direct p2p, but NAT firewall likely"; stat = "Candidate type - Local"; } else if (key == "candidateType_remote" && value == "srflx") { hint = "direct p2p, but NAT firewall likely"; stat = "Candidate type - Remote"; } else if (key == "height_url") { if (value == false) { return; } } else if (key == "width_url") { if (value == false) { return; } } else if (key == "height_url") { if (value == false) { return; } } else if (key == "version") { stat = "VDO.Ninja Version"; } else if (key == "platform") { stat = "Platform (OS)"; } else if (key == "iPhone12Up") { stat = "iPhone 12 and up"; } else if (key == "aec_url") { stat = "Echo-Cancellation"; } else if (key == "agc_url") { stat = "Auto-Gain (agc)"; } else if (key == "denoise_url") { stat = "De-noising "; } else if (key == "audio_level") { stat = "Audio Level"; } else if (key == "Jitter_Buffer_ms") { var unit = " ms"; stat = "Jitter Buffer Delay"; } else if (key == "Added_Buffer_Delay_ms") { var unit = " ms"; stat = "Added Buffer Delay"; hint = "Value of playout buffer delay added if using &buffer"; } else if (key == "Total_Playout_Delay_ms") { // doesn't include bluetooth / monitor / capture delay, etc. var unit = " ms"; stat = "Total Playout Delay"; hint = "Network latency + Jitter buffer + any manually added playout delay"; } else if (value === null) { value = "null"; } else if (key == "stereo_url") { stat = "Pro-Audio
    (Stereo-mode)"; if (value == 3) { value = "3 (outbound hi-fi)
    Use Headphones"; } else if (value == 1) { value = "1 (in & out hi-fi)
    Use Headphones"; } else if (value == 2) { value = "3 (inbound hi-fi)"; } else if (value == 4) { value = "3 (multichannel)
    Use Headphones"; } else if (value == 5) { value = "5 (auto-mode)
    Use Headphones"; } } else if (value === false) { return; } else if (value === "false") { return; } else if (key == "lat") { lat = value; if (lat && lon) { const mapWidth = 250; const mapHeight = 250; const x = (lon + 180) * (mapWidth / 360); const mapRatio = mapHeight / mapWidth; const latRad = (lat * Math.PI) / 180; const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2)); const y = mapHeight / 2 - ((mapWidth * mercN) / (2 * Math.PI)) * mapRatio; out += '
    \ World Map\
    \
    '; } } else if (key == "lon") { lon = value; if (lat && lon) { const mapWidth = 250; const mapHeight = 250; const x = (lon + 180) * (mapWidth / 360); const mapRatio = mapHeight / mapWidth; const latRad = (lat * Math.PI) / 180; const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2)); const y = mapHeight / 2 - ((mapWidth * mercN) / (2 * Math.PI)) * mapRatio; out += '
    \ World Map\
    \
    '; out += 'Open Location in Google Maps'; } } stat = stat.replaceAll("_", " "); stat = stat.trim(); if (hint) { out += "
  • " + stat + "" + value + unit + "
  • "; } else { out += "
  • " + stat + "" + value + unit + "
  • "; } } catch (e) { warnlog(e); } } }); return out; } function processMeshcastStats(UUID) { try { session.rpcs[UUID].whep.getStats().then(function (stats) { if (!(UUID in session.rpcs)) { return; } if (!session.rpcs[UUID].stats["WHEP_Connection"]) { // meshcast session.rpcs[UUID].stats["WHEP_Connection"] = {}; } // var qos = false;] var nominatedCandidate = false; var candidates = {}; var ipleakingAllowedRemote = false; var ipleakingAllowedLocal = false; stats.forEach(stat => { if (stat.id && stat.id.startsWith("DEPRECATED_")) { return; } var trackID = stat.trackIdentifier || stat.id || false; if (stat.type == "remote-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedRemote = true; } } else if (stat.type == "local-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedLocal = true; } } else if (stat.type == "candidate-pair" && stat.nominated) { if (!nominatedCandidate) { nominatedCandidate = stat; } else if (nominatedCandidate.priority < stat.priority) { nominatedCandidate = stat; } } else if (stat.type == "track" && stat.remoteSource) { if (stat.id in session.rpcs[UUID].stats) { session.rpcs[UUID].stats[stat.id]._trackID = stat.trackIdentifier; session.rpcs[UUID].stats[stat.id].Jitter_Buffer_ms = parseInt((1000 * (parseFloat(stat.jitterBufferDelay) - session.rpcs[UUID].stats[stat.id]._jitter_delay)) / (parseInt(stat.jitterBufferEmittedCount) - session.rpcs[UUID].stats[stat.id]._jitter_count)) || 0; session.rpcs[UUID].stats[stat.id]._jitter_delay = parseFloat(stat.jitterBufferDelay) || 0; session.rpcs[UUID].stats[stat.id]._jitter_count = parseInt(stat.jitterBufferEmittedCount) || 0; if ("frameWidth" in stat) { if ("frameHeight" in stat) { session.rpcs[UUID].stats[stat.id].Resolution = stat.frameWidth + " x " + stat.frameHeight; session.rpcs[UUID].stats[stat.id]._frameWidth = stat.frameWidth; session.rpcs[UUID].stats[stat.id]._frameHeight = stat.frameHeight; } } } else { session.rpcs[UUID].stats[stat.id] = {}; session.rpcs[UUID].stats[stat.id]._jitter_delay = parseFloat(stat.jitterBufferDelay) || 0; session.rpcs[UUID].stats[stat.id]._jitter_count = parseInt(stat.jitterBufferEmittedCount) || 0; session.rpcs[UUID].stats[stat.id].Jitter_Buffer_ms = 0; session.rpcs[UUID].stats[stat.id]._trackID = stat.trackIdentifier; if (stat.kind && stat.kind == "audio") { session.rpcs[UUID].stats[stat.id].type = "Audio Track"; session.rpcs[UUID].stats[stat.id]._type = "audio"; } else if (stat.kind && stat.kind == "video") { session.rpcs[UUID].stats[stat.id].type = "Video Track"; session.rpcs[UUID].stats[stat.id]._type = "video"; } } } else if (stat.type == "transport") { if ("bytesReceived" in stat) { if ("_bytesReceived" in session.rpcs[UUID].stats["WHEP_Connection"]) { if (session.rpcs[UUID].stats["WHEP_Connection"]._timestamp) { if (stat.timestamp) { session.rpcs[UUID].stats["WHEP_Connection"].total_recv_bitrate_kbps = parseInt((8 * (stat.bytesReceived - session.rpcs[UUID].stats["WHEP_Connection"]._bytesReceived)) / (stat.timestamp - session.rpcs[UUID].stats["WHEP_Connection"]._timestamp)); } } } session.rpcs[UUID].stats["WHEP_Connection"]._bytesReceived = stat.bytesReceived; } if ("timestamp" in stat) { session.rpcs[UUID].stats["WHEP_Connection"]._timestamp = stat.timestamp; if (!session.rpcs[UUID].stats["WHEP_Connection"]._timestampStart) { session.rpcs[UUID].stats["WHEP_Connection"]._timestampStart = stat.timestamp; } else { session.rpcs[UUID].stats["WHEP_Connection"].time_active_minutes = parseInt((stat.timestamp - session.rpcs[UUID].stats["WHEP_Connection"]._timestampStart) / 600) / 100; } } } else if (stat.type == "inbound-rtp" && trackID) { session.rpcs[UUID].stats[trackID] = session.rpcs[UUID].stats[trackID] || {}; if (stat.trackIdentifier) { session.rpcs[UUID].stats[trackID]._trackID = stat.trackIdentifier; } if ("jitterBufferDelay" in stat) { session.rpcs[UUID].stats[trackID].Jitter_Buffer_ms = parseInt((1000 * (parseFloat(stat.jitterBufferDelay) - session.rpcs[UUID].stats[trackID]._jitter_delay_2)) / (parseInt(stat.jitterBufferEmittedCount) - session.rpcs[UUID].stats[trackID]._jitter_count_2)) || 0; session.rpcs[UUID].stats[trackID]._jitter_delay_2 = parseFloat(stat.jitterBufferDelay) || 0; session.rpcs[UUID].stats[trackID]._jitter_count_2 = parseInt(stat.jitterBufferEmittedCount) || 0; } if ("frameWidth" in stat) { if ("frameHeight" in stat) { session.rpcs[UUID].stats[trackID].Resolution = stat.frameWidth + " x " + stat.frameHeight; session.rpcs[UUID].stats[trackID]._frameWidth = stat.frameWidth; session.rpcs[UUID].stats[trackID]._frameHeight = stat.frameHeight; } } session.rpcs[UUID].stats[trackID].Bitrate_in_kbps = parseInt((8 * (stat.bytesReceived - (session.rpcs[UUID].stats[trackID]._last_bytes || 0))) / (stat.timestamp - session.rpcs[UUID].stats[trackID]._last_time)); session.rpcs[UUID].stats[trackID]._last_bytes = stat.bytesReceived || session.rpcs[UUID].stats[trackID]._last_bytes; session.rpcs[UUID].stats[trackID]._last_time = stat.timestamp || session.rpcs[UUID].stats[trackID]._last_time; session.rpcs[UUID].stats._codecId = stat.codecId; session.rpcs[UUID].stats._codecIdTrackId = trackID; if (stat.mediaType == "video") { session.rpcs[UUID].stats[trackID].type = "Video Stream"; session.rpcs[UUID].stats[trackID]._type = "video"; if (session.obsfix && "codec" in session.rpcs[UUID].stats && session.rpcs[UUID].stats.codec == "video/VP8") { session.rpcs[UUID].stats[trackID].pliDelta = stat.pliCount - session.rpcs[UUID].stats[trackID].keyFramesRequested_pli || 0; session.rpcs[UUID].stats[trackID].nackTrigger = stat.nackCount - session.rpcs[UUID].stats[trackID].streamErrors_nackCount + session.rpcs[UUID].stats[trackID].nackTrigger || 0; log("OBS PLI FIX MODE ON"); if (session.rpcs[UUID].stats[trackID].pliDelta === 0 && session.rpcs[UUID].stats[trackID].nackTrigger >= session.obsfix) { // heavy packet loss with no pliCount? session.requestKeyframe(UUID); session.rpcs[UUID].stats[trackID].nackTrigger = 0; log("TRYING KEYFRAME"); } else if (session.rpcs[UUID].stats[trackID].pliDelta > 0) { session.rpcs[UUID].stats[trackID].nackTrigger = 0; } } else if (session.obsfix && "codec" in session.rpcs[UUID].stats && session.rpcs[UUID].stats.codec == "video/VP9") { session.rpcs[UUID].stats[trackID].pliDelta = stat.pliCount - session.rpcs[UUID].stats[trackID].keyFramesRequested_pli || 0; session.rpcs[UUID].stats[trackID].nackTrigger = stat.nackCount - session.rpcs[UUID].stats[trackID].streamErrors_nackCount + session.rpcs[UUID].stats[trackID].nackTrigger || 0; log("OBS PLI FIX MODE ON"); if (session.rpcs[UUID].stats[trackID].pliDelta === 0 && session.rpcs[UUID].stats[trackID].nackTrigger >= session.obsfix * 4) { // heavy packet loss with no pliCount? well, VP9 will trigger hopefully not as often. session.requestKeyframe(UUID); session.rpcs[UUID].stats[trackID].nackTrigger = 0; log("TRYING KEYFRAME"); } else if (session.rpcs[UUID].stats[trackID].pliDelta > 0) { session.rpcs[UUID].stats[trackID].nackTrigger = 0; } } session.rpcs[UUID].stats[trackID].keyFramesRequested_pli = stat.pliCount || 0; session.rpcs[UUID].stats[trackID].streamErrors_nackCount = stat.nackCount || 0; //warnlog(stat); if ("framesPerSecond" in stat) { session.rpcs[UUID].stats[trackID].FPS = parseInt(stat.framesPerSecond); } else if ("framesDecoded" in stat && stat.timestamp) { var lastFramesDecoded = 0; var lastTimestamp = 0; try { lastFramesDecoded = session.rpcs[UUID].stats[trackID]._framesDecoded; lastTimestamp = session.rpcs[UUID].stats[trackID]._timestamp; } catch (e) { } session.rpcs[UUID].stats[trackID].FPS = parseInt((10 * (stat.framesDecoded - lastFramesDecoded)) / (stat.timestamp / 1000 - lastTimestamp)) / 10; //session.rpcs[UUID].stats[trackID].FPS = parseInt((stat.framesDecoded - lastFramesDecoded)/(stat.timestamp/1000 - lastTimestamp)); session.rpcs[UUID].stats[trackID]._framesDecoded = stat.framesDecoded; session.rpcs[UUID].stats[trackID]._timestamp = stat.timestamp / 1000; } } else if (stat.mediaType == "audio") { //log("AUDIO LEVEL: "+stat.audioLevel); session.rpcs[UUID].stats[trackID].type = "Audio Stream"; session.rpcs[UUID].stats[trackID]._type = "audio"; if ("audioLevel" in stat) { session.rpcs[UUID].stats[trackID].audio_level = parseInt(parseFloat(stat.audioLevel) * 10000) / 10000.0; } } if ("packetsLost" in stat && "packetsReceived" in stat) { if (!("_packetsLost" in session.rpcs[UUID].stats[trackID])) { session.rpcs[UUID].stats[trackID]._packetsLost = stat.packetsLost; } if (!("_packetsReceived" in session.rpcs[UUID].stats[trackID])) { session.rpcs[UUID].stats[trackID]._packetsReceived = stat.packetsReceived; } if (!("packetLoss_in_percentage" in session.rpcs[UUID].stats[trackID])) { session.rpcs[UUID].stats[trackID].packetLoss_in_percentage = 0; } session.rpcs[UUID].stats[trackID].packetLoss_in_percentage = session.rpcs[UUID].stats[trackID].packetLoss_in_percentage * 0.35 + (0.65 * ((stat.packetsLost - session.rpcs[UUID].stats[trackID]._packetsLost) * 100.0)) / (stat.packetsReceived - session.rpcs[UUID].stats[trackID]._packetsReceived + (stat.packetsLost - session.rpcs[UUID].stats[trackID]._packetsLost)) || 0; if (session.rpcs[UUID].stats[trackID]._type === "video") { qos = session.rpcs[UUID].stats[trackID].packetLoss_in_percentage; // packet loss of video track } if (session.rpcs[UUID].signalMeter && session.rpcs[UUID].stats[trackID]._type === "video") { if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 0.01) { if (session.rpcs[UUID].stats[trackID].Bitrate_in_kbps == 0) { session.rpcs[UUID].signalMeter.dataset.level = 0; } else { session.rpcs[UUID].signalMeter.dataset.level = 5; } } else if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 0.3) { session.rpcs[UUID].signalMeter.dataset.level = 4; } else if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 1.0) { session.rpcs[UUID].signalMeter.dataset.level = 3; } else if (session.rpcs[UUID].stats[trackID].packetLoss_in_percentage < 3.5) { session.rpcs[UUID].signalMeter.dataset.level = 2; } else { session.rpcs[UUID].signalMeter.dataset.level = 1; } } session.rpcs[UUID].stats[trackID]._packetsReceived = stat.packetsReceived; session.rpcs[UUID].stats[trackID]._packetsLost = stat.packetsLost; } } else if ("_codecId" in session.rpcs[UUID].stats && stat.id == session.rpcs[UUID].stats._codecId) { if ("mimeType" in stat) { if (session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId]) { session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId].codec = stat.mimeType; } else { session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId] = {}; session.rpcs[UUID].stats[session.rpcs[UUID].stats._codecIdTrackId].codec = stat.mimeType; } } if ("frameHeight" in stat) { if ("frameWidth" in stat) { session.rpcs[UUID].stats.Resolution = parseInt(stat.frameWidth) + " x " + parseInt(stat.frameHeight); } } } else if (Firefox) { if ("mimeType" in stat && "type" in stat && "id" in stat && stat.type == "codec") { if (stat.mimeType.includes("video")) { session.rpcs[UUID].stats.video_codec = stat.mimeType.split("video/")[1]; } else if (stat.mimeType.includes("audio")) { session.rpcs[UUID].stats.audio_codec = stat.mimeType.split("audio/")[1]; if (stat.clockRate) { session.rpcs[UUID].stats.audio_clockRate = stat.clockRate; if (stat.channels) { session.rpcs[UUID].stats.audio_clockRate += " / " + stat.channels; } } else if (stat.sdpFmtpLine) { session.rpcs[UUID].stats.fmtp = stat.sdpFmtpLine; } } } if ("frameWidth" in stat) { session.rpcs[UUID].stats.resolution = stat.frameWidth + " x " + stat.frameHeight; if ("framesPerSecond" in stat) { session.rpcs[UUID].stats.resolution += " @ " + stat.framesPerSecond; } } if ("bytesReceived" in stat) { if ("kind" in stat && stat.kind == "video") { if ("_bytesReceived_video" in session.rpcs[UUID].stats) { session.rpcs[UUID].stats.videoBitrate_kbps = parseInt((stat.bytesReceived - session.rpcs[UUID].stats._bytesReceived_video) / ((1024 * session.statsInterval) / 8000)); } session.rpcs[UUID].stats._bytesReceived_video = stat.bytesReceived; } else if ("kind" in stat && stat.kind == "audio") { if ("_bytesReceived_audio" in session.rpcs[UUID].stats) { session.rpcs[UUID].stats.audioBitrate_kbps = parseInt((stat.bytesReceived - session.rpcs[UUID].stats._bytesReceived_audio) / ((1024 * session.statsInterval) / 8000)); } session.rpcs[UUID].stats._bytesReceived_audio = stat.bytesReceived; } } } }); //////////// if (nominatedCandidate) { if ("currentRoundTripTime" in nominatedCandidate) { session.rpcs[UUID].stats["WHEP_Connection"].Round_Trip_Time_ms = nominatedCandidate.currentRoundTripTime * 1000; } } if (nominatedCandidate && nominatedCandidate.remoteCandidateId) { if (candidates[nominatedCandidate.remoteCandidateId]) { var candidate = candidates[nominatedCandidate.remoteCandidateId]; if ("candidateType" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].candidateType_remote = candidate.candidateType; if (candidate.candidateType === "relay") { if ("relayProtocol" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].remote_relay_protocol = candidate.relayProtocol; } if ("ip" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].remote_relay_IP = candidate.ip; } } else { try { delete session.rpcs[UUID].stats["WHEP_Connection"].local_relay_IP; delete session.rpcs[UUID].stats["WHEP_Connection"].local_relay_protocol; } catch (e) { } } if ("networkType" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].remote_networkType = candidate.networkType; } } } } if (nominatedCandidate && nominatedCandidate.localCandidateId) { if (candidates[nominatedCandidate.localCandidateId]) { var candidate = candidates[nominatedCandidate.localCandidateId]; if ("candidateType" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].candidateType_local = candidate.candidateType; if (candidate.candidateType === "relay") { if ("relayProtocol" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].local_relay_protocol = candidate.relayProtocol; } if ("ip" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].local_relay_IP = candidate.ip; } } else { try { delete session.rpcs[UUID].stats["WHEP_Connection"].local_relay_IP; delete session.rpcs[UUID].stats["WHEP_Connection"].local_relay_protocol; } catch (e) { } } } if ("networkType" in candidate) { session.rpcs[UUID].stats["WHEP_Connection"].local_networkType = candidate.networkType; } } } /* try{ // we want to let meshcast know if our node is getting overloaded, to avoid making it worse if ((qos!==false) && session.rpcs[UUID].settings && session.rpcs[UUID].settings.url){ var request = new XMLHttpRequest(); var node = session.rpcs[UUID].settings.url.split("https://")[1].split(".meshcast.io")[0]; if (node){ request.open('POST', " https://qos.meshcast.io/?name="+node); request.send(qos); } } } catch(e){ errorlog(e); } */ //if (session.buffer!==false){ playoutdelay(UUID); // it will handle itself for now on I guess //} }); } catch (e) { errorlog(e); } } function safeAppendToMenu(menu, text) { const li = document.createElement("li"); const h2 = document.createElement("h2"); h2.title = text; h2.textContent = text; // Safely assigns text content, avoiding HTML parsing li.appendChild(h2); menu.appendChild(li); } function printMyStats(menu, screenshare = false) { // see: setupStatsMenu if (!session) { return; } var scrollLeft = getById("menuStatsBox").scrollLeft; var scrollTop = getById("menuStatsBox").scrollTop; menu.innerHTML = ""; try { session.stats.outbound_connections = Object.keys(session.pcs).length; session.stats.inbound_connections = Object.keys(session.rpcs).length; } catch (e) { } try { var obscam = false; if (document.querySelector("select#videoSource3")) { var videoSelect = document.querySelector("select#videoSource3").options; if (videoSelect.length) { if (videoSelect[videoSelect.selectedIndex].text.startsWith("OBS-Camera")) { // OBS Virtualcam obscam = true; } else if (videoSelect[videoSelect.selectedIndex].text.startsWith("OBS Virtual Camera")) { // OBS Virtualcam obscam = true; } } } if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(function (track) { session.currentCameraConstraints = track.getSettings(); if (screen && screen.orientation && screen.orientation.type) { if (!screen.orientation.type.includes("portrait")) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } else if (!window.matchMedia("(orientation: portrait)").matches) { // legacy if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } if (obscam && parseInt(session.currentCameraConstraints.frameRate) == 30) { session.stats.video_settings = (session.currentCameraConstraints.width || 0) + "x" + (session.currentCameraConstraints.height || 0); } else { var frameRateFPS = session.currentCameraConstraints.frameRate; if (frameRateFPS) { session.stats.video_settings = (session.currentCameraConstraints.width || 0) + "x" + (session.currentCameraConstraints.height || 0) + " @ " + parseInt(frameRateFPS * 100) / 100.0 + "fps"; } else { session.stats.video_settings = (session.currentCameraConstraints.width || 0) + "x" + (session.currentCameraConstraints.height || 0); } } }); } } catch (e) { errorlog(e); } function printViewValues(obj, UUID = false) { if (!document.getElementById("menuStatsBox")) { return; } var keys = Object.keys(obj).sort(); keys.forEach(key => { if (typeof obj[key] === "object") { try { let sanitizedKey = sanitizeChat(key); if (sanitizedKey === "info") { sanitizedKey = "Remote Peer Info"; } safeAppendToMenu(menu, sanitizedKey); } catch (e) { errorlog(e); } try { printViewValues(obj[key]); } catch (e) { errorlog(e); } menu.innerHTML += "
    "; } }); if (session.promptAccess) { if (UUID && session.pcs[UUID]) { menu.innerHTML += ""; } } if (UUID && session.pcs[UUID] && session.pcs[UUID].restartIce) { // only show if available menu.innerHTML += ""; } keys.forEach(key => { if (typeof obj[key] !== "object") { if (key.startsWith("_")) { return; } let unit = ""; let hint = ""; var stat = sanitizeChat(key); stat = stat.charAt(0).toUpperCase() + stat.slice(1); var value = obj[key]; if (typeof value == "string") { value = sanitizeChat(value); } if (value === false) { return; } if (key == "useragent") { value = "" + value + ""; } if (key == "local_relay_IP") { value = "" + value + ""; } if (key == "remote_relay_IP") { value = "" + value + ""; } if (key == "watch_URL") { value = "" + value + ""; } if (key == "candidateType_local" && value == "relay") { stat = "Candidate type - Local"; value = "💸

    relay server

    "; } else if (key == "candidateType_remote" && value == "relay") { stat = "Candidate type - Remote"; value = "💸

    relay server

    "; } else if (key == "candidateType_local" && value == "host") { stat = "Candidate type - Local"; value = "

    host

    "; } else if (key == "candidateType_remote" && value == "host") { stat = "Candidate type - Remote"; value = "

    host

    "; } else if (key == "candidateType_local" && value == "srflx") { stat = "Candidate type - Local"; value = "

    srflx

    "; } else if (key == "candidateType_remote" && value == "srflx") { stat = "Candidate type - Remote"; value = "

    srflx

    "; } else if (key == "local_ip_blocking" && value) { value = "⚠️ You're blocking"; hint = "no direct p2p connection made because of YOUR browser or system setting"; } else if (key == "remote_ip_blocking" && value) { value = "⚠️ They're blocking"; hint = "no direct p2p connection made because of THEIR browser or system setting"; } else if (key == "label" && value) { value = "" + value + ""; } stat = stat.replaceAll("_", " "); if (hint) { menu.innerHTML += "
  • " + stat + "" + value + unit + "
  • "; } else { menu.innerHTML += "
  • " + stat + "" + value + unit + "
  • "; } } }); if (UUID && session.pcs[UUID]) { if (session.pcs[UUID].maxBandwidth) { menu.innerHTML += "
  • max bandwidth target" + session.pcs[UUID].maxBandwidth + "
  • "; } if (session.pcs[UUID].setBitrate) { menu.innerHTML += '
  • init bitrate target" + session.pcs[UUID].setBitrate + "
  • "; } if (session.pcs[UUID].savedBitrate) { menu.innerHTML += "
  • current bitrate target" + session.pcs[UUID].savedBitrate + "
  • "; } if (session.showSlider || (!session.roomid && !session.pcs[UUID].whipout && session.meshcast !== "audio")) { menu.innerHTML += "
  • adjust video bitrate
  • "; if (!session.hidehome) { menu.innerHTML += "
    More info on setting bitrates higher here
    "; } } } } printViewValues(session.stats); menu.innerHTML += ""; if (!screenshare && session.meshcast && session.whipOut && session.whipOut.stats) { printViewValues({ Meshcast_connection: session.whipOut.stats }); menu.innerHTML += "
    "; } else if (!screenshare && session.whipOut && session.whipOut.stats) { printViewValues({ Whip_Out_connection: session.whipOut.stats }); menu.innerHTML += "
    "; } if (!screenshare && session.whepIn && session.whepIn.stats) { printViewValues({ Whep_In_connection: session.whepIn.stats }); menu.innerHTML += "
    "; } for (var uuid in session.pcs) { if (screenshare) { if (session.pcs[uuid].realUUID) { printViewValues(session.pcs[uuid].stats, uuid); menu.innerHTML += "
    "; } } else if (!session.pcs[uuid].realUUID) { printViewValues(session.pcs[uuid].stats, uuid); menu.innerHTML += "
    "; } } if (iOS || iPad) { menu.innerHTML += "
    "; } try { getById("menuStatsBox").scrollLeft = scrollLeft; getById("menuStatsBox").scrollTop = scrollTop; } catch (e) { } } function updateLocalStats() { if (!session) { return; } var totalBitrate = 0; var totalBitrate2 = 0; var cpuLimited = false; var relayUsed = false; var totalVideo = 0; var totalAudio = 0; var totalScenes = 0; var meshcastActive = false; var nackRate = 0; var totalStreams = 0; var miscSenders = []; if (session.whipOut && session.whipOut.getSenders && session.whipOut.stats) { miscSenders.push(session.whipOut); } miscSenders.forEach(data => { try { var atot = 0; var senders = data.getSenders(); // for any connected peer, update the video they have if connected with a video already. senders.forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (sender.track && sender.track.kind == "video" && sender.track.enabled) { meshcastActive = true; } else if (sender.track && sender.track.kind == "audio" && sender.track.enabled && !session.muted) { meshcastActive = true; } }); //totalAudio += atot; if ("video_bitrate_kbps" in data.stats) { totalBitrate += data.stats.video_bitrate_kbps || 0; } if ("audio_bitrate_kbps" in data.stats) { totalBitrate += data.stats.audio_bitrate_kbps || 0; } if ("total_sending_bitrate_kbps" in data.stats) { totalBitrate2 += data.stats.total_sending_bitrate_kbps || 0; } if ("quality_limitation_reason" in data.stats) { if (data.stats.quality_limitation_reason == "cpu") { cpuLimited = true; } } if ("nacks_per_second" in data.stats) { nackRate += data.stats.nacks_per_second; totalStreams += 1; } setTimeout( function (data) { if (!data) { return; } data.getStats().then(function (stats) { if ("audio_bitrate_kbps" in data.stats) { data.stats.audio_bitrate_kbps = 0; } var nominatedCandidate = false; var candidates = {}; var ipleakingAllowedRemote = false; var ipleakingAllowedLocal = false; stats.forEach(stat => { if (stat.id && stat.id.startsWith("DEPRECATED_")) { return; } if (stat.type == "transport") { if ("bytesSent" in stat) { if ("_bytesSent" in data.stats) { if (data.stats._timestamp) { if (stat.timestamp) { data.stats.total_sending_bitrate_kbps = parseInt((8 * (stat.bytesSent - data.stats._bytesSent)) / (stat.timestamp - data.stats._timestamp)); } } } data.stats._bytesSent = stat.bytesSent; } if ("timestamp" in stat) { data.stats._timestamp = stat.timestamp; } } else if (stat.type == "outbound-rtp") { if (stat.kind == "video") { if ("framesPerSecond" in stat) { data.stats.resolution = stat.frameWidth + " x " + stat.frameHeight + " @ " + stat.framesPerSecond; } else if ("frameHeight" in stat) { if ("framesEncoded" in stat && stat.timestamp) { var lastFramesEncoded = 0; var lastTimestamp = 0; try { lastFramesEncoded = data.stats._framesEncoded; lastTimestamp = data.stats._timestamp; } catch (e) { } data.stats._FPS = parseInt((10 * (stat.framesEncoded - lastFramesEncoded)) / (stat.timestamp / 1000 - lastTimestamp)) / 10 || "?"; data.stats._framesEncoded = stat.framesEncoded; data.stats._timestamp = stat.timestamp / 1000; data.stats.resolution = stat.frameWidth + " x " + stat.frameHeight + " @ " + data.stats._FPS; } else { data.stats.resolution = stat.frameWidth + " x " + stat.frameHeight; } } if ("encoderImplementation" in stat) { data.stats.video_encoder = stat.encoderImplementation; if (stat.encoderImplementation == "ExternalEncoder") { data.stats._hardwareEncoder = true; // I won't set this to false again, just because once I know it has one, I just need to assume it could always be used unexpectednly data.encoder = true; } else if (stat.encoderImplementation == "MediaFoundationVideoEncodeAccelerator") { data.stats._hardwareEncoder = true; // I won't set this to false again, just because once I know it has one, I just need to assume it could always be used unexpectednly data.encoder = true; } else { data.encoder = false; // this may not be actually accurate, but lets assume so. } } if ("qualityLimitationReason" in stat) { if (data.stats.quality_limitation_reason) { if (data.stats.quality_limitation_reason !== stat.qualityLimitationReason) { try { var miniInfo = {}; miniInfo.qlr = stat.qualityLimitationReason; if ("_hardwareEncoder" in data.stats) { miniInfo.hw_enc = data.stats._hardwareEncoder; } else { miniInfo.hw_enc = null; } session.sendMessage({ miniInfo: miniInfo }); } catch (e) { warnlog(e); } } } data.stats.quality_limitation_reason = stat.qualityLimitationReason; } if ("bytesSent" in stat) { if ("_bytesSentVideo" in data.stats) { if (data.stats._timestamp1) { data.stats.video_bitrate_kbps = parseInt((8 * (stat.bytesSent - data.stats._bytesSentVideo)) / (stat.timestamp - data.stats._timestamp1)); if (stat.timestamp) { } } } data.stats._bytesSentVideo = stat.bytesSent; } if ("nackCount" in stat) { if ("_nackCount" in data.stats) { if (data.stats._timestamp1) { if (stat.timestamp) { data.stats.nacks_per_second = parseInt((10000 * (stat.nackCount - data.stats._nackCount)) / (stat.timestamp - data.stats._timestamp1)) / 10; } } } } if ("retransmittedBytesSent" in stat) { if ("_retransmittedBytesSent" in data.stats) { if (data.stats._timestamp1) { if (stat.timestamp) { data.stats.retransmitted_kbps = parseInt((8 * (stat.retransmittedBytesSent - data.stats._retransmittedBytesSent)) / (stat.timestamp - data.stats._timestamp1)); } } } } if ("nackCount" in stat) { data.stats._nackCount = stat.nackCount; } if ("retransmittedBytesSent" in stat) { data.stats._retransmittedBytesSent = stat.retransmittedBytesSent; } if ("timestamp" in stat) { data.stats._timestamp1 = stat.timestamp; } if ("pliCount" in stat) { data.stats.total_pli_count = stat.pliCount; } if ("keyFramesEncoded" in stat) { data.stats.total_key_frames_encoded = stat.keyFramesEncoded; } } else if (stat.kind == "audio") { if ("bytesSent" in stat) { if (data.stats._bytesSentAudio) { if (data.stats._timestamp2) { if (stat.timestamp) { if ("audio_bitrate_kbps" in data.stats) { data.stats.audio_bitrate_kbps += parseInt((8 * (stat.bytesSent - data.stats._bytesSentAudio)) / (stat.timestamp - data.stats._timestamp2)); } else { data.stats.audio_bitrate_kbps = 0; } } } } } if ("timestamp" in stat) { data.stats._timestamp2 = stat.timestamp; } if ("bytesSent" in stat) { data.stats._bytesSentAudio = stat.bytesSent; } } } else if (stat.type == "remote-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedRemote = true; } } else if (stat.type == "local-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedLocal = true; } } else if (stat.type == "candidate-pair" && stat.nominated) { if (!nominatedCandidate) { nominatedCandidate = stat; } else if (nominatedCandidate.priority < stat.priority) { nominatedCandidate = stat; } } else if ("mimeType" in stat && "type" in stat && stat.type == "codec") { if (stat.mimeType.includes("video")) { data.stats.video_codec = stat.mimeType.split("video/")[1]; } else if (stat.mimeType.includes("audio")) { data.stats.audio_codec = stat.mimeType.split("audio/")[1]; if (stat.clockRate) { data.stats.audio_clockRate = stat.clockRate; if (stat.channels) { data.stats.audio_clockRate += " / " + stat.channels; } } else if (stat.sdpFmtpLine) { data.stats.fmtp = stat.sdpFmtpLine; } } } return; }); if (nominatedCandidate) { if ("availableOutgoingBitrate" in nominatedCandidate) { data.stats.available_outgoing_bitrate_kbps = parseInt(nominatedCandidate.availableOutgoingBitrate / 1024); if (session.maxBandwidth !== false) { session.limitMaxBandwidth(data.stats.available_outgoing_bitrate_kbps, session.pcs[UUID], false); } } if ("totalRoundTripTime" in nominatedCandidate) { if ("responsesReceived" in nominatedCandidate) { data.stats.average_roundTripTime_ms = parseInt((nominatedCandidate.totalRoundTripTime / nominatedCandidate.responsesReceived) * 1000); } } } if (nominatedCandidate && nominatedCandidate.remoteCandidateId) { if (candidates[nominatedCandidate.remoteCandidateId]) { var candidate = candidates[nominatedCandidate.remoteCandidateId]; if ("candidateType" in candidate) { data.stats.candidateType_remote = candidate.candidateType; if (candidate.candidateType === "relay") { if ("ip" in candidate) { data.stats.remote_relay_IP = candidate.ip; } if ("relayProtocol" in candidate) { data.stats.remote_relay_protocol = candidate.relayProtocol; } data.stats.remote_ip_blocking = !ipleakingAllowedRemote; } else { try { delete data.stats.remote_relay_IP; delete data.stats.remote_relay_protocol; delete data.stats.remote_ip_blocking; } catch (e) { } } } } } if (nominatedCandidate && nominatedCandidate.localCandidateId) { if (candidates[nominatedCandidate.localCandidateId]) { var candidate = candidates[nominatedCandidate.localCandidateId]; if ("candidateType" in candidate) { data.stats.candidateType_local = candidate.candidateType; if (candidate.candidateType === "relay") { if ("ip" in candidate) { data.stats.local_relay_IP = candidate.ip; } if ("relayProtocol" in candidate) { data.stats.local_relay_protocol = candidate.relayProtocol; } data.stats.local_ip_blocking = !ipleakingAllowedLocal; } else { try { delete data.stats.local_relay_IP; delete data.stats.local_relay_protocol; delete data.stats.local_ip_blocking; } catch (e) { } } } } } return; }); }, 0, data ); } catch (e) { errorlog(e); } }); for (var uuid in session.pcs) { if (!session.pcs[uuid].stats) { continue; } var atot = 0; var senders = getSenders2(uuid); // for any connected peer, update the video they have if connected with a video already. senders.forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (sender.track && sender.track.kind == "video" && sender.track.enabled) { totalVideo += 1; } else if (sender.track && sender.track.kind == "audio" && sender.track.enabled && !session.muted) { atot = 1; } }); totalAudio += atot; if ("scene" in session.pcs[uuid]) { if (session.pcs[uuid].scene !== false) { totalScenes += 1; } } if ("video_bitrate_kbps" in session.pcs[uuid].stats) { totalBitrate += session.pcs[uuid].stats.video_bitrate_kbps || 0; } if ("audio_bitrate_kbps" in session.pcs[uuid].stats) { totalBitrate += session.pcs[uuid].stats.audio_bitrate_kbps || 0; } if ("total_sending_bitrate_kbps" in session.pcs[uuid].stats) { totalBitrate2 += session.pcs[uuid].stats.total_sending_bitrate_kbps || 0; } if ("quality_limitation_reason" in session.pcs[uuid].stats) { if (session.pcs[uuid].stats.quality_limitation_reason == "cpu") { cpuLimited = true; } } if ("nacks_per_second" in session.pcs[uuid].stats) { nackRate += session.pcs[uuid].stats.nacks_per_second; totalStreams += 1; } if (uuid in session.rpcs) { if (session.pcs[uuid].stats.label) { session.pcs[uuid].stats.label = session.rpcs[uuid].label; } if (session.pcs[uuid].stats.streamID) { session.pcs[uuid].stats.streamID = session.rpcs[uuid].streamID; } } var screenTracksIds = []; if (session.screenStream !== false) { // null if already used. false if never used. session.screenStream.getTracks().forEach(trk => { screenTracksIds.push(trk.id); }); } setTimeout( function (UUID) { if (!session.pcs[UUID]) { return; } if (session.pcs[UUID].realUUID && session.pcs[session.pcs[UUID].realUUID]) { var thisIsAlt = true; var node = session.pcs[session.pcs[UUID].realUUID]; } else { var thisIsAlt = false; var node = session.pcs[UUID]; } node.getStats().then(function (stats) { if (!(UUID in session.pcs)) { return; } if ("audio_bitrate_kbps" in session.pcs[UUID].stats) { session.pcs[UUID].stats.audio_bitrate_kbps = 0; } var nominatedCandidate = false; var candidates = {}; var ipleakingAllowedRemote = false; var ipleakingAllowedLocal = false; var statObject = []; var altStreamList = {}; stats.forEach(stat => { statObject.push(stat); if (screenTracksIds.includes(stat.trackIdentifier)) { altStreamList[stat.id] = stat.trackIdentifier; } }); statObject.forEach(stat => { if (stat.id && stat.id.startsWith("DEPRECATED_")) { return; } if (stat.type == "transport") { if ("bytesSent" in stat) { if ("_bytesSent" in session.pcs[UUID].stats) { if (session.pcs[UUID].stats._timestamp3) { if (stat.timestamp) { session.pcs[UUID].stats.total_sending_bitrate_kbps = parseInt((8 * (stat.bytesSent - session.pcs[UUID].stats._bytesSent)) / (stat.timestamp - session.pcs[UUID].stats._timestamp3)); } } } session.pcs[UUID].stats._bytesSent = stat.bytesSent; } if ("timestamp" in stat) { session.pcs[UUID].stats._timestamp3 = stat.timestamp; } } else if (stat.type == "outbound-rtp") { if (thisIsAlt && stat.mediaSourceId && !altStreamList[stat.mediaSourceId]) { // this isn't an alt stream, but we are in alt mode return; } else if (!thisIsAlt && stat.mediaSourceId && altStreamList[stat.mediaSourceId]) { // this is an alt stream, but we are not in alt mode return; } if (stat.kind == "video") { if ("framesPerSecond" in stat) { session.pcs[UUID].stats.resolution = stat.frameWidth + " x " + stat.frameHeight + " @ " + stat.framesPerSecond; } else if ("frameHeight" in stat) { if ("framesEncoded" in stat && stat.timestamp) { var lastFramesEncoded = 0; var lastTimestamp = 0; try { lastFramesEncoded = session.pcs[UUID].stats._framesEncoded; lastTimestamp = session.pcs[UUID].stats._timestamp; } catch (e) { } session.pcs[UUID].stats._FPS = parseInt((10 * (stat.framesEncoded - lastFramesEncoded)) / (stat.timestamp - lastTimestamp)) / 10; session.pcs[UUID].stats._framesEncoded = stat.framesEncoded; session.pcs[UUID].stats._timestamp = stat.timestamp; session.pcs[UUID].stats.resolution = stat.frameWidth + " x " + stat.frameHeight + " @ " + session.pcs[UUID].stats._FPS; } else { session.pcs[UUID].stats.resolution = stat.frameWidth + " x " + stat.frameHeight; } } var miniInfo = {}; var sendMini = false; if ("encoderImplementation" in stat) { session.pcs[UUID].stats.video_encoder = stat.encoderImplementation; if (stat.encoderImplementation == "ExternalEncoder") { session.pcs[UUID].stats._hardwareEncoder = true; // I won't set this to false again, just because once I know it has one, I just need to assume it could always be used unexpectednly if (session.pcs[UUID].encoder !== true) { session.pcs[UUID].encoder = true; miniInfo.hw_enc = true; sendMini = true; } } else if (stat.encoderImplementation == "MediaFoundationVideoEncodeAccelerator") { session.pcs[UUID].stats._hardwareEncoder = true; // I won't set this to false again, just because once I know it has one, I just need to assume it could always be used unexpectednly if (session.pcs[UUID].encoder !== true) { session.pcs[UUID].encoder = true; miniInfo.hw_enc = true; sendMini = true; } } else { if (session.pcs[UUID].encoder === true) { session.pcs[UUID].encoder = false; miniInfo.hw_enc = false; sendMini = true; } } } if ("qualityLimitationReason" in stat) { if (session.pcs[UUID].stats.quality_limitation_reason) { if (session.pcs[UUID].stats.quality_limitation_reason !== stat.qualityLimitationReason) { try { sendMini = true; miniInfo.qlr = stat.qualityLimitationReason; } catch (e) { warnlog(e); } } } session.pcs[UUID].stats.quality_limitation_reason = stat.qualityLimitationReason; } if (sendMini) { session.sendMessage({ miniInfo: miniInfo }, UUID); } if ("bytesSent" in stat) { if ("_bytesSentVideo" in session.pcs[UUID].stats) { if (session.pcs[UUID].stats._timestamp1) { if (stat.timestamp) { session.pcs[UUID].stats.video_bitrate_kbps = parseInt((8 * (stat.bytesSent - session.pcs[UUID].stats._bytesSentVideo)) / (stat.timestamp - session.pcs[UUID].stats._timestamp1)); } } } session.pcs[UUID].stats._bytesSentVideo = stat.bytesSent; } if ("nackCount" in stat) { if ("_nackCount" in session.pcs[UUID].stats) { if (session.pcs[UUID].stats._timestamp1) { if (stat.timestamp) { session.pcs[UUID].stats.nacks_per_second = parseInt((10000 * (stat.nackCount - session.pcs[UUID].stats._nackCount)) / (stat.timestamp - session.pcs[UUID].stats._timestamp1)) / 10; } } } } if ("retransmittedBytesSent" in stat) { if ("_retransmittedBytesSent" in session.pcs[UUID].stats) { if (session.pcs[UUID].stats._timestamp1) { if (stat.timestamp) { session.pcs[UUID].stats.retransmitted_kbps = parseInt((8 * (stat.retransmittedBytesSent - session.pcs[UUID].stats._retransmittedBytesSent)) / (stat.timestamp - session.pcs[UUID].stats._timestamp1)); } } } } if ("nackCount" in stat) { session.pcs[UUID].stats._nackCount = stat.nackCount; } if ("retransmittedBytesSent" in stat) { session.pcs[UUID].stats._retransmittedBytesSent = stat.retransmittedBytesSent; } if ("timestamp" in stat) { session.pcs[UUID].stats._timestamp1 = stat.timestamp; } if ("pliCount" in stat) { session.pcs[UUID].stats.total_pli_count = stat.pliCount; } if ("keyFramesEncoded" in stat) { session.pcs[UUID].stats.total_key_frames_encoded = stat.keyFramesEncoded; } } else if (stat.kind == "audio") { if ("bytesSent" in stat) { if (session.pcs[UUID].stats._bytesSentAudio) { if (session.pcs[UUID].stats._timestamp2) { if (stat.timestamp) { if ("audio_bitrate_kbps" in session.pcs[UUID].stats) { session.pcs[UUID].stats.audio_bitrate_kbps += parseInt((8 * (stat.bytesSent - session.pcs[UUID].stats._bytesSentAudio)) / (stat.timestamp - session.pcs[UUID].stats._timestamp2)); } else { session.pcs[UUID].stats.audio_bitrate_kbps = 0; } } } } } if ("timestamp" in stat) { session.pcs[UUID].stats._timestamp2 = stat.timestamp; } if ("bytesSent" in stat) { session.pcs[UUID].stats._bytesSentAudio = stat.bytesSent; } } } else if (stat.type == "remote-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedRemote = true; } } else if (stat.type == "local-candidate") { candidates[stat.id] = stat; if (stat.candidateType != "relay") { ipleakingAllowedLocal = true; } } else if (stat.type == "candidate-pair" && stat.nominated) { if (!nominatedCandidate) { nominatedCandidate = stat; } else if (nominatedCandidate.priority < stat.priority) { nominatedCandidate = stat; } } else if ("mimeType" in stat && "type" in stat && stat.type == "codec") { if (stat.mimeType.includes("video")) { session.pcs[UUID].stats.video_codec = stat.mimeType.split("video/")[1]; } else if (stat.mimeType.includes("audio")) { session.pcs[UUID].stats.audio_codec = stat.mimeType.split("audio/")[1]; if (stat.clockRate) { session.pcs[UUID].stats.audio_clockRate = stat.clockRate; if (stat.channels) { session.pcs[UUID].stats.audio_clockRate += " / " + stat.channels; } } else if (stat.sdpFmtpLine) { session.pcs[UUID].stats.fmtp = stat.sdpFmtpLine; } } } return; }); if (nominatedCandidate) { if ("availableOutgoingBitrate" in nominatedCandidate) { session.pcs[UUID].stats.available_outgoing_bitrate_kbps = parseInt(nominatedCandidate.availableOutgoingBitrate / 1024); if (session.maxBandwidth !== false) { session.limitMaxBandwidth(session.pcs[UUID].stats.available_outgoing_bitrate_kbps, session.pcs[UUID], false); } } if ("totalRoundTripTime" in nominatedCandidate) { if ("responsesReceived" in nominatedCandidate) { session.pcs[UUID].stats.average_roundTripTime_ms = parseInt((nominatedCandidate.totalRoundTripTime / nominatedCandidate.responsesReceived) * 1000); } } } if (nominatedCandidate && nominatedCandidate.remoteCandidateId) { if (candidates[nominatedCandidate.remoteCandidateId]) { var candidate = candidates[nominatedCandidate.remoteCandidateId]; if ("candidateType" in candidate) { session.pcs[UUID].stats.candidateType_remote = candidate.candidateType; if (candidate.candidateType === "relay") { if ("ip" in candidate) { session.pcs[UUID].stats.remote_relay_IP = candidate.ip; } if ("relayProtocol" in candidate) { session.pcs[UUID].stats.remote_relay_protocol = candidate.relayProtocol; } session.pcs[UUID].stats.remote_ip_blocking = !ipleakingAllowedRemote; } else { try { delete session.pcs[UUID].stats.remote_relay_IP; delete session.pcs[UUID].stats.remote_relay_protocol; delete session.pcs[UUID].stats.remote_ip_blocking; } catch (e) { } } } } } if (nominatedCandidate && nominatedCandidate.localCandidateId) { if (candidates[nominatedCandidate.localCandidateId]) { var candidate = candidates[nominatedCandidate.localCandidateId]; if ("candidateType" in candidate) { session.pcs[UUID].stats.candidateType_local = candidate.candidateType; if (candidate.candidateType === "relay") { if ("ip" in candidate) { session.pcs[UUID].stats.local_relay_IP = candidate.ip; } if ("relayProtocol" in candidate) { session.pcs[UUID].stats.local_relay_protocol = candidate.relayProtocol; } session.pcs[UUID].stats.local_ip_blocking = !ipleakingAllowedLocal; } else { try { delete session.pcs[UUID].stats.local_relay_IP; delete session.pcs[UUID].stats.local_relay_protocol; delete session.pcs[UUID].stats.local_ip_blocking; } catch (e) { } } } } } return; }); }, 0, uuid ); } try { var totalCon = Object.keys(session.pcs).length || 0; var headerStats = "🔗 "; headerStats += totalCon; if (meshcastActive) { if (totalAudio) { headerStats += ", 👂 " + totalAudio; } if (totalVideo) { headerStats += ", 👀 " + totalVideo; } headerStats += ", 📡Broadcast"; } else { headerStats += ", 👂 " + totalAudio; headerStats += ", 👀 " + totalVideo; } if (session.roomid) { headerStats += ", 🎬 " + totalScenes + ""; } var changed = false; if (!session.info.out) { session.info.out = {}; session.info.out.v = totalVideo; session.info.out.a = totalAudio; session.info.out.c = totalCon; session.info.out.s = totalScenes; changed = true; } else { if (session.info.out.a !== totalAudio) { session.info.out.a = totalAudio; // changed = true; // I'm not sending this data, so why bother } if (session.info.out.v !== totalVideo) { session.info.out.v = totalAudio; //changed = true; // I'm not sending this data, so why bother } if (session.info.out.c !== totalCon) { if (session.info.out.c) { changed = true; // update if I'm not the first one } session.info.out.c = totalCon; } if (session.info.out.s !== totalScenes) { if (session.info.out.s) { changed = true; // update if I'm not the first one } session.info.out.s = totalScenes; } } } catch (e) { } //session.info.out = {}; var uploadQuality = nackRate / totalStreams || 0; if (totalStreams === 0) { uploadQuality = "title='Connection seems good' style='color: transparent; text-shadow: 0 0 0 #4d9bff;'"; } else if (uploadQuality === 0) { uploadQuality = "title='Connection seems good' style='color: transparent; text-shadow: 0 0 0 #0F0;'"; } else if (uploadQuality <= 1) { uploadQuality = "title='Mild connection issues' style='color: transparent; text-shadow: 0 0 0 yellow;'"; } else if (uploadQuality <= 5) { uploadQuality = "title='Moderate connection issues' style='color: transparent; text-shadow: 0 0 0 orange;'"; } else { uploadQuality = "title='Severe connection issues' style='color: transparent; text-shadow: 0 0 0 #F00;'"; } if (Firefox && totalBitrate === 0 && totalBitrate2 === 0) { // does not support the current stats system } else if (totalBitrate > totalBitrate2) { headerStats += ", 🔺 " + Math.round(totalBitrate / 10.24) / 100 + "-Mbps"; } else if (totalBitrate2 > 1000) { headerStats += ", 🔺 " + Math.round(totalBitrate2 / 10.24) / 100 + "-Mbps
    "; } else { headerStats += ", 🔺 " + totalBitrate2 + "-kbps"; } if (session.director || !session.roomid) { // show stats if the director or if not in a group room if (cpuLimited) { headerStats += ", 🔥 CPU Overloaded"; } //if (relayUsed){ // headerStats += " 💸"; //} directorGraphStats(); } var miniInfo = {}; if (changed) { miniInfo.out = {}; miniInfo.out.c = session.info.out.c; } if (session.cpuLimited !== cpuLimited) { session.cpuLimited = cpuLimited; miniInfo.cpu = cpuLimited; changed = true; } if (changed) { for (var uuid in session.pcs) { session.sendMessage({ miniInfo: miniInfo }, uuid); // lets send it to everyone. } } if (document.getElementById("head5")) { try { if (Object.keys(session.pcs).length || meshcastActive) { document.getElementById("head5").classList.remove("hidden"); } } catch (e) { } document.getElementById("head5").innerHTML = headerStats; //miniTranslate(document.getElementById("head5")); document.getElementById("head5").onclick = function () { var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); }; } } function updateStats(obsvc = false) { if (document.getElementById("previewWebcam")) { var ele = document.getElementById("previewWebcam"); var wcs = "webcamstats"; } else if (document.getElementById("videosource")) { var ele = document.getElementById("videosource"); var wcs = "webcamstats3"; } else { return; } try { getById(wcs).innerHTML = ""; ele.srcObject.getVideoTracks().forEach(function (track) { if (obsvc && parseInt(track.getSettings().frameRate) == 30) { getById(wcs).innerHTML = "Video Settings: " + (track.getSettings().width || 0) + "x" + (track.getSettings().height || 0) + " @ up to 60fps"; } else { var frameRateFPS = track.getSettings().frameRate; if (frameRateFPS) { getById(wcs).innerHTML = "Current Video Settings: " + (track.getSettings().width || 0) + "x" + (track.getSettings().height || 0) + "@" + parseInt(frameRateFPS * 100) / 100.0 + "fps"; } else { getById(wcs).innerHTML = "Current Video Settings: " + (track.getSettings().width || 0) + "x" + (track.getSettings().height || 0); } } }); } catch (e) { errorlog(e); } } function toggleControlBar() { if (!getById("controlButtons").classList.contains("hidden")) { getById("controlButtons").dataset.enabled = true; getById("controlButtons").classList.add("hidden"); } else if (getById("controlButtons").dataset.enabled) { getById("controlButtons").classList.remove("hidden"); delete getById("controlButtons").dataset.enabled; } } function toggleMute(apply = false, event = false) { // TODO: I need to have this be MUTE, toggle, with volume not touched. var mouseUp = null; var touchEnd = null; var timeStart = Date.now(); if (event) { mouseUp = document.onmouseup; touchEnd = document.ontouchend; document.onmouseup = function () { document.onmouseup = mouseUp; document.ontouchend = touchEnd; if (Date.now() - timeStart < 500) { return; } else { toggleMute(); } }; document.ontouchend = function () { document.onmouseup = mouseUp; document.ontouchend = touchEnd; if (Date.now() - timeStart < 300) { return; } else { toggleMute(); } }; } if (session.director) { if (!session.directorEnabledPPT) { log("Director doesn't have PPT enabled yet"); // director has not enabled PTT yet. return; } } if (apply) { session.muted = !session.muted; // we flip here as we are going to flip again in a second. } //try{var ptt = getById("press2talk");} catch(e){var ptt=false;} if (session.muted == false) { session.muted = true; if (session.pendingMicRefreshTimeout) { clearTimeout(session.pendingMicRefreshTimeout); session.pendingMicRefreshTimeout = null; } getById("mutetoggle").className = "las la-microphone-slash toggleSize"; if (!session.cleanOutput) { getById("mutebutton").classList.add("red", "pulsate"); getById("mutebutton").ariaPressed = "true"; getById("header").classList.add("red"); if (session.localMuteElement) { session.localMuteElement.style.display = "block"; } } if (session.streamSrc) { session.streamSrc.getAudioTracks().forEach(track => { track.enabled = false; }); } if ((window.obsstudio || session.mobile) && session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getAudioTracks().forEach(track => { track.enabled = false; }); } } else { session.muted = false; getById("mutetoggle").className = "las la-microphone toggleSize"; if (!session.cleanOutput) { getById("mutebutton").classList.remove("red", "pulsate"); getById("mutebutton").ariaPressed = "false"; getById("header").classList.remove("red"); if (session.localMuteElement) { session.localMuteElement.style.display = "none"; } } if (session.streamSrc) { session.streamSrc.getAudioTracks().forEach(track => { track.enabled = true; }); } //if (session.mobile) { if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getAudioTracks().forEach(track => { track.enabled = true; }); } if (!apply && event && (iOS || iPad || SafariVersion)) { if (session.pendingMicRefreshTimeout) { clearTimeout(session.pendingMicRefreshTimeout); } session.pendingMicRefreshTimeout = setTimeout(() => { session.pendingMicRefreshTimeout = null; if (!session.muted) { refreshMicrophoneDevice(); // refresh mic only if still unmuted } }, 150); } //} // toggleMute(false, event) //if (ptt){ // ptt.innerHTML = "🔴 Push to Mute"; //} } try { postMessageIframe(document.getElementById("screensharesource"), { mic: !session.muted }); } catch (e) { } if (!apply) { // only if they are changing states do we bother to spam. var data = {}; data.muteState = session.muted; session.sendMessage(data); log("SEND MUTE STATE TO PEERS"); pokeIframeAPI("mic-mute-state", session.muted); pokeAPI("muted", session.muted); } } function postMessageIframe(iFrameEle, message) { // iframes seem to only have the contentWindow work on the last placed iframe object, so this checks the dom first. if (iFrameEle && iFrameEle.nodeName == "IFRAME") { try { if (iFrameEle.id && document.getElementById(iFrameEle.id)) { document.getElementById(iFrameEle.id).contentWindow.postMessage(message, "*"); } else { iFrameEle.contentWindow.postMessage(message, "*"); } } catch (e) { errorlog(e); } } } function toggleSpeakerMute(apply = false) { if (session.ignoreNextSpeakerToggle) { session.ignoreNextSpeakerToggle = false; return; } closeSpeakerVolumePanel(); // TODO: I need to have this be MUTE, toggle, with volume not touched. if (CtrlPressed) { resetupAudioOut(); } if (apply) { session.speakerMuted = !session.speakerMuted; } if (session.speakerMuted == false) { // mute output session.speakerMuted = true; getById("mutespeakertoggle").className = "las la-volume-mute toggleSize"; if (!session.cleanOutput) { getById("mutespeakerbutton").className = "float red"; } var sounds = document.getElementsByTagName("video"); if (iOS || iPad) { for (var i = 0; i < sounds.length; ++i) { if (sounds[i].id === "keepAlivePlayer") { // we need to keep this unmuted continue; } sounds[i].muted = !sounds[i].muted; sounds[i].muted = session.speakerMuted; } } else { for (var i = 0; i < sounds.length; ++i) { if (sounds[i].id === "keepAlivePlayer") { // we need to keep this unmuted continue; } sounds[i].muted = session.speakerMuted; } } } else { session.speakerMuted = false; // unmute output getById("mutespeakertoggle").className = "las la-volume-up toggleSize"; if (!session.cleanOutput) { getById("mutespeakerbutton").className = "float"; } var sounds = document.getElementsByTagName("video"); if (iOS || iPad) { // attempting to fix an iOS bug for (var i = 0; i < sounds.length; ++i) { sounds[i].muted = !sounds[i].muted; if (sounds[i].id === "videosource") { // don't unmute ourselves. feedback galore if so. sounds[i].muted = true; continue; } else if (sounds[i].id === "previewWebcam") { sounds[i].muted = true; continue; } else if (sounds[i].id === "screensharesource") { sounds[i].muted = true; continue; } else if (sounds[i].id === "screenshare") { // this is a webcam sounds[i].muted = true; continue; } else { sounds[i].muted = session.speakerMuted; } } } else { for (var i = 0; i < sounds.length; ++i) { if (sounds[i].id === "videosource") { // don't unmute ourselves. feedback galore if so. continue; } else if (sounds[i].id === "screensharesource") { // don't unmute ourselves. feedback galore if so. continue; } else if (sounds[i].id === "previewWebcam") { continue; } else if (sounds[i].id === "screenshare") { // this is a webm continue; } else { sounds[i].muted = session.speakerMuted; } } } } for (var UUID in session.rpcs) { applyMuteState(UUID); postMessageIframe(session.rpcs[UUID].iframeEle, { mute: session.speakerMuted }); } pokeIframeAPI("audio-mute-state", session.speakerMuted); if (!apply) { pokeAPI("speakerMuted", session.speakerMuted); } if (iOS || iPad) { resetupAudioOut(); } } const SPEAKER_VOLUME_HOLD_DELAY = 400; const SPEAKER_VOLUME_MIN_PERCENT = 1; const SPEAKER_VOLUME_MAX_PERCENT = 100; var speakerVolumeButton = null; var speakerVolumePanelElement = null; var speakerVolumeSliderElement = null; var speakerVolumeValueElement = null; var speakerVolumeHoldTimer = null; var speakerVolumePanelVisible = false; var speakerVolumeDocumentListenersActive = false; function clampSpeakerVolumePercent(percent) { percent = parseInt(percent, 10); if (isNaN(percent)) { percent = SPEAKER_VOLUME_MAX_PERCENT; } if (percent < SPEAKER_VOLUME_MIN_PERCENT) { percent = SPEAKER_VOLUME_MIN_PERCENT; } if (percent > SPEAKER_VOLUME_MAX_PERCENT) { percent = SPEAKER_VOLUME_MAX_PERCENT; } return percent; } function getCurrentSpeakerVolumePercent() { if (typeof session.volume === "number" && !isNaN(session.volume)) { return clampSpeakerVolumePercent(Math.round(session.volume * 100)); } return SPEAKER_VOLUME_MAX_PERCENT; } function convertSpeakerPercentToVolume(percent) { return clampSpeakerVolumePercent(percent) / 100; } function updateSpeakerVolumeSliderUI(volume) { if (!speakerVolumeSliderElement) { return; } var effectiveVolume = typeof volume === "number" && !isNaN(volume) ? volume : 1; if (effectiveVolume < SPEAKER_VOLUME_MIN_PERCENT / 100) { effectiveVolume = SPEAKER_VOLUME_MIN_PERCENT / 100; } if (effectiveVolume > 1) { effectiveVolume = 1; } var percent = clampSpeakerVolumePercent(Math.round(effectiveVolume * 100)); speakerVolumeSliderElement.value = percent; if (speakerVolumeValueElement) { speakerVolumeValueElement.textContent = percent + "%"; } } function setSessionPlaybackVolume(volume, target) { if (typeof volume !== "number" || isNaN(volume)) { return; } if (volume > 1) { volume = 1; } if (volume < 0) { volume = 0; } session.volume = volume; var applyToAll = !target || target === "*" || typeof target === "undefined"; if (applyToAll) { if (session.videoElement && typeof session.videoElement.volume === "number") { try { session.videoElement.volume = volume; } catch (e) { errorlog(e); } } try { var mediaElements = document.querySelectorAll("video, audio"); for (var i = 0; i < mediaElements.length; i++) { var media = mediaElements[i]; if (!media || typeof media.volume !== "number") { continue; } if (media.dataset && media.dataset.keepVolume === "1") { continue; } media.volume = volume; } } catch (e) { errorlog(e); } if (session.screenShareElement && typeof session.screenShareElement.volume === "number") { try { session.screenShareElement.volume = volume; } catch (e) { errorlog(e); } } } for (var UUID in session.rpcs) { if (!Object.prototype.hasOwnProperty.call(session.rpcs, UUID)) { continue; } try { var peer = session.rpcs[UUID]; if (!peer || !peer.videoElement) { continue; } if (!applyToAll && target && target !== "*" && peer.streamID && peer.streamID !== target) { continue; } peer.videoElement.volume = volume; } catch (e) { errorlog(e); } } updateSpeakerVolumeSliderUI(volume); } function handleSpeakerVolumeSliderInput(event) { if (!event || !event.target) { return; } var percent = clampSpeakerVolumePercent(event.target.value); var volume = convertSpeakerPercentToVolume(percent); setSessionPlaybackVolume(volume, "*"); } function clearSpeakerVolumeHoldTimer() { if (speakerVolumeHoldTimer) { clearTimeout(speakerVolumeHoldTimer); speakerVolumeHoldTimer = null; } } function openSpeakerVolumePanel() { clearSpeakerVolumeHoldTimer(); if (!speakerVolumePanelElement || speakerVolumePanelVisible) { return; } updateSpeakerVolumeSliderUI(typeof session.volume === "number" ? session.volume : 1); speakerVolumePanelElement.classList.remove("hidden"); speakerVolumePanelElement.setAttribute("aria-hidden", "false"); speakerVolumePanelVisible = true; if (speakerVolumeSliderElement) { try { speakerVolumeSliderElement.focus({ preventScroll: true }); } catch (e) { try { speakerVolumeSliderElement.focus(); } catch (err) { } } } if (!speakerVolumeDocumentListenersActive) { document.addEventListener("pointerdown", handleSpeakerVolumeDocumentPointerDown, true); document.addEventListener("keydown", handleSpeakerVolumeKeydown, true); speakerVolumeDocumentListenersActive = true; } } function closeSpeakerVolumePanel(options) { clearSpeakerVolumeHoldTimer(); if (!speakerVolumePanelElement || !speakerVolumePanelVisible) { if (speakerVolumePanelElement) { speakerVolumePanelElement.setAttribute("aria-hidden", "true"); } if (speakerVolumeDocumentListenersActive) { document.removeEventListener("pointerdown", handleSpeakerVolumeDocumentPointerDown, true); document.removeEventListener("keydown", handleSpeakerVolumeKeydown, true); speakerVolumeDocumentListenersActive = false; } if (!options || options.preserveToggleGuard !== true) { session.ignoreNextSpeakerToggle = false; } speakerVolumePanelVisible = false; return; } speakerVolumePanelElement.classList.add("hidden"); speakerVolumePanelElement.setAttribute("aria-hidden", "true"); speakerVolumePanelVisible = false; if (speakerVolumeDocumentListenersActive) { document.removeEventListener("pointerdown", handleSpeakerVolumeDocumentPointerDown, true); document.removeEventListener("keydown", handleSpeakerVolumeKeydown, true); speakerVolumeDocumentListenersActive = false; } if (!options || options.preserveToggleGuard !== true) { session.ignoreNextSpeakerToggle = false; } } function handleSpeakerVolumeDocumentPointerDown(event) { if (!speakerVolumePanelVisible) { return; } if (speakerVolumePanelElement && speakerVolumePanelElement.contains(event.target)) { return; } if (speakerVolumeButton && speakerVolumeButton.contains(event.target)) { return; } closeSpeakerVolumePanel(); } function handleSpeakerVolumeKeydown(event) { if (!speakerVolumePanelVisible) { return; } if (event.key === "Escape" || event.key === "Esc") { closeSpeakerVolumePanel(); } } function handleSpeakerButtonMouseDown(event) { if (!event) { return; } var target = event.target; var panel = speakerVolumePanelElement; if (!panel) { panel = getById("speakerVolumePanel"); } if (panel && panel.contains(target)) { event.stopPropagation(); return; } event.preventDefault(); event.stopPropagation(); } function speakerButtonPointerDown(event) { if (!speakerVolumeButton) { return; } if (event && typeof event.stopPropagation === "function") { event.stopPropagation(); } if (speakerVolumePanelVisible) { session.ignoreNextSpeakerToggle = true; closeSpeakerVolumePanel({ preserveToggleGuard: true }); return; } if (event && event.pointerType === "mouse" && typeof event.button === "number" && event.button !== 0) { return; } clearSpeakerVolumeHoldTimer(); speakerVolumeHoldTimer = setTimeout(function () { session.ignoreNextSpeakerToggle = true; openSpeakerVolumePanel(); }, SPEAKER_VOLUME_HOLD_DELAY); } function speakerButtonPointerUp(event) { if (event && typeof event.stopPropagation === "function") { event.stopPropagation(); } clearSpeakerVolumeHoldTimer(); if (speakerVolumePanelVisible) { session.ignoreNextSpeakerToggle = true; } } function speakerButtonPointerLeave() { clearSpeakerVolumeHoldTimer(); } function initSpeakerVolumeControl() { speakerVolumeButton = getById("mutespeakerbutton"); speakerVolumePanelElement = getById("speakerVolumePanel"); speakerVolumeSliderElement = getById("speakerVolumeSlider"); speakerVolumeValueElement = getById("speakerVolumeValue"); if (!speakerVolumeButton || !speakerVolumePanelElement || !speakerVolumeSliderElement) { return; } var initialPercent = getCurrentSpeakerVolumePercent(); speakerVolumeSliderElement.value = initialPercent; if (speakerVolumeValueElement) { speakerVolumeValueElement.textContent = initialPercent + "%"; } speakerVolumePanelElement.classList.add("hidden"); speakerVolumePanelElement.setAttribute("aria-hidden", "true"); speakerVolumePanelVisible = false; speakerVolumeButton.addEventListener("pointerdown", speakerButtonPointerDown); speakerVolumeButton.addEventListener("pointerup", speakerButtonPointerUp); speakerVolumeButton.addEventListener("pointerleave", speakerButtonPointerLeave); speakerVolumeButton.addEventListener("pointercancel", speakerButtonPointerLeave); speakerVolumePanelElement.addEventListener("pointerdown", function (event) { event.stopPropagation(); }); speakerVolumePanelElement.addEventListener("mousedown", function (event) { event.stopPropagation(); }); speakerVolumePanelElement.addEventListener("touchstart", function (event) { event.stopPropagation(); }); speakerVolumeSliderElement.addEventListener("mousedown", function (event) { event.stopPropagation(); }); speakerVolumeSliderElement.addEventListener("touchstart", function (event) { event.stopPropagation(); }); speakerVolumeSliderElement.addEventListener("input", handleSpeakerVolumeSliderInput); speakerVolumeSliderElement.addEventListener("change", handleSpeakerVolumeSliderInput); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initSpeakerVolumeControl); } else { initSpeakerVolumeControl(); } function getPeerDisplayName(UUID, fallback = "Someone", preferLabel = true) { if (!UUID || !session.rpcs || !(UUID in session.rpcs)) { return fallback; } let name = preferLabel ? (session.rpcs[UUID].label || session.rpcs[UUID].streamID || fallback) : (session.rpcs[UUID].streamID || session.rpcs[UUID].label || fallback); if (!name) { return fallback; } if (typeof name === "string") { name = sanitizeLabel(name); } return name || fallback; } const fileTransfers = {}; function toggleFileshare(UUID = false, event = null) { if (session.cleanOputput) { return; } let string = ''; if (UUID === false) { string = 'Share a file with the group
    '; } else if (session.directorList.indexOf(UUID) >= 0) { string = `The director requested you share a file with them.
    `; } else { const requester = getPeerDisplayName(UUID); string = `${requester} has requested you share a file with them.
    `; } warnUser(string, false, false); updateFileShare(); } const enhancedFileTransfers = { transfers: {}, initTransfer(fileId, total) { if (!this.transfers[fileId]) { this.transfers[fileId] = { progress: 0, total, speed: 0, lastUpdate: Date.now(), bytesLastSecond: 0, downloaders: new Map() }; } }, updateProgress(fileId, transferred, UUID) { const transfer = this.transfers[fileId]; if (!transfer) return; const now = Date.now(); const timeDiff = (now - transfer.lastUpdate) / 1000; if (UUID && transfer.downloaders.get(UUID)) { transfer.downloaders.set(UUID, { progress: (transferred / transfer.total) * 100, speed: timeDiff >= 1 ? Math.round((transferred - (transfer.downloaders.get(UUID).bytesTransferred || 0)) / timeDiff) : 0, bytesTransferred: transferred, lastUpdate: now }); } if (timeDiff >= 1) { transfer.speed = Math.round((transferred - transfer.bytesLastSecond) / timeDiff); transfer.lastUpdate = now; transfer.bytesLastSecond = transferred; } transfer.progress = (transferred / transfer.total) * 100; updateFileShare(); }, removeDownloader(fileId, UUID) { const transfer = this.transfers[fileId]; if (transfer) { transfer.downloaders.delete(UUID); updateFileShare(); } } }; let isFileManagerMinimized = false; function toggleFileManagerSize() { isFileManagerMinimized = !isFileManagerMinimized; updateFileShare(); } function updateFileShare() { if (session.cleanOutput) return; const activeSharesDiv = document.getElementById('activeShares'); activeSharesDiv.innerHTML = ''; const container = document.createElement('div'); container.className = `file-manager${isFileManagerMinimized ? ' minimized' : ''}`; // Header const header = document.createElement('div'); header.className = 'file-manager-header'; const headerControls = document.createElement('div'); headerControls.className = 'header-controls'; header.innerHTML = `

    File Sharing

    `; headerControls.innerHTML = ` `; header.appendChild(headerControls); container.appendChild(header); if (!isFileManagerMinimized) { // Hidden file input const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.id = 'fileInput'; fileInput.style.display = 'none'; fileInput.multiple = true; fileInput.onchange = (e) => session.shareFile(e.target, false, e); container.appendChild(fileInput); // File list const fileList = document.createElement('div'); fileList.className = 'file-list'; if (!(session.hostedFiles && session.hostedFiles.length)) { fileList.innerHTML = '
    No files being shared
    '; } else { session.hostedFiles.forEach(file => { const transfer = enhancedFileTransfers.transfers[file.id]; const fileItem = document.createElement('div'); fileItem.className = 'file-item'; const fileInfo = document.createElement('div'); fileInfo.className = 'file-info'; fileInfo.innerHTML = ` ${file.name} ${formatFileSize(file.size)} `; if (transfer && transfer.downloaders.size > 0) { const downloadList = document.createElement('div'); downloadList.className = 'transfer-info'; transfer.downloaders.forEach((data, UUID) => { const transferItem = document.createElement('div'); transferItem.className = 'transfer-item'; transferItem.innerHTML = `
    Downloading by: ${getLabelForUUID(UUID)}
    ${Math.round(data.progress)}% ${formatSpeed(data.speed)}
    `; downloadList.appendChild(transferItem); }); fileInfo.appendChild(downloadList); } const actions = document.createElement('div'); actions.innerHTML = ` `; fileItem.appendChild(fileInfo); fileItem.appendChild(actions); fileList.appendChild(fileItem); }); } container.appendChild(fileList); } activeSharesDiv.appendChild(container); } function getLabelForUUID(UUID) { if (!UUID) return 'Unknown'; let label = UUID; if (session.rpcs[UUID] && session.rpcs[UUID].label) { label = sanitizeLabel(session.rpcs[UUID].label); } else if (session.pcs[UUID] && session.pcs[UUID].label) { label = sanitizeLabel(session.pcs[UUID].label); } if (session.directorList.indexOf(UUID) >= 0) { label = 'Director: ' + label; } return label; } function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function formatSpeed(bytesPerSecond) { return bytesPerSecond ? formatFileSize(bytesPerSecond) + '/s' : ''; } function cancelFileTransfer(fileId) { console.log(fileId); if (fileTransfers[fileId]) { fileTransfers[fileId].channel.close(); delete fileTransfers[fileId]; } // Remove the file from hostedFiles array session.hostedFiles = session.hostedFiles.filter(file => file.id !== fileId); updateFileShare(); // Refresh the file share display } function truncateUrl(url) { try { const urlObj = new URL(url); const path = urlObj.pathname.length > 15 ? urlObj.pathname.substring(0, 15) + '...' : urlObj.pathname; return urlObj.hostname + path; } catch (e) { return url.length > 30 ? url.substring(0, 30) + '...' : url; } } session.sendFile = function (UUID, fileid) { log("SENDING FILE: " + fileid + " " + UUID); var fr = new FileReader(); var fid = session.hostedFiles.findIndex(file => file.id === fileid); if (fid === -1) { warnlog("requested file was not found"); return; } else if (session.hostedFiles[fid].state == 0) { warnlog("requested file has been removed."); return; } else if (session.hostedFiles[fid].restricted && session.hostedFiles[fid].restricted !== UUID) { warnlog("user didn't have access for this file."); return; } var chunksize = 16384; var cid = 0; var channelName = fileid; if (channelName === "sendChannel") { channelName = "sendChannel_" + session.generateStreamID(5); } var transferchannel; if (UUID in session.pcs) { transferchannel = session.pcs[UUID].createDataChannel(channelName); } else if (UUID in session.rpcs) { transferchannel = session.rpcs[UUID].createDataChannel(channelName); } else { warnlog("UUID does not exist"); return; } transferchannel.binaryType = "arraybuffer"; var chunk = session.hostedFiles[fid].file.slice(0, chunksize); // Use the stored File object fileTransfers[fileid] = { channel: transferchannel, progress: 0 }; transferchannel.onopen = () => { transferchannel.send(JSON.stringify({ type: "filetransfer", size: session.hostedFiles[fid].size, filename: session.hostedFiles[fid].name, id: session.hostedFiles[fid].id })); fr.readAsArrayBuffer(chunk); }; transferchannel.onclose = () => { try { var index = session.hostedTransfers.indexOf(transferchannel); if (index > -1) { session.hostedTransfers.splice(index, 1); } } catch (e) { errorlog(e); } log("Transfer ended"); delete fileTransfers[fileid]; updateFileShare(); // Refresh the file share display transferchannel = null; enhancedFileTransfers.removeDownloader(fileid, UUID); }; transferchannel.onmessage = event => { // console.log(event.data); }; session.hostedTransfers.push(transferchannel); fr.onload = function () { if (session.hostedFiles[fid].state == 0) { return; } var arrayBuffer = fr.result; try { transferchannel.send(arrayBuffer); } catch (e) { try { transferchannel.close(); } catch (e) { } warnlog(e); return; } cid += 1; const progress = Math.min(100, Math.round((cid * chunksize / session.hostedFiles[fid].size) * 100)); fileTransfers[fileid].progress = progress; updateFileShare(); // Update progress display enhancedFileTransfers.updateProgress(fileid, cid * chunksize, UUID); if (cid * chunksize < session.hostedFiles[fid].size) { try { chunk = session.hostedFiles[fid].file.slice(cid * chunksize, (cid + 1) * chunksize); // Use the stored File object fr.readAsArrayBuffer(chunk); } catch (e) { errorlog(e); } } else { transferchannel.send("EOF1"); transferchannel.close(); } }; }; function toggleChat(event = null) { const chatModule = document.getElementById('chatModule'); if (!chatModule.configured) { setupAdjustableChat(); } if (session.chat === false) { session.chat = true; chatModule.classList.remove("hidden"); getById("chatInput").focus(); getById("chatNotification").classList.remove("notification", "red"); getById("chattoggle").classList.remove("pulsate"); } else { session.chat = false; chatModule.classList.add("hidden"); } updateMessages(); } function toggleDirectFeedback(event = null) { const unmuteSelf = document.getElementById('unmuteSelf'); unmuteSelf.classList.remove("hidden"); if (session.videoElement) { session.videoElement.muted = session.videoElement.muted ? false : true; if (session.selfVolume) { session.selfVolume = parseFloat(session.selfVolume); if (session.selfVolume >= 1) { session.videoElement.volume = Math.min(100, Math.max(1, session.selfVolume)) / 100; session.selfVolume = null; } else { session.videoElement.volume = Math.min(1, Math.max(0, session.selfVolume)); session.selfVolume = null; } } } if (session.videoElement.muted) { unmuteSelf.classList.remove("red", "pulsate"); unmuteSelf.ariaPressed = "false"; } else { unmuteSelf.classList.add("red", "pulsate"); unmuteSelf.ariaPressed = "true"; } } function setupAdjustableChat() { const chatModule = document.getElementById('chatModule'); chatModule.configured = true; const chatBody = getById('chatBody'); let isResizing = false; let isDragging = false; let startY, startHeight, startX, startTop, startLeft; function initResize(e) { isResizing = true; document.body.style.userSelect = 'none'; startY = e.clientY; startHeight = parseInt(window.getComputedStyle(chatBody).height, 10); document.addEventListener('mousemove', resize); document.addEventListener('mouseup', stopResize); } function resize(e) { if (isResizing) { const maxHeight = window.innerHeight - chatModule.offsetTop - 20; // 20px buffer const newHeight = Math.min(startHeight + (e.clientY - startY), maxHeight); chatBody.style.height = `${Math.max(newHeight, 50)}px`; // Minimum height of 50px keepInBounds(); } } function stopResize() { isResizing = false; document.removeEventListener('mousemove', resize); document.removeEventListener('mouseup', stopResize); document.body.style.userSelect = ''; } function initDrag(e) { isDragging = true; document.body.style.userSelect = 'none'; startX = e.clientX - chatModule.offsetLeft; startY = e.clientY - chatModule.offsetTop; document.addEventListener('mousemove', drag); document.addEventListener('mouseup', stopDrag); } function drag(e) { if (isDragging) { const newLeft = Math.min(Math.max(e.clientX - startX, 0), window.innerWidth - chatModule.offsetWidth); const newTop = Math.min(Math.max(e.clientY - startY, 0), window.innerHeight - chatModule.offsetHeight); chatModule.style.left = `${newLeft}px`; chatModule.style.top = `${newTop}px`; chatModule.style.bottom = 'auto'; chatModule.style.right = 'auto'; keepInBounds(); } } function stopDrag() { isDragging = false; document.removeEventListener('mousemove', drag); document.removeEventListener('mouseup', stopDrag); document.body.style.userSelect = ''; } function keepInBounds() { const rect = chatModule.getBoundingClientRect(); if (rect.right > window.innerWidth) { chatModule.style.left = `${window.innerWidth - rect.width}px`; } if (rect.bottom > window.innerHeight) { chatModule.style.top = `${window.innerHeight - rect.height}px`; } if (rect.left < 0) { chatModule.style.left = '0px'; } if (rect.top < 0) { chatModule.style.top = '0px'; chatModule.style.bottom = 'auto'; } } chatModule.querySelector('.resizer').addEventListener('mousedown', initResize); chatModule.querySelector('.chat-header').addEventListener('mousedown', initDrag); // Keep chat window in bounds when window is resized window.addEventListener('resize', keepInBounds); // Initial positioning keepInBounds(); } function directorAdvanced(ele) { var target = document.createElement("div"); target.style = "position:absolute;float:left;width:270px;height:222px;background-color:#7E7E7E;"; var closeButton = document.createElement("button"); closeButton.innerHTML = " close"; closeButton.style.left = "5px"; closeButton.style.position = "relative"; closeButton.onclick = function () { target.parentNode.removeChild(target); }; target.appendChild(closeButton); var someButton = document.createElement("button"); someButton.innerHTML = " some action "; someButton.style.left = "5px"; someButton.style.position = "relative"; someButton.onclick = function () { var actionMsg = {}; session.sendRequest(actionMsg, ele.dataset.UUID); }; target.appendChild(someButton); ele.parentNode.appendChild(target); } function directorSendMessage(ele) { var UUID = ele.dataset.UUID; var target = document.querySelector("[data--u-u-i-d='" + UUID + "'][data-action-type='messaging-box']"); if (!target) { return; } if (target.classList.contains("hidden")) { target.classList.remove("hidden"); ele.classList.add("pressed"); ele.ariaPressed = "true"; } else { target.classList.add("hidden"); ele.classList.remove("pressed"); ele.ariaPressed = "false"; return; } var inputField = target.querySelector("[data-action-type='messaging-box-text']"); if (inputField) { inputField.focus(); inputField.select(); } if ("overlay" in target) { return; } target.overlay = true; if (inputField) { inputField.addEventListener("keydown", function (e) { if (e.keyCode == 13) { e.preventDefault(); sendButton.click(); } else if (e.keyCode == 27) { e.preventDefault(); inputField.value = ""; target.parentNode.removeChild(target); } }); } var sendButton = target.querySelector("[data-action-type='messaging-box-send']"); if (sendButton) { sendButton.onclick = function () { var chatMsg = {}; chatMsg.chat = inputField.value; if (sendButton.parentNode.overlay) { chatMsg.overlay = sendButton.parentNode.overlay; } session.sendRequest(chatMsg, ele.dataset.UUID); inputField.value = ""; //target.parentNode.removeChild(target); }; } var closeButton = target.querySelector("[data-action-type='messaging-box-close']"); if (closeButton) { closeButton.onclick = function () { inputField.value = ""; target.classList.add("hidden"); ele.classList.remove("pressed"); ele.ariaPressed = "false"; }; } var overlayMsg = target.querySelector("[data-action-type='messaging-box-toggle']"); if (overlayMsg) { overlayMsg.onclick = function (e) { log(e.target.parentNode.parentNode); if (e.target.parentNode.parentNode.overlay === true) { e.target.parentNode.parentNode.overlay = false; e.target.parentNode.innerHTML = ""; } else { e.target.parentNode.parentNode.overlay = true; e.target.parentNode.innerHTML = ""; } }; } } function toggleAutoVideoMute() { // for iOS devices, that tab out. // document.visibilityState if (!session.videoMuted && session.permaid !== false) { var msg = {}; msg.videoMuted = document.visibilityState === "hidden" || false; //try { session.sendMessage(msg); //} catch(e){errorlog(e);} pokeIframeAPI("video-mute-state", document.visibilityState); } } function toggleVideoMute(apply = false) { // TODO: I need to have this be MUTE, toggle, with volume not touched. if (apply) { session.videoMuted = !session.videoMuted; } if (!session.remoteVideoMuted) { getById("head8").classList.add("hidden"); } if (session.videoMuted == false) { session.videoMuted = true; getById("mutevideotoggle").className = "las la-video-slash toggleSize"; if (!session.cleanOutput) { getById("mutevideobutton").classList.add("red"); getById("mutevideobutton").ariaPressed = "true"; getById("header").classList.add("red"); if (session.remoteVideoMuted) { getById("head8").classList.remove("hidden"); } } if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(track => { track.enabled = false; }); } } else if (session.remoteVideoMuted) { // the director has muted this guest's video feed session.videoMuted = false; // just setting it back to the pre-toggled state getById("mutevideotoggle").className = "las la-video toggleSize"; if (!session.cleanOutput) { getById("head8").classList.remove("hidden"); getById("header").classList.add("red"); getById("mutevideobutton").classList.remove("red"); getById("mutevideobutton").ariaPressed = "false"; } if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(track => { track.enabled = false; }); } } else { session.videoMuted = false; getById("mutevideotoggle").className = "las la-video toggleSize"; if (!session.cleanOutput) { getById("mutevideobutton").classList.remove("red"); getById("mutevideobutton").ariaPressed = "false"; getById("header").classList.remove("red"); } if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(track => { track.enabled = true; }); } } if (session.avatar && session.avatar.ready && !apply) { updateRenderOutpipe(); if (session.videoMuted) { var msg = {}; msg.videoMuted = false; // doesn't matter the actual mute state; this is the avatar session.sendMessage(msg); } } else if (!apply) { var msg = {}; msg.videoMuted = session.videoMuted; session.sendMessage(msg); } pokeIframeAPI("video-mute-state", session.videoMuted || session.remoteVideoMuted); if (!apply) { pokeAPI("videoMuted", session.videoMuted || session.remoteVideoMuted); } if (session.style && session.style == 1) { if (!session.videoElement || session.videoElement.id !== "previewWebcam") { updateMixer(); } } } var toggleSettingsState = false; let settingsClickHandler = null; async function toggleSettings(forceShow = false) { if (session.nosettings) return; const multiselectTrigger = getById("multiselect-trigger3"); multiselectTrigger.dataset.state = "0"; multiselectTrigger.classList.add("closed"); multiselectTrigger.classList.remove("open"); getById("chevarrow2").classList.add("bottom"); const popupSelector = getById("popupSelector"); // For forceShow, if already open just update devices if (toggleSettingsState && forceShow) { await enumerateDevices().then(gotDevices2); return; } if (popupSelector.style.display === "none") { await showSettings(); } else { hideSettings(); } pokeIframeAPI("settings-menu-state", toggleSettingsState); } async function showSettings() { const popupSelector = getById("popupSelector"); const settingsButton = getById("settingsbutton"); updateConstraintSliders(); // Handler only closes the menu when clicking outside settingsClickHandler = (e) => { if (!popupSelector.contains(e.target) && !e.target.closest('#settingsbutton')) { hideSettings(); pokeIframeAPI("settings-menu-state", false); } }; setTimeout(() => { document.addEventListener("click", settingsClickHandler); }, 10); if (navigator.userAgent.indexOf("Chrome") !== -1) { try { const permissionResult = await navigator.permissions.query({ name: "camera" }); if (permissionResult.state === "prompt") { try { const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); await enumerateDevices().then(gotDevices2); stream.getTracks().forEach(track => track.stop()); } catch { await enumerateDevices().then(gotDevices2); } } else { await enumerateDevices().then(gotDevices2); } } catch { await enumerateDevices().then(gotDevices2); } } else { await enumerateDevices().then(gotDevices2); } popupSelector.style.display = "inline-block"; settingsButton.classList.add("brown"); settingsButton.ariaPressed = "true"; loadContentEffectsImages(); setTimeout(() => { popupSelector.style.right = "0px"; }, 1); toggleSettingsState = true; } function hideSettings() { const popupSelector = getById("popupSelector"); const settingsButton = getById("settingsbutton"); if (settingsClickHandler) { document.removeEventListener("click", settingsClickHandler); settingsClickHandler = null; } popupSelector.style.right = "-500px"; settingsButton.classList.remove("brown"); settingsButton.ariaPressed = "false"; setTimeout(() => { popupSelector.style.display = "none"; }, 200); toggleSettingsState = false; getById("videoSettings3").style.display = "none"; } function toggleFullscreenButtonSetting() { var btn = getById("toggleFullscreenButton"); var fullscreenPage = getById("fullscreenPage"); if (iOS || iPad) { warnUser("Fullscreen is not supported on iOS/iPadOS"); return; } if (session.fullscreenButton) { // Disable session.fullscreenButton = false; fullscreenPage.classList.add("hidden"); document.documentElement.style.removeProperty("--full-screen-button"); btn.innerText = "Enable"; btn.classList.remove("selected"); // Exit fullscreen if currently in it if (document.fullscreenElement) { try { document.exitFullscreen(); } catch(e) {} } } else { // Enable session.fullscreenButton = true; fullscreenPage.classList.remove("hidden"); document.documentElement.style.setProperty("--full-screen-button", "none"); btn.innerText = "Disable"; btn.classList.add("selected"); } try { setStorage("fullscreenButtonSetting", session.fullscreenButton); } catch(e) {} } function togglePIPButtonSetting() { var btn = getById("togglePIPButton"); var pipPage = getById("PictureInPicturePage"); if (typeof documentPictureInPicture === "undefined") { warnUser("Picture-in-Picture is not supported in this browser"); return; } if (pipPage.classList.contains("hidden")) { // Enable pipPage.classList.remove("hidden"); btn.innerText = "Disable"; btn.classList.add("selected"); try { setStorage("pipButtonSetting", true); } catch(e) {} } else { // Disable pipPage.classList.add("hidden"); btn.innerText = "Enable"; btn.classList.remove("selected"); try { setStorage("pipButtonSetting", false); } catch(e) {} } } function initButtonToggleSettings() { // Hide toggles on unsupported platforms if (iOS || iPad) { var fsContainer = getById("fullscreenToggleContainer"); if (fsContainer) fsContainer.style.display = "none"; } if (typeof documentPictureInPicture === "undefined") { var pipContainer = getById("pipToggleContainer"); if (pipContainer) pipContainer.style.display = "none"; } // Restore saved settings (only if URL params didn't already set them) try { var savedFullscreen = getStorage("fullscreenButtonSetting"); if (savedFullscreen === true && !session.fullscreenButton && !(iOS || iPad)) { toggleFullscreenButtonSetting(); } var savedPIP = getStorage("pipButtonSetting"); var pipPage = getById("PictureInPicturePage"); if (savedPIP === true && pipPage && pipPage.classList.contains("hidden") && typeof documentPictureInPicture !== "undefined") { togglePIPButtonSetting(); } } catch(e) {} // Update button states to reflect current state updateButtonToggleStates(); } function updateButtonToggleStates() { var fullscreenBtn = getById("toggleFullscreenButton"); var pipBtn = getById("togglePIPButton"); var fullscreenPage = getById("fullscreenPage"); var pipPage = getById("PictureInPicturePage"); if (fullscreenBtn && fullscreenPage) { if (!fullscreenPage.classList.contains("hidden")) { fullscreenBtn.innerText = "Disable"; fullscreenBtn.classList.add("selected"); } else { fullscreenBtn.innerText = "Enable"; fullscreenBtn.classList.remove("selected"); } } if (pipBtn && pipPage) { if (!pipPage.classList.contains("hidden")) { pipBtn.innerText = "Disable"; pipBtn.classList.add("selected"); } else { pipBtn.innerText = "Enable"; pipBtn.classList.remove("selected"); } } } let wakeLockObject = null; let wakeLockReleaseHandler = null; let wakeLockInteractionArmed = false; function armWakeLockOnInteraction() { if (wakeLockInteractionArmed || !("wakeLock" in navigator)) { return; } wakeLockInteractionArmed = true; const handler = () => { document.removeEventListener("pointerdown", handler); document.removeEventListener("keydown", handler); wakeLockInteractionArmed = false; acquireWakeLock(true); }; document.addEventListener("pointerdown", handler, { once: true }); document.addEventListener("keydown", handler, { once: true }); } async function acquireWakeLock(fromUserGesture = false) { if (!("wakeLock" in navigator)) { warnlog("Wake Lock API is not supported in this browser"); if (typeof session !== "undefined") { session.forceLegacyWakeLock = true; } startLegacyKeepAliveLoop(); return; } try { if (wakeLockObject && wakeLockReleaseHandler) { try { wakeLockObject.removeEventListener("release", wakeLockReleaseHandler); } catch (e) { errorlog(e); } } const lock = await navigator.wakeLock.request("screen"); wakeLockObject = lock; if (typeof session !== "undefined") { session.wakeLockActive = true; session.forceLegacyWakeLock = false; } wakeLockReleaseHandler = () => { wakeLockObject = null; wakeLockReleaseHandler = null; if (typeof session !== "undefined") { session.wakeLockActive = false; session.forceLegacyWakeLock = true; } startLegacyKeepAliveLoop(); armWakeLockOnInteraction(); }; wakeLockObject.addEventListener("release", wakeLockReleaseHandler); removeLegacyKeepAlivePlayer(); log("Wake Lock is active"); } catch (err) { if (typeof session !== "undefined") { session.wakeLockActive = false; session.forceLegacyWakeLock = true; } startLegacyKeepAliveLoop(); if (!fromUserGesture) { armWakeLockOnInteraction(); } errorlog(err); } } function handleVisibilityChangeWakeLock() { if (document.visibilityState === "visible") { acquireWakeLock(); armWakeLockOnInteraction(); } } function releaseWakeLock() { if (wakeLockObject) { wakeLockObject .release() .then(() => { wakeLockObject = null; if (typeof session !== "undefined") { session.wakeLockActive = false; session.forceLegacyWakeLock = true; } wakeLockReleaseHandler = null; startLegacyKeepAliveLoop(); log("Wake Lock is released"); }) .catch(err => { errorlog(err); }); } } // QoS Report - sends anonymous connection quality data on hangup function sendQosReport() { if (!session.qosEnabled || !session.qosData || session.qosData.sent) return; session.qosData.sent = true; // Prevent duplicate sends try { var qd = session.qosData; // Helper functions var avg = function(arr) { if (!arr || !arr.length) return null; return arr.reduce(function(a, b) { return a + b; }, 0) / arr.length; }; var max = function(arr) { if (!arr || !arr.length) return null; return Math.max.apply(null, arr); }; // Determine browser (using existing detection) var browser = "Unknown"; var browserVersion = 0; if (typeof Safari !== "undefined" && Safari) { browser = "Safari"; browserVersion = SafariVersion || 0; } else if (typeof Firefox !== "undefined" && Firefox) { browser = "Firefox"; browserVersion = Firefox; } else if (typeof ChromiumVersion !== "undefined" && ChromiumVersion) { browser = "Chrome"; browserVersion = ChromiumVersion; } // Determine platform var platform = "desktop"; if (typeof iOS !== "undefined" && iOS) platform = "mobile"; else if (typeof iPad !== "undefined" && iPad) platform = "tablet"; else if (/Android/i.test(navigator.userAgent)) platform = "mobile"; // Determine connection type var connectionType = "viewer"; if (session.director) connectionType = "director"; else if (session.streamSrc || session.videoElement) connectionType = "publisher"; // Get TURN server (already filtered to hostnames by client-side allowlist) var turnServer = qd.turnServersUsed && qd.turnServersUsed.length ? qd.turnServersUsed[0] : null; // Trim to 30 chars max (DB limit) if (turnServer && turnServer.length > 30) turnServer = turnServer.substring(0, 30); // Get meshcast server if used (only if &meshcast enabled) var meshcastServer = null; if (session.meshcast && qd.meshcastServersUsed && qd.meshcastServersUsed.length > 0) { meshcastServer = qd.meshcastServersUsed[0]; } // For publishers: also collect stats from outbound connections (session.pcs) // This supplements the inbound stats from processStats if (session.pcs) { for (var uuid in session.pcs) { try { var pcStats = session.pcs[uuid].stats; if (!pcStats) continue; // Get transport type from publisher connection if (pcStats.candidateType_local) { if (pcStats.candidateType_local === "relay") { qd.transportType = "turn"; // TURN hostname already tracked in processPcsQosStats via allowlist } else if (!qd.transportType || qd.transportType === "unknown") { qd.transportType = "p2p"; } if (!qd.candidateTypesLocal.includes(pcStats.candidateType_local)) { qd.candidateTypesLocal.push(pcStats.candidateType_local); } } if (pcStats.candidateType_remote && !qd.candidateTypesRemote.includes(pcStats.candidateType_remote)) { qd.candidateTypesRemote.push(pcStats.candidateType_remote); } // Get RTT from publisher stats if (pcStats.average_roundTripTime_ms) { qd.rttSamples.push(pcStats.average_roundTripTime_ms); } // Get resolution/codec from publisher stats if (pcStats.resolution && !qd.lastResolution) { qd.lastResolution = pcStats.resolution; } if (pcStats.encoder) { qd.lastVideoCodec = pcStats.encoder; } } catch (e) { } } } // Set transport type for WHIP/WHEP if not already set if (!qd.transportType || qd.transportType === "unknown") { if (session.whipOut) qd.transportType = "whip"; else if (session.whepIn || session.whepInput) qd.transportType = "whep"; } // For non-meshcast WHIP/WHEP, mark as "private" (don't log actual endpoint) if ((qd.transportType === "whip" || qd.transportType === "whep") && (!qd.meshcastServersUsed || qd.meshcastServersUsed.length === 0)) { meshcastServer = "private"; } // Build payload - NO room IDs, stream IDs, or passwords var payload = { // Session sessionDuration: Math.round((Date.now() - qd.startTime) / 1000), connectionType: connectionType, // Client (privacy-safe) browser: browser, browserVersion: browserVersion, platform: platform, // Transport transportType: qd.transportType || "unknown", turnServer: turnServer, meshcastServer: meshcastServer, wssSuccess: qd.wssSuccess, candidateLocal: qd.candidateTypesLocal.length > 0 ? qd.candidateTypesLocal[0] : null, candidateRemote: qd.candidateTypesRemote.length > 0 ? qd.candidateTypesRemote[0] : null, // Quality connectionSuccess: qd.connectionSuccesses > 0 || qd.rttSamples.length > 0, connectionFailures: qd.connectionFailures, iceRestarts: qd.iceRestarts, // Packet loss avgPacketLossVideo: avg(qd.packetLossVideoSamples) !== null ? Math.round(avg(qd.packetLossVideoSamples) * 100) / 100 : null, avgPacketLossAudio: avg(qd.packetLossAudioSamples) !== null ? Math.round(avg(qd.packetLossAudioSamples) * 100) / 100 : null, maxPacketLossVideo: max(qd.packetLossVideoSamples) !== null ? Math.round(max(qd.packetLossVideoSamples) * 100) / 100 : null, // Latency avgRtt: avg(qd.rttSamples) !== null ? Math.round(avg(qd.rttSamples)) : null, maxRtt: max(qd.rttSamples) !== null ? Math.round(max(qd.rttSamples)) : null, avgJitter: avg(qd.jitterSamples) !== null ? Math.round(avg(qd.jitterSamples)) : null, // Media videoCodec: qd.lastVideoCodec ? qd.lastVideoCodec.replace("video/", "") : null, audioCodec: qd.lastAudioCodec ? qd.lastAudioCodec.replace("audio/", "") : null, avgVideoBitrate: avg(qd.bitrateSamples) !== null ? Math.round(avg(qd.bitrateSamples)) : null, maxResolution: qd.lastResolution, // Errors (last 10, sanitized to remove private data) errors: (typeof errorReport !== "undefined" && errorReport && errorReport.length > 0) ? errorReport.slice(-10).map(function(e) { var msg = String(e.error || e || ""); // Strip private/personal data from error messages msg = msg // Remove full URLs and URL parameters .replace(/https?:\/\/[^\s"'<>)]+/gi, "[URL]") .replace(/wss?:\/\/[^\s"'<>)]+/gi, "[WSS]") // Remove UUIDs (various formats) .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "[UUID]") .replace(/[0-9a-f]{32,}/gi, "[HASH]") // Remove stream IDs, view IDs, push IDs .replace(/(streamID|stream_id|streamid|sid|push|view|scene)[=:]["']?[a-zA-Z0-9_-]{3,30}["']?/gi, "$1=[REDACTED]") // Remove room IDs .replace(/(room|roomid|room_id)[=:]["']?[a-zA-Z0-9_-]{3,30}["']?/gi, "$1=[REDACTED]") // Remove passwords and hashes .replace(/(password|pass|pwd|hash|salt|key|token|auth)[=:]["']?[^\s"'&]{1,50}["']?/gi, "$1=[REDACTED]") // Remove IP addresses (IPv4 and IPv6) .replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g, "[IP]") .replace(/\b([0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, "[IPv6]") // Remove base64-ish strings that might be tokens/credentials .replace(/[A-Za-z0-9+/=]{40,}/g, "[TOKEN]") // Remove email addresses .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[EMAIL]") // Remove query string parameters .replace(/\?[^\s"'<>]+/g, "?[PARAMS]"); return { msg: msg.substring(0, 200), line: e.line || 0, time: e.time ? parseInt(e.time) : 0 }; }) : null }; // Send using sendBeacon for reliability during page unload var blob = new Blob([JSON.stringify(payload)], { type: "application/json" }); navigator.sendBeacon("https://qos.vdo.ninja/v1/report", blob); log("QoS report sent"); } catch (e) { warnlog("QoS report error: " + e); } } // For view/scene links without explicit hangup - send QoS on page unload // Only triggers if there are no active push connections (publisher links have explicit hangup) if (typeof window !== "undefined") { window.addEventListener("beforeunload", function() { // Only send for viewers (no push) and if we haven't already sent if (session && session.qosEnabled && session.qosData && !session.qosData.sent) { // Check that we're not a publisher (publishers use explicit hangup) if (!session.streamSrc && !session.videoElement) { sendQosReport(); } } }); // Also trigger on visibility change to hidden (tab close, navigate away) document.addEventListener("visibilitychange", function() { if (document.visibilityState === "hidden") { if (session && session.qosEnabled && session.qosData && !session.qosData.sent) { // Only for viewers if (!session.streamSrc && !session.videoElement) { sendQosReport(); } } } }); } session.hangup = function (reload = false, estop = false) { // Send QoS report on hangup try { sendQosReport(); } catch (e) { warnlog(e); } try { window.removeEventListener("beforeunload", confirmUnload); } catch (e) { } // Clean up auto-end timer if it exists if (session.autoEndTimer) { clearTimeout(session.autoEndTimer); session.autoEndTimer = null; } if (session.autoEndInterval) { clearInterval(session.autoEndInterval); session.autoEndInterval = null; } try { const countdown = document.getElementById("autoEndCountdown"); if (countdown) { countdown.remove(); } } catch (e) { } try { if (estop) { recordLocalVideo("estop"); } } catch (e) { } try { if (estop) { recordLocalVideo("estop", false, false, true); // screen share } } catch (e) { } session.taintedSession = true; warnlog("hanging up"); try { recordLocalVideo("stop"); } catch (e) { } try { recordLocalVideo("stop", false, false, true); // screen share } catch (e) { } try { transferList.forEach(file => { if (file.writer) { file.writer.close(); } if (file.videoElement && file.videoElement.stopWriter) { file.videoElement.stopWriter(true); // estop } }); } catch (e) { errorlog(e); } try { var msg = {}; msg.videoMuted = true; // might not trigger msg.bye = true; session.sendMessage(msg); // make sure the remote video goes black. } catch (e) { } try { session.ws.close(); } catch (e) { } try { if (session.canvasSource && session.canvasSource.srcObject) { session.canvasSource.srcObject.getTracks().forEach(function (track) { session.canvasSource.srcObject.removeTrack(track); track.stop(); log("stopping old track"); }); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getTracks().forEach(function (track) { session.videoElement.srcObject.removeTrack(track); track.stop(); log("stopping old track"); }); } if (session.streamSrc) { session.streamSrc.getTracks().forEach(function (track) { session.streamSrc.removeTrack(track); track.stop(); log("stopping old track"); }); } if (session.streamSrcClone) { session.streamSrcClone.getTracks().forEach(function (track) { session.streamSrcClone.removeTrack(track); track.stop(); log("stopping old track"); }); } if (session.screenStream) { session.screenStream.getTracks().forEach(function (track) { session.screenStream.removeTrack(track); track.stop(); log("stopping old track"); }); } } catch (e) { errorlog(e); } try { for (i in session.rpcs) { try { if (session.rpcs[i].videoElement) { if (session.rpcs[i].videoElement.recording) { recordLocalVideo("stop", null, session.rpcs[i].videoElement); } } } catch (e) { } log("closing rpc due to hangup event"); session.closeRPC(i, true); } for (i in session.pcs) { log("closing 5"); session.closePC(i); } } catch (e) { errorlog(e); } for (var sid in session.watchTimeoutList) { clearTimeout(session.watchTimeoutList[sid]); } if (session.whipOut && session.whipOut.deleteme) { session.whipOut.deleteme(); } if (DebugLog && errorReport) { downloadLogs(); } if (session.popupChat) { if (!session.popupChat.closed) { session.popupChat.close(); session.popupChat = null; } } releaseWakeLock(); if (reload) { reloadRequested(); warnlog("Reloading? uh oh. Why didn't it?"); return; } else { setTimeout(function () { for (i in session) { try { delete session[i]; } catch (e) { } } delete session; }, 1200); hangupComplete(); log("HANG UP COMPLETE"); } }; function hangup(showhangup = true) { // TODO: I need to have this be MUTE, toggle, with volume not touched. if (session.hostedTransfers.length) { confirmAlt("There are still file transfer in progress\nAre you sure you wish to exit?").then(res => { if (res) { try { if (showhangup) { document.getElementById("main").innerHTML = document.getElementById("hangupTemplate").innerHTML; } else { document.getElementById("main").innerHTML = ""; document.getElementById("hangupTemplate").innerHTML = ""; } } catch (e) { } setTimeout(function () { session.hangup(); }, 0); } }); } else { try { if (showhangup) { document.getElementById("main").innerHTML = document.getElementById("hangupTemplate").innerHTML; } else { document.getElementById("main").innerHTML = ""; document.getElementById("hangupTemplate").innerHTML = ""; } } catch (e) { } setTimeout(function () { session.hangup(); }, 0); } } function hangup2() { session.hangupDirector(); getById("miniPerformer").innerHTML = ""; getById("press2talk").dataset.enabled = false; getById("screensharebutton").classList.add("hidden"); getById("screenshare2button").classList.add("hidden"); getById("screenshare3button").classList.add("hidden"); getById("settingsbutton").classList.add("hidden"); getById("mutebutton").classList.add("hidden"); getById("hangupbutton2").classList.add("hidden"); //getById("chatbutton").classList.remove("hidden"); getById("controlButtons").classList.remove("hidden"); //getById("mutespeakerbutton").classList.add("hidden"); getById("mutevideobutton").classList.add("hidden"); getById("screensharebutton").classList.remove("green"); getById("screensharebutton").ariaPressed = "false"; if (!session.showDirector) { getById("miniPerformer").innerHTML = ''; miniTranslate(getById("miniPerformer")); } else { getById("miniPerformer").innerHTML = ''; } getById("miniPerformer").className = ""; } function hangupComplete() { try { getById("main").innerHTML = document.getElementById("hangupTemplate").innerHTML; } catch (e) { } updateMixerRun = function () { }; pokeIframeAPI("hungup", true); // don't use Hangup, as that's an action. pokeAPI("hangup", true); if (session.redirectHangup) { setTimeout(function (href) { window.location.href = href; }, session.redirectHangupTimer || 0, session.redirectHangup); } } function reloadRequested() { pokeIframeAPI("reloading", true); window.removeEventListener("beforeunload", confirmUnload); // clear the confirm on reload location.reload(); // the main reload function call } function confirmUnload(event) { if (!session.noExitPrompt && !session.cleanOutput && session.scene === false && (session.seeding || session.roomid !== false || session.permaid !== false || session.director)) { (event || window.event).returnValue = "Are you sure you want to exit?"; //Gecko + IE return "Are you sure you want to exit?"; } else { return undefined; // ADDED OCT 29th; get rid of popup. Just close the socket connection if the user is refreshing the page. It's one or the other. } } function gobackSlide() { var data = {}; data.data = [176, 110, 10]; sendRawMIDI(data); try { pokeIframeAPI("back-slide", true); } catch (e) { } } function nextSlide() { var data = {}; data.data = [176, 110, 11]; sendRawMIDI(data); try { pokeIframeAPI("next-slide", true); } catch (e) { } } function raisehand() { if (session.raisehands !== 2 && session.directorUUID == false) { // fine log("no director in room yet"); return false; } var data = {}; var handstate = false; log(data); if (getById("raisehandbutton").dataset.raised == "0") { getById("raisehandbutton").dataset.raised = "1"; getById("raisehandbutton").classList.add("raisedHand"); data.chat = "Raised hand"; handstate = true; log("hand raised"); } else { log("hand lowered"); getById("raisehandbutton").dataset.raised = "0"; getById("raisehandbutton").classList.remove("raisedHand"); data.chat = "Lowered hand"; handstate = false; } if (session.raisehands == 2) { session.sendMessage(data); } else { for (var i = 0; i < session.directorList.length; i++) { data.UUID = session.directorList[i]; session.sendMessage(data, data.UUID); } } try { pokeIframeAPI("hand", handstate); } catch (e) { } return handstate; } function lowerhand() { log("hand lowered"); getById("raisehandbutton").dataset.raised = "0"; getById("raisehandbutton").classList.remove("raisedHand"); pokeIframeAPI("hand", false); return false; } var previousRoom = ""; var stillNeedRoom = true; var transferCancelled = false; var armedTransfer = false; var transferSettings = {}; async function directMigrate(ele, event, room = false) { // everyone in the room will hangup this guest also? I like that idea. What about the STREAM ID? I suppose we don't kick out if the viewID matches. log("directMigrate"); if (room) { var migrateRoom = room; } else if (event === false) { if (previousRoom === null) { // user cancelled in previous callback ele.innerHTML = ' transfer'; miniTranslate(ele); //ele.style.backgroundColor = null; ele.classList.remove("armed"); return false; } if (transferCancelled === true) { ele.innerHTML = ' transfer'; miniTranslate(ele); //ele.style.backgroundColor = null; ele.classList.remove("armed"); return false; } var migrateRoom = previousRoom; } else if (event.ctrlKey || event.metaKey) { ele.innerHTML = ' armed'; miniTranslate(ele); ele.classList.add("armed"); //ele.style.backgroundColor = "#BF3F3F"; transferCancelled = false; //armedTransfer=true; Callbacks.push([directMigrate, ele, stillNeedRoom]); stillNeedRoom = false; log("Migrate queued"); return true; // } else if (armedTransfer){ //migrateRoom = sanitizeRoomName(previousRoom); } else { if (armedTransfer !== false && previousRoom !== "") { var migrateRoom = sanitizeRoomName(previousRoom); } else { var broadcastMode = null; if ("broadcast" in transferSettings) { broadcastMode = transferSettings.broadcast; } else if (session.rpcs[ele.dataset.UUID] && session.rpcs[ele.dataset.UUID].stats.info && "broadcast_mode" in session.rpcs[ele.dataset.UUID].stats.info) { broadcastMode = session.rpcs[ele.dataset.UUID].stats.info.broadcast_mode; } else if (session.broadcastTransfer !== null) { broadcastMode = session.broadcastTransfer; } var queuedMode = null; if ("queue" in transferSettings) { queuedMode = transferSettings.queue; } else if (session.queueTransfer) { queuedMode = session.queueTransfer; } var updateurl = null; if ("updateurl" in transferSettings) { updateurl = transferSettings.updateurl; } window.focus(); var response = await promptTransfer(previousRoom, broadcastMode, updateurl, queuedMode); var migrateRoom = response.roomid; if (migrateRoom !== null) { transferSettings = response; } } stillNeedRoom = true; if (migrateRoom === null) { // user cancelled ele.innerHTML = ' transfer'; miniTranslate(ele); //ele.style.backgroundColor = null; ele.classList.remove("armed"); transferCancelled = true; return false; } try { migrateRoom = sanitizeRoomName(migrateRoom); previousRoom = migrateRoom; } catch (e) { } } ele.innerHTML = ' transfer'; miniTranslate(ele); //ele.style.backgroundColor = null; ele.classList.remove("armed"); if (migrateRoom) { previousRoom = migrateRoom; session.directMigrateIssue(migrateRoom, transferSettings, ele.dataset.UUID); return true; } } var stillNeedHangupTarget = 1; async function directHangup(ele, event) { // everyone in the room will hangup this guest? I like that idea. var confirmHangup = false; var blockUser = false; if (event == false) { // Multi-user armed hangup mode if (stillNeedHangupTarget === 1) { window.focus(); confirmHangup = confirm(getTranslation("confirm-disconnect-users")); stillNeedHangupTarget = confirmHangup; } else { confirmHangup = stillNeedHangupTarget; } } else if (event === true) { confirmHangup = true; } else if (event.ctrlKey || event.metaKey) { ele.innerHTML = ' ARMED'; miniTranslate(ele); ele.classList.add("armed"); //ele.style.backgroundColor = "#BF3F3F"; stillNeedHangupTarget = 1; Callbacks.push([directHangup, ele, false]); log("Hangup queued"); return; } else { // Single user hangup - show dialog with block option window.focus(); var result = await confirmHangupWithBlock(getTranslation("confirm-disconnect-user")); confirmHangup = result.confirmed; blockUser = result.block; } if (confirmHangup) { var msg = {}; msg.hangup = true; // If director chose to block, just set the flag - guest will use their own room info if (blockUser) { msg.block = true; } log(msg); log(ele.dataset.UUID); var targetUUID = ele.dataset.UUID; session.sendRequest(msg, targetUUID); pokeIframeAPI("hungup", "directing", targetUUID); //session.anysend(msg); // send to everyone in the room, so they know if they are on air or not. // Delayed fallback: if the hangup message never arrives (dead connection), // clean up locally after 4 seconds to prevent stuck control boxes for co-directors if (session.rpcs[targetUUID]) { var sessionAtHangup = session.rpcs[targetUUID].session; // Capture session ID to verify it's the same connection var fallbackTimeout = setTimeout(function(uuid, origSession) { try { // Only close if it's still the same session (not a reconnect or re-request) if (!(uuid in session.rpcs) || session.rpcs[uuid].session !== origSession) { return; } // Skip if connection AND data channel are healthy - message should have been delivered var rpc = session.rpcs[uuid]; var connState = rpc.connectionState || "unknown"; var channelOpen = rpc.receiveChannel && rpc.receiveChannel.readyState === "open"; if (connState === "connected" && channelOpen) { warnlog("Hangup fallback: skipping - connection and channel healthy"); return; } warnlog("Hangup fallback: cleaning up (conn: " + connState + ", channel: " + (channelOpen ? "open" : "closed") + ")"); session.closeRPC(uuid, true); } catch (e) { warnlog(e); } }, 4000, targetUUID, sessionAtHangup); // Store timeout so closeRPC can cancel it if cleanup happens normally session.rpcs[targetUUID].hangupFallbackTimeout = fallbackTimeout; } return true; } else { ele.innerHTML = ' Hangup'; miniTranslate(ele); //ele.style.backgroundColor = null; ele.classList.remove("armed"); return false; } } function getAutoAssignChannel() { // Returns channel number (1-8) to assign based on session.autochannels config if (!session.autochannels || !session.autochannels.length) return false; // Build usage map: channel -> count of guests using it var usage = {}; session.autochannels.forEach(function(ch) { usage[ch] = 0; }); // Count current assignments from sceneAudioChannel buttons in guest containers var buttons = document.querySelectorAll('#guestFeeds [data-action-type="sceneAudioChannel"][data-state="1"]'); buttons.forEach(function(btn) { var ch = parseInt(btn.dataset.channel); if (ch in usage) { usage[ch]++; } }); if (session.autochannelmode === "roundrobin") { // Pick next in sequence, wrap around var ch = session.autochannels[session.autochannelIndex % session.autochannels.length]; session.autochannelIndex++; return ch; } else { // "leastused" mode: pick channel with fewest guests (enables stacking) var minCount = Infinity; var bestChannel = session.autochannels[0]; for (var i = 0; i < session.autochannels.length; i++) { var ch = session.autochannels[i]; if (usage[ch] < minCount) { minCount = usage[ch]; bestChannel = ch; } } return bestChannel; } } function autoAssignAudioChannel(UUID) { // Auto-assign a newly joined guest to an audio channel based on session.autochannels config if (!session.autochannels) return; var channel = false; // Check if guest has a preferred channel and it's in the allowed list if (session.rpcs[UUID] && session.rpcs[UUID].preferChannel) { var preferred = session.rpcs[UUID].preferChannel; if (session.autochannels.includes(preferred)) { channel = preferred; log("Using guest's preferred channel C" + channel); } } // Fall back to auto-assignment if no valid preferred channel if (!channel) { channel = getAutoAssignChannel(); } if (!channel) return; // Find the guest's container var container = getById("container_" + UUID); if (!container) return; // Find the channel button in the guest's container var btn = container.querySelector('[data-action-type="sceneAudioChannel"][data-channel="' + channel + '"]'); if (!btn) return; // Set button state (skip C4 warning dialog since user configured allowed channels) btn.dataset.state = "1"; btn.classList.add("pressed"); btn.ariaPressed = "true"; // Build and send message to scene viewers var msg = {}; msg.audioOutputChannel = channel; msg.sid = session.rpcs[UUID].streamID; for (var uuid in session.pcs) { if (session.pcs[uuid].scene !== false) { session.sendMessage(msg, uuid); } } // Sync to co-directors syncDirectorState(btn); log("Auto-assigned " + session.rpcs[UUID].streamID + " to channel C" + channel); } async function directAudioChannel(ele, event, director = false) { var UUID = ele.dataset.UUID; var channel = parseInt(ele.dataset.channel); var added = false; if (!(event.ctrlKey || event.metaKey)) { if (ele.dataset.state == "1") { ele.dataset.state = "0"; ele.classList.remove("pressed"); ele.ariaPressed = "false"; } else { if (channel == 4) { if (event) { let ret = await confirmAlt("⚠️Warning! Channel 4 should be avoided.\n\nChannel 4 in OBS is used for low-frequency audio and may distort the audio if you record to it.\n\nDo you wish to proceed?", false); if (!ret) { return; } } } ele.dataset.state = "1" ele.classList.add("pressed"); ele.ariaPressed = "true"; added = true; } } else if (ele.dataset.state == "1") { added = true; } if (director) { if (added) { document.querySelectorAll('#controls_director [data-action-type="sceneAudioChannel"][data-state="1"]:not([data-channel="' + channel + '"])').forEach(el => { el.dataset.state = "0"; el.classList.remove("pressed"); el.ariaPressed = "false"; }); } else { document.querySelectorAll('#controls_director [data-action-type="sceneAudioChannel"][data-state="1"][data-channel]').forEach(el => { el.dataset.state = "0"; el.classList.remove("pressed"); el.ariaPressed = "false"; }); channel = false; } } else { if (added) { document.querySelectorAll('[data-sid][data--u-u-i-d="' + UUID + '"][data-action-type="sceneAudioChannel"][data-state="1"]:not([data-channel="' + channel + '"])').forEach(el => { el.dataset.state = "0"; el.classList.remove("pressed"); el.ariaPressed = "false"; }); } else { document.querySelectorAll('[data-sid][data--u-u-i-d="' + UUID + '"][data-channel][data-action-type="sceneAudioChannel"][data-state="1"]').forEach(el => { el.dataset.state = "0"; el.classList.remove("pressed"); el.ariaPressed = "false"; }); channel = false; } } var msg = {}; msg.audioOutputChannel = channel; if (director) { msg.sid = session.streamID; } else { msg.sid = ele.dataset.sid; } for (var uuid in session.pcs) { if (session.pcs[uuid].scene !== false) { session.sendMessage(msg, uuid); } } syncDirectorState(ele); if (channel) { return true; } else { return false; } } function directEnable(ele, event, director = false) { // A directing room only is controlled by the Director, with the exception of MUTE. var scene = ele.dataset.scene; if (!(event.ctrlKey || event.metaKey)) { if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; if (ele.children[1]) { ele.children[1].innerHTML = "Add to Scene " + scene; } if (director) { var cc = 0; getById("container_director") .querySelectorAll('[data-action-type="addToScene"]') .forEach(ge => { if (ge.value == 1) { cc += 1; } }); if (!cc) { getById("container_director").style.backgroundColor = null; getById("container_director").classList.remove("containerGreen"); } } else { var cc = 0; getById("container_" + ele.dataset.UUID) .querySelectorAll('[data-action-type="addToScene"]') .forEach(ge => { if (ge.value == 1) { cc += 1; log("ge.value: '" + ge.value + "'"); } else { log("ge.value:--'" + ge.value + "'"); } }); log(cc + " " + "container_" + ele.dataset.UUID); if (!cc) { getById("container_" + ele.dataset.UUID).style.backgroundColor = null; getById("container_" + ele.dataset.UUID).classList.remove("containerGreen"); } } } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; if (ele.children[1]) { ele.children[1].innerHTML = "Remove"; } if (director) { getById("container_director").classList.add("containerGreen"); } else { getById("container_" + ele.dataset.UUID).classList.add("containerGreen"); } } } var msg = {}; scene = scene + ""; msg.scene = scene; msg.action = "display"; msg.value = ele.value; msg.target = ele.dataset.sid; try { if (msg.value == 1) { pokeIframeAPI("add-to-scene", scene, ele.dataset.UUID); } else { pokeIframeAPI("remove-from-scene", scene, ele.dataset.UUID); } } catch (e) { } //for (var uuid in session.pcs){ // removing this since it's obsolete at this point. // if (session.pcs[uuid].stats.info && ("version" in session.pcs[uuid].stats.info) && (session.pcs[uuid].stats.info.version < 17.2)){ //// msg.request = "sendroom"; // session.sendMsg(msg); // return; // } //} for (var uuid in session.pcs) { if (session.pcs[uuid].scene === scene) { session.sendMessage(msg, uuid); } } syncDirectorState(ele); if (msg.value) { return true; } else { return false; } } function syncDirectorState(ele) { //if (session.director){ // assumed director, since this is a directEnable sub-function var msg = {}; msg.directorState = getDetailedState(ele.dataset.sid); for (var uuid in session.pcs) { if (session.pcs[uuid].coDirector) { session.sendMessage(msg, uuid); } } for (var i in session.directorList) { var uuid = session.directorList[i]; if (session.rpcs[uuid]) { session.sendRequest(msg, uuid); } } pokeAPI("details", msg.directorState); } function getQuickStats(sid = false) { var stats = {}; try { stats.inbound = {}; stats.outbound = {}; stats.streamID = session.streamID; if (session.whipOut && session.whipOut.stats) { myStats.whip_outbound = session.whipOut.stats; } if (session.whepIn && session.whepIn.stats) { myStats.whep_inbound = session.whepIn.stats; } for (var i in session.rpcs) { if (session.rpcs[i].streamID) { stats.inbound[session.rpcs[i].streamID] = session.rpcs[i].stats; } } for (var i in session.pcs) { stats.outbound[i] = session.pcs[i].stats; } } catch (e) { } if (sid) { if (sid in stats.inbound) { return stats.inbound[sid]; } else { return null; } } return stats; } function getDetailedState(sid = false) { var streamList = {}; var guestFeeds = document.getElementById("guestFeeds"); for (var UUID in session.rpcs) { if (session.rpcs[UUID].streamID) { if (sid && sid !== session.rpcs[UUID].streamID) { continue; } let item = {}; item.streamID = session.rpcs[UUID].streamID; item.label = session.rpcs[UUID].label; item.group = session.rpcs[UUID].group; if (session.rpcs[UUID].stats && session.rpcs[UUID].stats.info) { item.miscellaneous = session.rpcs[UUID].stats.info; } try { item.layout = session.rpcs[UUID].layout; if (session.director && session.slotmode) { item.slot = getSlotState(UUID); } else if (session.currentSlots) { item.slot = Object.keys(session.currentSlots).find(key => session.currentSlots[key] === session.rpcs[UUID].streamID) || false; } if (item.slot) { item.slot = parseInt(item.slot); } if (session.director) { let featured = query("[data--u-u-i-d='" + UUID + "'][data-action-type='solo-video']"); if (featured && parseInt(featured.value)) { item.featured = true; } else { item.featured = false; } } else if (session.infocus && session.infocus === UUID) { item.featured = true; } else { item.featured = false; } } catch (e) { errorlog(e); } item.iframeSrc = session.rpcs[UUID].iframeSrc; item.localStream = false; item.muted = session.rpcs[UUID].remoteMuteState; item.videoMuted = session.rpcs[UUID].videoMuted; try { item.activeSpeaker = session.rpcs[UUID].activelySpeaking; item.defaultSpeaker = session.rpcs[UUID].defaultSpeaker; } catch (e) { errorlog(e); } item.videoVisible = session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.checkVisibility(); if (session.rpcs[UUID].videoElement) { item.videoVolume = session.rpcs[UUID].videoElement.volume; } item.iframeVisible = session.rpcs[UUID].iframeVisible && session.rpcs[UUID].iframeVisible.checkVisibility(); if (session.directorList.indexOf(UUID) >= 0) { item.director = true; } else { item.director = false; } try { if (session.director) { if (guestFeeds) { var lock = parseInt(document.getElementById("position_" + UUID).dataset.locked); if (lock) { item.position = lock; // probably should make a universal function to do this, for all lock requesting } else { var child = document.getElementById("container_" + UUID); if (child) { var parent = child.parentNode; if (parent.id == "guestFeeds") { item.position = Array.prototype.indexOf.call(parent.children, child) + 1; } } } } var scenes = getById("container_" + UUID).querySelectorAll('[data-action-type="addToScene"][data-scene][data--u-u-i-d="' + UUID + '"]'); var sceneState = {}; for (var i = 0; i < scenes.length; i++) { if (scenes[i].value == 1) { sceneState[scenes[i].dataset.scene] = true; } else { sceneState[scenes[i].dataset.scene] = false; } } item.scenes = sceneState; var others = getById("container_" + UUID).querySelectorAll('[data-action-type][data--u-u-i-d="' + UUID + '"]'); var otherState = {}; for (var i = 0; i < others.length; i++) { if (!others[i] || !others[i].dataset) { continue; } if (others[i].dataset.actionType === "solo-video" && others[i].classList && others[i].classList.contains("altpress")) { otherState[others[i].dataset.actionType] = "alt"; continue; } else if (others[i].dataset.actionType === "remove-queue") { if (others[i].classList.contains("hidden")) { otherState[others[i].dataset.actionType] = false; } else { otherState[others[i].dataset.actionType] = true; } continue; } else if (others[i].dataset.actionType === "hand-raised") { if (others[i].classList.contains("hidden")) { otherState[others[i].dataset.actionType] = false; } else { otherState[others[i].dataset.actionType] = true; } continue; } if ("scene" in others[i].dataset) { continue; } else if ("toggle-group" == others[i].dataset.actionType) { continue; } else if ("value" in others[i]) { if (others[i].value !== "") { otherState[others[i].dataset.actionType] = others[i].value; } } else { try { if (others[i].querySelector(".altpress")) { otherState[others[i].dataset.actionType] = "alt"; } else if (others[i].querySelector(".pressed")) { otherState[others[i].dataset.actionType] = true; } else if (others[i].dataset.actionType == "soloChat") { otherState[others[i].dataset.actionType] = false; } } catch (e) { errorlog(e); } } } item.others = otherState; } } catch (e) { } streamList[session.rpcs[UUID].streamID] = item; } } if (sid && sid !== session.streamID) { return streamList; } streamList[session.streamID] = {}; try { if (session.director) { var sceneState = {}; var scenes = getById("container_director").querySelectorAll('[data-action-type="addToScene"][data-scene]'); for (var i = 0; i < scenes.length; i++) { if (scenes[i].value == 1) { sceneState[scenes[i].dataset.scene] = true; } else { sceneState[scenes[i].dataset.scene] = false; } } streamList[session.streamID].scenes = sceneState; } } catch (e) { } if (session.director) { let featured = document.querySelector("#highlightDirector[data-action-type='solo-video'], #container_director [data-action-type='solo-video']"); if (featured && parseInt(featured.value)) { streamList[session.streamID].featured = true; } else { streamList[session.streamID].featured = false; } } else if (session.infocus && session.infocus === true) { streamList[session.streamID].featured = true; } else { streamList[session.streamID].featured = false; } streamList[session.streamID].label = session.label; streamList[session.streamID].meta = session.meta; streamList[session.streamID].group = session.group; streamList[session.streamID].groupView = session.groupView; streamList[session.streamID].scene = session.scene; streamList[session.streamID].streamID = session.streamID; streamList[session.streamID].iframeSrc = session.iframeSrc; streamList[session.streamID].director = session.directorState; //session.director is what you want to be; session.directorState is what you are streamList[session.streamID].localstream = true; // deprecated. streamList[session.streamID].localStream = true; streamList[session.streamID].seeding = session.seeding; streamList[session.streamID].muted = session.muted; streamList[session.streamID].videoMuted = session.videoMuted; streamList[session.streamID].videoVisible = session.videoElement && session.videoElement.checkVisibility(); streamList[session.streamID].speakerMuted = session.speakerMuted; streamList[session.streamID].position = null; streamList[session.streamID].meshcast = session.meshcast; streamList[session.streamID].layout = session.layout; try { if (session.streamID && session.slotmode) { // Properly check for director's slots by looking directly at currentSlots let directorSlot = false; // Look for the director's main stream in currentSlots Object.entries(session.currentSlots).forEach(([slot, sid]) => { if (sid === session.streamID) { directorSlot = parseInt(slot); } }); // If the director has a slot assigned, use it if (directorSlot) { streamList[session.streamID].slot = directorSlot; } else { // No slot found for director streamList[session.streamID].slot = false; } } } catch (e) { errorlog(e); } if (session.info && session.info.out) { streamList[session.streamID].outbound = session.info.out; } if (session.showDirector && session.director) { var child = document.getElementById("container_director"); if (child) { var parent = child.parentNode; if (parent.id == "guestFeeds") { streamList[session.streamID].position = Array.prototype.indexOf.call(parent.children, child) + 1; } } } if (session.notifyScreenShare) { streamList[session.streamID].screenSharing = session.screenShareState; } else { streamList[session.streamID].screenSharing = false; } if (session.streamSrc) { streamList[session.streamID].audioTrack = session.streamSrc.getAudioTracks().length !== 0; streamList[session.streamID].videoTrack = session.streamSrc.getVideoTracks().length !== 0; } else { streamList[session.streamID].audioTrack = false; streamList[session.streamID].videoTrack = false; } return streamList; } function getGuestList() { var guestFeeds = document.getElementById("guestFeeds"); if (!guestFeeds) { return {}; } var streamList = {}; for (var i = 0; i < guestFeeds.children.length; i++) { try { if (session.rpcs[guestFeeds.children[i].dataset.UUID]) { streamList[i + 1 + ""] = { streamID: session.rpcs[guestFeeds.children[i].dataset.UUID].streamID, label: session.rpcs[guestFeeds.children[i].dataset.UUID].label || "" }; } else if (guestFeeds.children[i].id == "container_director") { streamList[i + 1 + ""] = { streamID: session.streamID, label: session.label || "" }; } else if (guestFeeds.children[i].id == "container_screen_director") { streamList[i + 1 + ""] = { streamID: session.streamID + ":s", label: session.screenShareLabel || "" }; } } catch (e) { errorlog(e); } } return streamList; } function syncOtherState(sid) { if (!session.syncState) { return; } if (!session.syncState[sid]) { return; } /* if (session.rpcs[ele.dataset.UUID].directorMutedState==1){ pokeIframeAPI("director-mute-state", true, ele.dataset.UUID); pokeAPI("directorMuted", true, session.rpcs[ele.dataset.UUID].streamID); } else { pokeIframeAPI("director-mute-state", false, ele.dataset.UUID); pokeAPI("directorMuted", false, session.rpcs[ele.dataset.UUID].streamID); } */ var others = session.syncState[sid].others; log(others); for (var other in others) { if (other == "toggle-group") { continue; } var ele = document.querySelector('[data-sid="' + sid + '"][data-action-type="' + other + '"]'); if (ele) { var state = others[other]; if (state === "alt" && other === "solo-video") { if ("value" in ele) { ele.value = 1; } if (ele.nodeName && ele.nodeName.toLowerCase() == "input") { try { ele.checked = true; } catch (e) { } } ele.classList.add("altpress"); ele.classList.remove("pressed"); ele.ariaPressed = "false"; continue; } else if (other === "remove-queue") { if (state) { ele.classList.remove("hidden"); } else { ele.classList.add("hidden"); } continue; } else if (other === "hand-raised") { if (state) { ele.classList.remove("hidden"); } else { ele.classList.add("hidden"); } continue; } else { ele.classList.remove("altpress"); } if (state) { if (!("value" in ele)) { errorlog("NO DEFAULT VALUE IN SPECIFIED ELEMENT; guessing default: " + other); ele.value = 0; } var changed = true; if (ele.value == state) { changed = false; } if (other == "mute-guest") { if (changed) { remoteMute(ele, false, true); } } else if (other == "hide-guest") { if (changed) { remoteHideVideo(ele, true, true); } } else if (other == "mute-video-guest") { if (changed) { remoteMuteVideo(ele, true, true); } } else { ele.value = state; if (ele.nodeName.toLowerCase() == "input") { ele.value = parseInt(state); } else if (parseInt(state)) { ele.classList.add("pressed"); ele.ariaPressed = "true"; } else { ele.classList.remove("pressed"); ele.ariaPressed = "false"; } } } } } var UUID = document.querySelector('[data-sid="' + sid + '"][data--u-u-i-d'); if (UUID && UUID.dataset.UUID) { UUID = UUID.dataset.UUID; if (session.syncState[sid].group && session.rpcs[UUID]) { session.rpcs[UUID].group = session.syncState[sid].group; syncGroup(session.rpcs[UUID].group, UUID); } } } function htmlToElement(html) { var template = document.createElement("template"); html = html.trim(); // Never return a text node of whitespace as the result template.innerHTML = html; return template.content.firstChild; } function syncGroup(groups, UUID) { if (!groups || typeof groups !== "object") { errorlog("Group isn't an object"); return; } groups.forEach(group => { var ele = getById("container_" + UUID).querySelector('[data-action-type="toggle-group"][data--u-u-i-d="' + UUID + '"][data-group="' + group + '"]'); if (!ele) { var newGroup = htmlToElement('"); var added = false; getById("container_" + UUID) .querySelectorAll(".customGroup>[data-group]") .forEach(ele => { log(ele); if (!added && ele.dataset.group > group + "") { ele.parentNode.insertBefore(newGroup, ele); added = true; } }); if (!added) { var newGroupCon = getById("container_" + UUID).querySelector(".customGroup"); if (!newGroupCon) { newGroupCon = document.createElement("div"); newGroupCon.classList.add("customGroup"); getById("container_" + UUID).appendChild(newGroupCon); } newGroupCon.appendChild(newGroup); } } }); var elements = document.querySelectorAll('[data-action-type="toggle-group"][data--u-u-i-d="' + UUID + '"][data-group]'); if (elements.length) { for (var i = 0; i < elements.length; i++) { if (session.rpcs[UUID].group.includes(elements[i].dataset.group)) { elements[i].classList.add("pressed"); elements[i].ariaPressed = "true"; } else { elements[i].classList.remove("pressed"); elements[i].ariaPressed = "false"; } } log("synced group"); } else { log("not syncing group buttons; don't exist"); } } function syncLabelState(sid) { if (!session.syncState || !session.syncState[sid]) { return; } var newLabel = session.syncState[sid].label; // Find the UUID for this streamID for (var UUID in session.rpcs) { if (session.rpcs[UUID].streamID === sid) { // Update the RPC label if (session.rpcs[UUID].label !== newLabel) { session.rpcs[UUID].label = newLabel; // Label came from director sync - mark it to prevent info message from overwriting if (newLabel) { session.rpcs[UUID].labelSetByDirector = true; } else { session.rpcs[UUID].labelSetByDirector = false; } // Update the UI label element var labelEle = document.getElementById("label_" + UUID); if (labelEle) { if (newLabel) { labelEle.innerText = newLabel; labelEle.classList.remove("addALabel"); } else if (session.directorUUID === UUID) { miniTranslate(labelEle, "main-director"); labelEle.classList.remove("addALabel"); } else if (session.directorList.indexOf(UUID) >= 0) { miniTranslate(labelEle, "co-director"); labelEle.classList.remove("addALabel"); } else { miniTranslate(labelEle, "add-a-label"); labelEle.classList.add("addALabel"); } } log("synced label for " + sid + ": " + newLabel); } break; } } } function syncSceneState(sid) { if (!session.syncState) { return; } if (!session.syncState[sid]) { return; } var scenes = session.syncState[sid].scenes || []; for (var scene in scenes) { try { var ele = document.querySelector('[data-sid="' + sid + '"][data-action-type="addToScene"][data-scene="' + scene + '"]'); if (ele) { if (scenes[scene]) { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; getById("container_" + ele.dataset.UUID).classList.add("containerGreen"); if (ele.children[1]) { ele.children[1].innerHTML = "Remove"; } } else { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; if (ele.children[1]) { ele.children[1].innerHTML = "Add to Scene " + scene; } } } } catch (e) { } } } function issueLayout(scene = false, UUID = false) { // A directing room only is controlled by the Director, with the exception of MUTE. log("issueLayout() called"); var msg = {}; msg.layout = session.layout; msg.layout_array = session.layout_array; //try { // pokeIframeAPI("layout", {layout:layout, scene:scene}); //} catch(e){} /* session.layout = { "stevetestA": { x:0, y:0, w:40, h:40, z:0, c:false }, "stevetestB": { x:50, y:50, w:40, h:40, z:1, c:true } }; */ if (UUID) { if (session.pcs[UUID] && scene !== false && session.pcs[UUID].scene === scene + "" && !session.pcs[UUID].solo && session.pcs[UUID].layout) { // scene specified session.sendMessage(msg, UUID); session.pcs[UUID].layoutState = normalizeLayoutStateValue(session.layout); } else if (session.pcs[UUID] && session.pcs[UUID].layout && !session.pcs[UUID].solo) { // no scene targetted session.sendMessage(msg, UUID); session.pcs[UUID].layoutState = normalizeLayoutStateValue(session.layout); log("broadcast"); } } else { for (var uuid in session.pcs) { if (scene !== false && session.pcs[uuid].scene === scene + "" && !session.pcs[uuid].solo && session.pcs[uuid].layout) { session.sendMessage(msg, uuid); session.pcs[uuid].layoutState = normalizeLayoutStateValue(session.layout); } else if (session.pcs[uuid].layout && !session.pcs[uuid].solo) { session.sendMessage(msg, uuid); session.pcs[uuid].layoutState = normalizeLayoutStateValue(session.layout); log("broadcast"); } } } } async function issueLayoutOBS(data) { // A directing room only is controlled by the Director, with the exception of MUTE. var layout = data.layout || false; var scene = data.scene || false; var UUID = data.UUID || false; var obsCommand = data.obsCommand || false; const normalizedLayoutState = normalizeLayoutStateValue(layout); log("issueLayoutOBS() called"); var msg = {}; msg.layout = layout; msg.obsCommand = obsCommand; if (data.remote) { msg.remote = data.remote; } else { msg.remote = session.remote || true; } msg = await session.encodeRemote(msg); if (UUID) { try { log("CONTROL STATE" + session.pcs[UUID].obsState.details.controlLevel); } catch (e) { } if (session.pcs[UUID] && scene !== false && session.pcs[UUID].scene === scene + "") { if (!session.pcs[UUID].solo) { session.sendMessage(msg, UUID); session.pcs[UUID].layoutState = normalizedLayoutState; } } else if (session.pcs[UUID] && session.pcs[UUID].layout) { session.sendMessage(msg, UUID); session.pcs[UUID].layoutState = normalizedLayoutState; log("broadcast"); } } else { for (var uuid in session.pcs) { try { log("CONTROL STATE" + session.pcs[UUID].obsState.details.controlLevel); } catch (e) { } if (scene !== false && session.pcs[uuid].scene === scene + "") { if (!session.pcs[uuid].solo) { session.sendMessage(msg, uuid); session.pcs[uuid].layoutState = normalizedLayoutState; } } else if (session.pcs[uuid].layout) { session.sendMessage(msg, uuid); session.pcs[uuid].layoutState = normalizedLayoutState; log("broadcast"); } } } } var previousURL = ""; var stillNeedURL = true; var reloadCancelled = false; var armedReload = false; async function directPageReload(ele, event) { log("URL Page reload"); if (event === false) { if (previousURL === null) { // user cancelled in previous callback ele.innerHTML = ' change URL'; miniTranslate(ele); ele.classList.remove("armed"); // ele.style.backgroundColor = null; return; } if (reloadCancelled === true) { ele.innerHTML = ' change URL'; miniTranslate(ele); ele.classList.remove("armed"); //ele.style.backgroundColor = null; return; } reloadURL = previousURL; } else if (event.ctrlKey || event.metaKey) { ele.innerHTML = ' armed'; miniTranslate(ele); ele.classList.add("armed"); //ele.style.backgroundColor = "#BF3F3F"; reloadCancelled = false; armedReload = true; Callbacks.push([directPageReload, ele, stillNeedURL]); stillNeedURL = false; log("URL update queued"); return; } else if (armedReload) { reloadURL = previousURL; } else { window.focus(); var reloadURL = await promptAlt(getTranslation("transfer-guest-to-url"), false, false, previousURL); stillNeedURL = true; if (reloadURL === null) { // user cancelled ele.innerHTML = ' change URL'; miniTranslate(ele); ele.classList.remove("armed"); //ele.style.backgroundColor = null; reloadCancelled = true; return; } try { previousURL = reloadURL; } catch (e) { } } ele.innerHTML = ' change URL'; miniTranslate(ele); ele.classList.remove("armed"); //ele.style.backgroundColor = null; if (reloadURL) { previousURL = reloadURL; var msg = {}; msg.changeURL = reloadURL; if (ele.dataset.UUID in session.rpcs) { session.rpcs[ele.dataset.UUID].receiveChannel.send(JSON.stringify(msg)); } } } async function directTimer(ele, event = false, manualSetTime = false) { // A directing room only is controlled by the Director, with the exception of MUTE. log("directTimer"); var msg = {}; ele.classList.remove("blue"); ele.classList.remove("red"); if (!event || !(event.ctrlKey || event.metaKey)) { if (ele.value == 0 || ele.value == 2) { if (manualSetTime !== false) { var getTime = parseFloat(manualSetTime) || 0; } else { var getTime = await promptAlt("Time to set count down timer", false, false, parseInt(getById("overlayClockContainer").dataset.initial), true); } if (getTime === null) { return; } getById("overlayClockContainer").dataset.initial = parseInt(getTime); ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.classList.remove("red"); msg.setClock = getTime; msg.showClock = true; msg.startClock = true; ele.innerHTML = ' Remove Timer'; } else if (ele.value == 3) { ele.value = 1; msg.resumeClock = true; ele.classList.add("red"); } else { ele.value = 2; ele.classList.remove("pressed"); ele.ariaPressed = "false"; msg.stopClock = true; msg.hideClock = true; ele.innerHTML = ' Create Timer'; } //miniTranslate(ele); } else if (event.ctrlKey || event.metaKey) { if (ele.value == 1) { ele.value = 3; msg.pauseClock = true; ele.classList.add("blue"); } else if (ele.value == 3) { ele.value = 1; msg.resumeClock = true; ele.classList.add("red"); } } if (!session.director) { return; } if (ele.dataset.UUID) { if (session.sendRequest(msg, ele.dataset.UUID)) { return true; } } else { if (session.sendRequest(msg)) { return true; } } return false; } function formatTime24h(date, clock24 = true) { let hours = date.getHours(); let minutes = date.getMinutes(); // Ensure hours are always 0-23 hours = hours % 24; // Pad single digit hours and minutes with leading zeros hours = hours.toString().padStart(2, '0'); minutes = minutes.toString().padStart(2, '0'); if (clock24) { // 24-hour format return `${hours}:${minutes}`; } else { // 12-hour format let period = hours >= 12 ? 'PM' : 'AM'; hours = hours % 12 || 12; // Convert 0 to 12 for midnight return `${hours}:${minutes} ${period}`; } } function toggleClock(clock24 = session.clock24) { if (session.showTime === false) { return; } if (session.showTime) { clearInterval(session.showTime); session.showTime = null; var clock = getById("overlayClock2"); clock.ctx = null; clock.canvas = null; if (document.pictureInPictureElement && clock.video) { if (document.pictureInPictureElement == clock.video) { document.exitPictureInPicture(); pokeIframeAPI("picture-in-picture", false); } clock.video.remove; } clock.video = null; clock.innerHTML = ""; getById("overlayClockContainer2").classList.add("hidden"); } else { var time = new Date(); var clock = getById("overlayClock2"); if (clock.ctx) { clock.ctx.beginPath(); clock.ctx.rect(0, 0, 230, 40); clock.ctx.fillStyle = "#000"; clock.ctx.fill(); clock.ctx.fillStyle = "#FFF"; clock.ctx.font = "50px monospace"; clock.ctx.textAlign = "center"; clock.ctx.fillText(formatTime24h(time, clock24), 115, 37); } else { clock.innerHTML = formatTime24h(time, clock24); } session.showTime = setInterval(function () { var time = new Date(); var clock = getById("overlayClock2"); if (clock.ctx) { clock.ctx.beginPath(); clock.ctx.rect(0, 0, 230, 40); clock.ctx.fillStyle = "#000"; clock.ctx.fill(); clock.ctx.fillStyle = "#FFF"; clock.ctx.font = "50px monospace"; clock.ctx.textAlign = "center"; clock.ctx.fillText(formatTime24h(time, clock24), 115, 37); } else { getById("overlayClock2").innerHTML = formatTime24h(time, clock24); } }, 2000); getById("overlayClockContainer2").classList.remove("hidden"); } return; } async function directRoomClock(ele, event = false) { if (ele.active) { ele.active = false; session.showRoomTime = false; ele.classList.remove("pressed"); ele.ariaPressed = "false"; } else { ele.active = true; session.showRoomTime = true; ele.classList.add("pressed"); ele.ariaPressed = "true"; } if (session.showTime !== false) { if (ele.active && !session.showTime) { toggleClock(); } else if (!ele.active && session.showTime) { toggleClock(); } } var msg = {}; if (session.clock24 !== null) { msg.clock24 = session.clock24; } msg.showTime = session.showRoomTime; session.sendRequest(msg); } async function directRoomTimer(ele, event = false, preSetTime = false) { // A directing room only is controlled by the Director, with the exception of MUTE. log("directGlobalRoomTimer"); var msg = {}; ele.classList.remove("blue"); ele.classList.remove("red"); getById("overlayClockContainer").style.fontSize = "50px"; if (!event || !(event.ctrlKey || event.metaKey || event.altKey)) { if (ele.value == 0 || ele.value == 2) { if (preSetTime !== false) { var getTime = preSetTime; } else { var getTime = await promptAlt("Time to set count down timer", false, false, parseInt(getById("overlayClockContainer").dataset.initial), true); } if (getTime === null) { return; } getTime = parseInt(getTime); getById("overlayClockContainer").dataset.initial = getTime; ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.classList.remove("red"); session.roomTimer = Date.now() / 1000 + getTime; session.roomTimerGlobal = false; msg.setClock = getTime; setClock(getTime); msg.showClock = true; showClock(); msg.startClock = true; startClock(); ele.innerHTML = ' Remove Timer'; } else if (ele.value == 3) { ele.value = 1; msg.resumeClock = true; resumeClock(); // this needed to be removed, right? if (!session.roomTimer) { session.roomTimer = false; } else if (session.roomTimer > 0) { session.roomTimer = false; } else { session.roomTimer = Date.now() / 1000 - session.roomTimer; } ele.innerHTML = ' Remove Timer'; ele.classList.add("red"); } else { ele.value = 2; ele.classList.remove("pressed"); ele.ariaPressed = "false"; session.roomTimer = false; msg.stopClock = true; stopClock(); msg.hideClock = true; hideClock(); ele.innerHTML = ' Create Timer'; } //miniTranslate(ele); } else if (event.ctrlKey || event.metaKey || event.altKey) { if (ele.value == 1) { ele.value = 3; msg.pauseClock = true; pauseClock(); if (!session.roomTimer) { session.roomTimer = false; } else if (session.roomTimer < Date.now() / 1000) { session.roomTimer = false; } else { session.roomTimer = Date.now() / 1000 - session.roomTimer; } ele.innerHTML = ' Resume Timer'; ele.classList.add("blue"); } else if (ele.value == 3) { ele.value = 1; msg.resumeClock = true; resumeClock(); if (!session.roomTimer) { session.roomTimer = false; } else if (session.roomTimer > 0) { session.roomTimer = false; } else { session.roomTimer = Date.now() / 1000 - session.roomTimer; } ele.innerHTML = ' Remove Timer'; ele.classList.add("red"); } else if (event.altKey && ele.dataset.actionType && !ele.dataset.UUID && (ele.dataset.actionType == "create-timer-global")) { if (preSetTime !== false) { var getTime = preSetTime; } else { var getTime = await promptAlt("Time to set count down timer .\n(This alt-timer will show in scenes-also)", false, false, parseInt(getById("overlayClockContainer").dataset.initial), true); } if (getTime === null) { return; } getTime = parseInt(getTime); getById("overlayClockContainer").dataset.initial = getTime; ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.classList.remove("red"); session.roomTimer = Date.now() / 1000 + getTime; session.roomTimerGlobal = true; msg.setClock = getTime; setClock(getTime); msg.showClock = true; showClock(); msg.startClock = true; startClock(); ele.innerHTML = ' Remove Global Timer'; } } if (!session.director) { return; } if (ele.dataset.UUID) { session.sendRequest(msg, ele.dataset.UUID); } else if (session.roomTimerGlobal) { session.sendPeers(msg); } else { session.sendRequest(msg); } } function updateRemoteTimerButton(UUID, currentTime) { var elements = document.querySelectorAll('[data-action-type="create-timer"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { if (elements[0].value != 2) { var time = parseInt(currentTime) || 0; elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].value = 1; if (time < 0) { time = time * -1; var minutes = Math.floor(time / 60); var seconds = time - minutes * 60; elements[0].classList.add("red"); elements[0].innerHTML = ' -' + minutes + "m : " + zpadTime(seconds) + "s"; } else { var minutes = Math.floor(time / 60); var seconds = time - minutes * 60; elements[0].classList.remove("red"); elements[0].innerHTML = ' ' + minutes + "m : " + zpadTime(seconds) + "s"; } } else { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; elements[0].classList.remove("red"); elements[0].innerHTML = ' Create Timer'; } } } function directMute(ele, event = false) { // A directing room only is controlled by the Director, with the exception of MUTE. log("mute 2"); if (!event || !(event.ctrlKey || event.metaKey)) { if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; miniTranslate(ele, "mute-scene"); } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; miniTranslate(ele, "unmute"); } } var msg = {}; msg.scene = true; msg.action = "mute"; msg.value = ele.value; msg.target = ele.dataset.sid; log(msg); log("ele:"); log(ele); //for (var uuid in session.pcs){ // obsolete at this point; v22 // if (session.pcs[uuid].stats.info && ("version" in session.pcs[uuid].stats.info) && (session.pcs[uuid].stats.info.version < 17.2)){ // msg.request = "sendroom"; // session.sendMsg(msg); // return; // } //} for (var uuid in session.pcs) { if (session.pcs[uuid].scene !== false) { // send to all scenes (but scene = 0) session.sendMessage(msg, uuid); } } syncDirectorState(ele); if (msg.value) { return true; } else { return false; } } function requestFileUpload(ele) { ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.disabled = true; ele.innerHTML = ' Requesting..'; setTimeout( function (ele) { try { ele.innerHTML = ' Request File'; ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.disabled = false; } catch (e) { } }, 15000, ele ); var msg = { requestUpload: true, UUID: ele.dataset.UUID }; session.sendRequest(msg, ele.dataset.UUID); } function remoteSpeakerMute(ele, event = false) { log("speaker mute"); if (!event || !(event.ctrlKey || event.metaKey)) { if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.innerHTML = ' Deafen'; } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.innerHTML = ' Undeafen'; } miniTranslate(ele); } var msg = {}; if (ele.value == 0) { msg.speakerMute = false; } else { msg.speakerMute = true; } msg.UUID = ele.dataset.UUID; session.sendRequest(msg, ele.dataset.UUID); syncDirectorState(ele); errorlog(msg); return msg.speakerMute; } function updateRemoteSpeakerMute(UUID) { var ele = document.querySelectorAll('[data-action-type="toggle-remote-speaker"][data--u-u-i-d="' + UUID + '"]'); if (ele[0]) { ele[0].classList.add("pressed"); ele[0].ariaPressed = "true"; ele[0].value = 1; ele[0].innerHTML = ' undeafen'; miniTranslate(ele[0]); } return true; } function updateRemoteDisplayMute(UUID, blind = true) { var ele = document.querySelectorAll('[data-action-type="toggle-remote-display"][data--u-u-i-d="' + UUID + '"]'); if (ele[0]) { if (blind) { ele[0].classList.add("pressed"); ele[0].ariaPressed = "true"; ele[0].value = 1; ele[0].innerHTML = ' unblind'; miniTranslate(ele[0]); return true; } else { ele[0].classList.remove("pressed"); ele[0].ariaPressed = "false"; ele[0].value = 0; ele[0].innerHTML = ' blind'; miniTranslate(ele[0]); return false; } } return false; } function blindAllGuests(ele, event = false) { if (!session.director) { if (!session.cleanOutput) { warnUser("Only a director can mute other guests"); } return; } // only a director can use this button. log("blind all display mute"); if (!event || !(event.ctrlKey || event.metaKey)) { if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.classList.remove("red"); ele.innerHTML = ''; } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.classList.add("red"); ele.innerHTML = ''; } } var msg = {}; if (ele.value == 0) { msg.displayMute = false; session.directorBlindAllGuests = false; } else { msg.displayMute = true; session.directorBlindAllGuests = true; } for (var UUID in session.rpcs) { // doesn't include scenes, as they don't publiish and this is rpcs if (session.directorList.indexOf(UUID) >= 0) { continue; } // don't try to mute other directors try { session.sendRequest(msg, UUID); updateRemoteDisplayMute(UUID, msg.displayMute); } catch (e) { errorlog(e); } } syncDirectorState(ele); return msg.displayMute; } function remoteDisplayMute(ele, event = false) { log("display mute"); if (!event || !(event.ctrlKey || event.metaKey)) { if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.innerHTML = ' Blind'; } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.innerHTML = ' Unblind'; } miniTranslate(ele); } var msg = {}; if (ele.value == 0) { msg.displayMute = false; } else { msg.displayMute = true; } msg.UUID = ele.dataset.UUID; session.sendRequest(msg, ele.dataset.UUID); syncDirectorState(ele); return msg.displayMute; } function remoteLowerhands(UUID) { var msg = {}; msg.lowerhand = true; msg.UUID = UUID; session.sendRequest(msg, UUID); try { getById("hands_" + UUID).classList.add("hidden"); session.rpcs[UUID].remoteRaisedHandElement.classList.add("hidden"); } catch (e) { } // Sync hand-lowered state to co-directors (only main director syncs) if (session.directorState !== false) { try { if (session.rpcs[UUID] && session.rpcs[UUID].streamID) { var ele = { dataset: { sid: session.rpcs[UUID].streamID } }; syncDirectorState(ele); } } catch (e) { errorlog(e); } } return true; } function remoteMute(ele, event = false, skipSend = false) { log("mute"); var val = parseInt(ele.value) || 0; if (!event || !(event.ctrlKey || event.metaKey)) { if (val == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; //ele.innerHTML = ' mute'; miniTranslate(ele, "mute"); //ele.innerHTML += getTranslation("mute"); } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; //ele.innerHTML = ' unmute'; miniTranslate(ele, "unmute"); } } try { session.rpcs[ele.dataset.UUID].directorMutedState = ele.value; var volume = session.rpcs[ele.dataset.UUID].directorVolumeState; } catch (e) { errorlog(e); var volume = 100; } if (!skipSend) { var msg = {}; if (val == 1) { msg.volume = volume; } else { msg.volume = 0; } msg.UUID = ele.dataset.UUID; session.sendRequest(msg, ele.dataset.UUID); syncDirectorState(ele); log(msg); } if (session.rpcs[ele.dataset.UUID].directorMutedState == 1) { pokeIframeAPI("director-mute-state", true, ele.dataset.UUID); pokeAPI("directorMuted", true, session.rpcs[ele.dataset.UUID].streamID); } else { pokeIframeAPI("director-mute-state", false, ele.dataset.UUID); pokeAPI("directorMuted", false, session.rpcs[ele.dataset.UUID].streamID); } if (val) { return true; } else { return false; } } function toggleQualityGear3() { toggle(document.getElementById("videoSettings3"), (inline = false)); if (getById("gear_webcam3").style.display === "inline-block") { var videoSelect = document.querySelector("select#videoSource3").options; var obscam = false; log(videoSelect[videoSelect.selectedIndex].text); if (videoSelect[videoSelect.selectedIndex].text.startsWith("OBS-Camera")) { // OBS Virtualcam obscam = true; } else if (videoSelect[videoSelect.selectedIndex].text.startsWith("OBS Virtual Camera")) { // OBS Virtualcam obscam = true; } updateStats(obscam); } } function remoteHideVideo(ele, event = false, skipSend = false) { log("video hide"); if (!event || event.ctrlKey || event.metaKey) { //ele.children[1].innerHTML = getTranslation("armed"); miniTranslate(ele.children[1], "armed"); //ele.style.backgroundColor = "#BF3F3F"; ele.classList.add("armed"); Callbacks.push([remoteHideVideo, ele, false]); log("video queued"); return; } else { if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.innerHTML = ' Hide'; } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.innerHTML = ' Unhide'; } miniTranslate(ele); ele.classList.remove("armed"); //ele.style.backgroundColor = null; } var msg = {}; if (ele.value == 0) { msg.directVideoMuted = false; } else { msg.directVideoMuted = true; } if (!skipSend) { for (var i in session.pcs) { msg.target = ele.dataset.UUID; if (i === msg.target) { msg.target = true; } try { session.pcs[i].sendChannel.send(JSON.stringify(msg)); } catch (e) { } } syncDirectorState(ele); } pokeIframeAPI("director-video-hide-state", msg.directVideoMuted, ele.dataset.UUID); pokeAPI("directorVideoHide", msg.directVideoMuted, session.rpcs[ele.dataset.UUID].streamID); return msg.directVideoMuted; } function remoteMuteVideo(ele, event = false, skipSend = false) { log("video mute"); if (!event || event.ctrlKey || event.metaKey) { //ele.children[1].innerHTML = getTranslation("armed"); miniTranslate(ele.children[1], "armed"); ele.classList.add("armed"); //ele.style.backgroundColor = "#BF3F3F"; Callbacks.push([remoteMuteVideo, ele, false]); log("video queued"); return; } else { if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.innerHTML = ' Video off'; } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.innerHTML = ' Video on'; } miniTranslate(ele); ele.classList.remove("armed"); } var msg = {}; if (ele.value == 0) { msg.remoteVideoMuted = false; } else { msg.remoteVideoMuted = true; } if (!skipSend) { session.sendRequest(msg, ele.dataset.UUID); syncDirectorState(ele); } pokeIframeAPI("remote-video-mute-state", msg.remoteVideoMuted, ele.dataset.UUID); pokeAPI("remoteVideoMuted", msg.remoteVideoMuted, session.rpcs[ele.dataset.UUID].streamID); return msg.remoteVideoMuted; } function updateDirectorVideoHide(UUID) { var ele = document.querySelectorAll('[data-action-type="hide-guest"][data--u-u-i-d="' + UUID + '"]'); if (ele[0]) { ele[0].value = 1; ele[0].classList.add("pressed"); ele[0].ariaPressed = "true"; ele[0].innerHTML = ' Unhide'; miniTranslate(ele[0]); } return true; } function updateDirectorVideoMute(UUID) { var ele = document.querySelectorAll('[data-action-type="mute-video-guest"][data--u-u-i-d="' + UUID + '"]'); if (ele[0]) { ele[0].value = 1; ele[0].classList.add("pressed"); ele[0].ariaPressed = "true"; ele[0].innerHTML = ' Video on'; miniTranslate(ele[0]); } return true; } function directVolume(ele) { // NOT USED ANYMORE log("volume"); var msg = {}; msg.scene = true; msg.action = "volume"; msg.target = ele.dataset.sid; // i want to focus on the STREAM ID, not the UUID... msg.value = ele.value; //for (var uuid in session.pcs){ // if (session.pcs[uuid].stats.info && ("version" in session.pcs[uuid].stats.info) && (session.pcs[uuid].stats.info.version < 17.2)){ // msg.request = "sendroom"; // session.sendMsg(msg); // return; // } //} for (var uuid in session.pcs) { if (session.pcs[uuid].scene !== false) { // send to all scenes (but scene = 0) session.sendMessage(msg, uuid); } } syncDirectorState(ele); return msg.value; } function applyMuteState(UUID) { // this is the mute state of PLAYBACK audio; not the microphone or outbound. if (!(UUID in session.rpcs)) { return "UUID not found"; } var muteOutcome = session.rpcs[UUID].mutedState || session.rpcs[UUID].mutedStateMixer || session.rpcs[UUID].mutedStateScene || session.speakerMuted || session.rpcs[UUID].bandwidthMuted; if (session.pauseInvisible) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.isInvisible) { muteOutcome = true; } } if (!muteOutcome && session.noaudio !== false) { if (session.noaudio === true) { muteOutcome = true; } else if (session.noaudio.length) { if (("streamID" in session.rpcs[UUID]) && session.rpcs[UUID].streamID && !session.noaudio.includes(session.rpcs[UUID].streamID)) { muteOutcome = true; } } else { muteOutcome = true; } } else if (!muteOutcome && session.excludeaudio) { if (("streamID" in session.rpcs[UUID]) && session.rpcs[UUID].streamID && session.excludeaudio.includes(session.rpcs[UUID].streamID)) { muteOutcome = true; } } if (session.rpcs[UUID].videoElement) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.usermuted === 1) { return "usermuted 1"; } if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.usermuted === 2) { return "usermuted 2"; } session.rpcs[UUID].videoElement.muted = muteOutcome; } // session.scene return muteOutcome; } function checkMuteState(UUID) { // this is the mute state of PLAYBACK audio; not the microphone or outbound. if (!(UUID in session.rpcs)) { return false; } if (session.pauseInvisible) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.isInvisible) { return true; } } return session.rpcs[UUID].mutedState || session.rpcs[UUID].mutedStateMixer || session.rpcs[UUID].mutedStateScene || session.speakerMuted || session.rpcs[UUID].bandwidthMuted; } var volumeLUT = [0, 1, 2, 2.4, 2.7, 3, 3.4, 3.7, 4, 4.5, 4.8, 5, 5.6, 6, 6.4, 6.8, 7, 7.7, 8, 8.6, 9, 9.5, 10, 10.4, 10.9, 11, 12, 12.5, 13, 13.6, 14, 14.7, 15, 15.6, 16, 17, 17.7, 18, 19, 19.7, 20, 21, 21.8, 22, 23, 24, 24.7, 25, 26, 27, 28, 28.5, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 45, 46, 47, 48, 49, 51, 52, 53, 55, 56, 58, 59, 61, 62, 64, 65, 67, 68, 70, 72, 74, 75, 77, 79, 81, 83, 85, 87, 89, 91, 93, 95, 97, 100, 102, 104, 107, 109, 112, 114, 117, 119, 122, 125, 128, 131, 134, 137, 140, 143, 146, 149, 152, 156, 159, 163, 166, 170, 174, 177, 181, 185, 189, 193, 198, 202, 206, 211, 215, 220, 225, 230, 234, 240, 245, 250, 255, 261, 266, 272, 278, 284, 290, 296, 302, 308, 315, 322, 328, 335, 342, 350, 357, 364, 372, 380, 388, 396, 404, 413, 421, 430, 439, 448, 458, 467, 477, 487, 497, 507, 518, 528, 539, 551, 562, 574, 586, 598, 610, 623, 635, 649, 662, 676, 690, 704, 718, 733, 748, 764, 779, 795, 812, 828]; function initAudioButtons(audioGain, UUID) { if (audioGain === 0) { var ele = document.querySelector('[data-action-type="mute-guest"][data--u-u-i-d="' + UUID + '"]'); if (ele) { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; miniTranslate(ele.children[1], "unmute"); session.rpcs[UUID].directorMutedState = 1; } pokeIframeAPI("director-mute-state", true, UUID); } else { var ele = document.querySelector('[data-action-type="volume"][data--u-u-i-d="' + UUID + '"]'); if (ele) { if (audioGain == 100) { ele.value = audioGain; } else { audioGain = parseInt(audioGain) || 0; ele.value = 200; for (var i = 1; i <= 200; i++) { if (volumeLUT[i] >= audioGain) { ele.value = i; break; } } } session.rpcs[UUID].directorVolumeState = audioGain; remoteVolumeUI(ele); } } } function remoteVolumeUI(ele) { var value = ele.value; value = volumeLUT[parseInt(value)]; ele.nextElementSibling.innerHTML = value + "%"; if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); remoteVolume(ele); } //setVolumeColor(ele); return value; } function remoteVolume(ele) { // A directing room only is controlled by the Director, with the exception of MUTE. log("volume: " + session.rpcs[ele.dataset.UUID].directorMutedState); var msg = {}; var muted = session.rpcs[ele.dataset.UUID].directorMutedState; var value = ele.value; value = volumeLUT[parseInt(value)]; //log(ele); if (muted == true) { // 1 is a string, not an int, so == and not ===. this happens in a few places :/ session.rpcs[ele.dataset.UUID].directorVolumeState = value; } else { session.rpcs[ele.dataset.UUID].directorVolumeState = value; msg.volume = value; msg.UUID = ele.dataset.UUID; session.sendRequest(msg, ele.dataset.UUID); } //const minLog = Math.log10(0.01); // Log10 of minimum gain // const maxLog = Math.log10(10); // Log10 of maximum gain // const normalizedValue = (msg.volume / 75) * (maxLog - minLog) + minLog; // const gainValue = Math.pow(10, normalizedValue); //console.log(gainValue*100); pokeIframeAPI("director-volume-state", value, ele.dataset.UUID); syncDirectorState(ele); return value; } /* function setVolumeColor(ele){ var vol1 = 200-parseInt(ele.value); if (vol1<0){vol1=0}; ele.style.backgroundColor = "hsl("+vol1+", 100%, 50%)"; } */ function clearDirectorSettings() { // make sure to wipe the director's room settings if creating a new room. //console.warn("Clearing"); removeStorage("directorCustomize"); removeStorage("directorWebsiteShare"); } function saveDirectorSettings() { //console.warn("Saving"); var settings = {}; if (getById("customizeLinks").classList.contains("hidden")) { settings.customizeLinks = true; } var customizeLinks1 = getById("customizeLinks1").querySelectorAll("input"); settings.customizeLinks1 = {}; for (var i = 0; i < customizeLinks1.length; i++) { settings.customizeLinks1[customizeLinks1[i].dataset.param] = customizeLinks1[i].checked; } var customizeLinks3 = getById("customizeLinks3").querySelectorAll("input"); settings.customizeLinks3 = {}; for (var i = 0; i < customizeLinks3.length; i++) { settings.customizeLinks3[customizeLinks3[i].dataset.param] = customizeLinks3[i].checked; } var directorLinks1 = getById("directorLinks1").querySelectorAll("input"); settings.directorLinks1 = {}; for (var i = 0; i < directorLinks1.length; i++) { settings.directorLinks1[directorLinks1[i].dataset.param] = directorLinks1[i].checked; } var directorLinks2 = getById("directorLinks2").querySelectorAll("input"); settings.directorLinks2 = {}; for (var i = 0; i < directorLinks2.length; i++) { settings.directorLinks2[directorLinks2[i].dataset.param] = directorLinks2[i].checked; } setStorage("directorCustomize", settings); } function loadDirectorSettings() { //console.warn("LOAD DIRECTOR SETTING"); var settings = getStorage("directorCustomize"); if (!settings || typeof settings !== "object") { return; } if (settings.customizeLinks && !session.cleanDirector && !session.cleanOutput) { try { hideDirectorinvites(getById("directorLinksButton"), false); } catch (e) { errorlog(e); } } if (settings.customizeLinks1) { var customizeLinks1 = getById("customizeLinks1"); Object.keys(settings.customizeLinks1).forEach((key, index) => { try { if (settings.customizeLinks1[key]) { customizeLinks1.querySelector('[data-param="' + key + '"]').checked = settings.customizeLinks1[key]; customizeLinks1.querySelector('[data-param="' + key + '"]').onchange(); } } catch (e) { errorlog(e); } }); } if (settings.customizeLinks3) { var customizeLinks3 = getById("customizeLinks3"); Object.keys(settings.customizeLinks3).forEach((key, index) => { try { if (settings.customizeLinks3[key]) { customizeLinks3.querySelector('[data-param="' + key + '"]').checked = settings.customizeLinks3[key]; customizeLinks3.querySelector('[data-param="' + key + '"]').onchange(); } } catch (e) { errorlog(e); } }); } if (settings.directorLinks1) { var directorLinks1 = getById("directorLinks1"); Object.keys(settings.directorLinks1).forEach((key, index) => { try { if (key in settings.directorLinks1) { directorLinks1.querySelector('[data-param="' + key + '"]').checked = settings.directorLinks1[key]; directorLinks1.querySelector('[data-param="' + key + '"]').onchange(); } } catch (e) { errorlog("key :" + key); errorlog(e); } }); } if (settings.directorLinks2) { var directorLinks2 = getById("directorLinks2"); Object.keys(settings.directorLinks2).forEach((key, index) => { try { if (key in settings.directorLinks2) { directorLinks2.querySelector('[data-param="' + key + '"]').checked = settings.directorLinks2[key]; directorLinks2.querySelector('[data-param="' + key + '"]').onchange(); } } catch (e) { errorlog("key :" + key); errorlog(e); } }); } } function sendChat(chatmessage = "hi", UUID = false, overlay = false) { // A directing room only is controlled by the Director, with the exception of MUTE. log("Chat message"); var msg = {}; msg.chat = chatmessage; msg.overlay = overlay; session.sendPeers(msg, UUID); return true; } // ===================== // TIPPING FUNCTIONALITY // ===================== // Initialize default tip settings if (typeof session.receiveTips === 'undefined') session.receiveTips = false; if (typeof session.tipId === 'undefined') session.tipId = null; if (typeof session.tipsId === 'undefined') session.tipsId = null; // Overlay token for SSE if (typeof session.tipServer === 'undefined') session.tipServer = "https://ninjabacker.com"; if (typeof session.tipAmounts === 'undefined') session.tipAmounts = [5, 10, 25, 50, 100]; if (typeof session.tipCurrency === 'undefined') session.tipCurrency = "USD"; if (typeof session.tipEventSource === 'undefined') session.tipEventSource = null; if (typeof session.tipStripe === 'undefined') session.tipStripe = null; // Cache for performer validation results var tipPerformerCache = {}; // Validate that a performer has completed Stripe setup and can receive tips async function validateTipPerformer(tipId, tipServer) { if (!tipId) return false; tipServer = tipServer || session.tipServer || "https://ninjabacker.com"; var cacheKey = tipServer + "/" + tipId; // Return cached result if available if (cacheKey in tipPerformerCache) { return tipPerformerCache[cacheKey]; } try { var response = await fetch(tipServer + "/v1/performer/" + tipId); var isValid = response.ok; // 200 = performer exists and has charges_enabled tipPerformerCache[cacheKey] = isValid; return isValid; } catch(e) { // Network error - assume not valid, don't cache to allow retry return false; } } // Add tip icon overlay to video container (two-way opt-in system) async function addTipIconToVideo(UUID) { if (!session.showTips || session.cleanOutput) return; var peer = session.rpcs[UUID] || session.pcs[UUID]; if (!peer || !peer.acceptsTips) return; // Build tip page URL for QR code - only if performer has a registered tipId var tipServer = peer.tipServer || session.tipServer || "https://ninjabacker.com"; var tipId = peer.tipId; // Must be explicitly set - don't fall back to UUID // If no tipId, performer hasn't set up tipping properly - don't show icon if (!tipId) return; // Validate performer has completed Stripe setup before showing tip icon var isValidPerformer = await validateTipPerformer(tipId, tipServer); if (!isValidPerformer) return; // Find video container - try different naming patterns var videoContainer = document.getElementById("videoContainer_" + UUID); var videoElement = document.getElementById("videosource_" + UUID); if (!videoContainer) { // Try finding the video element's parent if (videoElement && videoElement.parentElement) { videoContainer = videoElement.parentElement; } } // If container doesn't exist yet, try again later if (!videoContainer) { setTimeout(function() { addTipIconToVideo(UUID); }, 1000); return; } // Don't add duplicate icons if (videoContainer.querySelector(".tipIconOverlay")) return; // Determine if we're in OBS or a scene/view link (not a guest/publisher) var isOBS = !!window.obsstudio; var isSceneOrView = session.scene !== false || session.view; // scene link or view parameter // QR code shown only if: // 1. User explicitly set &tipqrsize, OR // 2. In OBS studio, OR // 3. It's a scene/view link (not a guest publisher page) var showQRCode = false; if (session.tipQRSize && session.tipQRSize !== 150) { // User explicitly set size showQRCode = true; } else if (isOBS || isSceneOrView) { showQRCode = !session.noTipQR; // Can be disabled with ¬ipqr } // Check video display size - only show QR if video is large enough (min 640x360) if (showQRCode) { var videoWidth = videoContainer.offsetWidth || (videoElement ? videoElement.offsetWidth : 0); var videoHeight = videoContainer.offsetHeight || (videoElement ? videoElement.offsetHeight : 0); var minWidthForQR = 640; var minHeightForQR = 360; if (videoWidth < minWidthForQR || videoHeight < minHeightForQR) { showQRCode = false; } } var tipPageUrl = tipServer + "/" + (tipId || ""); var qrSize = Math.max(session.tipQRSize || 150, 100); // Minimum 100px for scanability // Create container for heart + label + QR var tipOverlay = document.createElement("div"); tipOverlay.className = "tipIconOverlay"; if (!showQRCode) { tipOverlay.classList.add("noQR"); } if (isOBS) { tipOverlay.classList.add("obsMode"); } tipOverlay.title = "Send a tip"; tipOverlay.dataset.UUID = UUID; // Heart icon with dollar sign var heartIcon = document.createElement("div"); heartIcon.className = "tipHeart"; heartIcon.innerHTML = '$'; tipOverlay.appendChild(heartIcon); // "Send a Tip" label var tipLabel = document.createElement("div"); tipLabel.className = "tipLabel"; tipLabel.textContent = "Send a Tip"; tipOverlay.appendChild(tipLabel); // Only add QR code if appropriate if (showQRCode) { var qrContainer = document.createElement("div"); qrContainer.className = "tipQR"; qrContainer.style.width = qrSize + "px"; qrContainer.style.height = qrSize + "px"; // Generate styled QR code generateStyledTipQR(qrContainer, tipPageUrl, qrSize); tipOverlay.appendChild(qrContainer); } // Click handler tipOverlay.onclick = function(e) { e.stopPropagation(); if (typeof openTipModal === 'function') { openTipModal(this.dataset.UUID); } }; videoContainer.appendChild(tipOverlay); // Start animation based on mode if (showQRCode && isOBS) { // OBS mode: QR code with occasional "Send a Tip" text startTipQRAnimation(tipOverlay, true); } else if (showQRCode) { // Scene/view mode: QR code with occasional "Send a Tip" text startTipQRAnimation(tipOverlay, false); } else { // Guest mode: Heart with occasional "Send a Tip" label startTipLabelAnimation(tipOverlay); } } // Animate between heart/label and QR code // OBS mode: Show QR most of the time, occasionally show "Send a Tip" label // Scene mode: Show QR periodically (every 45 seconds for 8 seconds) function startTipQRAnimation(tipOverlay, isOBS) { if (isOBS) { // OBS: Start with QR showing, periodically show "Send a Tip" label tipOverlay.classList.add("showQR"); function showLabel() { tipOverlay.classList.remove("showQR"); tipOverlay.classList.add("showLabel"); // Show label for 6 seconds setTimeout(function() { if (tipOverlay && tipOverlay.parentElement) { tipOverlay.classList.remove("showLabel"); tipOverlay.classList.add("showQR"); } }, 6000); } // Show label every 60 seconds tipOverlay.qrInterval = setInterval(function() { if (tipOverlay && tipOverlay.parentElement) { showLabel(); } }, 60000); // First label after 20 seconds setTimeout(function() { if (tipOverlay && tipOverlay.parentElement) { showLabel(); } }, 20000); } else { // Scene/view mode: Show heart normally, QR periodically function showQR() { tipOverlay.classList.add("showQR"); // Show QR for 8 seconds setTimeout(function() { if (tipOverlay && tipOverlay.parentElement) { tipOverlay.classList.remove("showQR"); } }, 8000); } // Show QR every 45 seconds tipOverlay.qrInterval = setInterval(function() { if (tipOverlay && tipOverlay.parentElement) { showQR(); } }, 45000); // First QR after 15 seconds setTimeout(function() { if (tipOverlay && tipOverlay.parentElement) { showQR(); } }, 15000); } } // Animate "Send a Tip" label for guest/publisher mode (no QR) function startTipLabelAnimation(tipOverlay) { function showLabel() { tipOverlay.classList.add("showLabel"); // Show label for 5 seconds setTimeout(function() { if (tipOverlay && tipOverlay.parentElement) { tipOverlay.classList.remove("showLabel"); } }, 5000); } // Show label every 45 seconds tipOverlay.labelInterval = setInterval(function() { if (tipOverlay && tipOverlay.parentElement) { showLabel(); } }, 45000); // First label after 10 seconds setTimeout(function() { if (tipOverlay && tipOverlay.parentElement) { showLabel(); } }, 10000); } // Clean up tip icon animation when video removed function removeTipIconFromVideo(UUID) { var tipOverlay = document.querySelector('.tipIconOverlay[data-uuid="' + UUID + '"]'); if (tipOverlay) { if (tipOverlay.qrInterval) { clearInterval(tipOverlay.qrInterval); } if (tipOverlay.labelInterval) { clearInterval(tipOverlay.labelInterval); } tipOverlay.remove(); } } // Generate QR code using built-in thirdparty/qrcode.min.js library var tipQRPendingContainers = []; // Queue for containers waiting for library function generateStyledTipQR(container, url, size) { // Use existing QRCode library from thirdparty/qrcode.min.js if (window.QRCode) { createTipQR(container, url, size); } else { // Queue this container and load library tipQRPendingContainers.push({ container: container, url: url, size: size }); loadQR(function() { // Process all pending containers tipQRPendingContainers.forEach(function(item) { createTipQR(item.container, item.url, item.size); }); tipQRPendingContainers = []; }); } } function createTipQR(container, url, size) { try { // Create inner div for QR code var qrDiv = document.createElement("div"); qrDiv.style.cssText = "width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#fff;border-radius:4px;"; container.appendChild(qrDiv); var qrcode = new QRCode(qrDiv, { width: size - 8, height: size - 8, colorDark: "#e53935", // Red color to match heart theme colorLight: "#FFFFFF", correctLevel: QRCode.CorrectLevel.H // High error correction for video compression }); qrcode.makeCode(url); // Remove default title qrDiv.title = ""; setTimeout(function() { qrDiv.title = ""; // Style the generated image var imgs = qrDiv.getElementsByTagName("img"); if (imgs.length) { imgs[0].style.cursor = "pointer"; imgs[0].style.margin = "auto"; imgs[0].style.borderRadius = "4px"; } var canvas = qrDiv.getElementsByTagName("canvas"); if (canvas.length) { canvas[0].style.borderRadius = "4px"; } }, 100); } catch (e) { errorlog("QR code error:", e); } } // Show onboarding modal for first-time tip setup function showTipOnboardingModal() { // Check if already seen if (getStorage("tipOnboardingSeen")) return; var tipServer = session.tipServer || "https://ninjabacker.com"; // If user already has tipsId set, they're fully configured - skip onboarding if (session.tipsId) { setStorage("tipOnboardingSeen", "true", 9999); return; } var step2Content = '

    2. Enter Your Username

    ' + '

    After registering, enter your username below:

    ' + '
    ' + '' + '' + '
    ' + '

    Enter your registered username to enable tipping.

    '; var modalHTML = '
    ' + '
    ' + '×' + '

    Tipping Setup

    ' + '

    To receive tips, follow these steps:

    ' + '

    1. Register Your Account

    ' + '

    Create a username and connect your Stripe account:

    ' + '' + 'Register & Create Username' + '' + step2Content + '

    3. Viewer Setup

    ' + '

    Viewers need &showtips in their URL to see tip buttons.

    ' + '

    4. QR Code Feature

    ' + '

    A scannable QR code will appear on your video periodically. Use &notipqr to disable, or &tipqrsize=200 to resize.

    ' + '
    ' + '' + '' + '
    ' + '
    ' + '
    ' + '
    '; document.body.insertAdjacentHTML("beforeend", modalHTML); } function closeTipOnboarding(permanent) { if (permanent) { setStorage("tipOnboardingSeen", "true", 9999); // Never show again } else { setStorage("tipOnboardingSeen", "true", 1); // Show again in 1 day } var modal = document.getElementById("tipOnboardingModal"); var backdrop = document.getElementById("tipOnboardingBackdrop"); if (modal) modal.remove(); if (backdrop) backdrop.remove(); } async function applyTipUsername() { var input = document.getElementById("tipUsernameInput"); if (!input) return; var username = input.value.trim().toLowerCase(); if (!username) { warnUser("Please enter a username"); return; } // Validate username format (alphanumeric, underscore, hyphen, 3-30 chars) if (!/^[a-z0-9_-]{3,30}$/.test(username)) { warnUser("Username must be 3-30 characters (letters, numbers, _ or -)"); return; } // Fetch performer info from API to get overlay token var tipServer = session.tipServer || "https://ninjabacker.com"; try { var response = await fetch(tipServer + "/v1/performer/" + username); if (!response.ok) { warnUser("Username not found or not registered for tips"); return; } var data = await response.json(); if (!data.overlay_token) { warnUser("Performer account not fully set up"); return; } // Build new URL with tipsid parameter (overlay token) var url = new URL(window.location.href); url.searchParams.delete("tip"); url.searchParams.delete("tips"); url.searchParams.delete("tipid"); url.searchParams.set("tipsid", data.overlay_token); // Reload with new parameter window.location.href = url.toString(); } catch(e) { errorlog("Failed to lookup performer:", e); warnUser("Failed to verify username. Please try again."); } } // Get currency symbol helper function getTipCurrencySymbol(currency) { var symbols = { USD: "$", EUR: "\u20AC", GBP: "\u00A3", CAD: "C$", AUD: "A$", JPY: "\u00A5" }; return symbols[currency] || currency + " "; } // Show on-screen tip banner (performer only) function showTipBanner(tipData) { var currencySymbol = getTipCurrencySymbol(tipData.currency || "USD"); var fromLabel = sanitizeLabel(tipData.fromLabel || tipData.from || "Anonymous"); var amount = tipData.amount; // Create banner element var banner = document.createElement("div"); banner.className = "tipBanner"; banner.innerHTML = currencySymbol + amount + " tip from " + fromLabel; if (tipData.message) { banner.innerHTML += '
    "' + sanitizeChat(tipData.message) + '"
    '; } document.body.appendChild(banner); // Trigger animation setTimeout(function() { banner.classList.add("tipBannerShow"); }, 10); // Remove after 5 seconds setTimeout(function() { banner.classList.remove("tipBannerShow"); banner.classList.add("tipBannerHide"); setTimeout(function() { banner.remove(); }, 500); // Wait for fade out animation }, 5000); } // Process incoming tip message function processTipMessage(tipData, UUID) { log("Tip received:", tipData); var currencySymbol = getTipCurrencySymbol(tipData.currency || "USD"); var fromLabel = sanitizeLabel(tipData.fromLabel || tipData.from || "Anonymous"); var message = tipData.message ? sanitizeChat(tipData.message) : ""; // Plain text version for notification var notifyMsg = currencySymbol + tipData.amount + " tip from " + fromLabel; if (message) { notifyMsg += ': "' + message + '"'; } var data = { time: Date.now(), type: "tip", msg: notifyMsg, label: "Tip" }; messageList.push(data); messageList = messageList.slice(-100); // Play notification sound if (session.beepToNotify) { playtone(); showNotification("Tip received", notifyMsg); } updateMessages(); // Show on-screen banner only for SSE-received tips (performer's own tips) if (UUID === null) { showTipBanner(tipData); } // Chat notification (red dot) when chat is closed if (session.chat == false) { getById("chattoggle").className = "las la-comments toggleSize pulsate"; getById("chatbutton").className = "float"; if (getById("chatNotification").value) { getById("chatNotification").value = getById("chatNotification").value + 1; } else { getById("chatNotification").value = 1; } getById("chatNotification").classList.add("notification", "red"); } // Broadcast to popout chat window if (session.broadcastChannel !== false) { session.broadcastChannel.postMessage(data); } // Browser notification if (Notification.permission === "granted") { try { new Notification("Tip Received!", { body: notifyMsg, icon: "./media/logo.png" }); } catch(e) {} } // API callback if (typeof pokeAPI === 'function') { pokeAPI("tip", tipData); } // Iframe postMessage if (isIFrame && session.iframetarget) { try { parent.postMessage({ action: "tip", value: tipData }, session.iframetarget); } catch(e) {} } } // Initialize SSE connection for tip notifications function initTipNotifications() { if (!session.receiveTips) return; if (session.tipEventSource) return; // Already connected // Use tipsId (overlay token) for SSE subscription, fallback to streamID for legacy var subscribeId = session.tipsId || session.streamID; if (!subscribeId) return; var tipServer = session.tipServer || "https://ninjabacker.com"; var sseURL = tipServer + "/v1/subscribe/" + subscribeId; try { session.tipEventSource = new EventSource(sseURL); session.tipEventSource.onmessage = function(event) { try { var tipData = JSON.parse(event.data); if (tipData.type === "tip") { processTipMessage(tipData, null); // Broadcast to room peers broadcastTipReceived(tipData); } } catch(e) { errorlog("Tip SSE parse error:", e); } }; session.tipEventSource.onerror = function(err) { warnlog("Tip SSE connection error, will retry..."); }; log("Tip notifications initialized for: " + subscribeId); } catch(e) { errorlog("Failed to initialize tip notifications:", e); } } // Fetch performer info (username) from tipsId token and set session.tipId async function fetchPerformerFromToken() { if (!session.tipsId) return; if (session.tipId) return; // Already have username var tipServer = session.tipServer || "https://ninjabacker.com"; try { var response = await fetch(tipServer + "/v1/performer/" + session.tipsId); if (response.ok) { var data = await response.json(); if (data.username) { session.tipId = data.username; log("Performer username from token: " + data.username); } } } catch(e) { errorlog("Failed to fetch performer from token:", e); } } // Close SSE connection function closeTipNotifications() { if (session.tipEventSource) { session.tipEventSource.close(); session.tipEventSource = null; } } // Broadcast tip received to all peers (so viewers see it too) function broadcastTipReceived(tipData) { var msg = { tip: tipData }; session.sendPeers(msg); } // Open tip modal for a peer function openTipModal(UUID) { var peer = session.rpcs[UUID] || session.pcs[UUID]; if (!peer || !peer.acceptsTips) { warnUser("This user does not accept tips"); return; } var peerLabel = sanitizeLabel(peer.tipId || peer.label || peer.streamID || "Performer"); var amounts = peer.tipAmounts || session.tipAmounts || [5, 10, 25, 50, 100]; var currency = peer.tipCurrency || session.tipCurrency || "USD"; var currencySymbol = getTipCurrencySymbol(currency); var modalID = "tipModal_" + UUID; var zindex = 32 + document.querySelectorAll(".promptModal").length + document.querySelectorAll(".alertModal").length; var amountButtons = amounts.map(function(amt) { return ''; }).join(''); var modalTemplate = '
    ' + '
    ' + '' + '
    ' + '

    \uD83D\uDCB0 Send a tip to ' + peerLabel + '

    ' + '
    ' + '
    ' + amountButtons + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + '' + '
    ' + '
    ' + '
    ' + 'Powered by' + '' + '
    ' + '
    ' + 'Selected: ' + currencySymbol + '0' + '
    ' + '' + '' + '
    ' + '
    '; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Initialize Stripe Elements initTipStripeElements(UUID, peer); } // Load Stripe.js dynamically if not already loaded function loadStripeJS() { return new Promise(function(resolve, reject) { if (typeof Stripe !== 'undefined') { resolve(); return; } var script = document.createElement('script'); script.src = 'https://js.stripe.com/v3/'; script.onload = resolve; script.onerror = function() { reject(new Error('Failed to load Stripe.js')); }; document.head.appendChild(script); }); } // Initialize Stripe Elements for tip modal async function initTipStripeElements(UUID, peer) { peer = peer || {}; var tipServer = peer.tipServer || session.tipServer || "https://ninjabacker.com"; try { // Load Stripe.js if needed await loadStripeJS(); // Get performer-specific Stripe publishable key (supports test/live mode per account) var performerId = peer.tipId || peer.tipsId || peer.streamID; var stripeKey = null; if (performerId) { try { var perfResponse = await fetch(tipServer + "/v1/performer/" + performerId); if (perfResponse.ok) { var perfData = await perfResponse.json(); stripeKey = perfData.stripePublishableKey; } } catch(e) { // Fall back to config endpoint } } // Fallback to global config if performer-specific key not available if (!stripeKey) { var response = await fetch(tipServer + "/v1/config"); var config = await response.json(); stripeKey = config.stripePublishableKey; } if (!stripeKey) { document.getElementById("tipError_" + UUID).textContent = "Tipping not configured"; document.getElementById("tipError_" + UUID).classList.remove("hidden"); return; } // Initialize Stripe with performer-specific key var stripe = Stripe(stripeKey); var elements = stripe.elements(); var cardElement = elements.create('card', { hidePostalCode: true, style: { base: { color: '#ffffff', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontSize: '16px', '::placeholder': { color: '#a0a0a0' } } } }); cardElement.mount('#tipCardElement_' + UUID); // Store for later use if (!window.tipStripeElements) window.tipStripeElements = {}; window.tipStripeElements[UUID] = { stripe: stripe, cardElement: cardElement }; cardElement.on('change', function(event) { var amount = window.tipStripeElements[UUID].selectedAmount || 0; document.getElementById("tipConfirmBtn_" + UUID).disabled = !event.complete || amount <= 0; }); } catch(e) { errorlog("Failed to init Stripe:", e); document.getElementById("tipError_" + UUID).textContent = "Failed to load payment form"; document.getElementById("tipError_" + UUID).classList.remove("hidden"); } } // Select a predefined tip amount function selectTipAmount(btn, UUID) { document.querySelectorAll('#tipModal_' + UUID + ' .tipAmountBtn').forEach(function(b) { b.classList.remove('selected'); }); btn.classList.add('selected'); var amount = parseFloat(btn.dataset.amount); var peer = session.rpcs[UUID] || session.pcs[UUID]; var currency = peer?.tipCurrency || session.tipCurrency || "USD"; var currencySymbol = getTipCurrencySymbol(currency); document.getElementById('tipSelectedAmount_' + UUID).textContent = currencySymbol + amount.toFixed(2); document.getElementById('tipCustomInput_' + UUID).value = ""; if (window.tipStripeElements && window.tipStripeElements[UUID]) { window.tipStripeElements[UUID].selectedAmount = amount; } // Enable button if card is ready checkTipButtonState(UUID); } // Handle custom amount input function customTipAmount(UUID, value) { var amount = parseFloat(value); var peer = session.rpcs[UUID] || session.pcs[UUID]; var currency = peer?.tipCurrency || session.tipCurrency || "USD"; var currencySymbol = getTipCurrencySymbol(currency); // Deselect preset buttons document.querySelectorAll('#tipModal_' + UUID + ' .tipAmountBtn').forEach(function(b) { b.classList.remove('selected'); }); if (amount > 0) { document.getElementById('tipSelectedAmount_' + UUID).textContent = currencySymbol + amount.toFixed(2); if (window.tipStripeElements && window.tipStripeElements[UUID]) { window.tipStripeElements[UUID].selectedAmount = amount; } } else { document.getElementById('tipSelectedAmount_' + UUID).textContent = currencySymbol + "0"; if (window.tipStripeElements && window.tipStripeElements[UUID]) { window.tipStripeElements[UUID].selectedAmount = 0; } } checkTipButtonState(UUID); } // Check if tip button should be enabled function checkTipButtonState(UUID) { if (!window.tipStripeElements || !window.tipStripeElements[UUID]) return; var amount = window.tipStripeElements[UUID].selectedAmount || 0; // Button state is also controlled by Stripe card element change event } // Confirm and process tip payment async function confirmTip(UUID) { if (!window.tipStripeElements || !window.tipStripeElements[UUID]) return; var stripeData = window.tipStripeElements[UUID]; var peer = session.rpcs[UUID] || session.pcs[UUID]; if (!peer || !stripeData.selectedAmount || stripeData.selectedAmount <= 0) { return; } var tipServer = peer.tipServer || session.tipServer || "https://ninjabacker.com"; var amount = stripeData.selectedAmount; var currency = peer.tipCurrency || session.tipCurrency || "USD"; var tipperName = document.getElementById('tipName_' + UUID)?.value || "Anonymous"; var message = document.getElementById('tipMessageInput_' + UUID)?.value || ""; var performerUsername = peer.tipId || peer.streamID; var submitBtn = document.getElementById('tipConfirmBtn_' + UUID); var errorEl = document.getElementById('tipError_' + UUID); submitBtn.disabled = true; submitBtn.textContent = "Processing..."; errorEl.classList.add('hidden'); try { // Create PaymentIntent var intentResponse = await fetch(tipServer + "/v1/tip/intent", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: amount, currency: currency, performerUsername: performerUsername, tipperName: tipperName, message: message }) }); if (!intentResponse.ok) { var errData = await intentResponse.json(); throw new Error(errData.error || 'Failed to create payment'); } var intentData = await intentResponse.json(); // Confirm payment with Stripe var result = await stripeData.stripe.confirmCardPayment(intentData.clientSecret, { payment_method: { card: stripeData.cardElement } }); if (result.error) { throw new Error(result.error.message); } if (result.paymentIntent.status === 'succeeded') { // Confirm with backend await fetch(tipServer + "/v1/tip/confirm", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tipId: intentData.tipId, paymentIntentId: result.paymentIntent.id }) }); // Success! closeTipModal('tipModal_' + UUID, UUID); warnUser("Tip sent successfully! Thank you!", 3000); } } catch(e) { errorlog("Tip payment error:", e); errorEl.textContent = e.message; errorEl.classList.remove('hidden'); submitBtn.disabled = false; submitBtn.textContent = "Send Tip"; } } // Close tip modal function closeTipModal(modalID, UUID) { var modal = document.getElementById(modalID); if (modal) modal.remove(); // Cleanup Stripe elements if (window.tipStripeElements && window.tipStripeElements[UUID]) { if (window.tipStripeElements[UUID].cardElement) { window.tipStripeElements[UUID].cardElement.destroy(); } delete window.tipStripeElements[UUID]; } } // ===================== // END TIPPING FUNCTIONALITY // ===================== var activatedStream = false; async function publishScreen() { if (activatedStream == true) { return; } activatedStream = true; setTimeout(function () { activatedStream = false; }, 1000); formSubmitting = false; var quality = 0; if (document.getElementById("webcamquality2")) { quality = parseInt(document.getElementById("webcamquality2").elements.namedItem("resolution2").value) || 0; } session.quality_ss = quality; if (session.quality !== false) { quality = session.quality; // override the user's setting } if (session.screensharequality !== false) { quality = session.screensharequality; } var video = {}; if (quality == -1) { // unlocked capture resolution } else if (quality == -2) { video.width = { ideal: 3840 }; video.height = { ideal: 2160 }; } else if (quality == -3) { video.width = { ideal: 2560 }; video.height = { ideal: 1440 }; } else if (quality == 0) { video.width = { ideal: 1920 }; video.height = { ideal: 1080 }; } else if (quality == 1) { video.width = { ideal: 1280 }; video.height = { ideal: 720 }; } else if (quality == 2) { video.width = { ideal: 640 }; video.height = { ideal: 360 }; } else if (quality >= 3) { // lowest video.width = { ideal: 320 }; video.height = { ideal: 180 }; } else { video.width = { min: 640 }; video.height = { min: 360 }; } if (session.width) { video.width = { ideal: session.width }; } if (session.height) { video.height = { ideal: session.height }; } var constraints = { audio: { echoCancellation: false, autoGainControl: false, noiseSuppression: false }, video: video }; if (session.noiseSuppression === true) { constraints.audio.noiseSuppression = true; // the defaults for screen publishing should be off. } if (session.autoGainControl === true) { constraints.audio.autoGainControl = true; // the defaults for screen publishing should be off. } if (session.echoCancellation === true) { constraints.audio.echoCancellation = true; // the defaults for screen publishing should be off. } if (session.voiceIsolation === true) { constraint.audio.voiceIsolation = true; } try { let supportedConstraints = navigator.mediaDevices.getSupportedConstraints(); if (supportedConstraints.cursor) { if (session.screensharecursor) { constraints.video.cursor = ["always", "motion"]; } else { constraints.video.cursor = "never"; } } if (session.suppressLocalAudioPlayback && supportedConstraints.suppressLocalAudioPlayback) { constraints.audio.suppressLocalAudioPlayback = true; } // if (session.preferCurrentTab) { constraints.preferCurrentTab = true; } if (session.selfBrowserSurface) { constraints.selfBrowserSurface = session.selfBrowserSurface; // exclude or include } if (session.surfaceSwitching) { constraints.surfaceSwitching = session.surfaceSwitching; // exclude or include } if (session.systemAudio) { constraints.systemAudio = session.systemAudio; // exclude or include } if (session.displaySurface && supportedConstraints.displaySurface) { constraints.video.displaySurface = session.displaySurface; // monitor, window, or browser } } catch (e) { warnlog("navigator.mediaDevices.getSupportedConstraints() not supported"); } var overrideFramerate = false; if (session.screensharefps !== false) { constraints.video.frameRate = { ideal: session.screensharefps, max: session.screensharefps }; } else if (session.frameRate !== false && session.maxframeRate != false) { overrideFramerate = session.frameRate; constraints.video.frameRate = { ideal: session.maxframeRate, max: session.maxframeRate }; } else if (session.frameRate !== false) { constraints.video.frameRate = session.frameRate; } else if (session.maxframeRate != false) { constraints.video.frameRate = { ideal: session.maxframeRate, max: session.maxframeRate }; } else { constraints.video.frameRate = { ideal: 60 }; } var outputSelect = getById("outputSourceScreenshare"); try { session.sink = outputSelect.options[outputSelect.selectedIndex].value; // will probably fail on Safari. log("Session Sink: " + session.sink); saveSettings(); } catch (e) { warnlog(e); } session.audioDevice = selectedScreenShareAudioDevices; return await publishScreen2(constraints, selectedScreenShareAudioDevices, true, overrideFramerate) .then(res => { if (res == false) { return; } // no screen selected log("streamID is: " + session.streamID); if (session.transcript) { setTimeout(function () { setupClosedCaptions(); }, 1000); } if (!session.cleanOutput && !session.cleanViewer) { getById("mutebutton").classList.remove("hidden"); getById("mutespeakerbutton").classList.remove("hidden"); //getById("mutespeakerbutton").className="float"; getById("chatbutton").className = "float"; getById("sharefilebutton").classList.remove("hidden"); // we won't override "display:none", if set, though. getById("mutevideobutton").className = "float"; getById("hangupbutton").className = "float"; if (session.showSettings) { getById("settingsbutton").className = "float"; } if (session.raisehands) { getById("raisehandbutton").className = "float"; } if (session.pptControls) { getById("pptbackbutton").classList.remove("hidden"); getById("pptnextbutton").classList.remove("hidden"); } if (session.recordLocal !== false) { getById("recordLocalbutton").classList.remove("hidden"); } if (session.screensharebutton) { getById("screensharebutton").className = "float"; } getById("controlButtons").classList.remove("hidden"); // getById("legal").classList.remove("hidden"); //getById("helpbutton").style.display = "inherit"; //getById("reportbutton").style.display = ""; } else if (session.cleanish && session.recordLocal !== false) { getById("recordLocalbutton").classList.remove("hidden"); getById("mutebutton").classList.add("hidden"); getById("mutespeakerbutton").classList.add("hidden"); getById("chatbutton").classList.add("hidden"); getById("mutevideobutton").classList.add("hidden"); getById("hangupbutton").classList.add("hidden"); getById("hangupbutton2").classList.add("hidden"); getById("controlButtons").classList.remove("hidden"); getById("settingsbutton").classList.add("hidden"); getById("screenshare2button").classList.add("hidden"); getById("screensharebutton").classList.add("hidden"); getById("screenshare3button").classList.add("hidden"); getById("queuebutton").classList.add("hidden"); } else { getById("controlButtons").classList.add("hidden"); } if (session.chatbutton === true) { getById("chatbutton").classList.remove("hidden"); getById("controlButtons").classList.remove("hidden"); } else if (session.chatbutton === false) { getById("chatbutton").classList.add("hidden"); } if (session.screensharebutton === true) { getById("controlButtons").classList.remove("hidden"); getById("screensharebutton").className = "float"; } if (session.hangupbutton === true) { getById("controlButtons").classList.remove("hidden"); getById("hangupbutton").className = "float"; } getById("head1").className = "hidden"; getById("head2").className = "hidden"; return res; }) .catch(() => { }); } function getWidth() { return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.documentElement.clientWidth); } function getHeight() { return Math.max(document.documentElement.clientHeight); } function updateForceRotate(skipLastBit = false) { var capabilities = { facingMode: "unknown" }; var FirefoxSucks = false; if (session.orientation) { try { var track = false; if (session.streamSrc) { var tracks = session.streamSrc.getVideoTracks(); if (tracks.length) { track = tracks[0]; } } if (!track) { return; } const settings = track.getSettings(); session.currentCameraConstraints = settings; if (screen && screen.orientation && screen.orientation.type) { if (!screen.orientation.type.includes("portrait")) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } else if (!window.matchMedia("(orientation: portrait)").matches) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } session.forceRotate = 0; if (track.getCapabilities) { capabilities = track.getCapabilities(); // firefox sucks? if ("width" in settings) { if ("height" in settings) { if (settings.width < settings.height) { if (session.orientation == "landscape") { if (capabilities) { if (capabilities.facingMode == "environment") { session.forceRotate = 270; } else { session.forceRotate = 90; } } } else { session.forceRotate = 0; } } else if (settings.width > settings.height) { if (session.orientation == "portrait") { if (capabilities) { if (capabilities.facingMode == "environment") { session.forceRotate = 90; } else { session.forceRotate = 270; } } } else { session.forceRotate = 0; } } else { session.forceRotate = 0; } } else { return; } } } else if (Firefox && session.mobile) { // firefox sucks... try { var vs1 = document.getElementById("videoSourceSelect") || document.getElementById("videoSource3"); if (vs1) { vs1 = vs1.options[vs1.selectedIndex].textContent; if (vs1.includes(" back")) { capabilities = { facingMode: "environment" }; FirefoxSucks = 2; } else if (vs1.includes(" front")) { capabilities = { facingMode: "user" }; FirefoxSucks = 1; } } getById("videosource").style.transform = ""; if (session.orientation == "landscape") { if (FirefoxSucks === 1) { session.forceRotate = 90; // video needs to be flipped getById("videosource").style.transform = "rotate(90deg)"; } else if (FirefoxSucks === 2) { getById("videosource").style.transform = "rotate(270deg)"; session.forceRotate = 270; } } } catch (e) { errorlog(e); } } else { return; } var msg = {}; msg.rotate_video = 0; if (session.forceRotate !== false) { if (session.rotate) { msg.rotate_video = session.forceRotate + parseInt(session.rotate); } else { msg.rotate_video = session.forceRotate; } } else { msg.rotate_video = parseInt(session.rotate) || 0; } if (msg.rotate_video && msg.rotate_video >= 360) { msg.rotate_video -= 360; } for (var UUID in session.pcs) { try { if (session.pcs[UUID].rotation != msg.rotate_video) { // 0 == false will skip I think session.pcs[UUID].rotation = msg.rotate_video; session.sendMessage(msg, UUID); //log("sending updated rotation info"); } } catch (e) { errorlog(e); if (session.pcs[UUID].startTime + 100000 < Date.now()) { warnlog("RTC Connection seems to be dead or not yet open? 8"); } else { log("RTC Connection seems to be dead or not yet open? 8"); } } } } catch (e) { errorlog(e); } if (!(Firefox && session.mobile)) { updateForceRotatedCSS(); } } else if (Firefox && session.mobile) { try { var vs1 = document.getElementById("videoSourceSelect") || document.getElementById("videoSource3"); if (vs1) { vs1 = vs1.options[vs1.selectedIndex].textContent; if (vs1.includes(" back")) { FirefoxSucks = 2; } else if (vs1.includes(" front")) { FirefoxSucks = 1; } } if (screen && screen.orientation && screen.orientation.type) { if (screen.orientation.type.includes("portrait")) { session.forceRotate = 0; } else if (screen.orientation.type.includes("landscape")) { if (FirefoxSucks === 1) { session.forceRotate = 90; } else if (FirefoxSucks === 2) { session.forceRotate = 270; } } } else if (window.matchMedia("(orientation: portrait)").matches) { // legacy support; it seems to update late, 100ms or so after screen.orientation, so lets not use it session.forceRotate = 0; } else if (window.matchMedia("(orientation: landscape)").matches) { if (FirefoxSucks === 1) { session.forceRotate = 90; } else if (FirefoxSucks === 2) { session.forceRotate = 270; } } var msg = {}; msg.rotate_video = 0; if (session.forceRotate !== false) { if (session.rotate) { msg.rotate_video = session.forceRotate + parseInt(session.rotate); } else { msg.rotate_video = session.forceRotate; } if (msg.rotate_video && msg.rotate_video >= 360) { msg.rotate_video -= 360; } //warnlog("FIREFOX MOBILE ONLY ROTATE: "+msg.rotate_video); //session.sendMessage(msg); } else { msg.rotate_video = parseInt(session.rotate) || 0; if (msg.rotate_video && msg.rotate_video >= 360) { msg.rotate_video -= 360; } //warnlog("FIREFOX MOBILE ONLY ROTATE: "+msg.rotate_video); } for (var UUID in session.pcs) { try { if (session.pcs[UUID].rotation != msg.rotate_video) { session.pcs[UUID].rotation = msg.rotate_video; session.sendMessage(msg, UUID); //log("sending updated rotation info"); } } catch (e) { errorlog(e); if (session.pcs[UUID].startTime + 100000 < Date.now()) { warnlog("RTC Connection seems to be dead or not yet open? 8"); } else { log("RTC Connection seems to be dead or not yet open? 8"); } } } } catch (e) { errorlog(e); } } else { var msg = {}; msg.rotate_video = 0; if (session.forceRotate !== false) { if (session.rotate) { msg.rotate_video = session.forceRotate + parseInt(session.rotate); } else { msg.rotate_video = session.forceRotate; } } else { msg.rotate_video = parseInt(session.rotate) || 0; } if (msg.rotate_video && msg.rotate_video >= 360) { msg.rotate_video -= 360; } for (var UUID in session.pcs) { try { if (session.pcs[UUID].rotation != msg.rotate_video) { session.pcs[UUID].rotation = msg.rotate_video; session.sendMessage(msg, UUID); //log("sending updated rotation info"); } } catch (e) { errorlog(e); if (session.pcs[UUID].startTime + 100000 < Date.now()) { warnlog("RTC Connection seems to be dead or not yet open? 8"); } else { log("RTC Connection seems to be dead or not yet open? 8"); } } } } if (!skipLastBit) { applyMirror(session.mirrorExclude); session.setResolution(); // probably only triggers with mobile devices? } } function updateForceRotatedCSS(rotateThis = session.forceRotate) { if (rotateThis == 270) { document.body.setAttribute("style", "transform: rotate(270deg);position: absolute;top: 100vh;left: 0;height: 100vw;width: 100vh;transform-origin: 0 0;"); document.body.dataset.rotated = "1"; } else if (rotateThis == 90) { document.body.setAttribute("style", "transform: rotate(90deg);position: absolute;top: 0;left: 100vw;height: 100vw;width: 100vh;transform-origin: 0 0;"); document.body.dataset.rotated = "1"; } else if (rotateThis == 180) { document.body.setAttribute("style", "transform: rotate(180deg);position: absolute;top: 100vh;left: 100vw;height: 100vh;width: 100vw;transform-origin: 0 0;"); document.body.dataset.rotated = ""; } else { document.body.setAttribute("style", ""); document.body.dataset.rotated = ""; } } async function joinDataMode() { // join the room, but without publishing anything. await session.connect(); if (session.roomid) { getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); joinRoom(session.roomid); } else if (session.view) { window.onresize = updateMixer; play(); if (session.permaid !== false) { session.postPublish(); } } else if (session.permaid !== false) { session.postPublish(); } } function publishWebcam(btn = false, miconly = false) { if (btn) { if (btn.dataset.ready == "false") { warnlog("Clicked too quickly; button not enabled yet"); return; } if (getById("passwordBasicInput").value.length) { session.password = getById("passwordBasicInput").value; session.password = sanitizePassword(session.password); if (session.password.length == 0) { session.password = false; } else { session.defaultPassword = false; if (urlParams.has("pass")) { updateURL("pass=" + session.password); } else if (urlParams.has("pw")) { updateURL("pw=" + session.password); } else if (urlParams.has("p")) { updateURL("p=" + session.password); } else { updateURL("password=" + session.password); } } } } if (activatedStream == true) { return; } activatedStream = true; log("PRESSED PUBLISH WEBCAM!!"); formSubmitting = false; window.scrollTo(0, 0); // iOS has a nasty habit of overriding the CSS when changing camaera selections, so this addresses that. getById("head2").className = "hidden"; if (session.mobile && !session.roomid && session.permaid === false) { if (!getById("rememberStreamID").classList.contains("hidden")) { if (getById("rememberStreamIDcheck").checked) { session.streamID = getStorage("permaid") || session.streamID; setStorage("permaid", session.streamID, 99999); // ~ 13 months? setStorage("rememberStreamIDmobile", "true", 99999); } else { removeStorage("permaid"); setStorage("rememberStreamIDmobile", "false", 99999); } } } if (session.roomid !== false) { // they are in a room or a faux room window.onresize = updateMixer; window.onorientationchange = function () { if (Firefox) { updateForceRotate(true); } setTimeout(async function () { if (session.forceAspectRatio) { await updateCameraConstraints("aspectRatio", session.forceAspectRatio); } if (session.effect && session.effect === "7") { digitalZoom(); // true needed to restart or start } updateForceRotate(); updateMixer(); }, 200); }; if (session.roomid === "" && (!session.view || session.view === "")) { // no room, no viewing, viewing disabled if (session.manual === null) { session.manual = session.manual === null ? true : session.manual; } if (!session.cleanOutput) { var showReshare = getStorage("showReshare"); if (showReshare) { generateHash(session.streamID + session.salt + "bca321", 4) .then(function (hash) { // million to one error. if (showReshare === hash) { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); } else if (session.permaid === null) { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); } }) .catch(errorlog); } } } else { log("ROOM ID ENABLED"); log("Update Mixer Event on REsize SET"); getById("main").style.overflow = "hidden"; //session.cbr=0; // we're just going to override it if (session.stereo == 5) { if (session.roomid === "") { session.stereo = 1; } else { session.stereo = 3; } } joinRoom(session.roomid); if (session.roomid !== "") { if (!session.cleanOutput) { // Check if user joined via text input (casual user) if (sessionStorage.getItem("jvi")) { sessionStorage.removeItem("jvi"); if (!urlParams.has("push") && !urlParams.has("id") && !urlParams.has("permaid") && !urlParams.has("perma") && !urlParams.has("sticky")) { // Show invite header INSTEAD of "You are in room" var inviteURL = location.protocol + "//" + location.host + location.pathname + "?room=" + session.roomid; var urlPW = urlParams.get("password") || urlParams.get("pass") || urlParams.get("pw") || urlParams.get("p"); if (urlPW === "false" || urlPW === "0" || urlPW === "off") { // Password explicitly disabled inviteURL += "&password=false"; getById("inviteLinkURL").href = inviteURL; getById("inviteLinkURL").innerText = inviteURL; getById("head9").classList.remove("hidden"); } else if (urlPW) { // Actual password in URL - generate hash generateHash(session.password + session.salt, 4).then(function(hash) { inviteURL += "&hash=" + hash; getById("inviteLinkURL").href = inviteURL; getById("inviteLinkURL").innerText = inviteURL; getById("head9").classList.remove("hidden"); }); } else { // No password in URL - use default, no param needed getById("inviteLinkURL").href = inviteURL; getById("inviteLinkURL").innerText = inviteURL; getById("head9").classList.remove("hidden"); } } else { getById("head2").className = ""; } } else { getById("head2").className = ""; } } } getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); } } else { // they are not in a room or faux room if (session.manual === null) { session.manual = session.manual === null ? true : session.manual; } getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); getById("logoname").style.display = "none"; generateHash(session.streamID + session.salt + "bca321", 4) .then(function (hash) { // million to one error. setStorage("showReshare", hash, 24 * 30); }) .catch(errorlog); } log("streamID is: " + session.streamID); getById("head1").className = "hidden"; if (!session.cleanOutput) { getById("mutebutton").classList.remove("hidden"); getById("mutespeakerbutton").classList.remove("hidden"); //getById("mutespeakerbutton").className="float"; getById("chatbutton").className = "float"; getById("sharefilebutton").classList.remove("hidden"); // we won't override "display:none", if set, though. getById("mutevideobutton").className = "float"; getById("hangupbutton").className = "float"; if (session.showSettings) { getById("settingsbutton").className = "float"; } if (session.raisehands) { getById("raisehandbutton").className = "float"; } if (session.pptControls) { getById("pptbackbutton").classList.remove("hidden"); getById("pptnextbutton").classList.remove("hidden"); } if (session.recordLocal !== false) { getById("recordLocalbutton").classList.remove("hidden"); } if (session.screensharebutton) { if (session.roomid) { if (session.screenshareType === 3) { getById("screenshare3button").className = "float"; getById("screensharebutton").className = "float hidden"; getById("screenshare2button").className = "float hidden"; } else if (session.screenshareType === 1) { getById("screensharebutton").className = "float"; getById("screenshare3button").className = "float hidden"; getById("screenshare2button").className = "float hidden"; } else if (session.screenshareType === 2) { getById("screenshare2button").className = "float"; getById("screensharebutton").className = "float hidden"; getById("screenshare3button").className = "float hidden"; } else { getById("screenshare3button").className = "float"; getById("screensharebutton").className = "float hidden"; getById("screenshare2button").className = "float hidden"; } } else { getById("screensharebutton").className = "float"; getById("screenshare2button").className = "float hidden"; getById("screenshare3button").className = "float hidden"; } } getById("controlButtons").classList.remove("hidden"); // getById("legal").classList.remove("hidden"); //getById("helpbutton").style.display = "inherit"; //getById("reportbutton").style.display = ""; } else if (session.cleanish && session.recordLocal !== false) { getById("recordLocalbutton").classList.remove("hidden"); getById("mutebutton").classList.add("hidden"); getById("mutespeakerbutton").classList.add("hidden"); getById("chatbutton").classList.add("hidden"); getById("mutevideobutton").classList.add("hidden"); getById("hangupbutton").classList.add("hidden"); getById("hangupbutton2").classList.add("hidden"); getById("controlButtons").classList.remove("hidden"); getById("settingsbutton").classList.add("hidden"); getById("screenshare2button").classList.add("hidden"); getById("screensharebutton").classList.add("hidden"); getById("queuebutton").classList.add("hidden"); } else { getById("controlButtons").classList.add("hidden"); } if (session.chatbutton === true) { getById("chatbutton").classList.remove("hidden"); getById("controlButtons").classList.remove("hidden"); } else if (session.chatbutton === false) { getById("chatbutton").classList.add("hidden"); } updatePushId(); if (session.dataMode) { // skip the media stuff. errorlog("this shoulnd't happen.."); session.postPublish(); return; } if (!session.streamSrc) { checkBasicStreamsExist(); // create srcObject + videoElement } if (session.mobile && session.streamSrc && needsLegacyWakeLock()) { if (Firefox) { startLegacyKeepAliveLoop(true); } else if (!session.avatar) { try { if (session.streamSrc.getVideoTracks && !session.streamSrc.getVideoTracks().length) { startLegacyKeepAliveLoop(); } } catch (e) { errorlog(e); } } } session.publishStream(getById("previewWebcam")); // calls session.postPublish at the end. } function createYoutubeLink(vidid) { return "https://www.youtube.com/embed/" + vidid + "?modestbranding=1&playsinline=1&enablejsapi=1&autoplay=1"; } function parseURL4Iframe(iframeURL) { if (iframeURL == "") { iframeURL = "./"; } if (iframeURL === session.iframeSrc) { return iframeURL; } if (!iframeURL.startsWith("https://") && !iframeURL.startsWith("http://")) { if (iframeURL.includes(".") && !iframeURL.startsWith("./") && !iframeURL.startsWith("/")) { iframeURL = "https://" + iframeURL; } } if (iframeURL.startsWith("http://") && !electronApi && (location.hostname !== "insecure.vdo.ninja")) { try { iframeURL = "https://" + iframeURL.split("http://")[1]; } catch (e) { errorlog(e); } } if (iframeURL.startsWith("https://") || iframeURL.startsWith("http://")) { var domain; try { domain = new URL(iframeURL); domain = domain.hostname; } catch (e) { errorlog(e); return iframeURL; } if (domain == "youtu.be") { iframeURL = iframeURL.replace("youtu.be/", "youtube.com/watch?v="); } if (domain == "youtu.be" || domain == "www.youtube.com" || domain == "youtube.com") { if (iframeURL.includes("/v/")) { var vidMatch = iframeURL.match(/\/v\/([^\/\?#]+)/); if (vidMatch && vidMatch[1] && vidMatch[1].length == 11) { return createYoutubeLink(vidMatch[1]); } } var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/; var match = iframeURL.match(regExp); var vidid = match && match[7] && match[7].length == 11 ? match[7] : false; if (iframeURL.includes("/live_chat")) { if (!iframeURL.includes("&embed_domain=")) { iframeURL += "&embed_domain=" + location.hostname; } return iframeURL; } if (vidid) { iframeURL = createYoutubeLink(vidid); } else { iframeURL = iframeURL.replace("playlist?list=", "embed/videoseries?list="); var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(videoseries\?))\??list?=?([^#&?]*).*/; var match = iframeURL.match(regExp); var plid = match && match[7] && match[7].length == 34 ? match[7] : false; if (plid) { iframeURL = "https://www.youtube.com/embed/videoseries?list=" + plid + "&autoplay=1&modestbranding=1&playsinline=1&enablejsapi=1"; } } } else if (domain == "twitch.tv" || domain == "www.twitch.tv") { if (iframeURL.includes("twitch.tv/popout/")) { iframeURL = iframeURL.replace("/popout/", "/embed/"); iframeURL = iframeURL.replace("?popout=", "?parent=" + location.hostname); iframeURL = iframeURL.replace("?popout", "?parent=" + location.hostname); iframeURL = iframeURL.replace("&popout=", "?parent=" + location.hostname); iframeURL = iframeURL.replace("&popout", "?parent=" + location.hostname); if (iframeURL.includes("darkpopout=")) { iframeURL = iframeURL.replace("?darkpopout=", "?darkpopout=&parent=" + location.hostname); } else { iframeURL = iframeURL.replace("?darkpopout", "?darkpopout&parent=" + location.hostname); } } else { var vidid = iframeURL.split("/").pop().split("#")[0].split("?")[0]; if (vidid) { iframeURL = "https://player.twitch.tv/?channel=" + vidid + "&parent=" + location.hostname; } } } else if (domain == "www.vimeo.com" || domain == "vimeo.com") { iframeURL = iframeURL.replace("//vimeo.com/", "//player.vimeo.com/video/"); iframeURL = iframeURL.replace("//www.vimeo.com/", "//player.vimeo.com/video/"); } else if (domain.endsWith(".tiktok.com") || domain == "tiktok.com") { var split = iframeURL.split("/video/"); if (split.length > 1) { split = split[1].split("/")[0].split("?")[0].split("#")[0]; iframeURL = "https://www.tiktok.com/embed/v2/" + split; } } } return iframeURL; } function soloLinkGenerator(streamID, scene = true) { var codecGroupFlag = ""; if (session.codecGroupFlag) { codecGroupFlag = session.codecGroupFlag; } if (session.bitrateGroupFlag) { codecGroupFlag += session.bitrateGroupFlag; } var wss = ""; if (session.wssSetViaUrl) { if (session.customWSS && (session.customWSS !== true)) { wss = "&pie=" + session.customWSS; } else if (session.customWSS == true) { wss = "&wss=" + session.wss; } else { wss = "&wss2=" + session.wss; } } var passAdd2 = ""; if (session.password) { if (session.defaultPassword === false) { passAdd2 = "&password=" + session.password; } } if (session.token) { passAdd2 += "&token=" + session.token; } // Add auth parameters if in auth mode var authParams = ""; if (session.authMode) { // For view links, we need a universal token that bypasses auth if (session.universalViewToken) { authParams = "&universaltoken=" + session.universalViewToken; } else { // Fallback: include auth flag so viewer knows auth is required authParams = "&auth=true"; } } if (scene) { return "https://" + location.host + location.pathname + "?view=" + streamID + "&solo" + codecGroupFlag + "&room=" + session.roomid + passAdd2 + authParams + wss + soloLinkAppended; } else { return "https://" + location.host + location.pathname + "?view=" + streamID + codecGroupFlag + passAdd2 + authParams + wss + soloLinkAppended; } } function YoutubeAPI(iframe, func, args) { // playVideo, pauseVideo, stopVideo if (!(iframe && iframe.contentWindow)) { return; } try { iframe.contentWindow.postMessage( JSON.stringify({ event: "command", func: func, args: args || [], id: iframe.id || "unknown" }), "*" ); } catch (e) { } } function YoutubeListen(iframe_id) { var iframe = document.getElementById(iframe_id); if (!iframe) { return; } if (iframe.loadedYoutubeListen) { return; } try { iframe.contentWindow.postMessage(JSON.stringify({ event: "listening", id: iframe_id }), "*"); // } catch (e) { } setTimeout( function (iframe_id) { YoutubeListen(iframe_id); }, 1000, iframe_id ); } function processYoutubeEvent(e) { if (!(e.type && e.type === "message")) { return; } try { var data = JSON.parse(e.data); if ("id" in data) { var iframe = document.getElementById(data.id); if (!iframe) { return; } if (!iframe.loadedYoutubeListen) { iframe.loadedYoutubeListen = true; } } if (!("mediaReferenceTime" in data.info)) { return; } } catch (e) { return; } log(e); if (iframe.id == "iframe_source") { if (!session.iframeEle.sendOnNewConnect) { session.iframeEle.sendOnNewConnect = {}; session.iframeEle.sendOnNewConnect.ifs = {}; session.iframeEle.sendOnNewConnect.ifs.t = null; session.iframeEle.sendOnNewConnect.ifs.v = null; session.iframeEle.sendOnNewConnect.ifs.s = null; session.iframeEle.sendOnNewConnect.ifs.r = null; } try { var msg = {}; msg.ifs = {}; try { msg.ifs.t = parseFloat(data.info.mediaReferenceTime + 0.01) || 0; session.iframeEle.sendOnNewConnect.ifs.t = msg.ifs.t; } catch (e) { return; } if ("playerState" in data.info) { msg.ifs.s = parseInt(data.info.playerState); if (msg.ifs.s == -1) { msg.ifs.s = 0; } if (msg.ifs.s == 2) { if (session.iframeEle.sendOnNewConnect.ifs.s == 3) { delete msg.ifs.s; } else { msg.ifs.s = 3; } } if (msg.ifs.s && session.iframeEle.sendOnNewConnect.ifs.s != msg.ifs.s) { session.iframeEle.sendOnNewConnect.ifs.s = msg.ifs.s; } else { //delete(msg.ifs.s); } if ("videoData" in data.info) { if (session.iframeEle.sendOnNewConnect.ifs.v != data.info.videoData.video_id) { session.iframeEle.sendOnNewConnect.ifs.v = data.info.videoData.video_id; msg.ifs.v = data.info.videoData.video_id; var vidSrc = createYoutubeLink(msg.ifs.v); if (vidSrc !== session.iframeSrc) { session.iframeSrc = vidSrc; var data = {}; data.iframeSrc = session.iframeSrc; if (parseInt(msg.ifs.t) > 1) { data.iframeSrc += "&start=" + parseInt(Math.ceil(msg.ifs.t)) + ""; } for (var UUID in session.pcs) { if (session.pcs[UUID].allowIframe === true) { session.sendMessage(data, UUID); } } return; } } } // we will still be sending the msg data if available. } else if ("videoData" in data.info) { if (session.iframeEle.sendOnNewConnect.ifs.v != data.info.videoData.video_id) { msg.ifs.v = data.info.videoData.video_id; session.iframeEle.sendOnNewConnect.ifs.v = msg.ifs.v; var vidSrc = createYoutubeLink(msg.ifs.v); if (vidSrc !== session.iframeSrc) { session.iframeSrc = vidSrc; var data = {}; data.iframeSrc = session.iframeSrc; if (parseInt(msg.ifs.t) > 1) { data.iframeSrc += "&start=" + parseInt(Math.ceil(msg.ifs.t)) + ""; } if (session.iframeEle.sendOnNewConnect.ifs.s == "1") { data.iframeSrc += "&autoplay=1"; } else { data.iframeSrc += "&autoplay=0"; } for (var UUID in session.pcs) { if (session.pcs[UUID].allowIframe === true) { session.sendMessage(data, UUID); } } return; } } } else { if ("playbackRate" in data.info) { msg.ifs.r = parseFloat(data.info.playbackRate); if (session.iframeEle.sendOnNewConnect.ifs.r != msg.ifs.r) { session.iframeEle.sendOnNewConnect.ifs.r = msg.ifs.r; } else { delete msg.ifs.r; } } if (session.iframeEle.sendOnNewConnect.ifs.s == 1) { if ("t" in msg.ifs) { delete msg.ifs.t; } } } if (Object.keys(msg.ifs).length == 0) { return; } for (var UUID in session.pcs) { if (session.pcs[UUID].allowIframe) { session.sendMessage(msg); } } } catch (e) { return; } } else { try { var UUID = iframe.dataset.UUID; var msg = {}; msg.ifs = {}; if ("t" in msg.ifs) { msg.ifs.t = parseFloat(data.info.mediaReferenceTime + 0.01) || 0; /* if (!iframe.sendOnNewConnect){ iframe.sendOnNewConnect = msg; } else { iframe.sendOnNewConnect.ifs.t = msg.ifs.t; } */ } if ("playerState" in data.info) { msg.ifs.s = parseInt(data.info.playerState); } if ("videoData" in data.info) { msg.ifs.v = data.info.videoData.video_id; } if ("playbackRate" in data.info && data.info.playbackRate !== 1) { msg.ifs.r = parseFloat(data.info.playbackRate); } // TODO: the viewers don't have a way to tell the director if they reload what the time is at. session.sendRequest(msg, UUID); // send to the iframe's owner only. let them be the controller for others. } catch (e) { return; } } } function processIframeSyncFeedback(ifs, UUID) { // remote iframe feedback from the remote viewers // YoutubeAPI("iframe_source", "seekTo", [700]); // YoutubeAPI("iframe_source", "volume", [100]); warnlog(ifs); return; if (!session.iframeEle.sendOnNewConnect) { session.iframeEle.sendOnNewConnect = {}; session.iframeEle.sendOnNewConnect.ifs = {}; session.iframeEle.sendOnNewConnect.ifs.t = null; session.iframeEle.sendOnNewConnect.ifs.v = null; session.iframeEle.sendOnNewConnect.ifs.s = null; session.iframeEle.sendOnNewConnect.ifs.r = null; } if ("t" in ifs) { if (Math.abs(session.iframeEle.sendOnNewConnect.ifs.t - ifs.t) >= 1) { //session.iframeEle.sendOnNewConnect.ifs.t = ifs.t; } else { delete ifs.t; } } if ("v" in ifs) { if (session.iframeEle.sendOnNewConnect.ifs.v != ifs.v) { //session.iframeEle.sendOnNewConnect.ifs.v = ifs.v; } else { delete ifs.v; } } if ("s" in ifs) { if (ifs.s == -1) { ifs.s = 0; } if (session.iframeEle.sendOnNewConnect.ifs.s == -1) { session.iframeEle.sendOnNewConnect.ifs.s = 0; } if (ifs.s == 2) { ifs.s = 3; } if (session.iframeEle.sendOnNewConnect.ifs.s == 2) { session.iframeEle.sendOnNewConnect.ifs.s = 3; } if (session.iframeEle.sendOnNewConnect.ifs.s != ifs.s) { //session.iframeEle.sendOnNewConnect.ifs.s = ifs.s; } else { delete ifs.s; } } if ("r" in ifs) { if (session.iframeEle.sendOnNewConnect.ifs.r != ifs.r) { //session.iframeEle.sendOnNewConnect.ifs.r = ifs.r; } else { delete ifs.r; } } if (session.iframeEle) { if (ifs.v) { // I need to have this change videos . var vidSrc = createYoutubeLink(ifs.v); if (vidSrc !== session.iframeSrc) { session.iframeSrc = vidSrc; session.iframeEle.src = vidSrc; } } else if ("t" in ifs) { YoutubeAPI(session.iframeEle, "seekTo", [parseFloat(ifs.t)]); } else if (ifs.r) { /// setPlaybackRate YoutubeAPI(session.iframeEle, "setPlaybackRate", [parseFloat(ifs.r)]); } else if ("s" in ifs) { /// setPlaybackState if (ifs.s == -1) { YoutubeAPI(session.iframeEle, "stopVideo"); } else if (ifs.s == 0) { YoutubeAPI(session.iframeEle, "stopVideo"); } // player stops. else if (ifs.s == 1) { YoutubeAPI(session.iframeEle, "playVideo"); } //Video is playing else if (ifs.s == 2) { YoutubeAPI(session.iframeEle, "pauseVideo"); } //Video is paused else if (ifs.s == 3) { YoutubeAPI(session.iframeEle, "pauseVideo"); } //video is buffering else if (ifs.s == 5) { } //Video is cued. } } else if (session.iframeSrc) { if (ifs.v) { var vidSrc = createYoutubeLink(ifs.v); if (vidSrc !== session.iframeSrc) { session.iframeSrc = vidSrc; var data = {}; data.iframeSrc = session.iframeSrc; if (ifs.t && parseInt(ifs.t) > 1) { data.iframeSrc += "&start=" + parseInt(Math.ceil(ifs.t)); } if (ifs.s == "1") { data.iframeSrc += "&autoplay=1"; } else { data.iframeSrc += "&autoplay=0"; } for (var uuid in session.pcs) { if (uuid == UUID) { continue; } if (session.pcs[uuid].allowIframe === true) { session.sendMessage(data, uuid); } } return; } } // we're going to forward the message directly to the other viewers instead if ("s" in ifs) { /// setPlaybackState var msg = {}; msg.ifs = ifs; for (var uuid in session.pcs) { if (uuid == UUID) { continue; } if (session.pcs[uuid].allowIframe) { session.sendMessage(msg, uuid); } } } } } function processIframeSyncUpdates(ifs, UUID) { // playback updates from remote guest. // YoutubeAPI("iframe_source", "seekTo", [700]); // YoutubeAPI("iframe_source", "volume", [100]); if (ifs.v && "s" in ifs) { // } else if ("s" in ifs) { if ("t" in ifs) { YoutubeAPI(session.rpcs[UUID].iframeEle, "seekTo", [parseFloat(ifs.t)]); } YoutubeAPI(session.rpcs[UUID].iframeEle, "playVideo"); } else if ("t" in ifs) { YoutubeAPI(session.rpcs[UUID].iframeEle, "seekTo", [parseFloat(ifs.t)]); } if (ifs.r) { /// setPlaybackRate YoutubeAPI(session.rpcs[UUID].iframeEle, "setPlaybackRate", [parseFloat(ifs.r)]); } if ("s" in ifs) { /// setPlaybackState if (ifs.s == -1) { YoutubeAPI(session.rpcs[UUID].iframeEle, "stopVideo"); } else if (ifs.s == 0) { YoutubeAPI(session.rpcs[UUID].iframeEle, "stopVideo"); } // player stops. else if (ifs.s == 1) { YoutubeAPI(session.rpcs[UUID].iframeEle, "playVideo"); } //Video is playing else if (ifs.s == 2) { YoutubeAPI(session.rpcs[UUID].iframeEle, "pauseVideo"); } //Video is paused else if (ifs.s == 3) { YoutubeAPI(session.rpcs[UUID].iframeEle, "pauseVideo"); } //video is buffering else if (ifs.s == 5) { } //Video is cued. } } function updatePushId() { if (session.doNotSeed) { return; } if (urlParams.has("push")) { updateURL("push=" + session.streamID); } else if (urlParams.has("id")) { updateURL("id=" + session.streamID); } else if (urlParams.has("permaid")) { updateURL("permaid=" + session.streamID); } else { updateURL("push=" + session.streamID); } } session.publishIFrame = function (iframeURL) { if (!session.cleanOutput) { getById("websitesharebutton2").classList.remove("hidden"); } if (session.transcript) { setTimeout(function () { setupClosedCaptions(); }, 1000); } session.iframeSrc = parseURL4Iframe(iframeURL); if (!session.iFramesAllowed) { errorlog("Can't create iFRAME - security is tainted due to possible CSS injection"); warnUser("Can't create iFRAME - security is tainted due to possible CSS injection"); return; } var iframe = document.createElement("iframe"); iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;midi;screen-wake-lock;"; // do not allow location iframe.src = session.iframeSrc; iframe.id = "iframe_source"; iframe.setAttribute("allowtransparency", "true"); iframe.setAttribute("crossorigin", "anonymous"); iframe.setAttribute("credentialless", "true"); iframe.loadedYoutubeListen = false; session.iframeEle = iframe; var container = document.createElement("div"); iframe.container = container; container.id = "container_iframe"; container.appendChild(iframe); getById("gridlayout").appendChild(container); if (session.iframeSrc.startsWith("https://www.youtube.com/")) { // special handler. setTimeout( function (iframe_id) { YoutubeListen(iframe_id); }, 1000, iframe.id ); } if (session.cover) { container.style.setProperty("height", "100%", "important"); } if (session.roomid !== false) { if (session.roomid === "" && (!session.view || session.view === "")) { } else { log("ROOMID EANBLED"); getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); joinRoom(session.roomid); } } else { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); getById("logoname").style.display = "none"; } getById("head1").className = "hidden"; updatePushId(); getById("head1").className = "hidden"; getById("head2").className = "hidden"; if (!session.cleanOutput) { getById("chatbutton").className = "float"; getById("hangupbutton").className = "float"; getById("controlButtons").classList.remove("hidden"); // getById("legal").classList.remove("hidden"); getById("sharefilebutton").classList.remove("hidden"); // we won't override "display:none", if set, though. //getById("helpbutton").style.display = "inherit"; //getById("reportbutton").style.display = ""; } else { getById("controlButtons").classList.add("hidden"); } if (session.chatbutton === false) { getById("chatbutton").classList.add("hidden"); } if (session.director) { // } else if (session.scene !== false) { updateMixer(); } else if (session.roomid !== false) { if (session.roomid === "") { if (!session.view || session.view === "") { session.windowed = session.windowed === null ? true : session.windowed; container.classList.add("vidcon"); getById("mutespeakerbutton").classList.add("hidden"); container.style.width = "100%"; container.style.height = "100%"; container.style.alignItems = "center"; container.style.maxWidth = "100%"; container.style.maxHeight = "100%"; container.style.verticalAlign = "middle"; container.style.margin = "auto"; container.style.backgroundColor = "#666"; container.style.border = "2px solid"; } else { session.windowed = session.windowed === null ? false : session.windowed; window.onresize = updateMixer; updateMixer(); } } else { window.onresize = updateMixer; session.windowed = session.windowed === null ? false : session.windowed; updateMixer(); } } else { window.onresize = updateMixer; container.style.maxHeight = "1280px"; container.style.maxWidth = "720px"; container.style.verticalAlign = "middle"; container.style.height = "100%"; container.style.width = "100%"; container.style.margin = "auto"; container.style.alignItems = "center"; container.style.backgroundColor = "#666"; } session.seeding = true; updateReshareLink(); pokeIframeAPI("started-iframe-share"); session.seedStream(); return container; }; // publishIframe /* session.publishWhepSrc = function(){ if (!session.whepSrc){errorlog("no WHEP Src");return;} if (!session.cleanOutput){ getById("websitesharebutton2").classList.remove('hidden'); } var UUID = whepIn(session.whepSrc); var container = document.createElement("div"); iframe.container = container; container.id = "container_iframe"; container.appendChild(iframe); getById("gridlayout").appendChild(container); if (session.iframeSrc.startsWith("https://www.youtube.com/")){ // special handler. setTimeout(function(iframe_id){YoutubeListen(iframe_id);}, 1000, iframe.id); } if (session.cover){ container.style.setProperty('height', '100%', 'important'); } if (session.roomid!==false){ if ((session.roomid==="") && ((!(session.view)) || (session.view===""))){ } else { log("ROOMID EANBLED"); getById("head3").classList.add('hidden'); getById("head3a").classList.add('hidden'); joinRoom(session.roomid); } } else { getById("head3").classList.remove('hidden'); getById("head3a").classList.remove('hidden'); getById("logoname").style.display = 'none'; } getById("head1").className = 'hidden'; updatePushId() getById("head1").className = 'hidden'; getById("head2").className = 'hidden'; if (!(session.cleanOutput)){ getById("chatbutton").className="float"; getById("hangupbutton").className="float"; getById("controlButtons").classList.remove("hidden"); getById('sharefilebutton').classList.remove("hidden"); // we won't override "display:none", if set, though. getById("helpbutton").style.display = "inherit"; getById("reportbutton").style.display = ""; } else { getById("controlButtons").classList.add("hidden"); } if (session.chatbutton === false) { getById("chatbutton").classList.add("hidden"); } if (session.director){ // } else if (session.scene!==false){ updateMixer(); } else if (session.roomid!==false){ if (session.roomid===""){ if (!session.fullscreen && (!(session.view) || (session.view===""))){ session.windowed = session.windowed === null ? true : session.windowed; container.classList.add("vidcon"); getById("mutespeakerbutton").classList.add("hidden"); container.style.width="100%"; container.style.height="100%"; container.style.alignItems = "center"; container.style.maxWidth= "100%"; container.style.maxHeight= "100%"; container.style.verticalAlign= "middle"; container.style.margin= "auto"; container.style.backgroundColor = "#666"; container.style.border = "2px solid"; } else { session.windowed = session.windowed === null ? false : session.windowed; window.onresize = updateMixer; updateMixer(); } } else { window.onresize = updateMixer; session.windowed = session.windowed === null ? false : session.windowed; updateMixer(); } } else { window.onresize = updateMixer; container.style.maxHeight= "1280px"; container.style.maxWidth= "720px"; container.style.verticalAlign= "middle"; container.style.height="100%"; container.style.width= "100%"; container.style.margin= "auto"; container.style.alignItems = "center"; container.style.backgroundColor = "#666"; } session.seeding=true; updateReshareLink(); pokeIframeAPI('started-iframe-share'); session.seedStream(); return container; } // publishWhepSrc */ function disabledWebAudioPathway() { log("Executing disabledWebAudioPathway."); // if (session.disableWebAudio) { then run this instead; or if webaudio nodes fail.} // if (iOS || iPad){return session.streamSrc;} // Original comment: iOS devices can't remap video tracks, else KABOOM. Might as well do this for android also. // This iOS specific return was in comments, if it's critical, it should be uncommented. // However, the logic below also attempts to handle stream cloning. if (session.streamSrcClone) { log("disabledWebAudioPathway: Cleaning up existing session.streamSrcClone"); session.streamSrcClone.getTracks().forEach(function (track) { session.streamSrcClone.removeTrack(track); track.stop(); }); session.streamSrcClone = null; } var newStream = createMediaStream(); // This will be the returned stream if (session.streamSrc && typeof session.streamSrc.clone === 'function' && !window.obsstudio) { // Prefer cloning if available and not obsstudio log("disabledWebAudioPathway: Cloning session.streamSrc (non-obsstudio path)"); newStream = session.streamSrc.clone(); } else { log("disabledWebAudioPathway: Creating new stream and adding tracks manually."); if (session.streamSrc) { session.streamSrc.getAudioTracks().forEach(function (track) { if (track.readyState === 'live') { // For obsstudio, audio tracks can also be cloned for consistency, though less critical than video. // For simplicity here, adding original, but could be `track.clone()` newStream.addTrack(track); log("disabledWebAudioPathway: Added audio track " + track.id); } }); } let videoSourceForDisabledPath = null; if (session.videoElement && session.videoElement.srcObject) { videoSourceForDisabledPath = session.videoElement.srcObject; } else if (session.streamSrc) { videoSourceForDisabledPath = session.streamSrc; } if (videoSourceForDisabledPath) { videoSourceForDisabledPath.getVideoTracks().forEach(function (track) { if (track.readyState === 'live') { if (window.obsstudio) { log(`disabledWebAudioPathway (obsstudio): Cloning video track ${track.id}`); try { const clonedVideoTrack = track.clone(); newStream.addTrack(clonedVideoTrack); } catch (e_clone_video) { errorlog(`disabledWebAudioPathway (obsstudio): Failed to clone video track ${track.id}. Adding original. Error:`, e_clone_video); newStream.addTrack(track); // Fallback } } else { log(`disabledWebAudioPathway (non-obsstudio): Adding original video track ${track.id}`); newStream.addTrack(track); // Original behavior } } }); } } if (iOS || iPad || session.streamSrcClone) { session.streamSrcClone = newStream; // Store the newly created/cloned stream } return newStream; } function outboundAudioPipeline(sourceStream = false) { if (session.disableWebAudio) { return disabledWebAudioPathway(); // Safemode } if (!session.streamSrc && !sourceStream) { errorlog("STREAM DOES NOT EXIST. This is a problem"); checkBasicStreamsExist(); return session.streamSrc; } var streamSrc = sourceStream || session.streamSrc; if (iOS || iPad) { if (session.streamSrcClone) { var audioTracksForCleanup = session.streamSrcClone.getAudioTracks(); if (audioTracksForCleanup.length) { for (var waid in session.webAudios) { if (session.webAudios[waid] && typeof session.webAudios[waid].stop === 'function') { session.webAudios[waid].stop(); } delete session.webAudios[waid]; } } session.streamSrcClone.getTracks().forEach(function (track) { session.streamSrcClone.removeTrack(track); track.stop(); }); session.streamSrcClone = null; } // Create iOS-compatible stream (always clone for iOS) if (session.streamSrc && typeof session.streamSrc.clone === 'function') { log("iOS: Cloning session.streamSrc"); streamSrc = session.streamSrc.clone(); session.streamSrcClone = streamSrc; } else { log("iOS: Creating new stream as backup"); session.streamSrcClone = createOptimizedStream(session.streamSrc, true); streamSrc = session.streamSrcClone; } } for (var waid in session.webAudios) { if (session.webAudios[waid] && typeof session.webAudios[waid].stop === 'function') { session.webAudios[waid].stop(); } delete session.webAudios[waid]; } session.webAudios = session.webAudios || {}; try { log("Web Audio processing initiated."); var audioTracks = streamSrc.getAudioTracks(); if (audioTracks.length) { var webAudio = initWebAudioNode(audioTracks[0].id); if (audioTracks.length > 1) { try { setupMultiTrackAudio(audioTracks, webAudio); } catch (e_multi) { errorlog("Error in multi-track audio setup, falling back: ", e_multi); try { webAudio.mediaStreamSource = webAudio.audioContext.createMediaStreamSource(streamSrc); webAudio.gainNode = audioGainNode(webAudio.mediaStreamSource, webAudio.audioContext); } catch (e_multi_fallback) { errorlog("Fallback failed: ", e_multi_fallback); return disabledWebAudioPathway(); } } } else { try { webAudio.mediaStreamSource = webAudio.audioContext.createMediaStreamSource(streamSrc); webAudio.gainNode = audioGainNode(webAudio.mediaStreamSource, webAudio.audioContext); } catch (e_single) { errorlog("Error creating single track setup: ", e_single); return disabledWebAudioPathway(); } } var anonNode = applyAudioProcessing(webAudio, streamSrc); const finalOutputStream = createMediaStream(); webAudio.destination.stream.getAudioTracks().forEach(audioTrack => { finalOutputStream.addTrack(audioTrack); }); addVideoTracksToStream(finalOutputStream, streamSrc); if (webAudio.audioContext && webAudio.audioContext.state === "suspended") { webAudio.audioContext.resume().catch(e => errorlog("AudioContext resume failed:", e)); } return finalOutputStream; } else { log("No audio tracks found. Handling video passthrough."); // Return video-only stream if (window.obsstudio) { log("OBS (no audio): Creating stream with cloned video tracks"); const newStream = createMediaStream(); addVideoTracksToStream(newStream, streamSrc); return newStream; } else { log("Non-OBS (no audio): Using direct video source"); if (session.videoElement && session.videoElement.srcObject) { return session.videoElement.srcObject; } const newStream = createMediaStream(); addVideoTracksToStream(newStream, streamSrc); return newStream; } } } catch (e_main) { errorlog("Critical error in outboundAudioPipeline: "); errorlog(e_main); return streamSrc; } } function createOptimizedStream(source, shouldClone = false) { const newStream = createMediaStream(); if (!source) return newStream; source.getAudioTracks().forEach(track => { if (track.readyState === 'live') { if (shouldClone) { try { newStream.addTrack(track.clone()); } catch (e) { errorlog("Failed to clone audio track. Adding original. Error:", e); newStream.addTrack(track); } } else { newStream.addTrack(track); } } }); addVideoTracksToStream(newStream, source); return newStream; } function addVideoTracksToStream(targetStream, sourceStream) { let videoSourceStream = sourceStream; // Find video source with fallback if (session.videoElement && session.videoElement.srcObject && session.videoElement.srcObject.getVideoTracks().length > 0) { videoSourceStream = session.videoElement.srcObject; } else if (!videoSourceStream || videoSourceStream.getVideoTracks().length === 0) { videoSourceStream = session.streamSrc; } if (videoSourceStream) { videoSourceStream.getVideoTracks().forEach(track => { if (track.readyState === 'live') { // Only clone for OBS Studio if (window.obsstudio) { log(`OBS: Cloning video track ${track.id}`); try { const clonedTrack = track.clone(); targetStream.addTrack(clonedTrack); } catch (e_clone) { errorlog(`OBS: Failed to clone track ${track.id}. Adding original. Error:`, e_clone); targetStream.addTrack(track); } } else { targetStream.addTrack(track); } } }); } } function initWebAudioNode(trackId) { var webAudio = { id: trackId, micDelay: false, compressor: false, analyser: false, gainNode: false, splitter: false, subGainNodes: false, lowEQ: false, midEQ: false, highEQ: false, lowcut1: false, lowcut2: false, lowcut3: false, waveShaper_vc: null, oscillator_vc: null, oscillatorGain_vc: null, delay_vc: null, lowEQ_vc: null, mid_vc: null }; // Create audio context if needed if (session.audioCtxOutbound) { // Already created } else if (session.outboundSampleRate) { try { session.audioCtxOutbound = new AudioContext({ sampleRate: session.outboundSampleRate }); } catch (e) { session.audioCtxOutbound = new AudioContext(); errorlog(e); } } else if (session.outboundSampleRate === false || Firefox || SafariVersion || session.mobile) { session.audioCtxOutbound = new AudioContext(); } else if (session.audioLatency !== false) { session.audioCtxOutbound = new AudioContext({ latencyHint: session.audioLatency / 1000.0, sampleRate: 48000 }); } else { try { session.audioCtxOutbound = new AudioContext({ sampleRate: 48000 }); } catch (e) { session.audioCtxOutbound = new AudioContext(); errorlog(e); } } if (session.audioCtxOutbound && session.audioCtxOutbound.sampleRate > 192000) { console.error("Warning: Your audio playback device has a very high sample rate set; lower it to 48000-Hz to avoid audio issues"); } webAudio.audioContext = session.audioCtxOutbound; webAudio.destination = session.audioCtxOutbound.createMediaStreamDestination(); return webAudio; } // Helper function to handle multi-track audio setup function setupMultiTrackAudio(audioTracks, webAudio) { var maxChannelCount = session.stereo === false ? 1 : 2; webAudio.subGainNodes = {}; var mergerNode = webAudio.audioContext.createChannelMerger(maxChannelCount); for (var i = 0; i < audioTracks.length; i++) { try { var tempIndividualTrackStream = createMediaStream(); tempIndividualTrackStream.addTrack(audioTracks[i]); var trackAudioSourceNode = webAudio.audioContext.createMediaStreamSource(tempIndividualTrackStream); webAudio.subGainNodes[audioTracks[i].id] = webAudio.audioContext.createGain(); trackAudioSourceNode.connect(webAudio.subGainNodes[audioTracks[i].id]); if (maxChannelCount == 2) { var individualSplitter = webAudio.audioContext.createChannelSplitter(2); webAudio.subGainNodes[audioTracks[i].id].connect(individualSplitter); individualSplitter.connect(mergerNode, 0, 0); try { individualSplitter.connect(mergerNode, 1, 1); } catch (e_stereo) { errorlog("Stereo connect ch1->input1 failed: ", e_stereo); try { individualSplitter.connect(mergerNode, 0, 1); } catch (e_stereo_fallback) { errorlog("Stereo connect ch0->input1 fallback failed: ", e_stereo_fallback); } } } else { webAudio.subGainNodes[audioTracks[i].id].connect(mergerNode, 0, 0); } } catch (e_track) { errorlog("Error processing track: ", e_track); throw e_track; } } webAudio.mediaStreamSource = mergerNode; webAudio.gainNode = audioGainNode(webAudio.mediaStreamSource, webAudio.audioContext); } function applyAudioProcessing(webAudio, streamSrc) { try { var anonNode = webAudio.gainNode; // Channel downmixing if (session.audioInputChannels == 1) { anonNode = applyDownmixing(anonNode, webAudio); } // Low cut filter if (session.lowcut) { anonNode = applyLowCut(anonNode, webAudio); } // Voice changer if (session.voicechanger) { anonNode = applyVoiceChanger(anonNode, webAudio); } // Equalizer if (session.equalizer) { anonNode = applyEqualizer(anonNode, webAudio); } // Compressor/Limiter if (session.compressor === 1) { webAudio.compressor = audioCompressor(anonNode, webAudio.audioContext); anonNode = webAudio.compressor; } else if (session.compressor === 2) { webAudio.compressor = audioLimiter(anonNode, webAudio.audioContext); anonNode = webAudio.compressor; } // Mic panning (publisher-side): force mono, then pan to stereo if (session.micPanning !== false) { anonNode = applyMicPanning(anonNode, webAudio, session.micPanning); } // Mic delay if (session.micDelay !== false) { webAudio.micDelay = micDelayNode(anonNode, webAudio.audioContext); anonNode = webAudio.micDelay; } // Twilio mix if (session.twilio && session.twilio.element && session.twilio.element.srcObject && session.twilio.element.srcObject.getAudioTracks().length) { const twilioSource = webAudio.audioContext.createMediaStreamSource(session.twilio.element.srcObject); twilioSource.connect(anonNode); } // Noise gate if (session.noisegate !== false) { webAudio.analyser = audioMeter(anonNode, webAudio.audioContext); anonNode = webAudio.analyser; webAudio.gatingNode = audioGatingNode(anonNode, webAudio.audioContext); webAudio.gatingNode.connect(webAudio.destination); } else { webAudio.analyser = audioMeter(anonNode, webAudio.audioContext); webAudio.analyser.connect(webAudio.destination); } webAudio.stop = createStopFunction(webAudio); if (streamSrc && webAudio.mediaStreamSource) { const tracks = streamSrc.getTracks(); if (tracks.length) { tracks.forEach(track => { track.addEventListener('ended', () => { log("Track ended, stopping webAudio"); webAudio.stop(); }); }); } else if (webAudio.mediaStreamSource.onended !== undefined) { // Fallback for older browsers webAudio.mediaStreamSource.onended = () => { log("MediaStreamSource ended, stopping webAudio"); webAudio.stop(); }; } } session.webAudios[webAudio.id] = webAudio; } catch (e) { console.error(e); return webAudio; } return anonNode; } function applyMicPanning(inputNode, webAudio, value) { // Convert 0..180 to -1..1 (90 center) if (value === true || value === "true") { value = 90; } value = parseFloat(value); if (isNaN(value)) { value = 90; } var panNorm = (value / 90.0) - 1.0; if (panNorm < -1) panNorm = -1; if (panNorm > 1) panNorm = 1; // Downmix to mono explicitly let splitter = webAudio.audioContext.createChannelSplitter(2); let mono = webAudio.audioContext.createChannelMerger(1); try { inputNode.connect(splitter); splitter.connect(mono, 0, 0); splitter.connect(mono, 1, 0); } catch (e) { // If connection fails (e.g., mono input), fallback to direct try { inputNode.connect(mono, 0, 0); } catch (ee) { } } // Pre-pan gain reduction to avoid clipping when panned webAudio.micPanGainNode = webAudio.audioContext.createGain(); webAudio.micPanGainNode.gain.value = 1 - Math.abs(panNorm) / 2; mono.connect(webAudio.micPanGainNode); // Create panner with Safari fallback if (webAudio.audioContext.createStereoPanner) { webAudio.micPanType = "stereo"; webAudio.micPanNode = webAudio.audioContext.createStereoPanner(); webAudio.micPanNode.pan.value = panNorm; } else { webAudio.micPanType = "panner"; webAudio.micPanNode = webAudio.audioContext.createPanner(); webAudio.micPanNode.panningModel = "equalpower"; webAudio.micPanNode.distanceModel = "inverse"; let x = panNorm; let z = 1 - Math.abs(panNorm); try { if (typeof webAudio.micPanNode.positionX !== "undefined") { webAudio.micPanNode.positionX.value = x; webAudio.micPanNode.positionY.value = 0; webAudio.micPanNode.positionZ.value = z; } else { webAudio.micPanNode.setPosition(x, 0, z); } } catch (e) { } } webAudio.micPanGainNode.connect(webAudio.micPanNode); return webAudio.micPanNode; } function changeMicPanning(value, deviceid = null) { // Update all active outbound webAudio chains let pan = parseFloat(value); if (isNaN(pan)) { pan = 90; } if (pan < 0) pan = 0; if (pan > 180) pan = 180; let norm = (pan / 90.0) - 1.0; if (norm < -1) norm = -1; if (norm > 1) norm = 1; session.micPanning = pan; for (var waid in session.webAudios) { try { let wa = session.webAudios[waid]; if (!wa) continue; if (wa.micPanNode) { if (wa.micPanType === "stereo" && wa.micPanNode.pan) { wa.micPanNode.pan.setValueAtTime(norm, wa.audioContext.currentTime); } else { let x = norm; let z = 1 - Math.abs(norm); if (typeof wa.micPanNode.positionX !== "undefined") { wa.micPanNode.positionX.setValueAtTime(x, wa.audioContext.currentTime); wa.micPanNode.positionY.setValueAtTime(0, wa.audioContext.currentTime); wa.micPanNode.positionZ.setValueAtTime(z, wa.audioContext.currentTime); } else if (wa.micPanNode.setPosition) { wa.micPanNode.setPosition(x, 0, z); } } } if (wa.micPanGainNode && wa.micPanGainNode.gain) { wa.micPanGainNode.gain.setValueAtTime(1 - Math.abs(norm) / 2, wa.audioContext.currentTime); } } catch (e) { errorlog(e); } } } // helper to keep approval popup text current with label/streamID session.updateApprovalPrompt = function (UUID) { try { if (!session.director || !session.approval_popup) { return; } var label = (session.rpcs[UUID] && session.rpcs[UUID].label) || ("Guest " + (UUID || '').substring(0, 8)); var sid = (session.rpcs[UUID] && session.rpcs[UUID].streamID) || UUID; try { label = ("" + label).replace(/[<>]/g, ""); sid = ("" + sid).replace(/[<>]/g, ""); } catch (e) { } var line = "A guest is waiting to be admitted.\n\n" + "Guest: " + label + "\n" + "ID: " + sid + "\n\n" + (session.directorState === false ? "Approve?\n(This sends the action to the main director.)" : "Approve?"); updateConfirmAlt('approval-' + UUID, line); } catch (e) { errorlog(e); } }; function requestChangeMicPanning(value, UUID, track = 0) { var msg = {}; msg.requestChangeMicPanning = true; msg.value = value; msg.UUID = UUID; msg.track = track; session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-change-micpanning", { value: value, track: track }, UUID); } function createStopFunction(webAudio) { return function () { // Prevent multiple calls if (webAudio.stopped) { errorlog("Trying to stop webaudio more than once"); return; } webAudio.stopped = true; // Clear analyzer interval if it exists try { if (webAudio.analyser && webAudio.analyser.interval) { clearInterval(webAudio.analyser.interval); } } catch (e) { errorlog("Error clearing analyser interval:", e); } // Special handling for subGainNodes (collection of nodes) if (webAudio.subGainNodes) { for (var id in webAudio.subGainNodes) { try { if (webAudio.subGainNodes[id]) { webAudio.subGainNodes[id].disconnect(); webAudio.subGainNodes[id] = null; } } catch (e) { errorlog("Error disconnecting subGainNode " + id + ":", e); } } webAudio.subGainNodes = null; } // List of properties to skip disconnecting const skipProperties = ["stop", "id", "audioContext", "mediaStreamSource", "subGainNodes", "stopped"]; // Disconnect all other nodes for (var node in webAudio) { if (!webAudio[node] || skipProperties.includes(node)) { continue; } try { // Only disconnect if it has a disconnect method (is an audio node) if (typeof webAudio[node].disconnect === 'function') { webAudio[node].disconnect(); log("Disconnected node: " + node); } webAudio[node] = null; } catch (e) { errorlog("Error disconnecting node " + node + ":", e); } } // Remove from session tracking if (session.webAudios && webAudio.id && session.webAudios[webAudio.id]) { delete session.webAudios[webAudio.id]; } }; } function changeLowCut(freq, deviceid = null) { log("LOW EQ"); for (var webAudio in session.webAudios) { if (!session.webAudios[webAudio].lowcut1) { errorlog("EQ not setup"); return; } if (!session.webAudios[webAudio].lowcut2) { errorlog("EQ not setup"); return; } if (!session.webAudios[webAudio].lowcut3) { errorlog("EQ not setup"); return; } session.webAudios[webAudio].lowcut1.frequency.setValueAtTime(freq, session.webAudios[webAudio].audioContext.currentTime); session.webAudios[webAudio].lowcut2.frequency.setValueAtTime(freq, session.webAudios[webAudio].audioContext.currentTime); session.webAudios[webAudio].lowcut3.frequency.setValueAtTime(freq, session.webAudios[webAudio].audioContext.currentTime); } } function changeLowEQ(lowEQ, deviceid = null) { log("LOW EQ"); for (var webAudio in session.webAudios) { if (!session.webAudios[webAudio].lowEQ) { errorlog("EQ not setup"); return; } session.webAudios[webAudio].lowEQ.gain.setValueAtTime(lowEQ, session.webAudios[webAudio].audioContext.currentTime); } } function changeMidEQ(midEQ, deviceid = null) { for (var webAudio in session.webAudios) { if (!session.webAudios[webAudio].midEQ) { errorlog("EQ not setup"); return; } session.webAudios[webAudio].midEQ.gain.setValueAtTime(midEQ, session.webAudios[webAudio].audioContext.currentTime); } } function changeHighEQ(highEQ, deviceid = null) { for (var webAudio in session.webAudios) { if (!session.webAudios[webAudio].highEQ) { errorlog("EQ not setup"); return; } session.webAudios[webAudio].highEQ.gain.setValueAtTime(highEQ, session.webAudios[webAudio].audioContext.currentTime); } } function changeMicDelay(delay, deviceid = null) { log("changeMicDelay :" + delay); for (var waid in session.webAudios) { // add a mic delay if (!session.webAudios[waid].micDelay) { errorlog("Mic Delay not setup"); } else { session.webAudios[waid].micDelay.delayTime.setValueAtTime(delay / 1000, session.webAudios[waid].audioContext.currentTime); } } } function changeSubGain(gain, deviceid = null) { if (gain !== false) { gain = parseFloat(gain / 100.0) || 0; } else { gain = 1.0; } for (var webAudio in session.webAudios) { try { if (!session.webAudios[webAudio].subGainNodes) { errorlog("EQ not setup"); return; } if (deviceid in session.webAudios[webAudio].subGainNodes) { session.webAudios[webAudio].subGainNodes[deviceid].gain.setValueAtTime(gain, session.webAudios[webAudio].audioContext.currentTime); } else { errorlog("NOT FOUND:" + deviceid); } break; } catch (e) { errorlog(e); } } } function changeMainGain(gain, fadeout = 0) { for (var webAudio in session.webAudios) { if (!session.webAudios[webAudio].gainNode) { return; } if (gain !== false) { gain = parseFloat(gain / 100.0) || 0; } else { gain = 1.0; } if (fadeout) { try { session.webAudios[webAudio].gainNode.gain.linearRampToValueAtTime(gain, session.webAudios[webAudio].audioContext.currentTime + fadeout / 1000); } catch (e) { session.webAudios[webAudio].gainNode.gain.setValueAtTime(gain, session.webAudios[webAudio].audioContext.currentTime); } } else { session.webAudios[webAudio].gainNode.gain.setValueAtTime(gain, session.webAudios[webAudio].audioContext.currentTime); } } } function changeGatingGain(gain, fadeout = 0) { for (var webAudio in session.webAudios) { if (!session.webAudios[webAudio].gatingNode) { return; } if (gain !== false) { gain = parseFloat(gain / 100.0) || 0; } else { gain = 1.0; } if (fadeout) { try { session.webAudios[webAudio].gatingNode.gain.linearRampToValueAtTime(gain, session.webAudios[webAudio].audioContext.currentTime + fadeout / 1000); } catch (e) { session.webAudios[webAudio].gatingNode.gain.setValueAtTime(gain, session.webAudios[webAudio].audioContext.currentTime); } } else { session.webAudios[webAudio].gatingNode.gain.setValueAtTime(gain, session.webAudios[webAudio].audioContext.currentTime); } } } function applyDownmixing(inputNode, webAudio) { // Complex downmixing logic with channel counting and gain adjustment let totalChannels = 0; let activeChannels = 0; let tracks = webAudio.audioContext._stream ? webAudio.audioContext._stream.getAudioTracks() : []; tracks.forEach(track => { if (track.getSettings && track.getSettings().channelCount) { let trackChannels = track.getSettings().channelCount; totalChannels += trackChannels; if (track.enabled) { activeChannels += trackChannels; } } else { // Fallback if getSettings is not available totalChannels += 2; // Assume stereo if (track.enabled) { activeChannels += 2; } } }); totalChannels = Math.max(totalChannels, 1); activeChannels = Math.max(activeChannels, 1); webAudio.splitter = webAudio.audioContext.createChannelSplitter(totalChannels); inputNode.connect(webAudio.splitter); webAudio.merger = webAudio.audioContext.createChannelMerger(1); // Create a gain node for volume adjustment webAudio.downmixGain = webAudio.audioContext.createGain(); // Connect splitter outputs to merger through the gain node for (let i = 0; i < totalChannels; i++) { webAudio.splitter.connect(webAudio.downmixGain, i, 0); } webAudio.downmixGain.connect(webAudio.merger, 0, 0); // Set gain to 1 / sqrt(activeChannels) to maintain perceived loudness let gainValue = 1 / Math.sqrt(activeChannels); webAudio.downmixGain.gain.setValueAtTime(gainValue, webAudio.audioContext.currentTime); log(`Downmixing ${totalChannels} total channels (${activeChannels} active) to mono. Gain set to ${gainValue.toFixed(3)}`); return webAudio.merger; } function applyLowCut(inputNode, webAudio) { // Apply high-pass filter chain for low frequency cut webAudio.lowcut1 = webAudio.audioContext.createBiquadFilter(); webAudio.lowcut1.type = "highpass"; webAudio.lowcut1.frequency.value = session.lowcut; webAudio.lowcut2 = webAudio.audioContext.createBiquadFilter(); webAudio.lowcut2.type = "highpass"; webAudio.lowcut2.frequency.value = session.lowcut; webAudio.lowcut3 = webAudio.audioContext.createBiquadFilter(); webAudio.lowcut3.type = "highpass"; webAudio.lowcut3.frequency.value = session.lowcut; inputNode.connect(webAudio.lowcut1); webAudio.lowcut1.connect(webAudio.lowcut2); webAudio.lowcut2.connect(webAudio.lowcut3); return webAudio.lowcut3; } function applyVoiceChanger(inputNode, webAudio) { function makeDistortionCurve(amount = 10) { var sampleRate = webAudio.audioContext.sampleRate || 48000; var curve = new Float32Array(sampleRate); var x; for (let i = 0; i < sampleRate; ++i) { x = (i * 2) / sampleRate - 1; curve[i] = ((3 + amount) * x * 20 * (Math.PI / 180)) / (Math.PI + amount * Math.abs(x)); } return curve; } let waveShaper = webAudio.audioContext.createWaveShaper(); waveShaper.curve = makeDistortionCurve(5); var realCoeffs = new Float32Array([1, 0]); var imagCoeffs = new Float32Array([0, 1]); var numCoeffs = 20; // The more coefficients you use, the better the approximation var realCoeffs = new Float32Array(numCoeffs); var imagCoeffs = new Float32Array(numCoeffs); realCoeffs[0] = 0.5; for (var i = 1; i < numCoeffs; i++) { // note i starts at 1 imagCoeffs[i] = (1 / (i * Math.PI)) * (1 - Math.random() / 2); } let oscillator = webAudio.audioContext.createOscillator(); oscillator.frequency.value = 10; const wave = webAudio.audioContext.createPeriodicWave(realCoeffs, imagCoeffs); oscillator.setPeriodicWave(wave); let oscillatorGain = webAudio.audioContext.createGain(); oscillatorGain.gain.value = 0.005; oscillator.connect(oscillatorGain); oscillator.start(0); let delay = webAudio.audioContext.createDelay(); delay.delayTime.value = 0.01; oscillatorGain.connect(delay.delayTime); let lowEQ = webAudio.audioContext.createBiquadFilter(); lowEQ.type = "peaking"; lowEQ.frequency.value = 200; lowEQ.Q.value = 0.5; lowEQ.gain.value = 6; let mid = webAudio.audioContext.createBiquadFilter(); mid.type = "peaking"; mid.frequency.value = 500; mid.Q.value = 0.5; mid.gain.value = -10; inputNode.connect(delay); delay.connect(waveShaper); waveShaper.connect(mid); mid.connect(lowEQ); return lowEQ; } function applyEqualizer(inputNode, webAudio) { // https://webaudioapi.com/samples/frequency-response/ for a tool to help set values webAudio.lowEQ = webAudio.audioContext.createBiquadFilter(); webAudio.lowEQ.type = "lowshelf"; webAudio.lowEQ.frequency.value = 100; webAudio.lowEQ.gain.value = 0; webAudio.midEQ = webAudio.audioContext.createBiquadFilter(); webAudio.midEQ.type = "peaking"; webAudio.midEQ.frequency.value = 1000; webAudio.midEQ.Q.value = 0.5; webAudio.midEQ.gain.value = 0; webAudio.highEQ = webAudio.audioContext.createBiquadFilter(); webAudio.highEQ.type = "highshelf"; webAudio.highEQ.frequency.value = 10000; webAudio.highEQ.gain.value = 0; inputNode.connect(webAudio.lowEQ); webAudio.lowEQ.connect(webAudio.midEQ); webAudio.midEQ.connect(webAudio.highEQ); return webAudio.highEQ; } function micDelayNode(mediaStreamSource, audioContext) { if (session.micDelay !== false) { var delay = parseFloat(session.micDelay / 1000) || 0; var delayNode = audioContext.createDelay(delay + 3); } else { var delay = 0; var delayNode = audioContext.createDelay(3); } delayNode.delayTime.value = delay; mediaStreamSource.connect(delayNode); return delayNode; } function audioGainNode(mediaStreamSource, audioContext) { var gainNode = audioContext.createGain(); if (session.audioGain !== false) { var gain = parseFloat(session.audioGain / 100.0) || 0; } else { var gain = 1.0; } gainNode.gain.value = gain; mediaStreamSource.connect(gainNode); return gainNode; } function audioGatingNode(mediaStreamSource, audioContext) { var gateNode = audioContext.createGain(); gateNode.gain.value = 1.0; mediaStreamSource.connect(gateNode); return gateNode; } function audioMeter(mediaStreamSource, audioContext) { var analyser = audioContext.createAnalyser(); mediaStreamSource.connect(analyser); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.05; var bufferLength = analyser.frequencyBinCount; var dataArray = new Uint8Array(bufferLength); var timer = null; var meter1 = document.getElementById("meter1") || false; var meter2 = document.getElementById("meter2") || false; var meter3 = document.getElementById("meter3") || false; var meter4 = document.getElementById("meter4") || false; var currentlyActive = 0; // mode 5 var ng1 = 10; var ng2 = 25; var ng3 = 30; if (session.noisegateSettings) { if (session.noisegateSettings.length) { ng1 = parseInt(session.noisegateSettings[0]) || 0; // gated volume target (lower to this level) } if (session.noisegateSettings.length > 1) { ng2 = parseInt(session.noisegateSettings[1]) || ng2; // not loud (threshold level) } if (session.noisegateSettings.length > 2) { ng3 = parseInt(session.noisegateSettings[2]) || 0; // stickiness; time (ms) ng3 = ng3 / 100.0; // convert to the actual units (100ms) } } if (session.noisegate) { changeGatingGain(ng1, 200); } function draw() { try { analyser.getByteFrequencyData(dataArray); var total = 0; for (var i = 0; i < dataArray.length; i++) { total += dataArray[i]; } total = total / 100; if (session.quietOthers && session.quietOthers == 2) { if (total > 10) { if (session.muted_activeSpeaker == false) { session.muted_activeSpeaker = true; session.speakerMuted = true; clearTimeout(timer); toggleSpeakerMute(true); // okay, sicne this is quietOthers } } else if (session.muted_activeSpeaker == true) { session.speakerMuted = false; session.muted_activeSpeaker = false; session.activelySpeaking = false; clearTimeout(timer); timer = setTimeout(function () { toggleSpeakerMute(true); }, 250); // okay, sicne this is quietOthers } } if (session.pushLoudness == true) { var loudnessObj = {}; loudnessObj[session.streamID] = parseInt(total); if (isIFrame) { parent.postMessage({ loudness: loudnessObj, action: "loudness", value: total }, session.iframetarget); } } if (session.noisegate) { if (total <= ng2) { if (currentlyActive == ng3) { changeGatingGain(ng1, 200); // set volume to 40% relative to what it is now. log("GAIN LOWERED"); currentlyActive = ng3 + 1; } else if (currentlyActive < ng3) { currentlyActive += 1; } } else if (currentlyActive == ng3 + 1) { changeGatingGain(100, 200); currentlyActive = 0; log("GAIN INCREASED"); } else { currentlyActive = 0; } } if (meter1) { if (document.getElementById("meter1")) { if (total == 0) { meter1.style.width = "1px"; meter2.style.width = "0px"; } else if (total <= 1) { meter1.style.width = "1px"; meter2.style.width = "0px"; } else if (total <= 150) { meter1.style.width = total + "px"; meter2.style.width = "0px"; } else if (total > 150) { if (total > 200) { total = 200; } meter1.style.width = "150px"; meter2.style.width = total - 150 + "px"; } } else { meter1 = false; } if (session.audioGain !== false) { if (document.getElementById("previewWebcam")) { changeMainGain(100); // full volume while in preview mode } else { changeMainGain(session.audioGain); } } return; } else if (toggleSettingsState && document.getElementById("meter3")) { if (total == 0) { meter3.style.width = "1px"; meter4.style.width = "0px"; } else if (total <= 1) { meter3.style.width = "1px"; meter4.style.width = "0px"; } else if (total <= 150) { meter3.style.width = total + "px"; meter4.style.width = "0px"; } else if (total > 150) { if (total > 200) { meter3.style.width = "150px"; meter4.style.width = "50px"; } else { meter3.style.width = "150px"; meter4.style.width = total - 150 + "px"; } } if (document.getElementById("mutetoggle")) { total *= 3; if (total > 255) { total = 255; } total = parseInt(total); document.getElementById("mutetoggle").style.color = "rgb(" + (255 - total) + ",255," + (255 - total) + ")"; } meter1 = false; return; } else if (session.cleanOutput) { meter1 = false; return; } else if (document.getElementById("mutetoggle")) { total *= 3; if (total > 255) { total = 255; } total = parseInt(total); document.getElementById("mutetoggle").style.color = "rgb(" + (255 - total) + ",255," + (255 - total) + ")"; } else { clearInterval(analyser.interval); warnlog("METERS NOT FOUND"); } meter1 = false; } catch (e) { errorlog(e); } } analyser.interval = setInterval(function () { draw(); }, 100); return analyser; } function audioCompressor(mediaStreamSource, audioContext) { var compressor = audioContext.createDynamicsCompressor(); compressor.threshold.value = -40; compressor.knee.value = 10; compressor.ratio.value = 4; // 3 compressor.attack.value = 0.002; // 0.001 compressor.release.value = 0.1; // 0.06 mediaStreamSource.connect(compressor); return compressor; } function audioLimiter(mediaStreamSource, audioContext) { var compressor = audioContext.createDynamicsCompressor(); compressor.threshold.value = -5; compressor.knee.value = 0; compressor.ratio.value = 20.0; // 1 to 20 compressor.attack.value = 0.001; compressor.release.value = 0.1; mediaStreamSource.connect(compressor); return compressor; } function activeSpeaker(border = false) { var lastActiveSpeaker = null; var someoneElseIfSpeaking = false; var anyoneIsSpeaking = 0; var defaultSpeaker = false; var anyVideoAvailable = false; // Track if any video streams are available at all var changed = false; // First pass: check if any video is available for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { anyVideoAvailable = true; break; } } for (var UUID in session.rpcs) { if (session.scene) { let pass = checkMuteState(UUID); // If no one is visible and this person has video, show them immediately if (pass && !anyoneIsSpeaking && !defaultSpeaker && anyVideoAvailable === false && session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { session.rpcs[UUID].defaultSpeaker = true; defaultSpeaker = true; anyVideoAvailable = true; changed = true; continue; } else if (pass) { session.rpcs[UUID].activelySpeaking = false; if (session.rpcs[UUID].defaultSpeaker && session.rpcs[UUID].defaultSpeaker !== true) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } session.rpcs[UUID].defaultSpeaker = false; continue; } } if (session.activeSpeaker > 2 && !(session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted)) { session.rpcs[UUID].activelySpeaking = false; // we're not showing audio-only sources in this mode. if (session.rpcs[UUID].defaultSpeaker && session.rpcs[UUID].defaultSpeaker !== true) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } session.rpcs[UUID].defaultSpeaker = false; continue; } if (session.rpcs[UUID].stats._Audio_Loudness_average) { if (session.rpcs[UUID].stats.Audio_Loudness && session.rpcs[UUID].stats.Audio_Loudness > 10) { session.rpcs[UUID].stats._Audio_Loudness_average = parseFloat(session.rpcs[UUID].stats.Audio_Loudness * 0.07 + session.rpcs[UUID].stats._Audio_Loudness_average * 0.93); } else { session.rpcs[UUID].stats._Audio_Loudness_average = parseFloat(session.rpcs[UUID].stats._Audio_Loudness_average * 0.975); } } else { session.rpcs[UUID].stats._Audio_Loudness_average = 1; } if (session.rpcs[UUID].stats._Audio_Loudness_average > 13) { if (border) { if (session.rpcs[UUID].videoElement) { session.rpcs[UUID].videoElement.style.border = "green solid 1px"; session.rpcs[UUID].videoElement.style.padding = "0"; } } else if (!session.rpcs[UUID].activelySpeaking) { session.rpcs[UUID].activelySpeaking = true; lastActiveSpeaker = UUID; session.rpcs[UUID].stats._Audio_Loudness_average += 50; } } else if (session.rpcs[UUID].stats._Audio_Loudness_average > 6) { // } else { if (border) { if (session.rpcs[UUID].videoElement) { session.rpcs[UUID].videoElement.style.border = ""; session.rpcs[UUID].videoElement.style.padding = "1px"; } } else if (session.rpcs[UUID].activelySpeaking) { session.rpcs[UUID].activelySpeaking = false; lastActiveSpeaker = UUID; } } if (session.rpcs[UUID].stats.Audio_Loudness > 13 || (session.rpcs[UUID].stats.Audio_Loudness > 5 && session.rpcs[UUID].stats._Audio_Loudness_average > 3) || session.rpcs[UUID].stats._Audio_Loudness_average > 6) { someoneElseIfSpeaking = true; } if (session.rpcs[UUID].activelySpeaking) { anyoneIsSpeaking += 1; } if (session.rpcs[UUID].defaultSpeaker === true) { defaultSpeaker = true; } } var loudest = null; var loudestActive = null; if (session.activeSpeaker === 1 || session.activeSpeaker === 3) { // will only show one speaker at a time; the loudest or last-loud speaker if (!anyoneIsSpeaking) { if (defaultSpeaker) { // already good to go. } else if (lastActiveSpeaker) { if (session.rpcs[lastActiveSpeaker].defaultSpeaker !== false) { clearTimeout(session.rpcs[lastActiveSpeaker].defaultSpeaker); } else { changed = true; log("lastActiveSpeaker is default"); } session.rpcs[lastActiveSpeaker].defaultSpeaker = true; } else if (session.scene === false || (session.nopreview === false && session.minipreview !== 1)) { // we don't need to care. } else if (anyVideoAvailable === false) { // Immediately select the first available video source if no one is currently visible for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now (no lull)"); } session.rpcs[UUID].defaultSpeaker = true; break; } } // Fall through to original logic if needed if (!changed) { for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now"); } session.rpcs[UUID].defaultSpeaker = true; break; } } if (!changed && session.activeSpeaker <= 2) { // switch to streams that have no video track for (var UUID in session.rpcs) { if (session.rpcs[UUID].label) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now"); } session.rpcs[UUID].defaultSpeaker = true; break; } else if (!changed) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now"); } session.rpcs[UUID].defaultSpeaker = true; } } } } } else { for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now"); } session.rpcs[UUID].defaultSpeaker = true; break; } } if (!changed && session.activeSpeaker <= 2) { // switch to streams that have no video track for (var UUID in session.rpcs) { if (session.rpcs[UUID].label) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now"); } session.rpcs[UUID].defaultSpeaker = true; break; } else if (!changed) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now"); } session.rpcs[UUID].defaultSpeaker = true; } } } } } else { for (var UUID in session.rpcs) { if (!("_Audio_Loudness_average" in session.rpcs[UUID].stats)) { // never could have been loudest, since no loudness value. continue; } if (session.rpcs[UUID].activelySpeaking) { if (!loudestActive) { loudestActive = UUID; } else if (session.rpcs[UUID].stats._Audio_Loudness_average > session.rpcs[loudestActive].stats._Audio_Loudness_average) { if (session.rpcs[loudestActive].defaultSpeaker === true) { if (!session.activeSpeakerTimeout) { session.rpcs[loudestActive].defaultSpeaker = false; changed = true; log(loudestActive + " is loudest but not speaker anymore"); } else { session.rpcs[loudestActive].defaultSpeaker = setTimeout( function (uuid) { session.rpcs[uuid].defaultSpeaker = false; updateMixer(); }, session.activeSpeakerTimeout, loudestActive ); } } loudestActive = UUID; } else if (session.rpcs[UUID].defaultSpeaker === true) { if (!session.activeSpeakerTimeout) { session.rpcs[UUID].defaultSpeaker = false; changed = true; log(UUID + " is not speaker anymore"); } else { session.rpcs[UUID].defaultSpeaker = setTimeout( function (uuid) { session.rpcs[uuid].defaultSpeaker = false; updateMixer(); }, session.activeSpeakerTimeout, UUID ); } } } else if (session.rpcs[UUID].defaultSpeaker === true) { if (!session.activeSpeakerTimeout) { session.rpcs[UUID].defaultSpeaker = false; changed = true; log(UUID + " is not speaker anymore"); } else { session.rpcs[UUID].defaultSpeaker = setTimeout( function (uuid) { session.rpcs[uuid].defaultSpeaker = false; updateMixer(); }, session.activeSpeakerTimeout, UUID ); } } } if (loudestActive && session.rpcs[loudestActive].defaultSpeaker !== true) { if (session.rpcs[loudestActive].defaultSpeaker) { clearTimeout(session.rpcs[loudestActive].defaultSpeaker); } else { changed = true; } for (let UUID in session.rpcs) { if (loudestActive !== UUID) { if (session.rpcs[UUID].defaultSpeaker === true) { session.rpcs[UUID].defaultSpeaker = false; // Reset immediately before any new logic changed = true; } else if (session.rpcs[UUID].defaultSpeaker) { clearTimeout(session.rpcs[UUID].defaultSpeaker); session.rpcs[UUID].defaultSpeaker = false; } } } session.rpcs[loudestActive].defaultSpeaker = true; } } } else if (session.activeSpeaker === 2 || session.activeSpeaker === 4) { // will show whoever is talking; mixed together; if no one is talking, just shows yourself if (!anyoneIsSpeaking) { if (defaultSpeaker) { // already good to go. } else if (lastActiveSpeaker) { if (session.rpcs[lastActiveSpeaker].defaultSpeaker !== false) { clearTimeout(session.rpcs[lastActiveSpeaker].defaultSpeaker); } else { changed = true; } session.rpcs[lastActiveSpeaker].defaultSpeaker = true; } else if (session.scene === false || (session.nopreview === false && session.minipreview !== 1)) { // we don't need to care. } else if (anyVideoAvailable === false) { // Immediately select the first available video source if no one is currently visible for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; log(UUID + " is speaker now (no lull)"); } session.rpcs[UUID].defaultSpeaker = true; break; } } // Fall through to original logic if needed if (!changed) { for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; } session.rpcs[UUID].defaultSpeaker = true; break; } } if (!changed && session.activeSpeaker <= 2) { for (var UUID in session.rpcs) { if (session.rpcs[UUID].label) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; } session.rpcs[UUID].defaultSpeaker = true; break; } else if (!changed) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; } session.rpcs[UUID].defaultSpeaker = true; } } } } } else { for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.srcObject && session.rpcs[UUID].videoElement.srcObject.getVideoTracks().length && !session.rpcs[UUID].videoMuted) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; } session.rpcs[UUID].defaultSpeaker = true; break; } } if (!changed && session.activeSpeaker <= 2) { for (var UUID in session.rpcs) { if (session.rpcs[UUID].label) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; } session.rpcs[UUID].defaultSpeaker = true; break; } else if (!changed) { if (session.rpcs[UUID].defaultSpeaker !== false) { clearTimeout(session.rpcs[UUID].defaultSpeaker); } else { changed = true; } session.rpcs[UUID].defaultSpeaker = true; } } } } } else { for (var UUID in session.rpcs) { if (session.rpcs[UUID].activelySpeaking && !session.rpcs[UUID].defaultSpeaker) { session.rpcs[UUID].defaultSpeaker = true; changed = true; } else if (!session.rpcs[UUID].activelySpeaking && session.rpcs[UUID].defaultSpeaker) { if (!session.activeSpeakerTimeout) { session.rpcs[UUID].defaultSpeaker = false; changed = true; } else if (session.rpcs[UUID].defaultSpeaker === true) { session.rpcs[UUID].defaultSpeaker = setTimeout( function (uuid) { session.rpcs[uuid].defaultSpeaker = false; updateMixer(); }, session.activeSpeakerTimeout, UUID ); } } } } } if (session.quietOthers && session.quietOthers === 1) { if (someoneElseIfSpeaking) { if (session.muted_activeSpeaker == false) { session.muted_activeSpeaker = true; session.muted = true; toggleMute(true); } } else if (session.muted_activeSpeaker == true) { session.muted = false; session.muted_activeSpeaker = false; toggleMute(true); } } else if (session.quietOthers && session.quietOthers === 3) { // purely for fun. It's the opposite of a noise-gate I guess. if (someoneElseIfSpeaking) { if (session.muted_activeSpeaker == false) { session.muted_activeSpeaker = true; session.speakerMuted = true; toggleSpeakerMute(true); // okay, sicne this is quietOthers } } else if (session.muted_activeSpeaker == true) { session.speakerMuted = false; session.muted_activeSpeaker = false; toggleSpeakerMute(true); // okay, sicne this is quietOthers } } if (changed) { setTimeout(function () { updateMixer(); }, 0); } } function randomizeArray(unshuffled) { var arr = unshuffled .map(a => ({ sort: Math.random(), value: a })) .sort((a, b) => a.sort - b.sort) .map(a => a.value); // shuffle once for (var i = arr.length - 1; i > 0; i--) { // shuffle twice var j = Math.floor(Math.random() * (i + 1)); var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } return arr; } async function joinRoom(roomname) { if (roomname.length) { roomname = sanitizeRoomName(roomname); log("Join room: " + roomname); // In auth mode, use auth-aware room joining if (session.authMode && window.vdoAuth) { const hasAccess = await window.vdoAuth.joinRoom(roomname); if (!hasAccess) { return; // Access denied or auth required } // Room ID might have changed if it was an alias roomname = session.roomid; } updateVolume(false); // chance of a race condition, but unlikely and not a big deal if so. session.joinRoom(roomname).then( function (response) { // callback from server; we've joined the room. Just the listing is returned if (session.joiningRoom === "seedPlz") { // allow us to seed, now that we have joined the room. session.joiningRoom = false; // joined session.seedStream(); } else { session.joiningRoom = false; // no seeding callback } // Create universal token for directors in auth mode if (session.director && session.authMode && session.authToken && !session.universalViewToken) { vdoAuth.createUniversalToken().then(() => { if (session.universalViewToken) { updateAllSoloLinks(); } }); } // Apply any pending room settings selected pre-join (access mode, allowlist) if (session.director && session.authMode && window.vdoAuth && session.authToken && session.pendingRoomSettings) { try { window.vdoAuth.updateRoomSettings(session.realRoomId || session.roomid, session.pendingRoomSettings); } catch (e) { console.error(e); } // Clear once applied session.pendingRoomSettings = null; } var token = ""; if (session.token) { token += "&token=" + session.token; } if (!session.cleanOutput) { if (session.roomhost) { if (session.defaultPassword === false) { if (session.password === false) { var invite = "https://" + location.host + location.pathname + "?room=" + session.roomid + "&password=false" + token; warnUser("You can invite others with:\n\n" + invite + "", false, false); } else { generateHash(session.password + session.salt, 4).then(function (hash) { // change the hash length from 4 to 3 when VDO.Ninja v24.10 or newer is in production. var invite = "https://" + location.host + location.pathname + "?room=" + session.roomid + "&hash=" + hash + token; warnUser("You can invite others with:\n\n" + invite + "", false, false); }); } } else { var invite = "https://" + location.host + location.pathname + "?room=" + session.roomid + token; warnUser("You can invite others with:\n\n" + invite + "", false, false); } } } log("Members in Room"); log(response); if (session.randomize === true) { response = randomizeArray(response); log("Randomized List of Viewers"); log(response); for (var i in response) { if ("UUID" in response[i]) { if (response[i].streamID) { if (response[i].UUID in session.rpcs) { log("RTC already connected"); /// lets just say instead of Stream, we have } else { log(response[i].streamID); var streamID = session.desaltStreamID(response[i].streamID); if (session.queue) { if (session.directorList.indexOf(response[i].UUID) >= 0) { // Only queueType 2 (&screen) sees director immediately. // queueType 3 (&hold) and 4 (&holdwithvideo) are fully isolated // from the director until activated. if (session.queueType == 2) { warnlog("PLAYING DIRECTOR"); play(streamID, response[i].UUID); } } else if (session.view_set && session.view_set.includes(streamID)) { play(streamID, response[i].UUID); } else if (session.queueList.length < 5000) { if (!(streamID in session.watchTimeoutList) && !session.queueList.includes(streamID)) { session.queueList.push(streamID); } } } else { log("STREAM ID DESALTED 3: " + streamID); setTimeout( function (sid) { play(sid); }, Math.floor(Math.random() * 100), streamID ); // add some furtherchance with up to 100ms added latency } } } } } } else { for (var i in response) { if ("UUID" in response[i]) { if (response[i].streamID) { if (response[i].UUID in session.rpcs) { log("RTC already connected"); /// lets just say instead of Stream, we have } else { log(response[i].streamID); var streamID = session.desaltStreamID(response[i].streamID); if (session.queue) { if (session.directorList.indexOf(response[i].UUID) >= 0) { // Only queueType 2 (&screen) sees director immediately. // queueType 3 (&hold) and 4 (&holdwithvideo) are fully isolated // from the director until activated. if (session.queueType == 2) { play(streamID, response[i].UUID); } } else if (session.view_set && session.view_set.includes(streamID)) { play(streamID, response[i].UUID); } else if (session.queueList.length < 5000) { if (!(streamID in session.watchTimeoutList) && !session.queueList.includes(streamID)) { session.queueList.push(streamID); } } } else { log("STREAM ID DESALTED 4: " + streamID); play(streamID, response[i].UUID); // play handles the group room mechanics here } } } } } } updateQueue(); pokeIframeAPI("joined-room-complete"); if (session.include.length) { // we want to request what hasn't been requested already, since we are joining a room. session.include.forEach(sid => { if (sid in session.waitingWatchList) { return; } else { session.watchStream(sid); } }); } }, function (error) { return {}; } ); } else { log("Room name not long enough or contained all bad characaters"); } } async function createRoom(roomname = false, reload = false) { if (reload === true) { let oldDirectorSettings = getStorage("directorOtherSettings"); var passwordRoom = oldDirectorSettings.password; if (passwordRoom === session.defaultPassword) { passwordRoom = ""; } else if (passwordRoom === false) { passwordRoom = ""; session.password = false; } roomname = oldDirectorSettings.roomid; if (!roomname) { warnUser("Couldn't load previous session"); return; } if (urlParams.has("dir")) { updateURL("dir=" + roomname, true, false); // make the link reloadable. } else { updateURL("director=" + roomname, true, false); // make the link reloadable. } session.codecGroupFlag = session.codecGroupFlag || oldDirectorSettings.codecGroupFlag || session.codecGroupFlag; session.label = session.label || oldDirectorSettings.label || session.label; session.codecGroupFlag = session.codecGroupFlag || oldDirectorSettings.codecGroupFlag || session.codecGroupFlag; session.showDirector = session.showDirector || oldDirectorSettings.showDirector || session.showDirector; if (oldDirectorSettings.broadcast) { getById("broadcastFlag").checked = true; } if (session.showDirector) { getById("showdirectorFlag").checked = true; } } else { if (roomname == false) { roomname = getById("videoname1").value; roomname = sanitizeRoomName(roomname); clearDirectorSettings(); if (roomname.length != 0) { if (urlParams.has("dir")) { updateURL("dir=" + roomname, true, false); // make the link reloadable. } else { updateURL("director=" + roomname, true, false); // make the link reloadable. } } } if (roomname.length == 0) { //if (!(session.cleanOutput)) { // warnUser("Please enter a room name before continuing"); //} getById("videoname1").focus(); getById("videoname1").classList.remove("shake"); setTimeout(function () { getById("videoname1").classList.add("shake"); }, 10); return; } log(roomname); var passwordRoom = document.getElementById("passwordRoom") ? sanitizePassword(document.getElementById("passwordRoom").value) : ""; // Pre-join SSO room setup (optional) try { var ssoBox = getById('useSSOForRoom'); if (ssoBox && ssoBox.checked) { // Enable auth mode for this room session.authMode = true; // Director should sign in before managing the room // Note: join gating handled by vdoAuth.joinRoom in joinRoom() // Capture desired access mode to apply after join var selected = document.querySelector('input[name="ssoAccessMode"]:checked'); var accessMode = (selected && selected.value) ? selected.value : 'public'; var allowlist = []; if (accessMode === 'allowlist') { var csv = (getById('preAllowlistCSV') && getById('preAllowlistCSV').value) ? getById('preAllowlistCSV').value : ''; if (csv) { allowlist = csv.split(',').map(x => x.trim()).filter(x => x.length > 0); } } // Store to apply after join session.pendingRoomSettings = { accessMode: accessMode, allowlist: allowlist }; // If guests must sign in (authenticated/allowlist), mark as requireAuth for UX if (accessMode === 'authenticated' || accessMode === 'allowlist') { session.requireAuth = true; } } } catch (e) { } } session.roomid = roomname; getById("dirroomid").innerHTML = decodeURIComponent(session.roomid); getById("roomid").innerHTML = session.roomid; var passAdd = ""; var passAdd2 = ""; if (passwordRoom.length) { session.password = passwordRoom; session.defaultPassword = false; if (session.password === "false" || session.password === "0" || session.password === "off") { session.password = false; if (urlParams.has("pass")) { updateURL("pass=0"); passAdd = "&pass=0"; passAdd2 = "&pass=0"; } else if (urlParams.has("pw")) { updateURL("pw=0"); passAdd = "&pw=0"; passAdd2 = "&pw=0"; } else if (urlParams.has("p")) { updateURL("p=0"); passAdd = "&p=0"; passAdd2 = "&p=0"; } else if (urlParams.has("password")) { updateURL("password=false"); passAdd = "&password=false"; passAdd2 = "&password=false"; } else { updateURL("p=0"); passAdd = "&p=0"; passAdd2 = "&p=0"; } } else { if (urlParams.has("pass")) { updateURL("pass=" + session.password); } else if (urlParams.has("pw")) { updateURL("pw=" + session.password); } else if (urlParams.has("p")) { updateURL("p=" + session.password); } else { updateURL("password=" + session.password); } } } await registerToken(); if (session.defaultPassword === false && session.password) { passAdd2 = "&password=" + session.password; return generateHash(session.password + session.salt, 4) .then(async function (hash) { passAdd = "&hash=" + hash; await createRoomCallback(passAdd, passAdd2); }) .catch(errorlog); } else if (session.defaultPassword === false && session.password === false) { passAdd = "&p=0"; passAdd2 = "&p=0"; await createRoomCallback(passAdd, passAdd2); } else { await createRoomCallback(passAdd, passAdd2); } } function copyVideoFrameToClipboard(videoElement, e = false) { try { var canvas = document.createElement("canvas"); canvas.width = videoElement.videoWidth; canvas.height = videoElement.videoHeight; var ctx = canvas.getContext("2d"); ctx.drawImage(videoElement, 0, 0); var img = new Image(); img.src = canvas.toDataURL(); canvas.toBlob(function (blob) { navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); }, "image/png"); popupMessage(e, "Frame copied to clipboard as as PNG Image"); } catch (e) { errorlog(e); } } function saveVideoFrameToDisk(videoElement, e = false, filename = false) { try { var canvas = document.createElement("canvas"); canvas.width = videoElement.videoWidth; canvas.height = videoElement.videoHeight; var ctx = canvas.getContext("2d"); ctx.drawImage(videoElement, 0, 0); var img = new Image(); img.src = canvas.toDataURL(); canvas.toBlob(function (blob) { var link = document.createElement("a"); if (filename) { link.download = filename; } else if (e) { link.download = (videoElement.id || "video") + "_" + parseInt(performance.now()) + ".png"; } else { link.download = (videoElement.id || "video") + "_" + parseInt(Date.now()) + ".png"; } link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); }, "image/png"); if (e) { popupMessage(e, "Saving current frame to disk"); } } catch (e) { errorlog(e); } } function sendVideoFrameToIframe(videoElement, e = false, request = {}) { try { var canvas = document.createElement("canvas"); canvas.width = videoElement.videoWidth; canvas.height = videoElement.videoHeight; var ctx = canvas.getContext("2d"); ctx.drawImage(videoElement, 0, 0); var img = new Image(); img.src = canvas.toDataURL(); canvas.toBlob(function (blob) { var response = {}; response.imageData = blob; response.imageType = "png"; if (request.streamID) { response.streamID = request.streamID; } if (request.UUID) { response.UUID = request.UUID; } if (request.cib) { response.cib = request.cib; } if (videoElement.id) { response.videoID = videoElement.id; } pokeIframeAPI("image-frame-capture", response); }, "image/png"); } catch (e) { errorlog(e); } } function isLivePeerConnection(pc) { if (!pc) { return false; } var state = pc.connectionState || pc.iceConnectionState || ""; if (!state) { return true; } state = state.toLowerCase(); return !(state === "failed" || state === "disconnected" || state === "closed"); } async function checkDirectorStreamID() { if (session.directorStreamID) { for (var UUID in session.rpcs) { if (!isLivePeerConnection(session.rpcs[UUID])) { continue; } if (session.rpcs[UUID].streamID) { var hashedSID = await generateHash(session.rpcs[UUID].streamID); if (hashedSID === session.directorStreamID) { session.directorUUID = UUID; // main director session.directorList = []; session.directorList.push(UUID); // approved co/directors session.directorUUID = UUID; session.newMainDirectorSetup(); return; } } } for (var UUID in session.pcs) { if (!isLivePeerConnection(session.pcs[UUID])) { continue; } if (session.pcs[UUID].streamID) { var hashedSID = await generateHash(session.pcs[UUID].streamID); if (hashedSID === session.directorStreamID) { session.directorList = []; session.directorList.push(UUID); session.directorUUID = UUID; session.newMainDirectorSetup(); return; } } } if (session.streamID == session.directorStreamID) { session.directorState = true; session.directorUUID = false; pokeAPI("director", true); pokeIframeAPI("director", true); warnlog("You are joining with a token, but are the director?"); } session.directorList = []; } } async function checkToken() { // this lets us use a server+password validation method for the director. if (!session.token) { return; } if (!session.roomid) { return; } if (session.mainDirectorPassword) { return; } try { var request = new XMLHttpRequest(); var hashedRoom = session.roomid; if (session.password) { hashedRoom += session.password; } hashedRoom += "i^4&u#Fz5Eu#MsK^chF5*XAEYi1g"; hashedRoom = await generateHash(hashedRoom); hashedRoom = hashedRoom.slice(0, 50); request.open("GET", "https://tokens.vdo.ninja/?token=" + session.token + "&room=" + hashedRoom, false); request.send(null); if (request.status === 200) { try { var result = JSON.parse(request.responseText); if ("UUID" in result) { session.directorUUID = result.UUID; session.directorList = []; session.directorList.push(session.directorUUID); session.directorStreamID = false; session.newMainDirectorSetup(); } else if ("streamID" in result) { session.directorStreamID = result.streamID; checkDirectorStreamID(); } } catch (e) { session.directorUUID = false; session.directorStreamID = false; session.directorList = []; errorlog(e); } } else { session.directorUUID = false; session.directorStreamID = false; session.directorList = []; errorlog("Didn't get a token response"); } } catch (e) { errorlog(e); } } async function registerToken() { // this lets us use a server+password validation method for the director. if (!session.roomid) { return; } if (!session.streamID) { return; } if (!session.mainDirectorPassword) { return; } var longToken = session.mainDirectorPassword + "3wJVW^5qYU4DxGi6VhxN6RF04Q%$"; // this lets us use the same token across multiple rooms var hashedToken = await generateHash(longToken); // keep it anonymous hashedToken = hashedToken.slice(0, 50); var hashedRoom = session.roomid; if (session.password) { hashedRoom += session.password; } hashedRoom += "i^4&u#Fz5Eu#MsK^chF5*XAEYi1g"; hashedRoom = await generateHash(hashedRoom); hashedRoom = hashedRoom.slice(0, 50); var data2send = {}; var hashedSID = await generateHash(session.streamID); data2send.streamID = hashedSID; // not sure if there's a way around this. data2send = JSON.stringify(data2send); var request = new XMLHttpRequest(); request.open("POST", "https://tokens.vdo.ninja/?token=" + hashedToken + "&room=" + hashedRoom, false); console.log("https://tokens.vdo.ninja/?token=" + hashedToken + "&room=" + hashedRoom); request.send(data2send); if (request.status === 200) { try { if (request.responseText && request.responseText.length === 16) { session.token = request.responseText; console.log("share token: " + session.token); session.directorState = true; pokeAPI("director", true); pokeIframeAPI("director", true); } } catch (e) { session.directorState = false; pokeAPI("director", false); pokeIframeAPI("director", false); } } else { session.directorState = false; pokeAPI("director", false); pokeIframeAPI("director", false); } } function hideDirectorinvites(ele, skip = true) { if (getById("directorLinks2").style.display == "none") { ele.innerHTML = ' LINKS (GUEST INVITES & SCENES)'; getById("directorLinks2").style.display = "inline-block"; getById("customizeLinks").classList.remove("hidden"); } else { ele.innerHTML = ' LINKS (GUEST INVITES & SCENES)'; getById("directorLinks2").style.display = "none"; getById("help_directors_room").style.display = "none"; getById("roomnotes2").style.display = "none"; getById("customizeLinks").classList.add("hidden"); } if (getById("directorLinks1").style.display == "none") { getById("directorLinks1").style.display = "inline-block"; getById("customizeLinks").classList.remove("hidden"); } else { getById("directorLinks1").style.display = "none"; getById("help_directors_room").style.display = "none"; getById("roomnotes2").style.display = "none"; getById("customizeLinks").classList.add("hidden"); } if (skip) { saveDirectorSettings(); } } function toggleCoDirector_changeurl(ele) { session.codirector_changeURL = ele.checked; // doesn't do anything yet though. } function toggleCoDirector_transfer(ele) { session.codirector_transfer = ele.checked; } function updateConfirmAlt(context, inputText) { try { if (!context) { return; } var ctx = ("" + context).replace(/["<>]/g, ""); var modal = document.querySelector('.promptModal[data-context="' + ctx + '"]'); if (!modal) { return; } var text = "" + ("" + inputText).replace("\n", "
    ") + ""; text = text.replace(/\n/g, "
    "); var msg = modal.querySelector('.promptModalMessage'); if (msg) { msg.innerHTML = text; } } catch (e) { /* noop */ } } function toggleCoDirector_approve(ele) { // UI label: "Allow co-directors to approve held guests" // Checked means approvals allowed; unchecked means disabled return; } // Route approvals are default; no UI toggle needed anymore. function toggleApprovalPopup(ele) { session.approval_popup = ele.checked; try { var token = ""; if (session.token) { token += "&token=" + session.token; } var url = "https://" + location.host + location.pathname + "?dir=" + session.roomid + "&codirector=" + session.directorPassword + token; if (session.approval_popup) { url += "&approvepopup"; } try { console.log("[flags] toggled approval_popup=" + session.approval_popup + "; co-director invite=" + url); } catch (e) { } if (session.password !== session.sitePassword) { if (session.password === false) { url += "&password=false"; } else { url += "&password=" + session.password; } } if (getById("codirectorSettings_invite")) { getById("codirectorSettings_invite").value = url; } } catch (e) { /* noop */ } } async function toggleCoDirector(ele) { //session.coDirectorAllowed = ele.checked; if (!ele.checked) { getById("codirectorSettings").style.display = "none"; return; } if (!session.directorPassword) { session.directorPassword = await promptAlt(getTranslation("enter-new-codirector-password"), false); if (!session.directorPassword) { session.directorPassword = false; ele.checked = false; return; } session.directorPassword = sanitizePassword(session.directorPassword); } updateURL("codirector=" + session.directorPassword, true, false); getById("coDirectorEnableSpan").style.display = "none"; await generateHash(session.directorPassword + session.salt + "abc123", 12) .then(function (hash) { // million to one error. log("dir room hash is " + hash); session.directorHash = hash; return; }) .catch(errorlog); if (session.codirector_transfer) { getById("codirectorSettings_transfer").checked = true; } else { getById("codirectorSettings_transfer").checked = false; } if (session.codirector_changeURL) { getById("codirectorSettings_changeurl").checked = true; } else { getById(codirectorSettings_changeurl).checked = false; } var token = ""; if (session.token) { token += "&token=" + session.token; } getById("codirectorSettings_invite").value = "https://" + location.host + location.pathname + "?dir=" + session.roomid + "&codirector=" + session.directorPassword + token; if (session.approval_popup) { getById("codirectorSettings_invite").value += "&approvepopup"; } if (session.password !== session.sitePassword) { if (session.password === false) { getById("codirectorSettings_invite").value += "&password=false"; } else { getById("codirectorSettings_invite").value += "&password=" + session.password; } } getById("codirectorSettings").style.display = "block"; } function getParentHostname() { const parentUrl = document.referrer; if (parentUrl) { const url = new URL(parentUrl); return url.hostname; } return null; } async function toggleWidgetURL(ele) { if (ele.id === "widgetURL") { ele = getById("widgetURCheck"); } else if (!ele.checked) { getById("widgetURL").classList.add("hidden"); session.widget = false; var data = {}; data.widgetSrc = false; for (var UUID in session.pcs) { if (session.pcs[UUID].allowWidget === true) { session.sendMessage(data, UUID); } } if (session.director) { let widget = document.getElementById("widget"); if (widget) { getById("widget").remove(); getById("directorlayout").classList.remove("widget"); getById("directorlayout").classList.remove("left"); } } pokeIframeAPI("widget-src", session.widget); return; } var widget = await promptAlt(getTranslation("enter-url-for-widget"), false, false, session.widget); if (widget !== null) { session.widget = widget; } if (session.widget) { getById("widgetURL").value = session.widget; getById("widgetURL").classList.remove("hidden"); updateMixer(); } else { session.widget = false; getById("widgetURL").classList.add("hidden"); ele.checked = false; } var data = {}; data.widgetSrc = session.widget; for (var UUID in session.pcs) { if (session.pcs[UUID].allowWidget === true) { session.sendMessage(data, UUID); } } if (session.director) { let widget = document.getElementById("widget"); if (!widget && session.widget && session.iFramesAllowed) { widget = document.createElement("iframe"); widget.id = "widget"; widget = loadIframe(parseURL4Iframe(session.widget), widget); if (widget) { getById("directorlayout").classList.add("widget"); if (session.widgetleft) { widget.classList.add("left"); getById("directorlayout").classList.add("left"); } log(widget.src); document.body.appendChild(widget); } } else if (session.widget && widget && session.iFramesAllowed) { loadIframe(parseURL4Iframe(session.widget), widget); } else if (widget) { getById("widget").remove(); getById("directorlayout").classList.remove("widget"); getById("directorlayout").classList.remove("left"); } } pokeIframeAPI("widget-src", session.widget); } async function createRoomCallback(passAdd, passAdd2) { if (session.meshcast) { if (!session.cleanOutput && !session.cleanDirector) { document.getElementById("meshcastMenu").classList.remove("hidden"); } } if (!session.switchMode) { getById("directorlayout").classList.remove("hidden"); getById("gridlayout").classList.add("hidden"); } var broadcastFlag = getById("broadcastFlag"); try { if (broadcastFlag.checked) { broadcastFlag = true; } else { broadcastFlag = false; } } catch (e) { broadcastFlag = false; } var broadcastString = ""; if (broadcastFlag) { broadcastString = "&broadcast"; getById("broadcastSlider").checked = true; //customizeLinks1 //saveDirectorSettings } var wss = ""; if (session.wssSetViaUrl) { if (session.customWSS && session.customWSS !== true) { wss = "&pie=" + session.customWSS; } else if (session.customWSS == true) { wss = "&wss=" + session.wss; } else { wss = "&wss2=" + session.wss; } } var queue = ""; if (session.queue) { queue = "&queue"; getById("directorLinks2").style.opacity = "0.2"; getById("directorLinks2").style.pointerEvents = "none"; getById("directorLinks2").style.cursor = "not-allowed"; } var showdirectorFlag = getById("showdirectorFlag"); try { if (showdirectorFlag.checked) { showdirectorFlag = true; } else { showdirectorFlag = false; } } catch (e) { showdirectorFlag = false; } if (showdirectorFlag) { updateURL("showdirector", true, false); session.showDirector = session.showDirector || true; //getById("broadcastSlider").checked=true; } var codecGroupFlag = getById("codecGroupFlag"); if (session.codecGroupFlag) { codecGroupFlag = session.codecGroupFlag || ""; } else if (codecGroupFlag) { if (codecGroupFlag.value) { if (codecGroupFlag.value === "vp9") { codecGroupFlag = "&codec=vp9"; getById("codech264toggle").disabled = true; } else if (codecGroupFlag.value === "h264") { codecGroupFlag = "&codec=h264"; getById("codech264toggle").checked = true; } else if (codecGroupFlag.value === "vp8") { codecGroupFlag = "&codec=vp8"; getById("codech264toggle").disabled = true; } else if (codecGroupFlag.value === "av1") { codecGroupFlag = "&codec=av1"; getById("codech264toggle").disabled = true; } else { codecGroupFlag = ""; } } else { codecGroupFlag = ""; } session.codecGroupFlag = session.codecGroupFlag || codecGroupFlag || session.codecGroupFlag; } if (session.bitrateGroupFlag) { codecGroupFlag += session.bitrateGroupFlag; } stashRoomSession(broadcastFlag); formSubmitting = false; try { var m = getById("mainmenu"); m.remove(); document.querySelectorAll(".hidden2").forEach(ele2 => { ele2.classList.remove("hidden2"); }); } catch (e) { } getById("head1").className = "hidden"; getById("head2").className = "hidden"; getById("head4").className = ""; try { if (session.label === false) { document.title = "Control Room"; } } catch (e) { errorlog(e); } session.director = true; screensharesupport = false; if (session.meterStyle === false) { session.meterStyle = 1; // director specific style } if (session.signalMeter === null) { session.signalMeter = true; } if (session.batteryMeter === null) { session.batteryMeter = true; } if (session.directorPassword) { getById("coDirectorEnable").checked = true; getById("coDirectorEnableSpan").style.display = "none"; var token = ""; if (session.token) { token += "&token=" + session.token; } getById("codirectorSettings_invite").value = "https://" + location.host + location.pathname + "?dir=" + session.roomid + "&codirector=" + session.directorPassword + token; if (session.approval_popup) { getById("codirectorSettings_invite").value += "&approvepopup"; } if (session.password !== session.sitePassword) { if (session.password == false) { getById("codirectorSettings_invite").value += "&password=false"; } else { getById("codirectorSettings_invite").value += "&password=" + session.password; } } if (session.codirector_transfer) { getById("codirectorSettings_transfer").checked = true; } else { getById("codirectorSettings_transfer").checked = false; } if (session.codirector_changeURL) { getById("codirectorSettings_changeurl").checked = true; } else { getById("codirectorSettings_changeurl").checked = false; } getById("codirectorSettings").style.display = "block"; } window.onresize = updateMixer; window.onorientationchange = function () { if (Firefox) { updateForceRotate(true); } setTimeout(async function () { if (session.forceAspectRatio) { await updateCameraConstraints("aspectRatio", session.forceAspectRatio); } if (session.effect && session.effect === "7") { digitalZoom(); } updateForceRotate(); updateMixer(); }, 200); }; getById("reshare").parentNode.removeChild(getById("reshare")); //getById("mutespeakerbutton").style.display = null; if (session.speakerMuted_default === false) { //session.speakerMuted = false; // the director will start with audio playback muted. toggleSpeakerMute(true); // let it be what it is. } else { session.speakerMuted = true; // the director will start with audio playback muted. toggleSpeakerMute(true); // okay since only run on start } var token = ""; if (session.token) { token += "&token=" + session.token; } // Add auth parameters if in auth mode var authParams = ""; if (session.authMode) { authParams = "&auth=true"; // Create universal token for scene links if we're authenticated if (session.authToken && !session.universalViewToken) { vdoAuth.createUniversalToken().then(() => { // Update all links once token is created if (session.universalViewToken) { // Update scene link with universal token var sceneAuthParams = "&universaltoken=" + session.universalViewToken; getById("director_block_3").dataset.raw = "https://" + location.host + location.pathname + "?scene&room=" + session.roomid + codecGroupFlag + passAdd2 + wss + token + sceneAuthParams; getById("director_block_3").href = "https://" + location.host + location.pathname + "?scene&room=" + session.roomid + codecGroupFlag + passAdd2 + wss + token + sceneAuthParams; getById("director_block_3").innerText = "https://" + location.host + location.pathname + "?scene&room=" + session.roomid + codecGroupFlag + passAdd2 + wss + token + sceneAuthParams; // Update all solo links updateAllSoloLinks(); } }); } } getById("director_block_1").dataset.raw = "https://" + location.host + location.pathname + "?room=" + session.roomid + broadcastString + passAdd + wss + queue + token + authParams; getById("director_block_1").href = "https://" + location.host + location.pathname + "?room=" + session.roomid + broadcastString + passAdd + wss + queue + token + authParams; getById("director_block_1").innerText = "https://" + location.host + location.pathname + "?room=" + session.roomid + broadcastString + passAdd + wss + queue + token + authParams; // For scene links, use universal token if available var sceneAuthParams = ""; if (session.authMode && session.universalViewToken) { sceneAuthParams = "&universaltoken=" + session.universalViewToken; } else if (session.authMode) { sceneAuthParams = authParams; } getById("director_block_3").dataset.raw = "https://" + location.host + location.pathname + "?scene&room=" + session.roomid + codecGroupFlag + passAdd2 + wss + token + sceneAuthParams; getById("director_block_3").href = "https://" + location.host + location.pathname + "?scene&room=" + session.roomid + codecGroupFlag + passAdd2 + wss + token + sceneAuthParams; getById("director_block_3").innerText = "https://" + location.host + location.pathname + "?scene&room=" + session.roomid + codecGroupFlag + passAdd2 + wss + token + sceneAuthParams; if (session.cleanDirector == false && session.cleanOutput == false) { getById("roomHeader").style.display = ""; //getById("directorLinks").style.display = ""; getById("directorLinks1").style.display = "inline-block"; getById("directorLinks2").style.display = "inline-block"; getById("calendarButton").style.display = "inline-block"; } else { getById("guestFeeds").innerHTML = ""; } getById("guestFeeds").style.display = ""; if (!session.cleanOutput) { if (session.queue) { getById("queuebutton").classList.remove("hidden"); } getById("chatbutton").classList.remove("hidden"); getById("sharefilebutton").classList.remove("hidden"); // we won't override "display:none", if set, though. getById("controlButtons").classList.remove("hidden"); // getById("legal").classList.remove("hidden"); getById("mutespeakerbutton").classList.remove("hidden"); getById("websitesharebutton").classList.remove("hidden"); //getById("screensharebutton").classList.remove("hidden"); if (session.totalRoomBitrate) { getById("roomsettingsbutton").classList.remove("hidden"); } if (!session.showDirector) { // if null or false, we want to show the solo link, since the director won't have their control box. The director will be visible in their solo link getById("miniPerformer").innerHTML = ''; //miniTranslate(getById("miniPerformer")); // Use soloLinkGenerator to get proper auth parameters var directorSoloLink = soloLinkGenerator(session.streamID, true); getById("grabDirectorSoloLink").dataset.raw = directorSoloLink; getById("grabDirectorSoloLink").href = directorSoloLink; getById("grabDirectorSoloLink").innerText = directorSoloLink; getById("grabDirectorSoloLinkParent").classList.remove("hidden"); } else { getById("miniPerformer").innerHTML = ''; } miniTranslate(getById("miniPerformer")); getById("miniPerformer").className = ""; var tabindex = 26; if (session.rooms && session.rooms.length > 0) { var container = getById("rooms"); container.innerHTML += 'Arm Transfer: '; session.rooms.forEach(function (r) { // if(session.roomid == r) return; //don't include self container.innerHTML += '"; tabindex++; }); } } else { getById("miniPerformer").style.display = "none"; getById("controlButtons").classList.add("hidden"); // getById("legal").classList.add("hidden"); } if (session.chatbutton === true) { getById("chatbutton").classList.remove("hidden"); getById("controlButtons").classList.remove("hidden"); } else if (session.chatbutton === false) { getById("chatbutton").classList.add("hidden"); } if (session.effect === false) { session.effect = null; // so the director can see the effects } getById("avatarDiv3").classList.remove("hidden"); // lets the director see the avatar option clearInterval(session.updateLocalStatsInterval); session.updateLocalStatsInterval = setInterval(function () { updateLocalStats(); }, session.statsInterval); var directorWebsiteShare = getStorage("directorWebsiteShare"); // {"website":session.iframeSrc, "roomid":session.roomid} if (typeof directorWebsiteShare === "object" && directorWebsiteShare !== null && "website" in directorWebsiteShare) { if (directorWebsiteShare.website == false) { clearDirectorSettings(); } else if (directorWebsiteShare.roomid && directorWebsiteShare.roomid == session.roomid) { session.iframeSrc = directorWebsiteShare.website; session.defaultIframeSrc = directorWebsiteShare.website; getById("websitesharebutton").classList.add("hidden"); getById("websitesharebutton2").classList.remove("hidden"); } } session.group.forEach(group => { // changeGroupDirectorAPI(group, state=null, update=true) changeGroupDirectorAPI(group, true, false); // update the UI only }); session.groupView.forEach(group => { // changeGroupDirectorAPI(group, state=null, update=true) changeGroupViewDirectorAPI(group, true); // update the UI only }); if (session.showDirector) { getById("highlightDirectorSpan").style.display = "none"; getById("highlightDirectorSpan").remove(); } else { getById("highlightDirector").dataset.sid = session.streamID; } setTimeout(() => { loadDirectorSettings(); if (broadcastFlag) { saveDirectorSettings(); } }, 100); joinRoom(session.roomid); pokeIframeAPI("create-room", session.roomid); try { if (!gotDevices2AlreadyRan && (iOS || iPad)) { await enumerateDevices().then(gotDevices2); // this is needed for iOS; was previous set to timeout at 100ms, but would be useful everywhere I think. (Breaks director's auto start, so just iOS for now) } } catch (e) { errorlog(e); } if (session.autostart) { setTimeout(function () { press2talk(true); }, 400); } else { session.seeding = true; session.seedStream(); } } // createRoomCallback function handleRoomSelect(room) { var elems = document.querySelectorAll(".btnArmTransferRoom"); [].forEach.call(elems, function (el) { el.classList.remove("selected"); }); if (previousRoom == room) { previousRoom = ""; armedTransfer = false; stillNeedRoom = true; } else { previousRoom = room; stillNeedRoom = false; armedTransfer = true; getById("roomselect_" + room).classList.add("selected"); } } function getDirectorSettings(scene = false) { var settings = {}; var eles = document.querySelectorAll('[data-action-type="solo-video"]'); settings.soloVideo = false; var soloVideoMode = null; for (var i = 0; i < eles.length; i++) { if (eles[i].value == 1) { warnlog(eles[i]); if (eles[i].dataset.sid) { if (eles[i].classList && eles[i].classList.contains("altpress")) { soloVideoMode = "alt"; } settings.soloVideo = eles[i].dataset.sid; // who is solo, if someone is solo } } } if (soloVideoMode) { settings.soloVideoMode = soloVideoMode; } else { delete settings.soloVideoMode; } if (scene) { var eles = document.querySelectorAll('[data-action-type="addToScene"][data-scene="' + scene + '"'); settings.scene = {}; for (var i = 0; i < eles.length; i++) { if (eles[i].value == 1) { if (eles[i].dataset.sid) { var msg = {}; msg.scene = scene; msg.action = "display"; msg.value = eles[i].value; msg.target = eles[i].dataset.sid; settings.scene[eles[i].dataset.sid] = msg; } } } } settings.showDirector = session.showDirector; settings.mute = {}; var eles = document.querySelectorAll('[data-action-type="mute-scene"]'); for (var i = 0; i < eles.length; i++) { if (eles[i].value == 1) { // if muted if (eles[i].dataset.sid) { var msg = {}; msg.action = "mute"; msg.scene = true; msg.value = 1; msg.target = eles[i].dataset.sid; settings.mute[eles[i].dataset.sid] = msg; } } } return settings; } function normalizeLayoutStateValue(state) { if (typeof state === "undefined") { return undefined; } if (state === null) { return false; } if (state === true) { return false; } if (state === false) { return false; } if (typeof state === "number") { return state ? state : false; } if (typeof state === "string") { const normalized = state.trim().toLowerCase(); if (!normalized) { return false; } if (normalized === "false" || normalized === "off" || normalized === "auto" || normalized === "0") { return false; } if (normalized === "true") { return false; } } return state; } function isAutoLayoutState(state) { const normalized = normalizeLayoutStateValue(state); return normalized === false || typeof normalized === "undefined" || normalized === null; } function requestInfocus(ele, evt = null, value = null) { try { var sid = ele.dataset.sid; } catch (e) { warnlog("no stream ID found; requestinfocus"); var sid = false; if (ele.id === "highlightDirector") { if (session.streamID) { sid = session.streamID; } } } if (value !== null) { if (value) { ele.value == 0; // we will toggle it in a second anyways. } else { ele.value == 1; } } var special = false; if (evt) { special = evt.ctrlKey || evt.metaKey || false; if (special) { special = true; } } if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.classList.remove("altpress"); var actionMsg = {}; actionMsg.infocus = false; //session.sendMessage(actionMsg); } else { var actionMsg = {}; if (special) { actionMsg.infocus2 = sid; } else { actionMsg.infocus = sid; } //session.sendMessage(actionMsg); var eles = document.querySelectorAll('[data-action-type="solo-video"]'); for (var i = 0; i < eles.length; i++) { log(eles); eles[i].classList.remove("pressed"); eles[i].ariaPressed = "false"; eles[i].classList.remove("altpress"); eles[i].value = 0; } ele.value = 1; if (special) { ele.classList.add("altpress"); } else { ele.classList.add("pressed"); ele.ariaPressed = "true"; } if (ele.id !== "highlightDirector") { getById("highlightDirector").checked = false; } } for (var uuid in session.pcs) { var layoutState = session.pcs[uuid].layoutState; if (!session.pcs[uuid].solo && isAutoLayoutState(layoutState)) { // only issue highlight commands to non-solo links when the scene is auto mixing session.sendMessage(actionMsg, uuid); } } syncDirectorState(ele); if (ele.value == 1) { return true; } else { return false; } } var fixScrollReset = null; var fixScrollResetValue = null; function requestAudioSettings(ele) { var UUID = ele.dataset.UUID; try { clearTimeout(fixScrollReset); fixScrollResetValue = getById("directorlayout").scrollTop; fixScrollReset = setTimeout( function (scrollpos) { fixScrollReset = null; getById("directorlayout").scrollTop = scrollpos; }, 1000, fixScrollResetValue ); query("#container_" + UUID + " [data-action-type='advanced-camera-settings']").value = 0; query("#container_" + UUID + " [data-action-type='advanced-camera-settings']").classList.remove("pressed"); query("#container_" + UUID + " [data-action-type='advanced-camera-settings']").ariaPressed = "false"; query("#container_" + UUID + " .advancedVideoSettings").classList.add("hidden"); query("#container_" + UUID + " .advancedVideoSettings").innerHTML = ""; } catch (e) { } if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; query("#container_" + UUID + " .advancedAudioSettings").classList.add("hidden"); query("#container_" + UUID + " .advancedAudioSettings").innerHTML = ""; return false; } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; query("#container_" + UUID + " .advancedAudioSettings").innerHTML = ""; var actionMsg = {}; actionMsg.getAudioSettings = true; session.sendRequest(actionMsg, UUID); return true; } } function requestVideoSettings(ele) { var UUID = ele.dataset.UUID; try { clearTimeout(fixScrollReset); fixScrollResetValue = getById("directorlayout").scrollTop; fixScrollReset = setTimeout( function (scrollpos) { fixScrollReset = null; getById("directorlayout").scrollTop = scrollpos; }, 1000, fixScrollResetValue ); query("#container_" + UUID + " [data-action-type='advanced-audio-settings']").value = 0; query("#container_" + UUID + " [data-action-type='advanced-audio-settings']").classList.remove("pressed"); query("#container_" + UUID + " [data-action-type='advanced-audio-settings']").ariaPressed = "false"; query("#container_" + UUID + " .advancedAudioSettings").classList.add("hidden"); query("#container_" + UUID + " .advancedAudioSettings").innerHTML = ""; } catch (e) { } if (ele.value == 1) { ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; query("#container_" + UUID + " .advancedVideoSettings").classList.add("hidden"); query("#container_" + UUID + " .advancedVideoSettings").innerHTML = ""; return false; } else { ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; query("#container_" + UUID + " .advancedVideoSettings").innerHTML = ""; var actionMsg = {}; actionMsg.getVideoSettings = true; session.sendRequest(actionMsg, UUID); return true; } } function combinedLayoutSimple(layout) { var combined = {}; Object.keys(layout).forEach(i => { if (!layout[i]) { return; } if (i === "") { layout[i].forEach(j => { if (!j) { return; } var streamID = null; if ("slot" in j) { try { streamID = session.currentSlots[parseInt(j.slot) + 1]; } catch (e) { errorlog(e); streamID = null; } } if (!streamID) { if (!combined[""]) { combined[""] = []; } combined[""].push(j); } else { combined[streamID] = j; } }); } else { var streamID = null; if ("slot" in layout[i]) { try { streamID = session.currentSlots[parseInt(layout[i].slot) + 1]; } catch (e) { errorlog(e); streamID = null; } } if (!streamID) { if (!combined[""]) { combined[""] = []; } combined[""].push(layout[i]); } else { combined[streamID] = layout[i]; } } }); return combined; } async function createDirectorOnlyBox() { var soloLink = soloLinkGenerator(session.streamID); if (document.getElementById("deleteme")) { getById("deleteme").parentNode.removeChild(getById("deleteme")); } var controls = getById("controls_directors_blank").cloneNode(true); controls.classList.remove("hidden"); controls.id = "controls_director"; var container = document.createElement("div"); container.className = "vidcon directorMargins"; container.id = "container_director"; // needed to delete on user disconnect container.setAttribute("aria-label", miscTranslations["your-camera"]); container.setAttribute("role", "region"); var buttons = ""; if (session.slotmode && session.showDirector) { var biggestSlot = 0; var slotDefault = null; // Check past slots first if (session.streamID in session.pastSlots) { slotDefault = session.pastSlots[session.streamID]; } // Get all current slots from RPC state var allSlots = []; if (session.slotmode == 1) { Object.entries(session.currentSlots).forEach(([currentSlot, sid]) => { if (currentSlot) { if (parseInt(currentSlot) > biggestSlot) { biggestSlot = parseInt(currentSlot); } if (slotDefault === parseInt(currentSlot)) { slotDefault = null; } allSlots.push(parseInt(currentSlot)); } }); biggestSlot += 1; } else if (slotDefault !== null && session.slotmode == 2) { // Check if slot is already in use - include director's stream const slotInUse = Object.keys(session.rpcs).some(UUID => getSlotState(UUID) === slotDefault ) || getSlotState(session.streamID) === slotDefault || getSlotState(session.streamID + ":s") === slotDefault; if (slotInUse) { slotDefault = null; // This slot is already in use } } // Determine final slot value if (slotDefault !== null) { biggestSlot = slotDefault; } else if (session.slotmode == 1) { var bestfree = 0; for (var i = 1; i <= biggestSlot; i++) { if (allSlots.includes(i)) { continue; } else { bestfree = i; break; } } biggestSlot = bestfree; } // Set slot name var slotName = biggestSlot ? "slot: " + biggestSlot : "unset"; var slotStyle = biggestSlot ? " style='background:" + getSlotColor(biggestSlot - 1) + ";'" : ""; // Build HTML with same structure buttons += `
    `; // Sync the initial state syncSlotState(session.streamID, biggestSlot, false); // false since UI is being created here } buttons += "
    \
    ID: " + session.streamID + "\ \ \ " + getTranslation("add-a-label") + "\
    \
    "; container.innerHTML = buttons; var oldGroups = []; document.querySelectorAll("#groups [data-action-type='toggle-group'][data-group]:not(.green)").forEach(ee => { oldGroups.push(ee.dataset.group); }); getById("groups").remove(); if (session.hidesololinks == false) { // won't be updating the solo link to a view-only one ever, since director is always expected to be in a room controls.innerHTML += "
    \ " + sanitizeChat(soloLink) + "\ \
    \
    "; if (session.directorUUID) { controls.innerHTML += "

    This is you, a co-director.
    You are also a performer.

    "; } else { controls.innerHTML += "

    This is you, the director.
    You are also a performer.

    "; } } controls.querySelectorAll("[data-action-type]").forEach(ele => { // give action buttons some self-reference ele.dataset.sid = session.streamID; }); container.appendChild(controls); getById("guestFeeds").appendChild(container); Object.keys(session.sceneList).forEach((scene, index) => { if (document.getElementById("container_director")) { if (!getById("container_director").querySelectorAll('[data-scene="' + scene + '"]').length) { var newScene = document.createElement("div"); newScene.innerHTML = '"; newScene.classList.add("customScene"); //getById("container_director").appendChild(newScene); var added = false; getById("container_director") .querySelectorAll(".customScene>[data-scene]") .forEach(ele => { if (!added && ele.dataset.scene > scene + "") { ele.parentNode.parentNode.insertBefore(newScene, ele.parentNode); added = true; } }); if (!added) { getById("container_director").appendChild(newScene); } } } }); getById("groups").showDirector = true; session.group.forEach(group => { // changeGroupDirectorAPI(group, state=null, update=true) changeGroupDirectorAPI(group, true, false); // update the UI only / }); oldGroups.forEach(group => { // changeGroupDirectorAPI(group, state=null, update=true) changeGroupDirectorAPI(group, false, false); // update the UI only / }); var labelID = document.getElementById("label_director"); labelID.onclick = async function (ee) { var oldlabel = ee.target.innerText; if (session.label === false) { oldlabel = ""; } window.focus(); var newlabel = await promptAlt(getTranslation("enter-new-display-name"), false, false, oldlabel); if (newlabel !== null) { newlabel = newlabel.trim(); if (newlabel === "") { newlabel = false; //ee.target.innerHTML = getTranslation("add-a-label"); miniTranslate(ee.target, "add-a-label"); ee.target.classList.add("addALabel"); } else { ee.target.innerText = newlabel; ee.target.classList.remove("addALabel"); } session.label = newlabel; var data = {}; data.changeLabel = true; data.value = session.label; session.sendMessage(data); stashRoomSession(); } }; labelID.style.float = "left"; labelID.style.top = "2px"; labelID.style.marginLeft = "5px"; labelID.style.position = "relative"; labelID.style.cursor = "pointer"; if (session.label) { labelID.innerText = session.label; } pokeIframeAPI("control-box", true, true); } async function createDirectorScreenshareOnlyBox() { // sstype=3 var screenStreamID = session.streamID + ":s"; var soloLink = soloLinkGenerator(screenStreamID); if (document.getElementById("deleteme")) { getById("deleteme").parentNode.removeChild(getById("deleteme")); } var controls = getById("controls_directors_blank").cloneNode(true); controls.classList.remove("hidden"); controls.id = "controls_screen_director"; var container = document.createElement("div"); container.className = "vidcon directorMargins"; container.id = "container_screen_director"; // needed to delete on user disconnect container.setAttribute("aria-label", miscTranslations["your-screenshare"]); container.setAttribute("role", "region"); var buttons = ""; if (session.slotmode) { var biggestSlot = 0; var slotDefault = null; if (screenStreamID in session.pastSlots) { slotDefault = session.pastSlots[screenStreamID]; } var allSlots = []; if (session.slotmode == 1) { Object.entries(session.currentSlots).forEach(([currentSlot, sid]) => { if (currentSlot) { if (parseInt(currentSlot) > biggestSlot) { biggestSlot = parseInt(currentSlot); } if (slotDefault === parseInt(currentSlot)) { slotDefault = null; } allSlots.push(parseInt(currentSlot)); } }); biggestSlot += 1; } // Determine final slot value if (slotDefault !== null) { biggestSlot = slotDefault; } else if (session.slotmode == 1) { var bestfree = 0; for (var i = 1; i <= biggestSlot; i++) { if (allSlots.includes(i)) { continue; } else { bestfree = i; break; } } biggestSlot = bestfree; } var slotName = biggestSlot ? "slot: " + biggestSlot : "unset"; var slotStyle = biggestSlot ? " style='background:" + getSlotColor(biggestSlot - 1) + ";'" : ""; buttons += `
    `; // Sync the initial state syncSlotState(screenStreamID, biggestSlot, false); // false since UI is being created here } buttons += "
    \
    ID: " + screenStreamID + "\ \ \ " + getTranslation("add-a-label") + "\
    \
    "; container.innerHTML = buttons; var oldGroups = []; document.querySelectorAll("#groups [data-action-type='toggle-group'][data-group]:not(.green)").forEach(ee => { oldGroups.push(ee.dataset.group); }); getById("groups").remove(); if (session.hidesololinks == false) { // won't be updating the solo link to a view-only one ever, since director is always expected to be in a room controls.innerHTML += "
    \ " + sanitizeChat(soloLink) + "\ \
    \
    "; if (session.directorUUID) { controls.innerHTML += "

    This is you, a co-director.
    You are also a performer.

    "; } else if (session.showDirector === false) { try { controls.querySelectorAll('[data-action-type="addToScene"]').forEach(ele => { ele.classList.add("hidden"); }); } catch (e) { errorlog(e); } controls.innerHTML += "

    This is your screen share
    It's *not* a performer.

    "; } else { controls.innerHTML += "

    This your screen share.
    It's also a performer.

    "; } } controls.querySelectorAll("[data-action-type]").forEach(ele => { // give action buttons some self-reference ele.dataset.sid = screenStreamID; }); container.appendChild(controls); getById("guestFeeds").appendChild(container); Object.keys(session.sceneList).forEach((scene, index) => { if (document.getElementById("container_screen_director")) { if (!getById("container_screen_director").querySelectorAll('[data-scene="' + scene + '"]').length) { var newScene = document.createElement("div"); newScene.innerHTML = '"; newScene.classList.add("customScene"); //getById("container_screen_director").appendChild(newScene); var added = false; getById("container_screen_director") .querySelectorAll(".customScene>[data-scene]") .forEach(ele => { if (!added && ele.dataset.scene > scene + "") { ele.parentNode.parentNode.insertBefore(newScene, ele.parentNode); added = true; } }); if (!added) { getById("container_screen_director").appendChild(newScene); } } } }); getById("groups").showDirector = true; session.group.forEach(group => { // changeGroupDirectorAPI(group, state=null, update=true) changeGroupDirectorAPI(group, true, false); // update the UI only / }); oldGroups.forEach(group => { // changeGroupDirectorAPI(group, state=null, update=true) changeGroupDirectorAPI(group, false, false); // update the UI only / }); document.querySelectorAll("#container_screen_director #label_director").forEach(elex => { elex.remove(); }); pokeIframeAPI("control-box", true, true); } function shiftPC(ele, shift, director = false) { if (director) { var target = document.getElementById("container_director"); } else { var target = document.getElementById("container_" + ele.dataset.UUID); } if (!target) { return; } target.shifted = true; var target2 = false; if (shift == 1) { if (target.nextSibling) { target2 = target.nextSibling; target.parentNode.insertBefore(target.nextSibling, target); } } else { if (target.previousSibling) { target2 = target.previousSibling; target.parentNode.insertBefore(target, target.previousSibling); } } updateLockedElements(); if (session.api) { var slots = {}; var elements = getById("guestFeeds").children; for (var i = 0; i < elements.length; i++) { if (elements[i] === target) { var tmp = target.querySelector("[data-sid]"); if (tmp) { var lock = target.querySelector("[data-locked]"); if (lock) { lock = parseInt(lock.dataset.locked); } tmp = tmp.dataset.sid; slots[tmp] = lock || i + 1; } } else if (elements[i] === target2) { var tmp2 = target2.querySelector("[data-sid]"); if (tmp2) { var lock = target2.querySelector("[data-locked]"); if (lock) { lock = parseInt(lock.dataset.locked); } tmp2 = tmp2.dataset.sid; slots[tmp2] = lock || i + 1; } } } pokeAPI("positionChange", slots); } } function updateLockedElements() { var eles = getById("guestFeeds").children; for (var i = 0; i < eles.length; i++) { try { var UUID = eles[i].UUID; var lock = document.getElementById("position_" + UUID).dataset.locked; if (parseInt(lock)) { lockPosition(document.getElementById("position_" + UUID), true); } } catch (e) { } } } function lockPosition(ele, apply = false) { var UUID = ele.dataset.UUID; if (apply) { if (ele.dataset.locked && parseInt(ele.dataset.locked)) { if (getById("guestFeeds")) { var currentPosition = Array.prototype.indexOf.call(getById("guestFeeds").children, document.getElementById("container_" + UUID)) + 1; ele.innerHTML = "#" + ele.dataset.locked + ""; ele.parentNode.classList.add("locked"); while (currentPosition > parseInt(ele.dataset.locked)) { var node = document.getElementById("container_" + UUID); (parent = node.parentNode), (prev = node.previousSibling), (oldChild = parent.removeChild(node)); parent.insertBefore(oldChild, prev); currentPosition = Array.prototype.indexOf.call(getById("guestFeeds").children, document.getElementById("container_" + UUID)) + 1; } while (currentPosition < parseInt(ele.dataset.locked) && getById("guestFeeds").children.length > currentPosition) { var node = document.getElementById("container_" + UUID); (parent = node.parentNode), (next = node.nextSibling), (oldChild = parent.removeChild(node)); parent.insertBefore(node, next.nextSibling); currentPosition = Array.prototype.indexOf.call(getById("guestFeeds").children, document.getElementById("container_" + UUID)) + 1; } } } else { ele.dataset.locked = 0; ele.innerHTML = ""; ele.parentNode.classList.remove("locked"); } } else { if (ele.dataset.locked && parseInt(ele.dataset.locked)) { ele.dataset.locked = 0; ele.innerHTML = ""; ele.parentNode.classList.remove("locked"); } else { if (getById("guestFeeds")) { ele.dataset.locked = Array.prototype.indexOf.call(getById("guestFeeds").children, document.getElementById("container_" + UUID)) + 1; ele.innerHTML = "#" + ele.dataset.locked + ""; ele.parentNode.classList.add("locked"); } } } } function allowDropSlot(event) { event.preventDefault(); } function dragSlot(event) { log("drag"); var ele = event.target; if (!ele.dataset.sid && ele.parentNode.dataset.sid) { ele = ele.parentNode; } event.dataTransfer.setDragImage(getById("dragImage"), 24, 24); event.dataTransfer.setData("text", ele.dataset.sid); var eles = document.querySelectorAll(".slotsbar"); for (var i = 0; i < eles.length; i++) { if (eles[i].dataset.sid == ele.dataset.sid) { continue; } eles[i].style.boxShadow = "0px 0px 8px 2px #FFF"; } } function dragendSlot(event) { var eles = document.querySelectorAll(".slotsbar"); for (var i = 0; i < eles.length; i++) { eles[i].style.boxShadow = "unset"; } return true; } function dropSlot(event) { log("drop"); event.preventDefault(); event.stopPropagation(); // Get the dragged streamID var SID = event.dataTransfer.getData("text"); if (!SID) return; var origThing = document.querySelector("[data-sid='" + SID + "'][data-slot]"); if (!origThing) return; // Get target streamID var targetSID = event.target.dataset.sid || event.target.parentNode.dataset.sid; if (!targetSID) return; var targetThing = document.querySelector("[data-sid='" + targetSID + "'][data-slot]"); if (!targetThing) return; // Get original slots const origSlot = parseInt(origThing.dataset.slot); const targetSlot = parseInt(targetThing.dataset.slot); // Key fix: We need to swap the DOM elements *and* swap the session.currentSlots entries // Save the original values const tempStreamID = session.currentSlots[targetSlot]; // Save the target slot's original value // Update session.currentSlots (this is the crucial part) session.currentSlots[targetSlot] = SID; session.currentSlots[origSlot] = tempStreamID; // Update the data-sid attributes for the visual swap targetThing.dataset.sid = SID; origThing.dataset.sid = targetSID; // Update the UI text as well const targetButton = targetThing.querySelector('button'); const origButton = origThing.querySelector('button'); if (targetButton) { targetButton.innerText = targetSlot ? `slot: ${targetSlot}` : 'unset'; } if (origButton) { origButton.innerText = origSlot ? `slot: ${origSlot}` : 'unset'; } // Update past slots for future reference session.pastSlots[SID] = targetSlot; // we don't need to run syncSlotState(), as this handles it session.pastSlots[targetSID] = origSlot; // Tell any iframes about the swap pokeIframeAPI("slot-updated", targetSlot, null, SID); pokeIframeAPI("slot-updated", origSlot, null, targetSID); // Notify all peers of the update broadcastSlotUpdate(); return false; } function dragenterSlot(event) { event.preventDefault(); if (event.target.classList.contains("slotsbar")) { event.target.style.border = "3px dotted black"; } } function dragleaveSlot(event) { event.preventDefault(); if (event.target.classList.contains("slotsbar")) { event.target.style.border = ""; } } async function changeSlot(event, ele) { var picker = document.getElementById("slotPicker"); if (picker) { clearTimeout(modalTimeout); if (document.getElementById("modalBackdrop")) { getById("alertModal").innerHTML = ""; // Delete modal getById("alertModal").remove(); getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); } zindex = 31 + document.querySelectorAll(".alertModal").length; message = picker.innerHTML; modalTemplate = `
    × ${message}
    `; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end document.getElementById("modalBackdrop").addEventListener("click", closeModal); document .getElementById("alertModalMessage") .querySelectorAll("div[data-slot]") .forEach(choice => { choice.onclick = function () { setSlot(ele, parseInt(this.dataset.slot)); closeModal(); }; }); if (event) { positionAlertModalNearEvent(document.getElementById("alertModal"), event); } getById("alertModal").addEventListener("click", function (e) { e.stopPropagation(); return false; }); } else { var slot = await promptAlt("Which slot to change to?"); setSlot(ele, slot); } } function setSlot(ele, slot) { log("setSlot()"); getById("slotPicker").classList.add("hidden"); if (slot !== null) { try { slot = parseInt(slot) || 0; // Find container with stream ID const container = ele.closest('[data-sid]'); const streamID = container ? container.dataset.sid : null; if (!streamID) { return false; } // Critical part: Check if the slot is already occupied and swap instead of replace const existingStreamID = session.currentSlots[slot]; if (existingStreamID && existingStreamID !== streamID) { // Find the current slot of the stream we're moving let currentSlot = null; Object.entries(session.currentSlots).forEach(([key, value]) => { if (value === streamID) { currentSlot = parseInt(key); } }); // If the stream we're moving is already in a slot, update that slot's value if (currentSlot !== null) { // Perform the swap session.currentSlots[slot] = streamID; session.currentSlots[currentSlot] = existingStreamID; // Update the other element's UI const otherSlotBar = document.querySelector(`[data-sid="${existingStreamID}"][data-slot]`); if (otherSlotBar) { otherSlotBar.dataset.slot = currentSlot; applySlotColor(otherSlotBar, currentSlot); const otherButton = otherSlotBar.querySelector('button'); if (otherButton) { otherButton.innerText = currentSlot ? `slot: ${currentSlot}` : 'unset'; } } // Update UI for the element we're setting container.dataset.slot = slot; applySlotColor(container, slot); ele.innerText = slot ? `slot: ${slot}` : 'unset'; // Update pastSlots session.pastSlots[streamID] = slot; session.pastSlots[existingStreamID] = currentSlot; // Update iframes pokeIframeAPI("slot-updated", slot, null, streamID); pokeIframeAPI("slot-updated", currentSlot, null, existingStreamID); } else { // We're moving a stream that wasn't in a slot before // First clear any current assignment for this stream Object.entries(session.currentSlots).forEach(([key, value]) => { if (value === streamID) { delete session.currentSlots[key]; } }); // Then assign it to the new slot session.currentSlots[slot] = streamID; // Update UI container.dataset.slot = slot; applySlotColor(container, slot); ele.innerText = slot ? `slot: ${slot}` : 'unset'; // Update pastSlots session.pastSlots[streamID] = slot; // Update iframe pokeIframeAPI("slot-updated", slot, null, streamID); } } else { // No conflict, just set the slot normally // Clear any existing slot for this stream Object.entries(session.currentSlots).forEach(([key, value]) => { if (value === streamID) { delete session.currentSlots[key]; } }); // Set the new slot if (slot) { session.currentSlots[slot] = streamID; } // Update UI container.dataset.slot = slot; applySlotColor(container, slot); ele.innerText = slot ? `slot: ${slot}` : 'unset'; // Update pastSlots session.pastSlots[streamID] = slot; // Update iframe pokeIframeAPI("slot-updated", slot, null, streamID); } // Always update all peers broadcastSlotUpdate(); } catch (e) { errorlog(e); return false; } return true; } return false; } function swapNodes(n1, n2) { log("swapping nodes"); var p1 = n1.parentNode; var p2 = n2.parentNode; var i1, i2; if (!p1 || !p2 || p1.isEqualNode(n2) || p2.isEqualNode(n1)) return; for (var i = 0; i < p1.children.length; i++) { if (p1.children[i].isEqualNode(n1)) { i1 = i; } } for (var i = 0; i < p2.children.length; i++) { if (p2.children[i].isEqualNode(n2)) { i2 = i; } } if (p1.isEqualNode(p2) && i1 < i2) { i2++; } p1.insertBefore(n2, p1.children[i1]); p2.insertBefore(n1, p2.children[i2]); } function getCurrentSlot(streamID) { const slotEntry = Object.entries(session.currentSlots).find(([_, sid]) => sid === streamID); return slotEntry ? slotEntry[0] : false; } function getSlotState(UUID) { if (!UUID || !(UUID in session.rpcs)) return false; return getCurrentSlot(session.rpcs[UUID].streamID); } function combinedLayout(layout) { if (!Array.isArray(layout)) return layout || {}; var combined = {}; for (var i = 0; i < layout.length; i++) { if (!layout[i] || !("slot" in layout[i])) { if (!combined[""]) combined[""] = []; combined[""].push(layout[i]); continue; } const slotNumber = parseInt(layout[i].slot || 0) + 1; const streamID = session.currentSlots[slotNumber]; if (!streamID) { if (!combined[""]) combined[""] = []; combined[""].push(layout[i]); continue; } combined[streamID] = layout[i]; } return combined; } function syncSlotState(streamID, slotValue = false, updateUI = true) { // Clear any existing slots for this stream Object.entries(session.currentSlots).forEach(([slot, sid]) => { if (sid === streamID) delete session.currentSlots[slot]; }); // Set new slot if one provided if (slotValue) { session.currentSlots[slotValue] = streamID; } // Update UI if requested if (updateUI) { const slotsBar = document.querySelector(`[data-sid="${streamID}"][data-slot]`); if (slotsBar) { slotsBar.dataset.slot = slotValue; applySlotColor(slotsBar, slotValue); const slotButton = slotsBar.querySelector('button'); if (slotButton) { slotButton.innerText = slotValue ? `slot: ${slotValue}` : 'unset'; } } } pokeIframeAPI("slot-updated", slotValue, null, streamID); // need to support self-director session.pastSlots[streamID] = slotValue || 0; clearTimeout(session.slotBroadcastThrottle); session.slotBroadcastThrottle = setTimeout(function () { broadcastSlotUpdate(); }, 10); return true; } function broadcastSlotUpdate(UUID = false) { try { if (!session.slotmode || !session.director) { return; } if (!UUID) { if (session.slotBroadcastThrottle) { clearTimeout(session.slotBroadcastThrottle); session.slotBroadcastThrottle = null; } session.sendMessage({ slotsUpdate: session.currentSlots }); } else { session.sendMessage({ slotsUpdate: session.currentSlots }, UUID); } } catch (e) { errorlog(e); } } function updateSlotUI() { // Update all slot UI elements based on the current state in session.currentSlots Object.entries(session.currentSlots).forEach(([slot, streamID]) => { const slotBar = document.querySelector(`[data-sid="${streamID}"][data-slot]`); if (slotBar) { slotBar.dataset.slot = slot; applySlotColor(slotBar, slot); const button = slotBar.querySelector('button'); if (button) { button.innerText = slot ? `slot: ${slot}` : 'unset'; } } }); } function createControlBox(UUID, soloLink, streamID, slot_init = false) { if (document.getElementById("deleteme")) { getById("deleteme").parentNode.removeChild(getById("deleteme")); } // Remove any existing container with this UUID to prevent stacking var existingContainer = document.getElementById("container_" + UUID); if (existingContainer) { existingContainer.parentNode.removeChild(existingContainer); } var controls = getById("controls_blank").cloneNode(true); controls.classList.remove("hidden"); controls.id = "controls_" + UUID; var container = document.createElement("div"); container.className = "vidcon directorMargins"; container.id = "container_" + UUID; // needed to delete on user disconnect container.UUID = UUID; container.dataset.UUID = UUID; container.dataset.sid = streamID; if (session.orderby) { try { var added = false; for (var i = 0; i < getById("guestFeeds").children.length; i++) { if (getById("guestFeeds").children[i].UUID && !getById("guestFeeds").children[i].shifted) { if (getById("guestFeeds").children[i].UUID in session.rpcs) { if (session.rpcs[getById("guestFeeds").children[i].UUID].streamID.toLowerCase() > streamID.toLowerCase()) { getById("guestFeeds").insertBefore(container, getById("guestFeeds").children[i]); added = true; break; } } } } if (!added) { getById("guestFeeds").appendChild(container); } } catch (e) { getById("guestFeeds").appendChild(container); } } else { getById("guestFeeds").appendChild(container); } //controls.innerHTML += ""; if (session.rpcs[UUID].pseudoguest) { controls.querySelectorAll("[data-action-type]").forEach(ele => { ele.dataset.UUID = UUID; ele.dataset.sid = streamID; }); return; } //controls.innerHTML += ""; if (!session.rpcs[UUID].voiceMeter) { if (session.meterStyle == 1) { // director specific style session.rpcs[UUID].voiceMeter = getById("voiceMeterTemplate2").cloneNode(true); } else { session.rpcs[UUID].voiceMeter = getById("voiceMeterTemplate").cloneNode(true); session.rpcs[UUID].voiceMeter.style.opacity = 0; if (session.meterStyle == 2) { session.rpcs[UUID].voiceMeter.classList.add("video-meter-2"); session.rpcs[UUID].voiceMeter.classList.remove("video-meter"); } else { session.rpcs[UUID].voiceMeter.classList.add("video-meter-director"); } } session.rpcs[UUID].voiceMeter.id = "voiceMeter_" + UUID; session.rpcs[UUID].voiceMeter.dataset.level = 0; session.rpcs[UUID].voiceMeter.classList.remove("hidden"); } session.rpcs[UUID].remoteMuteElement = getById("muteStateTemplate").cloneNode(true); session.rpcs[UUID].remoteMuteElement.id = ""; session.rpcs[UUID].remoteMuteElement.style.top = "5px"; session.rpcs[UUID].remoteMuteElement.style.right = "7px"; session.rpcs[UUID].remoteVideoMuteElement = getById("videoMuteStateTemplate").cloneNode(true); session.rpcs[UUID].remoteVideoMuteElement.id = ""; session.rpcs[UUID].remoteVideoMuteElement.style.top = "5px"; session.rpcs[UUID].remoteVideoMuteElement.style.right = "28px"; session.rpcs[UUID].remoteRaisedHandElement = getById("raisedHandTemplate").cloneNode(true); session.rpcs[UUID].remoteRaisedHandElement.id = ""; session.rpcs[UUID].remoteRaisedHandElement.style.top = "5px"; session.rpcs[UUID].remoteRaisedHandElement.style.right = "49px"; var handsID = "hands_" + UUID; // controls.innerHTML += "
    Links
    "; //Seems to create an empty div. if (session.hidesololinks == false) { controls.innerHTML += "
    \ " + sanitizeChat(soloLink) + "\ \
    "; } controls.innerHTML += '\ '; controls.innerHTML += '\ '; controls.querySelectorAll("[data-action-type]").forEach(ele => { // give action buttons some self-reference ele.dataset.UUID = UUID; ele.dataset.sid = streamID; }); var buttons = ""; if (session.slotmode && slot_init !== 0) { // slot_init === 0 means guest explicitly opted out of slots var biggestSlot = 0; var slotDefault = null; // Handle initial slot value from initialization or past slots if (slot_init && session.slotmode == 1) { slotDefault = slot_init || null; } if (streamID in session.pastSlots) { slotDefault = session.pastSlots[streamID]; } // Get all current slots from RPC state var allSlots = []; if (session.slotmode == 1) { Object.entries(session.currentSlots).forEach(([currentSlot, sid]) => { if (currentSlot) { if (parseInt(currentSlot) > biggestSlot) { biggestSlot = parseInt(currentSlot); } if (slotDefault === parseInt(currentSlot)) { slotDefault = null; } allSlots.push(parseInt(currentSlot)); } }); biggestSlot += 1; } else if (slotDefault !== null && session.slotmode == 2) { // Check if slot is already in use by any remote participant const remoteSlotInUse = Object.keys(session.rpcs).some(UUID => getSlotState(UUID) === slotDefault ); // Check if slot is used by the director const directorSlotInUse = Object.entries(session.currentSlots).some(([slot, sid]) => parseInt(slot) === slotDefault && (sid === session.streamID || sid === session.streamID + ":s") ); if (remoteSlotInUse || directorSlotInUse) { slotDefault = null; // This slot is already in use } } // Determine final slot value if (slotDefault !== null) { // the default slot is available biggestSlot = slotDefault; } else if (slot_init && session.slotmode == 1) { // was manually set, so can't be something else but 0 biggestSlot = 0; } else if (session.slotmode == 1) { var bestfree = 0; for (var i = 1; i <= biggestSlot; i++) { if (allSlots.includes(i)) { continue; } else { bestfree = i; break; } } biggestSlot = bestfree; } // Set slot name var slotName = biggestSlot ? "slot: " + biggestSlot : "unset"; var slotStyle = biggestSlot ? " style='background:" + getSlotColor(biggestSlot - 1) + ";'" : ""; buttons += `
    `; // Sync the initial state syncSlotState(streamID, biggestSlot, false); // false since UI is being created here } buttons += "
    ID: " + streamID + "\ \ \ \
    "; container.innerHTML = buttons; updateLockedElements(); var videoContainerControlBox = document.createElement("div"); videoContainerControlBox.className = "controlVideoBox"; container.containerControlBox = videoContainerControlBox; container.appendChild(videoContainerControlBox); var videoContainer = document.createElement("div"); videoContainer.id = "videoContainer_" + UUID; // needed to delete on user disconnect videoContainer.style.margin = "0"; videoContainer.style.position = "relative"; videoContainer.style.minHeight = "30px"; videoContainerControlBox.appendChild(videoContainer); if (session.signalMeter) { if (!session.rpcs[UUID].signalMeter) { session.rpcs[UUID].signalMeter = getById("signalMeterTemplate").cloneNode(true); session.rpcs[UUID].signalMeter.id = "signalMeter_" + UUID; session.rpcs[UUID].signalMeter.dataset.level = 0; session.rpcs[UUID].signalMeter.classList.remove("hidden"); session.rpcs[UUID].signalMeter.dataset.UUID = UUID; session.rpcs[UUID].signalMeter.title = getTranslation("signal-meter"); if (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.cpuLimited) { // was quality_limitation_reason session.rpcs[UUID].signalMeter.dataset.cpu = "1"; } if (session.statsMenu !== false) { session.rpcs[UUID].signalMeter.addEventListener("click", function (e) { // show stats of video if double clicked log("clicked signal meter"); try { e.preventDefault(); if (session.statsMenu !== false) { var uid = e.currentTarget.dataset.UUID; if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } e.stopPropagation(); return false; } catch (e) { errorlog(e); } }); } } videoContainer.appendChild(session.rpcs[UUID].signalMeter); } if (session.batteryMeter) { if (!session.rpcs[UUID].batteryMeter) { session.rpcs[UUID].batteryMeter = getById("batteryMeterTemplate").cloneNode(true); session.rpcs[UUID].batteryMeter.id = "batteryMeter_" + UUID; batteryMeterInfoUpdate(UUID); } videoContainer.appendChild(session.rpcs[UUID].batteryMeter); } if (session.showConnections) { if (!session.rpcs[UUID].connectionDetails) { createConnectionDetailsEle(UUID); } videoContainer.appendChild(session.rpcs[UUID].connectionDetails); } var iframeDetails = document.createElement("div"); iframeDetails.id = "iframeDetails_" + UUID; // needed to delete on user disconnect iframeDetails.className = "iframeDetails hidden"; videoContainer.appendChild(session.rpcs[UUID].voiceMeter); videoContainer.appendChild(session.rpcs[UUID].remoteMuteElement); videoContainer.appendChild(session.rpcs[UUID].remoteVideoMuteElement); videoContainer.appendChild(session.rpcs[UUID].remoteRaisedHandElement); videoContainer.appendChild(iframeDetails); container.appendChild(controls); session.group.forEach(group => { var ele = controls.querySelector('[data-action-type="toggle-group"][data--u-u-i-d="' + UUID + '"][data-group="' + group + '"]'); if (!ele) { var newGroup = htmlToElement('"); var added = false; container.querySelectorAll(".customGroup>[data-group]").forEach(ele => { log(ele); if (!added && ele.dataset.group > group + "") { ele.parentNode.insertBefore(newGroup, ele); added = true; } }); if (!added) { var newGroupCon = container.querySelector(".customGroup"); if (!newGroupCon) { newGroupCon = document.createElement("div"); newGroupCon.classList.add("customGroup"); container.appendChild(newGroupCon); } newGroupCon.appendChild(newGroup); } } }); initSceneList(UUID); syncSceneState(streamID); syncOtherState(streamID); pokeIframeAPI("control-box", true, UUID); // Broadcast updated slots immediately so scenes with &viewslot update if (session.slotmode && session.director) { broadcastSlotUpdate(); } } function createControlBoxScreenshare(UUID, soloLink, streamID) { if (document.getElementById("deleteme")) { getById("deleteme").parentNode.removeChild(getById("deleteme")); } var controls = getById("controls_blank").cloneNode(true); controls.classList.remove("hidden"); controls.id = "controls_" + UUID; var container = document.createElement("div"); container.className = "vidcon directorMargins"; container.id = "container_" + UUID; // needed to delete on user disconnect container.UUID = UUID; container.dataset.UUID = UUID; container.dataset.sid = streamID; if (session.orderby) { try { var added = false; for (var i = 0; i < getById("guestFeeds").children.length; i++) { if (getById("guestFeeds").children[i].UUID && !getById("guestFeeds").children[i].shifted) { if (getById("guestFeeds").children[i].UUID in session.rpcs) { if (session.rpcs[getById("guestFeeds").children[i].UUID].streamID.toLowerCase() > streamID.toLowerCase()) { getById("guestFeeds").insertBefore(container, getById("guestFeeds").children[i]); added = true; break; } } } } if (!added) { getById("guestFeeds").appendChild(container); } } catch (e) { getById("guestFeeds").appendChild(container); } } else { getById("guestFeeds").appendChild(container); } controls.querySelector(".controlsGrid").classList.add("notmain"); if (!session.rpcs[UUID].voiceMeter) { if (session.meterStyle == 1) { session.rpcs[UUID].voiceMeter = getById("voiceMeterTemplate2").cloneNode(true); } else { session.rpcs[UUID].voiceMeter = getById("voiceMeterTemplate").cloneNode(true); session.rpcs[UUID].voiceMeter.style.opacity = 0; if (session.meterStyle == 2) { session.rpcs[UUID].voiceMeter.classList.add("video-meter-2"); session.rpcs[UUID].voiceMeter.classList.remove("video-meter"); } else { session.rpcs[UUID].voiceMeter.classList.add("video-meter-director"); } } session.rpcs[UUID].voiceMeter.id = "voiceMeter_" + UUID; session.rpcs[UUID].voiceMeter.dataset.level = 0; session.rpcs[UUID].voiceMeter.classList.remove("hidden"); } session.rpcs[UUID].remoteMuteElement = getById("muteStateTemplate").cloneNode(true); session.rpcs[UUID].remoteMuteElement.id = ""; session.rpcs[UUID].remoteMuteElement.style.top = "5px"; session.rpcs[UUID].remoteMuteElement.style.right = "7px"; session.rpcs[UUID].remoteVideoMuteElement = getById("videoMuteStateTemplate").cloneNode(true); session.rpcs[UUID].remoteVideoMuteElement.id = ""; session.rpcs[UUID].remoteVideoMuteElement.style.top = "5px"; session.rpcs[UUID].remoteVideoMuteElement.style.right = "28px"; session.rpcs[UUID].remoteRaisedHandElement = getById("raisedHandTemplate").cloneNode(true); session.rpcs[UUID].remoteRaisedHandElement.id = ""; session.rpcs[UUID].remoteRaisedHandElement.style.top = "5px"; session.rpcs[UUID].remoteRaisedHandElement.style.right = "49px"; var videoContainer = document.createElement("div"); videoContainer.id = "videoContainer_" + UUID; // needed to delete on user disconnect videoContainer.style.margin = "0"; videoContainer.style.position = "relative"; videoContainer.style.minHeight = "30px"; var iframeDetails = document.createElement("div"); iframeDetails.id = "iframeDetails_" + UUID; // needed to delete on user disconnect iframeDetails.className = "iframeDetails hidden"; //controls.innerHTML += ""; //controls.innerHTML += ""; var handsID = "hands_" + UUID; controls.innerHTML += "
    "; if (session.hidesololinks == false) { controls.innerHTML += "
    \ " + sanitizeChat(soloLink) + "\ \
    "; } controls.innerHTML += '\
    '; controls.querySelectorAll("[data-action-type]").forEach(ele => { // give action buttons some self-reference ele.dataset.UUID = UUID; ele.dataset.sid = streamID; }); var buttons = ""; if (session.slotmode) { var biggestSlot = 0; var slotDefault = null; // Check past slots first if (streamID in session.pastSlots) { slotDefault = session.pastSlots[streamID]; } // Get all current slots from RPC state var allSlots = []; if (session.slotmode == 1) { Object.entries(session.currentSlots).forEach(([currentSlot, sid]) => { if (currentSlot) { if (parseInt(currentSlot) > biggestSlot) { biggestSlot = parseInt(currentSlot); } if (slotDefault === parseInt(currentSlot)) { slotDefault = null; } allSlots.push(parseInt(currentSlot)); } }); biggestSlot += 1; } // Determine final slot value if (slotDefault !== null) { biggestSlot = slotDefault; } else if (session.slotmode == 1) { var bestfree = 0; for (var i = 1; i <= biggestSlot; i++) { if (allSlots.includes(i)) { continue; } else { bestfree = i; break; } } biggestSlot = bestfree; } // Set slot name and update past slots var slotName = biggestSlot ? "slot: " + biggestSlot : "unset"; var slotStyle = biggestSlot ? " style='background:" + getSlotColor(biggestSlot - 1) + ";'" : ""; session.pastSlots[streamID] = biggestSlot; // Build HTML with same structure buttons += `
    `; // Sync the initial state syncSlotState(streamID, biggestSlot, false); // false since UI is being created here } buttons += "
    ID: " + streamID + "\ \ \ \
    "; container.innerHTML = buttons; updateLockedElements(); var videoContainerControlBox = document.createElement("div"); videoContainerControlBox.className = "controlVideoBox"; container.containerControlBox = videoContainerControlBox; container.appendChild(videoContainerControlBox); videoContainerControlBox.appendChild(videoContainer); if (session.signalMeter) { if (!session.rpcs[UUID].signalMeter) { session.rpcs[UUID].signalMeter = getById("signalMeterTemplate").cloneNode(true); session.rpcs[UUID].signalMeter.id = "signalMeter_" + UUID; session.rpcs[UUID].signalMeter.dataset.level = 0; session.rpcs[UUID].signalMeter.classList.remove("hidden"); session.rpcs[UUID].signalMeter.dataset.UUID = UUID; session.rpcs[UUID].signalMeter.title = getTranslation("signal-meter"); //if (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.cpu_maxed){ // session.rpcs[UUID].signalMeter.dataset.cpu = "1"; //} session.rpcs[UUID].signalMeter.addEventListener("click", function (e) { // show stats of video if double clicked log("clicked signal meter"); try { e.preventDefault(); if (session.statsMenu !== false) { var uid = e.currentTarget.dataset.UUID; if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } e.stopPropagation(); return false; } catch (e) { errorlog(e); } }); } videoContainer.appendChild(session.rpcs[UUID].signalMeter); } if (session.batteryMeter) { //////// if (!session.rpcs[UUID].batteryMeter) { session.rpcs[UUID].batteryMeter = getById("batteryMeterTemplate").cloneNode(true); session.rpcs[UUID].batteryMeter.id = "batteryMeter_" + UUID; batteryMeterInfoUpdate(UUID); } videoContainer.appendChild(session.rpcs[UUID].batteryMeter); } if (session.showConnections) { if (!session.rpcs[UUID].connectionDetails) { createConnectionDetailsEle(UUID); } videoContainer.appendChild(session.rpcs[UUID].connectionDetails); } videoContainer.appendChild(session.rpcs[UUID].voiceMeter); videoContainer.appendChild(session.rpcs[UUID].remoteMuteElement); videoContainer.appendChild(session.rpcs[UUID].remoteVideoMuteElement); videoContainer.appendChild(session.rpcs[UUID].remoteRaisedHandElement); videoContainer.appendChild(iframeDetails); videoContainer.appendChild(session.rpcs[UUID].videoElement); container.appendChild(controls); session.group.forEach(group => { var ele = controls.querySelector('[data-action-type="toggle-group"][data--u-u-i-d="' + UUID + '"][data-group="' + group + '"]'); if (!ele) { var newGroup = htmlToElement('"); var added = false; container.querySelectorAll(".customGroup>[data-group]").forEach(ele => { log(ele); if (!added && ele.dataset.group > group + "") { ele.parentNode.insertBefore(newGroup, ele); added = true; } }); if (!added) { var newGroupCon = container.querySelector(".customGroup"); if (!newGroupCon) { newGroupCon = document.createElement("div"); newGroupCon.classList.add("customGroup"); container.appendChild(newGroupCon); } newGroupCon.appendChild(newGroup); } } }); initSceneList(UUID); pokeIframeAPI("control-box", true, UUID); } function remoteRemoveQueue(ele) { let ts = { ...transferSettings }; ts.justResetting = true; session.directMigrateIssue(session.roomid, ts, ele.dataset.UUID); ele.classList.add("hidden"); try { session.applyQueueStateChange(ele.dataset.UUID, false, "remote-remove-queue"); } catch (e) { errorlog(e); } } function minimizeMe(button, director = false) { var container = null; if (!director) { container = getById("container_" + button.dataset.UUID); } else { container = getById(director); } if (!container) { return; } var wasMinimized = container.classList.contains("minimized"); if (!wasMinimized) { var measuredWidth = container.offsetWidth || container.scrollWidth; if (measuredWidth) { container.dataset.minimizedWidth = measuredWidth; } } var isMinimized = container.classList.toggle("minimized"); if (isMinimized) { var storedWidth = parseFloat(container.dataset.minimizedWidth); if (!storedWidth) { storedWidth = container.scrollWidth || container.offsetWidth; } if (storedWidth) { container.style.width = storedWidth + "px"; container.style.minWidth = storedWidth + "px"; } } else { container.style.removeProperty("width"); container.style.removeProperty("min-width"); var currentWidth = container.offsetWidth || container.scrollWidth; if (currentWidth) { container.dataset.minimizedWidth = currentWidth; } else { delete container.dataset.minimizedWidth; } } } function blackoutMode() { var overlay = document.getElementById("blackoutOverlay"); if (!overlay) { overlay = document.createElement('div'); overlay.id = "blackoutOverlay"; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: black; color: white; display: flex; justify-content: center; align-items: center; font-size: 14px; cursor: pointer; z-index: 9999; `; overlay.textContent = 'Click to exit black-out mode'; document.body.appendChild(overlay); } else { overlay.classList.remove("hidden"); } function exitBlackout() { overlay.classList.add("hidden"); overlay.removeEventListener('click', exitBlackout); } overlay.addEventListener('click', exitBlackout); } function cycleCameras() { if (session.screenShareState) { warnUser("Stop the screen-share first."); return; } var videoSelect = document.querySelector("select#videoSource3").options; // don't show flip option if only one camera. // don't show if not a mobile device // don't show if AD=0 var matched = false; var maxIndex = parseInt(getById("flipcamerabutton").dataset.maxIndex) || parseInt(videoSelect.length); if (maxIndex > parseInt(videoSelect.length)) { maxIndex = parseInt(videoSelect.length); } for (var i = 0; i < maxIndex; i++) { var selOption = videoSelect[i]; if (selOption.selected) { matched = true; } else if (matched) { if (getById("flipcamerabutton").classList.contains("flip")) { getById("flipcamerabutton").classList.remove("flip"); getById("flipcamerabutton").classList.add("flip2"); } else { getById("flipcamerabutton").classList.remove("flip2"); getById("flipcamerabutton").classList.add("flip"); } document.querySelector("select#videoSource3").value = selOption.value; activatedPreview = false; grabVideo(session.quality, "videosource", "select#videoSource3"); return; } } for (var i = 0; i < maxIndex; i++) { var selOption = videoSelect[i]; if (selOption.selected) { return; // do nothing; the camera that is selected is the only camera available it seems. } else { if (getById("flipcamerabutton").classList.contains("flip")) { getById("flipcamerabutton").classList.remove("flip"); getById("flipcamerabutton").classList.add("flip2"); } else { getById("flipcamerabutton").classList.remove("flip2"); getById("flipcamerabutton").classList.add("flip"); } document.querySelector("select#videoSource3").value = selOption.value; activatedPreview = false; grabVideo(session.quality, "videosource", "select#videoSource3"); return; } } } function addToGoogleCalendar() { var title = "Live Stream"; //var dates = "20180512T230000Z/20180513T030000Z"; var linkout = getById("director_block_1").innerText; var details = "Join the live stream as a performer at the following link:

    ===> " + linkout + "

    To test your connection and camera ahead of time, please visit https://vdo.ninja/speedtest

    Do not share the details of this invite with others, unless explicitly told to."; details = details.split(" ").join("+"); details = details.split("&").join("%26"); var linkToOpen = "https://calendar.google.com/calendar/r/eventedit?text=" + title + "&details=" + details; //https://calendar.google.com/calendar/r/eventedit?text=My+Custom+Event&dates=20180512T230000Z/20180513T030000Z&details=For+details,+link+here:+https://example.com/tickets-43251101208&location=Garage+Boston+-+20+Linden+Street+-+Allston,+MA+02134 window.open(linkToOpen); } function addToOutlookCalendar() { var title = "Live Stream"; var linkout = getById("director_block_1").innerText; var details = "Join the live stream as a performer at the following link:

    ===> " + linkout + "

    To test your connection and camera ahead of time, please visit https://vdo.ninja/speedtest

    Do not share the details of this invite with others, unless explicitly told to."; details = details.split(" ").join("%20"); details = details.split("&").join("%26"); var linkToOpen = "https://outlook.live.com/owa/?path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&subject=" + title + "&body=" + details; //https://calendar.google.com/calendar/r/eventedit?text=My+Custom+Event&dates=20180512T230000Z/20180513T030000Z&details=For+details,+link+here:+https://example.com/tickets-43251101208&location=Garage+Boston+-+20+Linden+Street+-+Allston,+MA+02134 window.open(linkToOpen); } function addToYahooCalendar() { var title = "Live Stream"; var linkout = getById("director_block_1").innerText; var details = "Join the live stream as a performer at the following link:

    ===> " + linkout + "

    To test your connection and camera ahead of time, please visit https://vdo.ninja/speedtest

    Do not share the details of this invite with others, unless explicitly told to."; details = details.split(" ").join("%20"); details = details.split("&").join("%26"); var linkToOpen = "https://calendar.yahoo.com?v60&title=" + title + "&desc=" + details; //https://calendar.google.com/calendar/r/eventedit?text=My+Custom+Event&dates=20180512T230000Z/20180513T030000Z&details=For+details,+link+here:+https://example.com/tickets-43251101208&location=Garage+Boston+-+20+Linden+Street+-+Allston,+MA+02134 window.open(linkToOpen); } function toggle(ele, tog = false, inline = true) { var x = ele; if (x.style.display === "none") { if (inline) { x.style.display = "inline-block"; } else { x.style.display = "block"; } } else { x.style.display = "none"; } if (tog) { if (tog.dataset.saved) { tog.innerHTML = tog.dataset.saved; delete tog.dataset.saved; } else { tog.dataset.saved = tog.innerHTML; tog.innerHTML = "Hide This"; } } } function toggleByDataset(filter) { var elements = document.querySelectorAll('[data-cluster="' + filter + '"]'); // ie: .cluster1 for (var i = 0; i < elements.length; i++) { elements[i].classList.toggle("hidden"); } } var SelectedAudioOutputDevices = false; // session.sink var SelectedAudioInputDevices = []; // .. var SelectedVideoInputDevices = []; // .. async function enumerateDevices() { log("enumerated start"); const timeout = new Promise((_, reject) => setTimeout(() => reject(function () { if (!session.cleanOutput) { warnUser("The browser has not responded to our request to list available media devices.\n\nPossible solutions:\n\n- Restart the computer and try again\n- Try another browser\n- Remove or uninstall devices that are not needed\n- Uninstall and reinstall your browser"); } new Error("Device enumeration timed out.\n\nThe browser has not responded to our request to list available media devices.\n\nPossible solutions:\n\n- Restart the computer and try again\n- Try another browser\n- Remove or uninstall devices that are not needed\n- Uninstall and reinstall your browser"); }), 15000) ); const enumeratePromise = new Promise(async (resolve, reject) => { try { if (typeof navigator.mediaDevices === "object" && typeof navigator.mediaDevices.enumerateDevices === "function") { resolve(await navigator.mediaDevices.enumerateDevices()); } else if (typeof navigator.enumerateDevices === "function") { log("enumerated failed 1"); resolve(await navigator.enumerateDevices()); } else { window.MediaStreamTrack.getSources(devices => { resolve( devices .filter(device => { return device.kind.toLowerCase() === "video" || device.kind.toLowerCase() === "videoinput"; }) .map(device => { return { deviceId: device.deviceId != null ? device.deviceId : "", groupId: device.groupId, kind: "videoinput", label: device.label, toJSON: /* istanbul ignore next */ function () { return this; } }; }) ); }); } } catch (e) { errorlog(e); if (!session.cleanOutput) { if (location.protocol !== "https:") { warnUser("Error listing the media devices.\n\nYour browser will not allow access to media devices without SSL enabled.\n\nPossible solutions include switching to https, accessing the site from http://localhost, or enabling the `unsafely-treat-insecure-origin-as-secure` browser switch."); } else if ("isSecureContext" in window && window.isSecureContext === false) { warnUser("Error listing the media devices.\n\nThe website may have assets loaded in an insecure context."); } else { warnUser("An unknown error occured while trying to list the media devices."); } } reject(e); } }); return Promise.race([enumeratePromise, timeout]); } function requestOutputAudioStream() { try { //warnlog("GET USER MEDIA"); warnlog("navigator.mediaDevices.getUserMedia starting..."); return navigator.mediaDevices .getUserMedia({ audio: true, video: false }) .then(function (stream1) { // Apple needs thi to happen before I can access EnumerateDevices. log("get media sources; request audio stream"); return enumerateDevices().then(function (deviceInfos) { stream1.getTracks().forEach(function (track) { // We don't want to keep it without audio; so we are going to try to add audio now. track.stop(); // I need to do this after the enumeration step, else it breaks firefox's labels }); const audioOutputSelect = getById("outputSourceScreenshare"); audioOutputSelect.remove(0); audioOutputSelect.removeAttribute("onclick"); for (let i = 0; i !== deviceInfos.length; ++i) { const deviceInfo = deviceInfos[i]; if (deviceInfo == null) { continue; } const option = document.createElement("option"); option.value = deviceInfo.deviceId; if (deviceInfo.kind === "audiooutput") { const option = document.createElement("option"); if (audioOutputSelect.length === 0) { option.dataset.default = true; } else { option.dataset.default = false; } option.value = deviceInfo.deviceId || "default"; if (option.value == session.sink) { option.selected = "true"; } option.text = deviceInfo.label || `Speaker ${audioOutputSelect.length + 1}`; audioOutputSelect.appendChild(option); } else { log("Some other kind of source/device: ", deviceInfo); } } }); }); } catch (e) { if (!session.cleanOutput) { if (window.isSecureContext) { warnUser("An error has occured when trying to access the default audio device. The reason is not known."); } else if (iOS || iPad) { warnUser("iOS version 13.4 and up is generally recommended; older than iOS 11 is not supported."); } else { warnUser("Error accessing the default audio device.\n\nThe website may be loaded in an insecure context.\n\nPlease see: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia"); } } } } let selectedScreenShareAudioDevices = []; async function requestAudioStream() { // for the screen share. const deviceList = document.getElementById('audioDeviceList'); const errorElement = document.getElementById('audioSelectError'); const showDevicesButton = document.getElementById('showAudioDevices'); try { // Request audio permission first const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); // Stop tracks after getting permission stream.getTracks().forEach(track => track.stop()); // Enumerate devices const devices = await navigator.mediaDevices.enumerateDevices(); const audioInputs = devices.filter(device => device.kind === 'audioinput'); // Clear and show device list deviceList.innerHTML = ''; deviceList.style.display = 'block'; showDevicesButton.style.display = 'none'; selectedScreenShareAudioDevices = []; // Create checkbox for each device audioInputs.forEach(device => { const deviceLabel = device.label || `Microphone ${device.deviceId.slice(0, 4)}`; const div = document.createElement('div'); div.className = 'audio-device-item'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = device.deviceId; checkbox.value = device.deviceId; // Check if device was previously selected if (session.audioDevice && ((typeof session.audioDevice === 'object' && session.audioDevice.includes(device.deviceId)) || normalizeDeviceLabel(deviceLabel).includes(session.audioDevice))) { checkbox.checked = true; selectedScreenShareAudioDevices.push(device.deviceId); } checkbox.addEventListener('change', function () { // Update session.audioDevice array // if (!session.audioDevice || typeof session.audioDevice !== 'object') { // session.audioDevice = []; // } if (!selectedScreenShareAudioDevices || typeof selectedScreenShareAudioDevices !== 'object') { selectedScreenShareAudioDevices = []; } if (this.checked) { // if (!session.audioDevice.includes(this.value)) { // session.audioDevice.push(this.value); // } if (!selectedScreenShareAudioDevices.includes(this.value)) { selectedScreenShareAudioDevices.push(this.value); } } else { //session.audioDevice = session.audioDevice.filter(id => id !== this.value); selectedScreenShareAudioDevices = selectedScreenShareAudioDevices.filter(id => id !== this.value); } }); const label = document.createElement('label'); label.htmlFor = device.deviceId; label.textContent = deviceLabel; div.appendChild(checkbox); div.appendChild(label); deviceList.appendChild(div); }); errorElement.style.display = 'none'; } catch (e) { let errorMessage = ''; if (!window.isSecureContext) { errorMessage = 'This website must be loaded in a secure context (HTTPS) to access audio devices.'; } else if (/ipad|iphone|ipod/.test(navigator.userAgent.toLowerCase())) { errorMessage = 'iOS 13.4 or later is recommended for audio device access.'; } else { errorMessage = 'An error occurred while accessing audio devices.'; } errorElement.textContent = errorMessage; errorElement.style.display = 'block'; } } function saveSettings() { if (session.store) { try { var tmp = {}; if (SelectedAudioInputDevices) { tmp.SelectedAudioInputDevices = SelectedAudioInputDevices.filter(n => n); } if (session.sink && session.sink != "default") { tmp.SelectedAudioOutputDevices = session.sink; } else if (!session.sink && SelectedAudioOutputDevices && SelectedAudioOutputDevices != "default") { tmp.SelectedAudioOutputDevices = SelectedAudioOutputDevices; } tmp.SelectedVideoInputDevices = SelectedVideoInputDevices; setStorage("session_store", JSON.stringify(tmp)); log("Saving settings"); } catch (e) { errorlog(e); } } } function loadSettings() { if (session.store) { try { session.store = getStorage("session_store"); if (session.store) { session.store = JSON.parse(session.store); } else { session.store = {}; } if (session.store && session.store.SelectedAudioOutputDevices) { if (typeof session.store.SelectedAudioOutputDevices == "string") { SelectedAudioOutputDevices = session.store.SelectedAudioOutputDevices; } else if (typeof session.store.SelectedAudioOutputDevices == "object") { if (session.store.SelectedAudioOutputDevices.length) { SelectedAudioOutputDevices = session.store.SelectedAudioOutputDevices[0]; } } } if (session.store && session.store.SelectedAudioInputDevices) { session.store.SelectedAudioInputDevices = session.store.SelectedAudioInputDevices.filter(n => n); SelectedAudioInputDevices = session.store.SelectedAudioInputDevices; } if (session.store && session.store.SelectedVideoInputDevices) { SelectedVideoInputDevices = session.store.SelectedVideoInputDevices; } } catch (e) { } } } function normalizeDeviceLabel(deviceName) { return String(deviceName).replace(/[\W]+/g, "_").toLowerCase(); } // Conservative audio label normalizer to alias Windows "Default -" / "Communications -" prefixed devices function normalizeAudioAliasLabel(label) { try { if (!label) return ""; let s = String(label).trim(); // Normalize case and whitespace s = s.replace(/^\s+|\s+$/g, ""); // Remove leading Default/Communications prefixes with common separators ("-", ":", em/en dashes) // Keep the rest intact (do NOT strip digits or other differences) s = s.replace(/^(?:Default|Communications)\s*[-:\u2013\u2014]?\s*/i, ""); return s.toLowerCase(); } catch (e) { return String(label || "").toLowerCase(); } } function gotDevices(deviceInfos, miconly = false) { log("got devices!1"); log(deviceInfos); deviceInfos.sort((a, b) => { // Put "default" devices first if (a.deviceId.toLowerCase() === "default") return -1; if (b.deviceId.toLowerCase() === "default") return 1; // Then sort by label if both exist if (a.label && b.label) { return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); } return 0; }); try { if (Firefox && !FirefoxEnumerated) { if (session.streamSrc && session.streamSrc.getTracks().length) { FirefoxEnumerated = true; } } var option = document.createElement("input"); option.type = "checkbox"; option.value = "ZZZ"; option.name = "multiselect1"; option.id = "multiselect1"; option.style.display = "none"; option.checked = true; var label = document.createElement("label"); label.for = option.name; label.innerHTML = ' No Audio'; var listele = document.createElement("li"); listele.appendChild(option); listele.appendChild(label); const audioInputSelect = document.getElementById("audioSource") || document.getElementById("audioSource3"); audioInputSelect.innerHTML = ""; audioInputSelect.appendChild(listele); const audioOutputSelect = document.getElementById("outputSource") || document.getElementById("outputSource3"); audioOutputSelect.innerHTML = ""; option.onchange = function (event) { // make sure to clear 'no audio option' if anything else is selected if (!getById("multiselect1").checked) { getById("multiselect1").checked = true; } else { var list = audioInputSelect.querySelectorAll("li>input"); for (var i = 0; i < list.length; i++) { if (list[i].id !== "multiselect1") { list[i].checked = false; } } } SelectedAudioInputDevices = [event.currentTarget.value]; saveSettings(); }; const multiselectTrigger = document.getElementById("multiselect-trigger") || document.getElementById("multiselect-trigger3"); multiselectTrigger.dataset.state = "0"; multiselectTrigger.classList.add("closed"); multiselectTrigger.classList.remove("open"); getById("chevarrow1").classList.add("bottom"); const videoSelect = document.getElementById("videoSourceSelect") || document.getElementById("videoSource3"); const selectors = [videoSelect]; const values = selectors.map(select => select.value); selectors.forEach(select => { while (select.firstChild) { select.removeChild(select.firstChild); } }); function comp(a, b) { if (a.kind === "audioinput") { return 0; } else if (a.kind === "audiooutput") { return 0; } const labelA = a.label.toUpperCase(); const labelB = b.label.toUpperCase(); if (labelA > labelB) { return 1; } else if (labelA < labelB) { return -1; } return 0; } //deviceInfos.sort(comp); // I like this idea, but it messes with the defaults. I just don't know what it will do. var deviceInfo; // This is to hide NDI from default device. NDI Tools fucks up. var tmp = []; for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (!(deviceInfo.kind === "videoinput" && (deviceInfo.label.toLowerCase().startsWith("ndi") || deviceInfo.label.toLowerCase().startsWith("newtek")))) { tmp.push(deviceInfo); } } for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (deviceInfo.kind === "videoinput" && (deviceInfo.label.toLowerCase().startsWith("ndi") || deviceInfo.label.toLowerCase().startsWith("newtek"))) { tmp.push(deviceInfo); log("V DEVICE FOUND = " + normalizeDeviceLabel(deviceInfo.label)); } } deviceInfos = tmp; if (typeof session.audioDevice == "object") { // this sorts according to users's manual selection var matched1 = []; var matched2 = []; var notmatched = []; for (let i = 0; i !== deviceInfos.length; ++i) { if (deviceInfos[i].kind === "audioinput") { var deviceMatched = false; if (session.audioDevice.includes(deviceInfos[i].deviceId)) { matched1.push(deviceInfos[i]); deviceMatched = true; } else if (session.audioDevice.includes(normalizeDeviceLabel(deviceInfos[i].label))) { matched1.push(deviceInfos[i]); deviceMatched = true; } else { for (var j = 0; j < session.audioDevice.length; j++) { if (normalizeDeviceLabel(deviceInfos[i].label).includes(session.audioDevice[j])) { matched2.push(deviceInfos[i]); log("A DEVICE FOUND = " + deviceInfos[i].label); deviceMatched = true; break; } } } if (!deviceMatched) { notmatched.push(deviceInfos[i]); } } else { notmatched.push(deviceInfos[i]); } } matched2.sort((a, b) => { if (a.label && b.label) { if (a.label.length < b.label.length) { return -1 } return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); } return 0; }); var matched = matched1.concat(matched2); deviceInfos = matched.concat(notmatched); } else if (session.store && session.store.SelectedAudioInputDevices) { var matched = []; var notmatch = []; for (let i = 0; i < deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (session.store.SelectedAudioInputDevices.includes(deviceInfo.deviceId)) { matched.push(deviceInfo); log("EXACT A DEVICE FOUND -- from saved session"); } else { notmatch.push(deviceInfo); } } deviceInfos = matched.concat(notmatch); } if (session.sink || SelectedAudioOutputDevices) { // this sorts according to users's manual selection var matched = []; var notmatch = []; for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (deviceInfo.kind === "audiooutput" && deviceInfo.deviceId === session.sink) { matched.push(deviceInfo); } else if (!session.sink && deviceInfo.kind === "audiooutput" && deviceInfo.deviceId === SelectedAudioOutputDevices) { matched.push(deviceInfo); } else { notmatch.push(deviceInfo); } } deviceInfos = matched.concat(notmatch); } if (session.videoDevice && session.videoDevice !== 1) { var tmp = []; var tmp2 = []; var tmp3 = []; var deviceIdMatch = false; // First pass - check for label matches for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).startsWith(session.videoDevice)) { tmp.push(deviceInfo); log("Starts With V DEVICE FOUND"); } else if (deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).includes(session.videoDevice)) { tmp2.push(deviceInfo); log("Includes With V DEVICE FOUND"); } else { tmp3.push(deviceInfo); } } // If no label matches found, try device ID match if (tmp.length === 0 && tmp2.length === 0) { for (let i = 0; i < tmp3.length; ++i) { deviceInfo = tmp3[i]; if (deviceInfo.kind === "videoinput" && deviceInfo.deviceId === session.videoDevice) { tmp.push(deviceInfo); deviceIdMatch = true; log("EXACT DEVICE ID MATCH FOUND"); break; } } } if (tmp2.length && !deviceIdMatch) { tmp = tmp.concat(tmp2); } if (tmp3.length) { tmp = tmp.concat(tmp3); } deviceInfos = tmp; log("VDEVICE:" + session.videoDevice); log(deviceInfos); } else if (session.videoDevice === false && session.facingMode) { var tmp = []; if (session.facingMode == "environment") { for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).includes("back")) { tmp.push(deviceInfo); log("V DEVICE FOUND = " + normalizeDeviceLabel(deviceInfo.label)); } else if (deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).includes("rear")) { tmp.push(deviceInfo); log("V DEVICE FOUND = " + normalizeDeviceLabel(deviceInfo.label)); } } } else if (session.facingMode == "user") { for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).includes("front")) { tmp.push(deviceInfo); log("V DEVICE FOUND = " + normalizeDeviceLabel(deviceInfo.label)); } } } for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (!(deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).includes(session.videoDevice))) { if (deviceInfo.deviceId !== session.videoDevice) { tmp.push(deviceInfo); } } } deviceInfos = tmp; log("VDECICE:" + session.videoDevice); log(deviceInfos); } else if (session.store && session.store.SelectedVideoInputDevices && session.videoDevice === false) { var matched = []; var notmatch = []; for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (session.store.SelectedVideoInputDevices.includes(deviceInfo.deviceId)) { matched.push(deviceInfo); log("EXACT V DEVICE FOUND -- from saved session"); } else { notmatch.push(deviceInfo); } } deviceInfos = matched.concat(notmatch); delete session.store.SelectedVideoInputDevices; } else if (session.mobile) { var tmp = []; for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).includes("front")) { tmp.push(deviceInfo); log("V DEVICE FOUND = " + normalizeDeviceLabel(deviceInfo.label)); } } for (let i = 0; i !== deviceInfos.length; ++i) { deviceInfo = deviceInfos[i]; if (!(deviceInfo.kind === "videoinput" && normalizeDeviceLabel(deviceInfo.label).includes(session.videoDevice))) { if (deviceInfo.deviceId !== session.videoDevice) { tmp.push(deviceInfo); } } } deviceInfos = tmp; log("AUTO FRONT:" + session.videoDevice); log(deviceInfos); } if (session.audioDevice && typeof session.audioDevice == "object") { var adMatch = [...session.audioDevice]; } else if (session.store && session.store.SelectedAudioInputDevices && session.store.SelectedAudioInputDevices.length) { var adMatch = [...session.store.SelectedAudioInputDevices]; } else { var adMatch = false; } if (session.store && session.store.SelectedAudioInputDevices) { delete session.store.SelectedAudioInputDevices; } var counter = 1; var addedDeviceIds = new Set(); // Track already added devices for (let i = 0; i !== deviceInfos.length; ++i) { var deviceInfo = deviceInfos[i]; if (deviceInfo == null) { continue; } if (deviceInfo.kind === "audioinput") { // Skip if this device was already added if (addedDeviceIds.has(deviceInfo.deviceId)) { log("Skipping duplicate audio device: " + deviceInfo.label); continue; } addedDeviceIds.add(deviceInfo.deviceId); option = document.createElement("input"); option.type = "checkbox"; counter++; listele = document.createElement("li"); listele.style.display = "none"; if (typeof adMatch == "object") { for (var j = 0; j < adMatch.length; j++) { if (!adMatch[j]) { // skip, already matched } else if (adMatch[j] == deviceInfo.deviceId) { option.checked = true; listele.style.display = "block"; option.style.display = "none"; getById("multiselect1").checked = false; try { getById("multiselect1").parentNode.style.display = "none"; } catch (e) { } adMatch[j] = null; break; } else if (normalizeDeviceLabel(deviceInfo.label).includes(adMatch[j])) { option.checked = true; listele.style.display = "block"; option.style.display = "none"; getById("multiselect1").checked = false; try { getById("multiselect1").parentNode.style.display = "none"; } catch (e) { } adMatch[j] = null; break; } } } if (typeof adMatch !== "object" && counter == 2) { option.checked = true; listele.style.display = "block"; option.style.display = "none"; getById("multiselect1").checked = false; try { getById("multiselect1").parentNode.style.display = "none"; } catch (e) { } } option.value = deviceInfo.deviceId || "default"; option.name = "multiselect" + counter; option.id = "multiselect" + counter; option.label = deviceInfo.label; label = document.createElement("label"); label.for = option.name; label.innerHTML = " " + (deviceInfo.label || "microphone " + ((audioInputSelect.length || 0) + 1)); listele.appendChild(option); listele.appendChild(label); audioInputSelect.appendChild(listele); option.onchange = function (event) { // make sure to clear 'no audio option' if anything else is selected getById("multiselect1").checked = false; log("UNCHECKED"); if (!CtrlPressed) { SelectedAudioInputDevices = []; audioInputSelect.querySelectorAll("input[type='checkbox']").forEach(function (item) { if (event.currentTarget.id !== item.id) { item.checked = false; } else { item.checked = true; SelectedAudioInputDevices = [event.currentTarget.value]; } }); } else { if (event.currentTarget.checked) { if (!SelectedAudioInputDevices) { SelectedAudioInputDevices = [event.currentTarget.value]; } else if (!SelectedAudioInputDevices.includes(event.currentTarget.value)) { SelectedAudioInputDevices.push(event.currentTarget.value); } } else if (event.currentTarget.value) { while (SelectedAudioInputDevices.includes(event.currentTarget.value)) { SelectedAudioInputDevices.splice(SelectedAudioInputDevices.indexOf(event.currentTarget.value), 1); } } } if (session.mobile && !(iOS || iPad) && event.currentTarget.label === "USB audio" && !session.cleanOutput) { warnUser("Notice: USB audio devices may not work on all mobile devices.\n\nConsider using FireFox mobile instead, as it tends to work with USB audio devices more often."); } saveSettings(); }; if (deviceInfo.label.includes("Yeti ")) { if (!session.cleanOutput) { //getById("audioTipContext1").innerHTML = getTranslation("blue-yeti-tip"); miniTranslate(getById("audioTipContext1"), "blue-yeti-tip"); getById("audioTip1").classList.remove("hidden"); } } } else if (deviceInfo.kind === "videoinput") { option = document.createElement("option"); option.value = deviceInfo.deviceId || "default"; option.text = deviceInfo.label || `camera ${videoSelect.length + 1}`; videoSelect.appendChild(option); } else if (deviceInfo.kind === "audiooutput") { option = document.createElement("option"); if (audioOutputSelect.length === 0) { option.dataset.default = true; } else { option.dataset.default = false; } option.value = deviceInfo.deviceId || "default"; if (option.value == session.sink) { option.selected = "true"; } else if (!session.sink && SelectedAudioOutputDevices && SelectedAudioOutputDevices == option.value) { option.selected = "true"; } option.text = deviceInfo.label || `Speaker ${audioOutputSelect.length + 1}`; audioOutputSelect.appendChild(option); } else { log("Some other kind of source/device: ", deviceInfo); } } if (Firefox && !session.mobile) { var option = document.createElement("option"); option.value = "others"; option.text = getTranslation("show-more-options"); audioOutputSelect.appendChild(option); } if (audioOutputSelect.childNodes.length == 0) { option = document.createElement("option"); option.value = "default"; option.text = getTranslation("system-default"); audioOutputSelect.appendChild(option); } option = document.createElement("option"); option.text = getTranslation("disable-video"); option.value = "ZZZ"; videoSelect.appendChild(option); // NO AUDIO OPTION if (miconly) { option.selected = "true"; } selectors.forEach((select, selectorIndex) => { if (Array.prototype.slice.call(select.childNodes).some(n => n.value === values[selectorIndex])) { select.value = values[selectorIndex]; } }); } catch (e) { errorlog(e); } } function getUserMediaVideoParams(resolutionFallbackLevel, isSafariBrowser) { switch (resolutionFallbackLevel) { case -1: return {}; case -2: if (isSafariBrowser) { return { width: { min: 360, ideal: 3840, max: 3840 }, height: { min: 360, ideal: 2160, max: 2160 } }; } else if (Firefox) { return { width: { ideal: 3840 }, height: { ideal: 2160 } }; } else { return { width: { min: 720, ideal: 3840, max: 3840 }, height: { min: 720, ideal: 2160, max: 2160 } }; } case -3: if (isSafariBrowser) { return { width: { min: 360, ideal: 2560, max: 1440 }, height: { min: 360, ideal: 1440, max: 1440 } }; } else if (Firefox) { return { width: { ideal: 2560 }, height: { ideal: 1440 } }; } else { return { width: { min: 720, ideal: 2560, max: 2560 }, height: { min: 720, ideal: 1440, max: 1440 } }; } case 0: if (isSafariBrowser) { return { width: { min: 360, ideal: 1920, max: 1920 }, height: { min: 360, ideal: 1080, max: 1080 } }; } else if (Firefox) { return { width: { ideal: 1920 }, height: { ideal: 1080 } }; } else { return { width: { min: 720, ideal: 1920, max: 1920 }, height: { min: 720, ideal: 1080, max: 1920 } }; } case 1: if (isSafariBrowser) { return { width: { min: 360, ideal: 1280, max: 1280 }, height: { min: 360, ideal: 720, max: 720 } }; } else if (Firefox) { return { width: { ideal: 1280 }, height: { ideal: 720 } }; } else { return { width: { min: 720, ideal: 1280, max: 1280 }, height: { min: 720, ideal: 720, max: 1280 } }; } case 2: if (isSafariBrowser) { return { width: { min: 640 }, height: { min: 360 } }; } else if (Firefox) { return { width: { ideal: 640 }, height: { ideal: 360 } }; } else { return { width: { min: 240, ideal: 640, max: 1280 }, height: { min: 240, ideal: 360, max: 1280 } }; } case 3: if (isSafariBrowser) { return { width: { min: 360, ideal: 1280, max: 1440 } }; } else { return { width: { min: 360, ideal: 1280, max: 1440 } }; } case 4: if (isSafariBrowser) { return { height: { min: 360, ideal: 720, max: 960 } }; } else { return { height: { ideal: 720, max: 960 } }; } case 5: if (isSafariBrowser) { return { width: { min: 360, ideal: 640, max: 1440 }, height: { min: 360, ideal: 360, max: 720 } }; } else { return { width: { ideal: 640, max: 1920 }, height: { ideal: 360, max: 1920 } }; // same as default, but I didn't want to mess with frameRates until I gave it all a try first } case 6: if (isSafariBrowser) { return {}; // iphone users probably don't need to wait any longer, so let them just get to it } else { return { width: { min: 360, ideal: 640, max: 3840 }, height: { min: 360, ideal: 360, max: 2160 } }; } case 7: return { // If the camera is recording in low-light, it may have a low frameRate. It coudl also be recording at a very high resolution. width: { min: 360, ideal: 640 }, height: { min: 360, ideal: 360 } }; case 8: return { width: { min: 360 }, height: { min: 360 }, frameRate: 10 }; // same as default, but I didn't want to mess with frameRates until I gave it all a try first case 9: return { frameRate: 0 }; // Some Samsung Devices report they can only support a frameRate of 0. case 10: return {}; default: return {}; } } function addScreenDevices(device) { if (device.kind == "audio") { const audioInputSelect = getById("audioSource3"); const listele = document.createElement("li"); listele.style.display = "block"; const option = document.createElement("input"); option.type = "checkbox"; option.checked = true; if (getById("multiselect-trigger3").dataset.state == 0) { option.style.display = "none"; } option.value = device.id; option.name = device.label; option.dataset.type = "screen"; option.label = device.label; const label = document.createElement("label"); label.for = option.name; label.innerHTML = " " + device.label; listele.appendChild(option); listele.appendChild(label); option.onchange = function (event) { // make sure to clear 'no audio option' if anything else is selected log("change 4644"); if (!CtrlPressed) { document.querySelectorAll("#audioSource3 input[type='checkbox']").forEach(function (item) { if (!item.value) { return; } if (event.currentTarget.value !== item.value) { // this shoulnd't happen, but if it does. item.checked = false; if (item.dataset.type == "screen") { item.parentElement.parentElement.removeChild(item.parentElement); } while (SelectedAudioInputDevices.indexOf(item.value) > -1) { SelectedAudioInputDevices.splice(SelectedAudioInputDevices.indexOf(item.value), 1); } activatedPreview = false; grabAudio("#audioSource3"); // exclude item.id } else { if (SelectedAudioInputDevices.indexOf(item.value) == -1) { if (SelectedAudioInputDevices.length && SelectedAudioInputDevices.includes("ZZZ")) { SelectedAudioInputDevices = []; } SelectedAudioInputDevices.push(item.value); } item.checked = true; activatedPreview = false; grabAudio("#audioSource3", item.value); // exclude item.id. we will reconnect, even if already connected, as a way to 'reset' a device if it isn't working. } }); } saveSettings(); event.stopPropagation(); return false; }; audioInputSelect.appendChild(listele); getById("audioSourceNoAudio2").checked = false; } else if (device.kind == "video") { const videoSelect = getById("videoSource3"); //const selectors = [ videoSelect]; //const values = selectors.map(select => select.value); const option = document.createElement("option"); option.value = device.id; option.text = device.label; option.selected = "true"; option.label = device.label; videoSelect.appendChild(option); } } var gotDevices2AlreadyRan = false; function gotDevices2(deviceInfos) { gotDevices2AlreadyRan = true; log("got devices!2"); log(deviceInfos); getById("multiselect-trigger3").dataset.state = "0"; getById("multiselect-trigger3").classList.add("closed"); getById("multiselect-trigger3").classList.remove("open"); getById("chevarrow2").classList.add("bottom"); if (!session.streamSrc) { checkBasicStreamsExist(); } var knownTrack = false; try { const audioInputSelect = getById("audioSource3"); const videoSelect = getById("videoSource3"); const audioOutputSelect = getById("outputSource3"); const selectors = [videoSelect]; // Build active audio deviceId and label sets to avoid duplicate selection const activeAudioIds = new Set(); const activeAudioLabels = new Set(); const activeAudioNormLabels = new Set(); try { if (session.streamSrc) { session.streamSrc.getAudioTracks().forEach(function (t) { try { if (t.label) { activeAudioLabels.add(t.label); activeAudioNormLabels.add(normalizeAudioAliasLabel(t.label)); } if (t.getSettings) { const s = t.getSettings(); if (s && s.deviceId) { activeAudioIds.add(s.deviceId); } } } catch (e) { } }); } } catch (e) { } // Identify normalized labels that have non-default/communications entries const nonDefaultNormLabelSet = new Set(); try { for (let i = 0; i !== deviceInfos.length; ++i) { const d = deviceInfos[i]; if (!d || d.kind !== "audioinput") continue; const id = (d.deviceId || "").toLowerCase(); if (id !== "default" && id !== "communications" && d.label) { nonDefaultNormLabelSet.add(normalizeAudioAliasLabel(d.label)); } } } catch (e) { } // Track which normalized labels we've already auto-checked to avoid duplicates const checkedByNormLabel = new Set(); // Track deviceIds we've already added to avoid duplicate entries from buggy drivers const addedDeviceIds = new Set(); [audioInputSelect].forEach(select => { while (select.firstChild) { select.removeChild(select.firstChild); } }); const values = selectors.map(select => select.value); selectors.forEach(select => { while (select.firstChild) { select.removeChild(select.firstChild); } }); [audioOutputSelect].forEach(select => { while (select.firstChild) { select.removeChild(select.firstChild); } }); var counter = 0; for (let i = 0; i !== deviceInfos.length; ++i) { const deviceInfo = deviceInfos[i]; if (deviceInfo == null) { continue; } if (deviceInfo.kind === "audioinput") { // Deduplicate by deviceId if possible (defensive against buggy drivers) try { if (deviceInfo.deviceId && addedDeviceIds.has(deviceInfo.deviceId)) { log("Skipping duplicate audio device: " + deviceInfo.label); continue; } if (deviceInfo.deviceId) { addedDeviceIds.add(deviceInfo.deviceId); } } catch (e) { } var option = document.createElement("input"); option.type = "checkbox"; counter++; var listele = document.createElement("li"); listele.style.display = "none"; // Auto-check selection based on active track deviceId first, fall back to normalized label try { let shouldCheck = false; const devIdLower = (deviceInfo.deviceId || "").toLowerCase(); const normLabel = normalizeAudioAliasLabel(deviceInfo.label || ""); if (activeAudioIds.size && deviceInfo.deviceId && activeAudioIds.has(deviceInfo.deviceId)) { shouldCheck = true; } else if (!activeAudioIds.size && deviceInfo.label && activeAudioNormLabels.has(normLabel)) { // Prefer non-default entries when multiple share a label const isDefaultish = (devIdLower === "default" || devIdLower === "communications"); if (checkedByNormLabel.has(normLabel)) { shouldCheck = false; } else if (isDefaultish && nonDefaultNormLabelSet.has(normLabel)) { shouldCheck = false; } else { shouldCheck = true; } } if (shouldCheck) { option.checked = true; listele.style.display = "inherit"; if (normLabel) { checkedByNormLabel.add(normLabel); } } } catch (e) { } option.style.display = "none"; option.value = deviceInfo.deviceId || "default"; option.name = "multiselecta" + counter; option.id = "multiselecta" + counter; option.dataset.label = deviceInfo.label || "microphone " + ((audioInputSelect.length || 0) + 1); try { option.dataset.norm = normalizeAudioAliasLabel(option.dataset.label); } catch (e) { } try { option.dataset.groupId = deviceInfo.groupId || ""; } catch (e) { } var label = document.createElement("label"); label.for = option.name; label.innerHTML = " " + (deviceInfo.label || "microphone " + ((audioInputSelect.length || 0) + 1)); listele.appendChild(option); listele.appendChild(label); audioInputSelect.appendChild(listele); option.onchange = function (event) { // make sure to clear 'no audio option' if anything else is selected log("change 4768"); if (!CtrlPressed) { document.querySelectorAll("#audioSource3 input[type='checkbox']").forEach(function (item) { if (event.currentTarget.value !== item.value) { item.checked = false; if (item.dataset.type == "screen") { item.parentElement.parentElement.removeChild(item.parentElement); } while (SelectedAudioInputDevices.indexOf(item.value) > -1) { SelectedAudioInputDevices.splice(SelectedAudioInputDevices.indexOf(item.value), 1); } } else { item.checked = true; if (SelectedAudioInputDevices.indexOf(event.currentTarget.value) == -1) { if (SelectedAudioInputDevices.length && SelectedAudioInputDevices.includes("ZZZ")) { SelectedAudioInputDevices = []; } SelectedAudioInputDevices.push(event.currentTarget.value); } } }); } else { if (SelectedAudioInputDevices.indexOf(event.currentTarget.value) == -1) { if (SelectedAudioInputDevices.length && SelectedAudioInputDevices.includes("ZZZ")) { SelectedAudioInputDevices = []; } SelectedAudioInputDevices.push(event.currentTarget.value); } getById("audioSourceNoAudio2").checked = false; } saveSettings(); }; } else if (deviceInfo.kind === "videoinput") { var option = document.createElement("option"); option.value = deviceInfo.deviceId || "default"; option.text = deviceInfo.label || `camera ${videoSelect.length + 1}`; try { if (!knownTrack && session.canvasSource) { session.canvasSource.srcObject.getVideoTracks().forEach(function (track) { if (option.text == track.label) { option.selected = "true"; knownTrack = true; } }); } if (!knownTrack && session.streamSrc) { session.streamSrc.getVideoTracks().forEach(function (track) { if (option.text == track.label) { option.selected = "true"; knownTrack = true; } }); } } catch (e) { errorlog(e); } videoSelect.appendChild(option); } else if (deviceInfo.kind === "audiooutput") { var option = document.createElement("option"); if (audioOutputSelect.length === 0) { option.dataset.default = true; } else { option.dataset.default = false; } option.value = deviceInfo.deviceId || "default"; if (option.value == session.sink) { option.selected = "true"; } else if (!session.sink && SelectedAudioOutputDevices && SelectedAudioOutputDevices == option.value) { option.selected = "true"; session.sink = option.value; // added 8-dec-22, as the director's saved mic wasn't applying otherwise. } option.text = deviceInfo.label || `Speaker ${audioOutputSelect.length + 1}`; audioOutputSelect.appendChild(option); } else { log("Some other kind of source/device: ", deviceInfo); } } if (Firefox && !session.mobile) { var option = document.createElement("option"); option.value = "others"; option.text = getTranslation("show-more-options"); audioOutputSelect.appendChild(option); } if (audioOutputSelect.childNodes.length == 0) { var option = document.createElement("option"); option.value = "default"; option.text = getTranslation("system-default"); audioOutputSelect.appendChild(option); } if (videoSelect.childNodes.length <= 1) { getById("flipcamerabutton").style.display = "none"; // don't show the camera cycle button getById("flipcamerabutton").dataset.maxndex = videoSelect.childNodes.length; } else { getById("flipcamerabutton").style.display = "unset"; getById("flipcamerabutton").dataset.maxIndex = videoSelect.childNodes.length; } //////////// session.streamSrc.getAudioTracks().forEach(function (track) { // add active ScreenShare audio tracks to the list log("Checking for screenshare audio"); var matched = false; for (var i = 0; i !== deviceInfos.length; ++i) { var deviceInfo = deviceInfos[i]; if (deviceInfo == null) { continue; } log("---"); if (track.label == deviceInfo.label) { matched = true; continue; } } if (matched == false) { // Not a gUM device var listele = document.createElement("li"); listele.style.display = "block"; var option = document.createElement("input"); option.type = "checkbox"; option.value = track.id; option.checked = true; option.style.display = "none"; option.name = track.label; option.label = track.label; option.dataset.type = "screen"; var label = document.createElement("label"); label.for = option.name; label.innerHTML = " " + track.label; listele.appendChild(option); listele.appendChild(label); option.onchange = function (event) { // make sure to clear 'no audio option' if anything else is selected log("change 4873"); var trackid = null; if (!CtrlPressed) { document.querySelectorAll("#audioSource3 input[type='checkbox']").forEach(function (item) { if (event.currentTarget.value !== item.value) { // this shoulnd't happen, but if it does. item.checked = false; if (item.dataset.type == "screen") { item.parentElement.parentElement.removeChild(item.parentElement); } } else { event.currentTarget.checked = true; trackid = item.value; } }); } else { //getById("audioSourceNoAudio2").checked=false; if (event.currentTarget.dataset.type == "screen") { event.currentTarget.parentElement.parentElement.removeChild(event.currentTarget.parentElement); } } activatedPreview = false; grabAudio("#audioSource3", trackid); // exclude item.id. event.stopPropagation(); return false; }; audioInputSelect.appendChild(listele); } }); /////////// no video option var optionss = false; if (screensharesupport) { optionss = document.createElement("option"); optionss.text = "Screen Share (replace camera)"; optionss.value = "XXX"; videoSelect.appendChild(optionss); // NO AUDIO OPTION } var option = document.createElement("option"); // no video option.text = getTranslation("disable-video"); option.value = "ZZZ"; videoSelect.appendChild(option); if (session.streamSrc.getVideoTracks().length == 0) { option.selected = "true"; } else if (knownTrack == false) { var option = document.createElement("option"); // no video option.text = session.streamSrc.getVideoTracks()[0].label; option.value = "YYY"; videoSelect.appendChild(option); option.selected = "true"; } if (optionss) { optionss.lastSelected = videoSelect.selectedIndex; } videoSelect.onchange = function (event) { try { if (event.target.options[event.target.options.selectedIndex].value === "XXX") { videoSelect.selectedIndex = event.target.options[event.target.options.selectedIndex].lastSelected; if (session.screenShareState == false) { toggleScreenShare(); } else { toggleScreenShare(true); } return; } } catch (e) { } activatedPreview = false; grabVideo(session.quality, "videosource", "select#videoSource3"); if (!getById("audioSource3").querySelectorAll("input[data-type='screen']").length) { if (session.screenShareState) { session.screenShareState = false; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); notifyOfScreenShare(); //session.refreshScale(); } getById("screensharebutton").classList.remove("green"); getById("screensharebutton").ariaPressed = "false"; } }; ///////////// /// NO AUDIO appended option var option = document.createElement("input"); option.type = "checkbox"; option.value = "ZZZ"; option.style.display = "none"; option.id = "audioSourceNoAudio2"; var label = document.createElement("label"); label.for = option.name; label.innerHTML = " No Audio"; var listele = document.createElement("li"); if (session.streamSrc.getAudioTracks().length == 0) { option.checked = true; } else { listele.style.display = "none"; option.checked = false; } option.onchange = function (event) { // make sure to clear 'no audio option' if anything else is selected log("change 4938"); if (!CtrlPressed) { document.querySelectorAll("#audioSource3 input[type='checkbox']").forEach(function (item) { if (event.currentTarget.value !== item.value) { item.checked = false; if (item.dataset.type == "screen") { item.parentElement.parentElement.removeChild(item.parentElement); } while (SelectedAudioInputDevices.indexOf(item.value) > -1) { SelectedAudioInputDevices.splice(SelectedAudioInputDevices.indexOf(item.value), 1); } } else { item.checked = true; if (SelectedAudioInputDevices.indexOf(event.currentTarget.value) == -1) { if (SelectedAudioInputDevices.length && SelectedAudioInputDevices.includes("ZZZ")) { SelectedAudioInputDevices = []; } SelectedAudioInputDevices.push(event.currentTarget.value); } } }); } else { document.querySelectorAll("#audioSource3 input[type='checkbox']").forEach(function (item) { if (event.currentTarget.value === item.value) { event.currentTarget.checked = true; if (SelectedAudioInputDevices.indexOf(event.currentTarget.value) == -1) { if (SelectedAudioInputDevices.length && SelectedAudioInputDevices.includes("ZZZ")) { SelectedAudioInputDevices = []; } SelectedAudioInputDevices.push(event.currentTarget.value); } } else { item.checked = false; if (item.dataset.type == "screen") { item.parentElement.parentElement.removeChild(item.parentElement); } while (SelectedAudioInputDevices.indexOf(item.value) > -1) { SelectedAudioInputDevices.splice(SelectedAudioInputDevices.indexOf(item.value), 1); } } }); } saveSettings(); }; listele.appendChild(option); listele.appendChild(label); audioInputSelect.appendChild(listele); //////////// //selectors.forEach((select, selectorIndex) => { // if (Array.prototype.slice.call(select.childNodes).some(n => n.value === values[selectorIndex])) { // select.value = values[selectorIndex]; // } //}); audioInputSelect.onchange = function () { log("Audio OPTION HAS CHANGED? 2"); activatedPreview = false; setTimeout(function () { grabAudio("#audioSource3"); }, 10); }; getById("refreshVideoButton").onclick = function () { refreshVideoDevice(); }; if (Firefox && !session.mobile && navigator.mediaDevices) { audioOutputSelect.onclick = function () { log("audioOutputSelect.onclick = function() {"); if (audioOutputSelect.options[audioOutputSelect.selectedIndex].value === "others") { log("Trying to increase the output device list"); navigator.mediaDevices.selectAudioOutput().then(device => { if (device.kind == "audiooutput") { session.sink = device.deviceId; try { var matched = false; audioOutputSelect.childNodes.forEach(ele => { if (ele.value === device.deviceId) { matched = true; ele.selected = true; } }); if (!matched) { var option = document.createElement("option"); option.value = device.deviceId; option.text = device.label; audioOutputSelect.appendChild(option); option.selected = true; } saveSettings(); // we're saving because there was an explicit action to change devices } catch (e) { errorlog(e); } if (!session.sink) { return; } // Not sure this would ever happen, but whatever. resetupAudioOut(); // we'll probalby use session.sink, since outputSelect3 doesn't exist. } }); } }; } else if (!navigator.mediaDevices) { console.warn("No navigator.mediaDevices found - try a different browser or check your settings."); } audioOutputSelect.onchange = function () { log("audioOutputSelect.onchange = function() {"); if (iOS || iPad) { return; } if (Firefox && !session.mobile) { if (audioOutputSelect.options[audioOutputSelect.selectedIndex].value === "others") { // we handle this elsewhere return; } } try { session.sink = audioOutputSelect.options[audioOutputSelect.selectedIndex].value; saveSettings(); } catch (e) { errorlog(e); } if (!session.sink) { return; } resetupAudioOut(); log("done audioOutputSelect.onchange = function() {"); }; } catch (e) { errorlog(e); } } function refreshMicrophoneDevice(UUID = false) { if (session.screenShareState || session.mediafileShare) { log("can't refresh a screenshare or fileshare"); if (UUID) { var data = {}; data.UUID = UUID; data.rejected = "can't refresh mic during screen or file share"; session.sendMessage(data, data.UUID); } return; } log("refreshing microphone.."); activatedPreview = false; grabAudio("#audioSource3", null, false, UUID); } function refreshVideoDevice(UUID = false) { if (session.screenShareState || session.mediafileShare) { log("can't refresh video during screenshare or fileshare"); if (UUID) { var data = {}; data.UUID = UUID; data.rejected = "can't refresh video during screen or file share"; session.sendMessage(data, data.UUID); } return; } log("refreshing video device.."); activatedPreview = false; grabVideo(session.quality, "videosource", "select#videoSource3"); } function directRefreshVideo(ele) { var UUID = ele.dataset.UUID; if (!UUID) { return; } var data = {}; data.refreshVideo = true; data.UUID = UUID; if (session.sendRequest(data, UUID)) { ele.classList.add("pressed"); setTimeout((ele) => { if (ele) { ele.classList.remove("pressed"); } }, 400, ele); } } function directRefreshConnection(ele) { var UUID = ele.dataset.UUID; if (!UUID) { return; } var data = {}; data.refreshConnection = true; data.UUID = UUID; if (session.sendRequest(data, UUID)) { ele.classList.add("pressed"); setTimeout((ele) => { if (ele) { ele.classList.remove("pressed"); } }, 400, ele); } } function directReconnectPeer(guestUUID, peerUUID) { // Tell a specific guest to reconnect to a specific peer var data = {}; data.reconnectPeer = peerUUID; data.UUID = guestUUID; session.sendRequest(data, guestUUID); log("Sent reconnectPeer command to " + guestUUID + " for peer " + peerUUID); } // Mesh diagram action wrappers function meshRefreshVideo(uuid) { var data = {}; data.refreshVideo = true; data.UUID = uuid; session.sendRequest(data, uuid); log("Sent refreshVideo to " + uuid); } function meshRefreshConnection(uuid) { var data = {}; data.refreshConnection = true; data.UUID = uuid; session.sendRequest(data, uuid); log("Sent refreshConnection (ICE restart) to " + uuid); } function meshRefreshAll(uuid) { var data = {}; data.refreshAll = true; data.UUID = uuid; session.sendRequest(data, uuid); log("Sent refreshAll to " + uuid); } function meshRefreshMic(uuid) { var data = {}; data.refreshMicrophone = true; data.UUID = uuid; session.sendRequest(data, uuid); log("Sent refreshMicrophone to " + uuid); } function meshRestartWhip(uuid) { var data = {}; data.restartWhip = true; data.UUID = uuid; session.sendRequest(data, uuid); log("Sent restartWhip to " + uuid); } // ============================================ // MESH NETWORK VISUALIZATION // ============================================ var meshData = { nodes: {}, // uuid -> node info edges: [], // connection info between nodes pendingResponses: 0, lastRefresh: 0, modalOpen: false, patchedConnections: {}, // "uuidA-uuidB" -> {prevStateA: bool, prevStateB: bool} for connections being relayed via mix-minus whipStatus: null, // {state, url, connected, reconnectAttempts} - WHIP outbound status whepConnections: {} // uuid -> {state, connected} - WHEP inbound connections }; // Patch a failed P2P connection via mix-minus relay // Director becomes the audio bridge between two guests function patchConnectionViaMixMinus(uuidA, uuidB) { if (!session.mixMinusState) { session.mixMinusState = {}; } // Initialize mix-minus state for both guests if needed if (!session.mixMinusState[uuidA]) { initMixMinusStateForGuest(uuidA); } if (!session.mixMinusState[uuidB]) { initMixMinusStateForGuest(uuidB); } // Record previous state before modifying (for proper restore on unpatch) var wasAExcludedFromB = session.mixMinusState[uuidB].excludeSources.includes(uuidA); var wasBExcludedFromA = session.mixMinusState[uuidA].excludeSources.includes(uuidB); var wasAEnabled = session.mixMinusState[uuidA].enabled || false; var wasBEnabled = session.mixMinusState[uuidB].enabled || false; // Enable mix-minus for both guests (required for patching to work) session.mixMinusState[uuidA].enabled = true; session.mixMinusState[uuidB].enabled = true; // Remove A from B's excludeSources (so B hears A via director) var idxAinB = session.mixMinusState[uuidB].excludeSources.indexOf(uuidA); if (idxAinB > -1) { session.mixMinusState[uuidB].excludeSources.splice(idxAinB, 1); } // Remove B from A's excludeSources (so A hears B via director) var idxBinA = session.mixMinusState[uuidA].excludeSources.indexOf(uuidB); if (idxBinA > -1) { session.mixMinusState[uuidA].excludeSources.splice(idxBinA, 1); } // Update the mixes updateMixMinusForGuest(uuidA); updateMixMinusForGuest(uuidB); // Track this patched connection with previous state for proper restore // Use sorted order so we can correctly restore regardless of call order var sorted = [uuidA, uuidB].sort(); var patchKey = sorted.join("-"); meshData.patchedConnections[patchKey] = { // Store as: was sorted[0] excluded from sorted[1]'s mix, and vice versa wasFirstExcludedFromSecond: sorted[0] === uuidA ? wasAExcludedFromB : wasBExcludedFromA, wasSecondExcludedFromFirst: sorted[0] === uuidA ? wasBExcludedFromA : wasAExcludedFromB, // Store enabled state for both guests wasFirstEnabled: sorted[0] === uuidA ? wasAEnabled : wasBEnabled, wasSecondEnabled: sorted[0] === uuidA ? wasBEnabled : wasAEnabled }; log("Patched connection via mix-minus: " + uuidA + " <-> " + uuidB); } // Unpatch a connection (when P2P recovers or manually) function unpatchConnection(uuidA, uuidB) { if (!session.mixMinusState) return; var sorted = [uuidA, uuidB].sort(); var patchKey = sorted.join("-"); var savedState = meshData.patchedConnections[patchKey]; // Restore previous exclude state (only add back if they were excluded before patching) // sorted[0] = first UUID alphabetically, sorted[1] = second var firstUUID = sorted[0]; var secondUUID = sorted[1]; if (session.mixMinusState[secondUUID] && savedState && savedState.wasFirstExcludedFromSecond) { // First was excluded from second's mix before - restore that if (!session.mixMinusState[secondUUID].excludeSources.includes(firstUUID)) { session.mixMinusState[secondUUID].excludeSources.push(firstUUID); } updateMixMinusForGuest(secondUUID); } if (session.mixMinusState[firstUUID] && savedState && savedState.wasSecondExcludedFromFirst) { // Second was excluded from first's mix before - restore that if (!session.mixMinusState[firstUUID].excludeSources.includes(secondUUID)) { session.mixMinusState[firstUUID].excludeSources.push(secondUUID); } updateMixMinusForGuest(firstUUID); } // Restore previous enabled state for both guests if (savedState) { if (session.mixMinusState[firstUUID]) { session.mixMinusState[firstUUID].enabled = savedState.wasFirstEnabled; updateMixMinusForGuest(firstUUID); } if (session.mixMinusState[secondUUID]) { session.mixMinusState[secondUUID].enabled = savedState.wasSecondEnabled; updateMixMinusForGuest(secondUUID); } } // Remove from patched tracking delete meshData.patchedConnections[patchKey]; log("Unpatched connection: " + uuidA + " <-> " + uuidB); } // Auto-patch all failed connections in the mesh function autoPatchAllFailed() { var patchCount = 0; meshData.edges.forEach(function(edge) { if (edge.state === "failed" || edge.state === "disconnected") { // Skip edges involving director or viewers - patching only makes sense for guest↔guest var sourceNode = meshData.nodes[edge.source]; var targetNode = meshData.nodes[edge.target]; if (sourceNode && (sourceNode.isDirector || sourceNode.isViewer)) return; if (targetNode && (targetNode.isDirector || targetNode.isViewer)) return; var patchKey = [edge.source, edge.target].sort().join("-"); if (!meshData.patchedConnections[patchKey]) { patchConnectionViaMixMinus(edge.source, edge.target); patchCount++; } } }); log("Auto-patched " + patchCount + " failed connections via mix-minus"); if (meshData.modalOpen) { renderMeshVisualization(); } return patchCount; } // Auto-unpatch connections that have recovered function autoUnpatchRecovered() { var unpatchCount = 0; // Collect keys to unpatch first (avoid modifying while iterating) var toUnpatch = []; for (var patchKey in meshData.patchedConnections) { // Find the corresponding edge var edge = meshData.edges.find(function(e) { return e.id === patchKey; }); if (edge && edge.state === "connected") { toUnpatch.push(patchKey); } } // Now unpatch collected connections toUnpatch.forEach(function(patchKey) { var uuids = patchKey.split("-"); unpatchConnection(uuids[0], uuids[1]); unpatchCount++; }); if (unpatchCount > 0) { log("Auto-unpatched " + unpatchCount + " recovered connections"); if (meshData.modalOpen) { renderMeshVisualization(); } } return unpatchCount; } // Check if a connection is currently patched function isConnectionPatched(uuidA, uuidB) { var patchKey = [uuidA, uuidB].sort().join("-"); return !!meshData.patchedConnections[patchKey]; } // UI wrapper for patching from mesh diagram function meshPatchConnection(uuidA, uuidB) { patchConnectionViaMixMinus(uuidA, uuidB); // Refresh the edge details panel var edgeId = [uuidA, uuidB].sort().join("-"); var edge = meshData.edges.find(function(e) { return e.id === edgeId; }); if (edge) { showEdgeDetails(edge); } renderMeshVisualization(); } // UI wrapper for unpatching from mesh diagram function meshUnpatchConnection(uuidA, uuidB) { unpatchConnection(uuidA, uuidB); // Refresh the edge details panel var edgeId = [uuidA, uuidB].sort().join("-"); var edge = meshData.edges.find(function(e) { return e.id === edgeId; }); if (edge) { showEdgeDetails(edge); } renderMeshVisualization(); } function requestMeshData() { // Request connection maps from all connected guests meshData.nodes = {}; meshData.edges = []; meshData.pendingResponses = 0; // Collect WHIP outbound status meshData.whipStatus = null; if (session.whipOut) { meshData.whipStatus = { state: session.whipOut.connectionState || session.whipOut.iceConnectionState || "unknown", url: session.whipOutput || "", connected: session.whipOut.connectionState === 'connected' || session.whipOut.iceConnectionState === 'connected' || session.whipOut.iceConnectionState === 'completed', reconnectAttempts: session.getWhipReconnectAttempts ? session.getWhipReconnectAttempts() : 0 }; } // Collect WHEP inbound connection statuses meshData.whepConnections = {}; for (var uuid in session.rpcs) { if (session.rpcs[uuid] && session.rpcs[uuid].whep) { meshData.whepConnections[uuid] = { state: session.rpcs[uuid].whep.connectionState || session.rpcs[uuid].whep.iceConnectionState || "unknown", connected: session.rpcs[uuid].whep.connectionState === 'connected' || session.rpcs[uuid].whep.iceConnectionState === 'connected' || session.rpcs[uuid].whep.iceConnectionState === 'completed' }; } } // Add director as a node - include its connections from rpcs and pcs var directorConnections = []; // Director's rpcs = guests publishing TO director = director receives from them for (var uuid in session.rpcs) { if (session.rpcs[uuid]) { var rpc = session.rpcs[uuid]; directorConnections.push({ peerUUID: uuid, peerStreamID: rpc.streamID || uuid, direction: "incoming", // Director receives from guest state: rpc.connectionState || "connected", bandwidth: rpc.bandwidth || -1, audioEnabled: true, videoEnabled: true }); } } // Director's pcs = director publishing TO peers (data channels, etc.) for (var uuid in session.pcs) { if (session.pcs[uuid]) { var pc = session.pcs[uuid]; directorConnections.push({ peerUUID: uuid, peerStreamID: pc.streamID || uuid, direction: "outgoing", // Director sends to guest/scene state: pc.connectionState || "connected", bandwidth: -1, audioEnabled: true, videoEnabled: true }); } } meshData.nodes[session.UUID] = { uuid: session.UUID, streamID: session.streamID, label: "Director", isDirector: true, connections: directorConnections, health: "healthy" }; // Request from all rpcs (guests we're receiving from) for (var uuid in session.rpcs) { if (session.rpcs[uuid]) { var data = { getConnectionMap: true, UUID: uuid }; session.sendRequest(data, uuid); meshData.pendingResponses++; // Add node placeholder meshData.nodes[uuid] = { uuid: uuid, streamID: session.rpcs[uuid].streamID || uuid, label: session.rpcs[uuid].label || session.rpcs[uuid].streamID || "Guest", isDirector: false, connections: [], health: "pending" }; } } log("Requested mesh data from " + meshData.pendingResponses + " guests"); // Set timeout to process after responses come in setTimeout(function() { aggregateMeshData(); renderMeshVisualization(); }, 2000); } function handleConnectionMapResponse(msg, UUID) { // Called when a guest responds with their connection map // UUID = the key from director's rpcs (how director identifies this guest) // msg.connectionMap.uuid = guest's session.UUID (might differ!) if (msg.connectionMap) { var map = msg.connectionMap; // Use the UUID parameter (director's key) for node matching, not map.uuid // This ensures we update the correct placeholder node var nodeKey = UUID; // Store the director's external UUID (as known by this guest) // This lets us map guest connections to director correctly if (map.requesterUUID) { meshData.directorExternalUUID = map.requesterUUID; } // Check if any connections use TURN (relay) var usingTurn = false; if (map.connections) { for (var i = 0; i < map.connections.length; i++) { if (map.connections[i].candidateType === "relay") { usingTurn = true; break; } } } // Update node info using director's UUID key if (meshData.nodes[nodeKey]) { meshData.nodes[nodeKey].streamID = map.streamID; meshData.nodes[nodeKey].label = map.label; meshData.nodes[nodeKey].guestUUID = map.uuid; // Store guest's self-reported UUID meshData.nodes[nodeKey].connections = map.connections; meshData.nodes[nodeKey].browser = map.browser || "Unknown"; meshData.nodes[nodeKey].usingTurn = usingTurn; } else { meshData.nodes[nodeKey] = { uuid: nodeKey, guestUUID: map.uuid, streamID: map.streamID, label: map.label, isDirector: false, connections: map.connections, browser: map.browser || "Unknown", usingTurn: usingTurn, health: "healthy" }; } meshData.pendingResponses--; log("Received connection map from " + map.label + " (UUID: " + nodeKey + ", " + map.connections.length + " connections)"); // Re-render whenever a response arrives and modal is open // This handles late responses even if some peers never respond if (meshData.modalOpen) { aggregateMeshData(); renderMeshVisualization(); } } } function aggregateMeshData() { // Build edges from all node connections meshData.edges = []; var edgeMap = {}; // edgeId -> edge object (to track bidirectionality) // Build a lookup from streamID to node UUID for edge matching var streamIdToUuid = {}; var directorNodeKey = null; for (var uuid in meshData.nodes) { var node = meshData.nodes[uuid]; if (node.streamID) { streamIdToUuid[node.streamID] = uuid; } if (node.isDirector) { directorNodeKey = uuid; } } for (var uuid in meshData.nodes) { var node = meshData.nodes[uuid]; var failedCount = 0; var degradedCount = 0; if (node.connections) { for (var i = 0; i < node.connections.length; i++) { var conn = node.connections[i]; // Try to resolve peer by streamID first (more reliable), then UUID var peerNodeUuid = conn.peerUUID; if (conn.peerStreamID && streamIdToUuid[conn.peerStreamID]) { peerNodeUuid = streamIdToUuid[conn.peerStreamID]; } // If peer doesn't exist as a node and this is an outgoing connection, // check if it's actually the director (using directorExternalUUID) if (!meshData.nodes[peerNodeUuid] && conn.direction === "outgoing") { // Check if this is a connection to the director if (meshData.directorExternalUUID && conn.peerUUID === meshData.directorExternalUUID) { // Map to director node instead of creating phantom peerNodeUuid = directorNodeKey; } else { // Create viewer/scene node for other unknown peers meshData.nodes[peerNodeUuid] = { uuid: peerNodeUuid, streamID: conn.peerStreamID || peerNodeUuid, label: conn.peerStreamID || "Viewer", isDirector: false, isViewer: true, connections: [], health: "healthy" }; if (conn.peerStreamID) { streamIdToUuid[conn.peerStreamID] = peerNodeUuid; } } } // Create unique edge ID (sorted UUIDs for deduplication) var edgeId = [uuid, peerNodeUuid].sort().join("-"); // Track direction: outgoing = publishing TO peer, incoming = receiving FROM peer var directionKey = conn.direction === "outgoing" ? "hasOutgoing" : "hasIncoming"; if (!edgeMap[edgeId]) { edgeMap[edgeId] = { id: edgeId, source: uuid, target: peerNodeUuid, sourceStreamID: node.streamID, targetStreamID: conn.peerStreamID, state: conn.state, bandwidth: conn.bandwidth, candidateType: conn.candidateType, nackCount: conn.nackCount, pliCount: conn.pliCount, hasOutgoing: false, hasIncoming: false, bidirectional: false }; } // Mark this direction as present edgeMap[edgeId][directionKey] = true; // Update bidirectional flag if (edgeMap[edgeId].hasOutgoing && edgeMap[edgeId].hasIncoming) { edgeMap[edgeId].bidirectional = true; } // Merge states - keep the worse one var stateRank = { "failed": 0, "disconnected": 1, "new": 1, "connecting": 1, "closed": 1, "connected": 2 }; var existingRank = stateRank[edgeMap[edgeId].state] !== undefined ? stateRank[edgeMap[edgeId].state] : 1; var newRank = stateRank[conn.state] !== undefined ? stateRank[conn.state] : 1; if (newRank < existingRank) { edgeMap[edgeId].state = conn.state; } // Track health based on RTCPeerConnection.connectionState if (conn.state === "failed") { failedCount++; } else if (conn.state === "disconnected" || conn.state === "new" || conn.state === "connecting" || conn.state === "closed") { degradedCount++; } } } // Update node health if (failedCount > 0) { node.health = "failed"; } else if (degradedCount > 0) { node.health = "degraded"; } else if (node.connections && node.connections.length > 0) { node.health = "healthy"; } else if (node.isViewer || node.isDirector) { // Viewers/scenes and director don't report connections, so no connections is expected node.health = "healthy"; } else { node.health = "isolated"; } } // Convert edgeMap to array meshData.edges = Object.values(edgeMap); // Calculate summary stats var totalConnections = meshData.edges.length; var failedConnections = meshData.edges.filter(e => e.state === "failed").length; var healthyConnections = meshData.edges.filter(e => e.state === "connected").length; var bidirectionalCount = meshData.edges.filter(e => e.bidirectional).length; var onewayCount = totalConnections - bidirectionalCount; var viewerCount = Object.values(meshData.nodes).filter(n => n.isViewer).length; meshData.summary = { totalNodes: Object.keys(meshData.nodes).length, viewerNodes: viewerCount, totalConnections: totalConnections, bidirectionalConnections: bidirectionalCount, onewayConnections: onewayCount, healthyConnections: healthyConnections, failedConnections: failedConnections, degradedConnections: totalConnections - healthyConnections - failedConnections }; meshData.lastRefresh = Date.now(); log("Aggregated mesh data: " + meshData.summary.totalNodes + " nodes, " + meshData.summary.totalConnections + " connections"); } function getMeshHealthBadge() { // Returns HTML for the health badge to show in director controls var s = meshData.summary; if (!s) return ""; var color = "#4CAF50"; // green var text = s.healthyConnections + "/" + s.totalConnections; if (s.failedConnections > 0) { color = "#F44336"; // red text = s.failedConnections + " failed"; } else if (s.degradedConnections > 0) { color = "#FF9800"; // orange } return '' + text + ''; } function openMeshVisualization() { if (meshData.modalOpen) return; meshData.modalOpen = true; // Create modal overlay var modal = document.createElement("div"); modal.id = "meshModal"; modal.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:10000;display:flex;flex-direction:column;"; // Toolbar var toolbar = document.createElement("div"); toolbar.style.cssText = "padding:10px 20px;background:#222;display:flex;align-items:center;gap:10px;border-bottom:1px solid #444;flex-wrap:wrap;"; toolbar.innerHTML = `

    Mesh Network Debug

    `; // SVG container var svgContainer = document.createElement("div"); svgContainer.id = "meshSvgContainer"; svgContainer.style.cssText = "flex:1;overflow:hidden;position:relative;"; // Detail panel (hidden by default) var detailPanel = document.createElement("div"); detailPanel.id = "meshDetailPanel"; detailPanel.style.cssText = "position:absolute;right:0;top:0;width:300px;height:100%;background:#1a1a1a;border-left:1px solid #444;padding:20px;display:none;overflow-y:auto;color:#fff;"; svgContainer.appendChild(detailPanel); modal.appendChild(toolbar); modal.appendChild(svgContainer); document.body.appendChild(modal); // Initialize layout button text to match current mode var layoutBtn = document.getElementById("meshLayoutBtn"); if (layoutBtn) { layoutBtn.textContent = "Layout: " + meshLayoutMode.charAt(0).toUpperCase() + meshLayoutMode.slice(1); } // Add keyboard shortcuts document.addEventListener("keydown", meshKeyHandler); // Request fresh data and render requestMeshData(); } function closeMeshVisualization() { var modal = document.getElementById("meshModal"); if (modal) { modal.remove(); } meshData.modalOpen = false; document.removeEventListener("keydown", meshKeyHandler); } function meshKeyHandler(e) { if (!meshData.modalOpen) return; switch(e.key) { case "Escape": closeMeshVisualization(); break; case "r": case "R": requestMeshData(); break; case "f": case "F": var cb = document.getElementById("meshFilterProblems"); if (cb) cb.checked = !cb.checked; renderMeshVisualization(); break; } } var meshLayoutMode = "circular"; // circular, grid, force function cycleMeshLayout() { if (meshLayoutMode === "force") { meshLayoutMode = "circular"; } else if (meshLayoutMode === "circular") { meshLayoutMode = "grid"; } else { meshLayoutMode = "force"; } var btn = document.getElementById("meshLayoutBtn"); if (btn) { btn.textContent = "Layout: " + meshLayoutMode.charAt(0).toUpperCase() + meshLayoutMode.slice(1); } renderMeshVisualization(); } function renderMeshVisualization() { var container = document.getElementById("meshSvgContainer"); if (!container) return; var existingSvg = container.querySelector("svg"); if (existingSvg) existingSvg.remove(); var width = container.clientWidth; var height = container.clientHeight - 50; // Leave room for status bar // Create SVG var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("width", width); svg.setAttribute("height", height); svg.style.display = "block"; // Filter problems only? var filterProblems = document.getElementById("meshFilterProblems")?.checked || false; // Calculate node positions based on layout mode var nodePositions = {}; var nodeArray = Object.values(meshData.nodes); var filteredNodes = filterProblems ? nodeArray.filter(n => n.health !== "healthy") : nodeArray; if (filteredNodes.length === 0 && filterProblems) { // Show message var text = document.createElementNS("http://www.w3.org/2000/svg", "text"); text.setAttribute("x", width / 2); text.setAttribute("y", height / 2); text.setAttribute("fill", "#4CAF50"); text.setAttribute("text-anchor", "middle"); text.setAttribute("font-size", "24"); text.textContent = "All connections healthy!"; svg.appendChild(text); container.insertBefore(svg, container.firstChild); return; } // Calculate positions var centerX = width / 2; var centerY = height / 2; var radius = Math.min(width, height) / 2 - 100; if (meshLayoutMode === "circular") { // Director in center, guests in a ring var guestNodes = filteredNodes.filter(n => !n.isDirector); var directorNode = filteredNodes.find(n => n.isDirector); // Place director in center if (directorNode) { nodePositions[directorNode.uuid] = { x: centerX, y: centerY }; } // Place guests in a ring around director guestNodes.forEach(function(node, i) { var angle = (2 * Math.PI * i) / guestNodes.length - Math.PI / 2; nodePositions[node.uuid] = { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }; }); } else if (meshLayoutMode === "grid") { var cols = Math.ceil(Math.sqrt(filteredNodes.length)); var spacing = Math.min(width, height) / (cols + 1); filteredNodes.forEach(function(node, i) { var col = i % cols; var row = Math.floor(i / cols); nodePositions[node.uuid] = { x: spacing + col * spacing, y: spacing + row * spacing }; }); } else { // Force-directed: simple random for now filteredNodes.forEach(function(node) { nodePositions[node.uuid] = { x: 100 + Math.random() * (width - 200), y: 100 + Math.random() * (height - 200) }; }); } // Add arrow marker definitions for one-way connections var defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); // Arrow markers for different colors ["#4CAF50", "#FF9800", "#F44336"].forEach(function(color, idx) { var marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); marker.setAttribute("id", "arrow-" + idx); marker.setAttribute("markerWidth", "10"); marker.setAttribute("markerHeight", "10"); marker.setAttribute("refX", "35"); marker.setAttribute("refY", "3"); marker.setAttribute("orient", "auto"); marker.setAttribute("markerUnits", "strokeWidth"); var path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "M0,0 L0,6 L9,3 z"); path.setAttribute("fill", color); marker.appendChild(path); defs.appendChild(marker); }); svg.appendChild(defs); // Draw edges first (so they appear behind nodes) meshData.edges.forEach(function(edge) { var sourcePos = nodePositions[edge.source]; var targetPos = nodePositions[edge.target]; if (!sourcePos || !targetPos) return; // Filter if needed if (filterProblems && edge.state === "connected") return; // For one-way connections, determine correct arrow direction // hasOutgoing means source publishes TO target (arrow: source→target) // hasIncoming only means target publishes TO source (arrow: target→source) var drawFromPos = sourcePos; var drawToPos = targetPos; if (!edge.bidirectional && edge.hasIncoming && !edge.hasOutgoing) { // Swap direction - target is actually the publisher drawFromPos = targetPos; drawToPos = sourcePos; } var line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.setAttribute("x1", drawFromPos.x); line.setAttribute("y1", drawFromPos.y); line.setAttribute("x2", drawToPos.x); line.setAttribute("y2", drawToPos.y); // Check if this edge is patched via mix-minus var isPatched = isConnectionPatched(edge.source, edge.target); // Style based on state var strokeColor = "#4CAF50"; // green var strokeWidth = 2; var dashArray = ""; var arrowIdx = 0; if (edge.state === "failed") { strokeColor = "#F44336"; strokeWidth = 3; dashArray = "5,5"; arrowIdx = 2; } else if (edge.state === "disconnected" || edge.state === "new" || edge.state === "connecting" || edge.state === "closed") { strokeColor = "#FF9800"; dashArray = "10,5"; arrowIdx = 1; } // Override style for patched connections - show as cyan with double-dash if (isPatched) { strokeColor = "#00BCD4"; // cyan - matches patch button strokeWidth = 3; dashArray = "8,3,2,3"; // distinctive double-dash pattern } line.setAttribute("stroke", strokeColor); line.setAttribute("stroke-width", strokeWidth); if (dashArray) line.setAttribute("stroke-dasharray", dashArray); line.setAttribute("data-edge-id", edge.id); line.style.cursor = "pointer"; // Add arrow for one-way connections (not bidirectional) if (!edge.bidirectional) { line.setAttribute("marker-end", "url(#arrow-" + arrowIdx + ")"); } // Click handler for edge line.onclick = function() { showEdgeDetails(edge); }; // Hover effect line.onmouseenter = function() { this.setAttribute("stroke-width", parseInt(strokeWidth) + 2); }; line.onmouseleave = function() { this.setAttribute("stroke-width", strokeWidth); }; svg.appendChild(line); }); // Draw nodes filteredNodes.forEach(function(node) { var pos = nodePositions[node.uuid]; if (!pos) return; var g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("transform", "translate(" + pos.x + "," + pos.y + ")"); g.style.cursor = "pointer"; // Node shape - circle for publishers, square for viewers/scenes var shape; if (node.isViewer) { // Square for viewers/scenes shape = document.createElementNS("http://www.w3.org/2000/svg", "rect"); shape.setAttribute("x", -25); shape.setAttribute("y", -25); shape.setAttribute("width", 50); shape.setAttribute("height", 50); shape.setAttribute("rx", 5); shape.setAttribute("fill", "#222"); } else { // Circle for publishers shape = document.createElementNS("http://www.w3.org/2000/svg", "circle"); shape.setAttribute("r", 30); shape.setAttribute("fill", "#333"); } // Border color based on health/type var borderColor = "#4CAF50"; if (node.health === "failed") borderColor = "#F44336"; else if (node.health === "degraded") borderColor = "#FF9800"; else if (node.health === "isolated") borderColor = "#9E9E9E"; else if (node.isDirector) borderColor = "#2196F3"; else if (node.isViewer) borderColor = "#9C27B0"; // Purple for viewers/scenes shape.setAttribute("stroke", borderColor); shape.setAttribute("stroke-width", 3); // Label var text = document.createElementNS("http://www.w3.org/2000/svg", "text"); text.setAttribute("text-anchor", "middle"); text.setAttribute("dy", 5); text.setAttribute("fill", "#fff"); text.setAttribute("font-size", "12"); text.textContent = (node.label || "?").substring(0, 8); // Badge for special node types if (node.isDirector) { var badge = document.createElementNS("http://www.w3.org/2000/svg", "text"); badge.setAttribute("text-anchor", "middle"); badge.setAttribute("y", -35); badge.setAttribute("fill", "#2196F3"); badge.setAttribute("font-size", "10"); badge.textContent = "DIRECTOR"; g.appendChild(badge); } else if (node.isViewer) { var badge = document.createElementNS("http://www.w3.org/2000/svg", "text"); badge.setAttribute("text-anchor", "middle"); badge.setAttribute("y", -30); badge.setAttribute("fill", "#9C27B0"); badge.setAttribute("font-size", "10"); badge.textContent = "SCENE/VIEW"; g.appendChild(badge); } g.appendChild(shape); g.appendChild(text); // Click handler g.onclick = function() { showNodeDetails(node); }; svg.appendChild(g); }); container.insertBefore(svg, container.firstChild); // Add status bar var statusBar = container.querySelector(".meshStatusBar"); if (!statusBar) { statusBar = document.createElement("div"); statusBar.className = "meshStatusBar"; statusBar.style.cssText = "position:absolute;bottom:0;left:0;right:0;padding:10px 20px;background:#222;color:#fff;font-size:14px;"; container.appendChild(statusBar); } var s = meshData.summary || {}; var nodeInfo = s.totalNodes || 0; if (s.viewerNodes > 0) { nodeInfo += " (" + s.viewerNodes + " scenes/viewers)"; } var connInfo = ""; if (s.bidirectionalConnections > 0) { connInfo += s.bidirectionalConnections + " bidirectional"; } if (s.onewayConnections > 0) { if (connInfo) connInfo += ", "; connInfo += s.onewayConnections + " one-way→"; } // Build WHIP/WHEP status display var whipWhepStatus = ""; if (meshData.whipStatus) { var whipColor = meshData.whipStatus.connected ? "#4CAF50" : "#F44336"; var whipIcon = meshData.whipStatus.connected ? "la-broadcast-tower" : "la-exclamation-triangle"; whipWhepStatus += ''; whipWhepStatus += ' WHIP: ' + meshData.whipStatus.state; if (!meshData.whipStatus.connected) { whipWhepStatus += ' '; } if (meshData.whipStatus.reconnectAttempts > 0) { whipWhepStatus += ' (' + meshData.whipStatus.reconnectAttempts + ' retries)'; } whipWhepStatus += ''; } // Count WHEP connection issues var whepTotal = Object.keys(meshData.whepConnections).length; var whepFailed = Object.values(meshData.whepConnections).filter(function(w) { return !w.connected; }).length; if (whepTotal > 0) { var whepColor = whepFailed === 0 ? "#4CAF50" : "#F44336"; whipWhepStatus += ''; whipWhepStatus += ' WHEP: ' + (whepTotal - whepFailed) + '/' + whepTotal + ' connected'; whipWhepStatus += ''; } statusBar.innerHTML = ` ${s.healthyConnections || 0} healthy | ${s.degradedConnections || 0} degraded | ${s.failedConnections || 0} failed | ${nodeInfo} nodes | ${connInfo || "0 connections"} ${whipWhepStatus} Last refresh: ${meshData.lastRefresh ? new Date(meshData.lastRefresh).toLocaleTimeString() : "Never"} `; } function showNodeDetails(node) { var panel = document.getElementById("meshDetailPanel"); if (!panel) return; panel.style.display = "block"; var connections = node.connections || []; var connHtml = connections.map(function(c) { var stateColor = c.state === "connected" ? "#4CAF50" : c.state === "failed" ? "#F44336" : "#FF9800"; var turnBadge = c.candidateType === "relay" ? ' TURN' : ''; return `
    ${c.peerStreamID || c.peerUUID.substring(0,8)}${turnBadge} ${c.state} ${c.bandwidth > 0 ? '
    ' + c.bandwidth + ' kbps' : ''}
    `; }).join(""); // Action buttons for guest nodes (not director) - recovery focused var actionButtons = ''; if (!node.isDirector && !node.isViewer) { actionButtons = `

    Recovery Actions

    `; } // Build browser/TURN info line var infoLine = ''; if (node.browser && node.browser !== "Unknown") { infoLine += '' + node.browser + ''; } if (node.usingTurn) { infoLine += 'TURN'; } // Add WHEP status if this node has a WHEP inbound connection if (meshData.whepConnections && meshData.whepConnections[node.uuid]) { var whepInfo = meshData.whepConnections[node.uuid]; var whepColor = whepInfo.connected ? "#4CAF50" : "#F44336"; var whepBg = whepInfo.connected ? "#1B5E20" : "#B71C1C"; infoLine += 'WHEP: ' + whepInfo.state + ''; } panel.innerHTML = `

    ${node.label}

    UUID: ${node.uuid.substring(0, 12)}...

    Stream ID: ${node.streamID}

    ${infoLine ? '

    ' + infoLine + '

    ' : ''}

    Health: ${node.health}

    Connections (${connections.length})

    ${connHtml || '

    No connections

    '} ${actionButtons}
    `; } function showEdgeDetails(edge) { var panel = document.getElementById("meshDetailPanel"); if (!panel) return; panel.style.display = "block"; var stateColor = edge.state === "connected" ? "#4CAF50" : edge.state === "failed" ? "#F44336" : "#FF9800"; // Describe directionality var directionText = ""; if (edge.bidirectional) { directionText = "↔ Bidirectional (both publish to each other)"; } else if (edge.hasOutgoing && !edge.hasIncoming) { directionText = "→ One-way (source publishes to target)"; } else if (edge.hasIncoming && !edge.hasOutgoing) { directionText = "← One-way (target publishes to source)"; } else { directionText = "Unknown"; } // Check if this connection is patched via mix-minus var isPatched = isConnectionPatched(edge.source, edge.target); var patchedStatus = isPatched ? '

    Status: 🔊 Patched via Mix-Minus

    ' : ''; // Check if patching is applicable (only guest↔guest, not director/viewer edges) var sourceNode = meshData.nodes[edge.source]; var targetNode = meshData.nodes[edge.target]; var canPatch = !(sourceNode && (sourceNode.isDirector || sourceNode.isViewer)) && !(targetNode && (targetNode.isDirector || targetNode.isViewer)); // Build action buttons based on state var actionButtons = ''; if (edge.state === 'failed' || edge.state === 'disconnected') { actionButtons += ``; if (canPatch && !isPatched) { actionButtons += ``; } else if (isPatched) { actionButtons += ``; } } else if (isPatched) { // Connection is healthy but still patched - offer to unpatch actionButtons += ``; } panel.innerHTML = `

    Connection Details

    From: ${edge.sourceStreamID || edge.source.substring(0,12)}

    To: ${edge.targetStreamID || edge.target.substring(0,12)}

    State: ${edge.state}

    ${patchedStatus}

    Direction: ${directionText}

    ${edge.bandwidth > 0 ? '

    Bandwidth: ' + edge.bandwidth + ' kbps

    ' : ''} ${edge.candidateType !== 'unknown' ? '

    Type: ' + edge.candidateType + '

    ' : ''}

    NACK count: ${edge.nackCount}

    PLI count: ${edge.pliCount}

    ${actionButtons}
    `; } function reconnectEdge(sourceUUID, targetUUID) { // Find the edge to determine direction var edgeId = [sourceUUID, targetUUID].sort().join("-"); var edgeData = meshData.edges.find(function(e) { return e.id === edgeId; }); // Determine who should initiate the reconnect // For outgoing edges: source publishes to target, so source reconnects // For incoming-only edges: target publishes to source, so target reconnects var initiator = sourceUUID; var peer = targetUUID; if (edgeData && edgeData.hasIncoming && !edgeData.hasOutgoing) { // Swap - target is the publisher initiator = targetUUID; peer = sourceUUID; } directReconnectPeer(initiator, peer); // Visual feedback var edge = document.querySelector('[data-edge-id="' + edgeId + '"]'); if (edge) { edge.setAttribute("stroke", "#FF9800"); edge.setAttribute("stroke-dasharray", "10,5"); } // Close detail panel var panel = document.getElementById("meshDetailPanel"); if (panel) panel.style.display = "none"; // Refresh after delay setTimeout(requestMeshData, 3000); } // ============================================ // END MESH NETWORK VISUALIZATION // ============================================ function directRefreshMicrophone(ele) { var UUID = ele.dataset.UUID; if (!UUID) { return; } // remoteAudioLabel_ // '[data-action-type="refresh-mic"][data--u-u-i-d="' + UUID + '"]' if (getById("remoteAudioLabel_" + UUID).dataset.nomic) { warnUser("No microphone is enabled for this user\n\nNothing to reresh."); } var data = {}; data.refreshMicrophone = true; data.UUID = UUID; if (session.sendRequest(data, UUID)) { // Viewer is requesting the PUBLISHER ele.classList.add("pressed"); setTimeout((ele) => { if (ele) { ele.classList.remove("pressed"); } }, 400, ele); } } function gotDevicesRemote(deviceInfos, UUID) { try { if (document.getElementById("remoteVideoSelect_" + UUID)) { var videoSelect = document.getElementById("remoteVideoSelect_" + UUID); var length = videoSelect.options.length; for (i = length - 1; i >= 0; i--) { videoSelect.options[i] = null; } } else { var videoSelect = document.createElement("select"); videoSelect.id = "remoteVideoSelect_" + UUID; videoSelect.onchange = function () { if (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.consent) { getById("requestVideoDevice_" + UUID).innerHTML = ' apply'; getById("requestVideoDevice_" + UUID).title = "This will update the remote device to the selected one"; } else { getById("requestVideoDevice_" + UUID).innerHTML = ' request'; getById("requestVideoDevice_" + UUID).title = "This will ask the remote guest for permission to change"; } }; var buttonGO = document.createElement("button"); buttonGO.innerHTML = ' refresh'; buttonGO.title = "This will refresh the current device"; buttonGO.id = "requestVideoDevice_" + UUID; buttonGO.onclick = function () { var data = {}; data.changeCamera = videoSelect.value; data.UUID = UUID; session.sendRequest(data, UUID); // Viewer is requesting the PUBLISHER }; var videoSelectDiv = document.createElement("div"); query("#container_" + UUID + " .advancedVideoSettings").appendChild(videoSelectDiv); videoSelectDiv.appendChild(videoSelect); videoSelectDiv.appendChild(buttonGO); } if (document.getElementById("remoteAudioSelect_" + UUID)) { log("remoteAudioSelect_ "); var audioSelect = document.getElementById("remoteAudioSelect_" + UUID); var length = audioSelect.options.length; for (i = length - 1; i >= 0; i--) { audioSelect.options[i] = null; } } else { var audioSelect = document.createElement("select"); audioSelect.id = "remoteAudioSelect_" + UUID; audioSelect.onchange = function () { log("ON CHANGE"); if (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.consent) { getById("requestAudioDevice_" + UUID).innerHTML = ' apply'; getById("requestAudioDevice_" + UUID).title = "This will update the remote device to the selected one"; } else { getById("requestAudioDevice_" + UUID).innerHTML = ' request'; getById("requestAudioDevice_" + UUID).title = "This will ask the remote guest for permission to change"; } }; var buttonGO = document.createElement("button"); buttonGO.innerHTML = ' refresh'; // buttonGO.style = "padding: 5px;"; buttonGO.title = "This will refresh the current device"; buttonGO.id = "requestAudioDevice_" + UUID; buttonGO.onclick = function () { var data = {}; data.changeMicrophone = audioSelect.value; data.UUID = UUID; session.sendRequest(data, UUID); // Viewer is requesting the PUBLISHER }; var audioSelectDiv = document.createElement("div"); query("#container_" + UUID + " .advancedAudioSettings").appendChild(audioSelectDiv); audioSelectDiv.appendChild(audioSelect); audioSelectDiv.appendChild(buttonGO); } if (document.getElementById("remoteAudioOutputSelect_" + UUID)) { var audioOutputSelect = document.getElementById("remoteAudioOutputSelect_" + UUID); var length = audioOutputSelect.options.length; for (i = length - 1; i >= 0; i--) { audioOutputSelect.options[i] = null; } } else { var audioOutputSelect = document.createElement("select"); audioOutputSelect.id = "remoteAudioOutputSelect_" + UUID; audioOutputSelect.onchange = function () { if (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.consent) { getById("requestAudioOutputDevice_" + UUID).innerHTML = ' apply'; getById("requestAudioOutputDevice_" + UUID).title = "This will update the remote device to the selected one"; } else { getById("requestAudioOutputDevice_" + UUID).innerHTML = ' request'; getById("requestAudioOutputDevice_" + UUID).title = "This will ask the remote guest for permission to change"; } }; var buttonGO = document.createElement("button"); buttonGO.innerHTML = ' refresh'; buttonGO.title = "This will refresh the current device"; buttonGO.id = "requestAudioOutputDevice_" + UUID; buttonGO.onclick = function () { var data = {}; data.changeSpeaker = audioOutputSelect.value; data.UUID = UUID; session.sendRequest(data, UUID); // Viewer is requesting the PUBLISHER }; var audioOutputSelectContainer = document.createElement("div"); query("#container_" + UUID + " .advancedAudioSettings").appendChild(audioOutputSelectContainer); audioOutputSelectContainer.appendChild(audioOutputSelect); audioOutputSelectContainer.appendChild(buttonGO); } var matched = false; var audiomatched = false; for (let i = 0; i !== deviceInfos.length; ++i) { const deviceInfo = deviceInfos[i]; if (deviceInfo == null) { continue; } if (deviceInfo.kind === "videoinput") { const option = document.createElement("option"); option.value = deviceInfo.deviceId || "default"; option.text = deviceInfo.label || `camera ${videoSelect.length + 1}`; if (getById("remoteVideoLabel_" + UUID).innerText == option.text) { option.selected = "true"; matched = true; } videoSelect.appendChild(option); } else if (deviceInfo.kind === "audioinput") { const option = document.createElement("option"); option.value = deviceInfo.deviceId || "default"; option.text = deviceInfo.label || `microphone ${audioSelect.length + 1}`; if (getById("remoteAudioLabel_" + UUID).innerText == option.text) { option.selected = "true"; audiomatched = true; } audioSelect.appendChild(option); } else if (deviceInfo.kind === "audiooutput") { const option = document.createElement("option"); option.value = deviceInfo.deviceId || "default"; option.text = deviceInfo.label || `speaker ${audioOutputSelect.length + 1}`; if (getById("remoteAudioOutputSelect_" + UUID).innerText == option.text) { option.selected = "true"; } audioOutputSelect.appendChild(option); } } if (!matched) { getById("requestVideoDevice_" + UUID).innerHTML = ' request'; getById("requestVideoDevice_" + UUID).title = "This will ask the remote guest for permission to change"; } if (!audiomatched) { getById("requestAudioDevice_" + UUID).innerHTML = ' request'; getById("requestAudioDevice_" + UUID).title = "This will ask the remote guest for permission to change"; } } catch (e) { errorlog(e); } pokeIframeAPI("remote-devices-info", deviceInfos, UUID); } var timeoutTone = false; function playtone(screen = false, tonename = "testtone") { if (timeoutTone) return; if (session.beepToNotify && tonename) { if (session.beepToNotify !== true) { let val = tonename.split("tone")[0]; if (!session.beepToNotify.includes(val)) { return; } } } const type = tonename.replace("tone", ""); if (session.notifyTypes && !session.notifyTypes.includes(type)) return; timeoutTone = true; setTimeout(() => timeoutTone = false, 500); const toneEle = document.getElementById(tonename); if (!toneEle) return; if (iOS || iPad) { toneEle.muted = false; toneEle.play().catch(e => console.error('Playback failed:', e)); return; } if (screen && getById("outputSourceScreenshare")) { session.sink = getById("outputSourceScreenshare").value; saveSettings(); } if (session.sink) { toneEle.setSinkId(session.sink) .then(() => toneEle.play()) .catch(() => toneEle.play()); } else { toneEle.play(); } } function updateAudioSource(newUrl, name = "testtone") { var audioElement = document.getElementById(name); if (!audioElement) { audioElement = createAudioElement(); audioElement.id = name; getById("testtone").parentNode.insertBefore(audioElement, getById("testtone").nextSibling); } audioElement.src = newUrl; var sources = audioElement.getElementsByTagName("source"); var extension = newUrl.split(".").pop().toLowerCase(); var mimeType; switch (extension) { case "mp3": mimeType = "audio/mpeg"; break; case "wav": mimeType = "audio/wav"; break; case "ogg": mimeType = "audio/ogg"; break; case "aac": case "m4a": mimeType = "audio/aac"; break; case "opus": mimeType = "audio/opus"; break; case "flac": mimeType = "audio/flac"; break; case "webm": mimeType = "audio/webm"; break; default: console.error("Unsupported file type:", extension); return; } if (sources && sources.length === 1) { sources[0].src = newUrl; sources[0].type = mimeType; } else { audioElement.innerHTML = ""; var newSource = document.createElement("source"); newSource.src = newUrl; newSource.type = mimeType; audioElement.appendChild(newSource); } audioElement.load(); } function showNotification(title, body = "") { if (!Notification) { return; } if (Notification.permission !== "granted") { Notification.requestPermission(); } else { let icon = "/media/old_icon.png"; //this is a large image may take more time to show notifiction, replace with small size icon let notification = new Notification(title, { body, icon }); notification.onclick = () => { notification.close(); window.parent.focus(); }; } } function toFirefoxConstraint(chromeConstraint) { if (!chromeConstraint || typeof chromeConstraint !== 'object') { return chromeConstraint; } const result = { ...chromeConstraint }; if (result.audio && typeof result.audio === 'object') { result.audio = flattenConstraints(result.audio); } if (result.video && typeof result.video === 'object') { result.video = flattenConstraints(result.video); } return result; } function flattenConstraints(constraints) { const result = { ...constraints }; for (const [key, value] of Object.entries(constraints)) { if (value && typeof value === 'object') { if ('exact' in value) { result[key] = value.exact; } else if ('ideal' in value) { result[key] = value.ideal; } } } return result; } async function getAudioOnly(selector, trackid = null, override = false, requestToken = null) { var audioSelect = document.querySelector(selector).querySelectorAll("input,option"); var audioList = []; var streams = []; log("getAudioOnly()"); // Fast-path: if override includes a specific deviceId, use it directly if (override && override.audio && (override.audio.deviceId || (override.audio.deviceId && override.audio.deviceId.exact))) { let o = JSON.parse(JSON.stringify(override)); if (typeof o.audio.deviceId === "string") { o.audio.deviceId = { exact: o.audio.deviceId }; } o.video = false; if (Firefox) { o = toFirefoxConstraint(o); } warnlog("navigator.mediaDevices.getUserMedia starting (override)..."); if (navigator.mediaDevices) { var stream = await navigator.mediaDevices .getUserMedia(o) .then(function (stream2) { log("get audio sucecss"); pokeIframeAPI("local-microphone-event"); return stream2; }) .catch(function (err) { warnlog(err); return false; }); if (stream) { if (requestToken !== null && requestToken !== getAudioUserMediaRequestID) { stream.getTracks().forEach(track => track.stop()); } else { streams.push(stream); } } } return streams; } for (var i = 0; i < audioSelect.length; i++) { if (audioSelect[i].value == "ZZZ") { continue; } else if (trackid == audioSelect[i].value) { // skip already excluded continue; } else if ("screen" == audioSelect[i].dataset.type) { // skip already excluded ---------- !!!!!! DOES THIS MAKE SENSE? TODO: CHECK continue; } else if (audioSelect[i].checked) { log(audioSelect[i]); audioList.push(audioSelect[i]); } else if (audioSelect[i].selected) { log(audioSelect[i]); audioList.push(audioSelect[i]); } } // Deduplicate selections so the same physical mic is not captured multiple times. try { const uniqueSeen = new Set(); const groupedByBase = new Map(); const orderedEntries = []; for (var i = 0; i < audioList.length; i++) { const el = audioList[i]; const rawLabel = (el.dataset && typeof el.dataset.label !== "undefined" && el.dataset.label) ? el.dataset.label : (el.label || el.text || el.textContent || ""); let normLabel = normalizeAudioAliasLabel(rawLabel); const deviceIdLower = (el.value || "").toLowerCase(); const groupId = (el.dataset && el.dataset.groupId) || ""; const hasGroupId = !!groupId; const isDefaultish = (deviceIdLower === "default" || deviceIdLower === "communications"); if (!normLabel) { if (!isDefaultish) { normLabel = rawLabel ? rawLabel.toLowerCase() : (groupId || deviceIdLower); } } const baseKey = groupId || normLabel || deviceIdLower || rawLabel; const groupKey = groupId || normLabel || deviceIdLower || baseKey; const uniqueKey = (isDefaultish && !groupId) ? (groupKey + "|alias") : (groupKey + "|" + ((el.value || "").toLowerCase() || normLabel || rawLabel || "")); if (uniqueSeen.has(uniqueKey)) { continue; } uniqueSeen.add(uniqueKey); const entry = { element: el, isDefaultish, hasGroupId, groupKey, baseKey, index: null }; let groupMap = groupedByBase.get(baseKey); if (!groupMap) { groupMap = new Map(); groupedByBase.set(baseKey, groupMap); } const existing = groupMap.get(groupKey); if (!existing) { entry.index = orderedEntries.length; orderedEntries.push(entry); groupMap.set(groupKey, entry); continue; } let replace = false; if (existing.isDefaultish && !entry.isDefaultish) { replace = true; } else if (!existing.isDefaultish && entry.isDefaultish) { replace = false; } else if (!existing.hasGroupId && entry.hasGroupId) { replace = true; } if (replace) { entry.index = existing.index; orderedEntries[existing.index] = entry; groupMap.set(groupKey, entry); } } audioList = orderedEntries.map(entry => entry.element); } catch (e) { /* non-fatal */ } for (var i = 0; i < audioList.length; i++) { if (session.echoCancellation !== false && session.autoGainControl !== false && session.noiseSuppression !== false && (session.voiceIsolation !== true)) { var constraint = { audio: { deviceId: { exact: audioList[i].value } } }; } else { // Just trying to avoid problems with some systems that don't support these features var constraint = { audio: { deviceId: { exact: audioList[i].value } } }; if (session.echoCancellation === false) { constraint.audio.echoCancellation = false; } else { constraint.audio.echoCancellation = true; } if (session.autoGainControl === false) { constraint.audio.autoGainControl = false; } else { constraint.audio.autoGainControl = true; } if (session.noiseSuppression === false) { constraint.audio.noiseSuppression = false; } else { constraint.audio.noiseSuppression = true; } if (session.voiceIsolation === true) { constraint.audio.voiceIsolation = true; } } constraint.video = false; if (override !== false) { log("Override true"); constraint = override; if (constraint.audio && typeof constraint.audio.deviceId === "string") { constraint.audio.deviceId = { exact: constraint.audio.deviceId }; } } if (audioList[i].value && SelectedAudioInputDevices) { if (SelectedAudioInputDevices.indexOf(audioList[i].value) === -1) { if (SelectedAudioInputDevices.length && SelectedAudioInputDevices.includes("ZZZ")) { SelectedAudioInputDevices = []; } SelectedAudioInputDevices.push(audioList[i].value); } } if (session.audioInputChannels) { if (constraint.audio === true) { constraint.audio = {}; constraint.audio.channelCount = session.audioInputChannels; } else if (constraint.audio) { constraint.audio.channelCount = session.audioInputChannels; } } if (session.micSampleRate) { if (constraint.audio === true) { constraint.audio = {}; constraint.audio.sampleRate = parseInt(session.micSampleRate); } else if (constraint.audio) { constraint.audio.sampleRate = parseInt(session.micSampleRate); } } if (session.micSampleSize) { if (constraint.audio === true) { constraint.audio = {}; constraint.audio.sampleSize = parseInt(session.micSampleSize); } else if (constraint.audio) { constraint.audio.sampleSize = parseInt(session.micSampleSize); } } log("CONSTRAINT"); log(constraint); if (Firefox) { constraint = toFirefoxConstraint(constraint); } warnlog("navigator.mediaDevices.getUserMedia starting..."); if (navigator.mediaDevices) { var stream = await navigator.mediaDevices .getUserMedia(constraint) .then(function (stream2) { log("get audio sucecss"); pokeIframeAPI("local-microphone-event"); return stream2; }) .catch(function (err) { warnlog(err); if (!session.cleanOutput) { if (override !== false) { if (err.name) { if (err.constraint) { warnUser(err["name"] + ": " + err["constraint"]); } } } } return false; }); // More error reporting maybe? if (stream) { if (requestToken !== null && requestToken !== getAudioUserMediaRequestID) { stream.getTracks().forEach(track => track.stop()); } else { streams.push(stream); } } } else { console.warn("navigator.mediaDevices was not found; try a different browser or check your settings"); } } if (requestToken !== null && requestToken !== getAudioUserMediaRequestID) { try { streams.forEach(stream => { if (!stream) { return; } stream.getTracks().forEach(track => track.stop()); }); } catch (e) { } return []; } return streams; } function applyMirror(mirror) { // true unmirrors as its already mirrored if (!session.videoElement) { return; } try { var transFlip = ""; var transNorm = ""; if (document.getElementById("videosource") && session.windowed) { transFlip = " translate(0, 50%)"; transNorm = " translate(0, -50%)"; } if (session.mirrored == 2) { mirror = true; } else if (session.mirrored === 0) { mirror = true; } if (!session.videoElement.style) { session.videoElement.style = ""; } if (session.permaMirrored) { mirror = !mirror; } if (mirror) { if (session.mirrored && session.flipped) { session.videoElement.style.transform = "scaleX(-1) scaleY(-1)" + transFlip; session.videoElement.classList.add("mirrorControl"); session.videoElement.dataset.transform = "scaleX(-1) scaleY(-1)"; } else if (session.mirrored) { session.videoElement.style.transform = "scaleX(-1)" + transNorm; session.videoElement.classList.add("mirrorControl"); session.videoElement.dataset.transform = "scaleX(-1)"; } else if (session.flipped) { session.videoElement.style.transform = "scaleY(-1) scaleX(1)" + transFlip; session.videoElement.classList.remove("mirrorControl"); session.videoElement.dataset.transform = "scaleX(1) scaleY(-11)"; } else { session.videoElement.style.transform = "scaleX(1)" + transNorm; session.videoElement.classList.remove("mirrorControl"); session.videoElement.dataset.transform = "scaleX(1)"; } } else { if (session.mirrored && session.flipped) { session.videoElement.style.transform = "scaleX(1) scaleY(-1)" + transFlip; session.videoElement.classList.remove("mirrorControl"); session.videoElement.dataset.transform = "scaleX(1) scaleY(-1)"; } else if (session.mirrored) { session.videoElement.style.transform = "scaleX(1)" + transNorm; session.videoElement.classList.remove("mirrorControl"); session.videoElement.dataset.transform = "scaleX(1)"; } else if (session.flipped) { session.videoElement.style.transform = "scaleY(-1) scaleX(-1)" + transFlip; session.videoElement.classList.add("mirrorControl"); session.videoElement.dataset.transform = "scaleX(-1) scaleY(-1)"; } else { session.videoElement.style.transform = "scaleX(-1)" + transNorm; session.videoElement.classList.add("mirrorControl"); session.videoElement.dataset.transform = "scaleX(-1)"; } } var rotate = 0; if (session.forceRotate !== false) { if (session.rotate) { rotate = session.forceRotate * -1 + parseInt(session.rotate); } else { rotate = session.forceRotate * -1; } if (session.forceRotate) { rotate += 180; } } else { rotate = session.rotate; } if (rotate && rotate >= 360) { rotate -= 360; } session.videoElement.rotated = rotate; session.videoElement.dataset.rotated = rotate; if (document.getElementById("previewWebcam") || document.getElementById("videosource")) { var eleName = document.getElementById("previewWebcam") || document.getElementById("videosource"); if (rotate) { if (eleName.style.transform) { eleName.style.transform += " rotate(" + rotate + "deg)"; } else { eleName.style.transform = "rotate(" + rotate + "deg)"; } eleName.classList.add("rotate"); } else { eleName.classList.remove("rotate"); } } else if (document.getElementById("container")) { if (rotate == 0) { document.getElementById("container").classList.remove("rotate"); document.getElementById("container").style.transform = "unset"; document.getElementById("container").style.transformOrigin = "unset"; } else { document.getElementById("container").style.transform = "rotate(" + rotate + "deg)"; } } else if (document.getElementById("minipreview")) { var eleName = document.getElementById("minipreview"); if (rotate == 90) { eleName.style.transform = "rotate(90deg)"; eleName.style.transformOrigin = "50% 100%"; eleName.style.height = eleName.style.width; eleName.style.width = "unset"; } else if (session.videoElement.rotated == 270) { eleName.style.transform = "rotate(270deg)"; eleName.style.transformOrigin = "50% 100%"; eleName.style.width = "unset"; eleName.style.height = eleName.style.width; } else if (session.videoElement.rotated == 180) { eleName.style.transform = "rotate(180deg)"; eleName.style.transformOrigin = "unset"; } else { eleName.classList.remove("rotate"); eleName.style.transform = "unset"; eleName.style.transformOrigin = "unset"; } } // if not one of these, then it's going to be handled by the automixer automatically for us. } catch (e) { errorlog(e); } } function applyMirrorGuest(mirror, videoElement, flip = undefined) { // true unmirrors as it's already mirrored try { const mirrored = !!mirror; let flipped; if (flip == null) { // Treats both null and undefined as "preserve existing" flipped = videoElement.dataset && videoElement.dataset.flipGuest === "true"; } else { flipped = !!flip; } if (videoElement.dataset) { videoElement.dataset.mirrorGuest = mirrored ? "true" : "false"; videoElement.dataset.flipGuest = flipped ? "true" : "false"; } updateVideoTransform(videoElement); if (mirrored) { videoElement.classList.add("mirrorControl"); } else { videoElement.classList.remove("mirrorControl"); } } catch (e) { errorlog(e); } } // Applies transform (mirror, flip, rotation) to video elements // Used for BOTH local preview (session.videoElement) AND remote guest videos (session.rpcs[UUID].videoElement) function updateVideoTransform(videoElement) { try { if (!videoElement || !videoElement.style) { return; } const dataset = videoElement.dataset || {}; let mirrored = false; let flipped = false; let hasMirrorControl = false; let hasFlipControl = false; // First priority: explicit dataset flags (from applyMirrorGuest or director controls) if (dataset.mirrorGuest) { mirrored = dataset.mirrorGuest === "true"; hasMirrorControl = true; } else { // Second priority: preserve existing inline transform (from applyMirror) const inlineTransform = videoElement.style.transform || ""; if (inlineTransform.includes("scaleX(-1)")) { mirrored = true; hasMirrorControl = true; } else if (inlineTransform.includes("scaleX(1)")) { mirrored = false; hasMirrorControl = true; } else { // Third priority: check computed style to preserve global CSS mirror (from &mirror parameter) // This ensures rotation doesn't strip CSS-only mirrors // Uses proper matrix decomposition to handle combined rotate+mirror try { const computed = window.getComputedStyle(videoElement); const matrix = computed.transform; if (matrix && matrix !== "none") { const values = matrix.match(/matrix.*\((.+)\)/); if (values) { const parts = values[1].split(",").map(parseFloat); const SCALE_EPS = 0.01; if (parts.length === 6) { // 2D matrix(a, b, c, d, tx, ty) // Decompose to extract scale with sign, accounting for rotation const [a, b] = parts; const scaleX = Math.sign(a || 1) * Math.hypot(a, b); // Set control if scale differs from 1, or is explicitly -1 const notOne = Math.abs(Math.abs(scaleX) - 1) > SCALE_EPS; if (notOne || Math.abs(scaleX + 1) < SCALE_EPS) { mirrored = scaleX < 0; hasMirrorControl = true; } } else if (parts.length === 16) { // 3D matrix3d(m00, m01, ..., m15) // For 3D, scaleX is first column magnitude: sqrt(m00² + m01² + m02²) const [m00, m01, m02] = parts; const scaleX = Math.sign(m00 || 1) * Math.hypot(m00, m01, m02); const notOne = Math.abs(Math.abs(scaleX) - 1) > SCALE_EPS; if (notOne || Math.abs(scaleX + 1) < SCALE_EPS) { mirrored = scaleX < 0; hasMirrorControl = true; } } } } } catch (e) { // Fallback: if computed style fails, leave hasMirrorControl=false } } } if (dataset.flipGuest) { flipped = dataset.flipGuest === "true"; hasFlipControl = true; } else { const inlineTransform = videoElement.style.transform || ""; if (inlineTransform.includes("scaleY(-1)")) { flipped = true; hasFlipControl = true; } else if (inlineTransform.includes("scaleY(1)")) { flipped = false; hasFlipControl = true; } else { // Check computed style for flip (scaleY), accounting for rotation try { const computed = window.getComputedStyle(videoElement); const matrix = computed.transform; if (matrix && matrix !== "none") { const values = matrix.match(/matrix.*\((.+)\)/); if (values) { const parts = values[1].split(",").map(parseFloat); const SCALE_EPS = 0.01; if (parts.length === 6) { // 2D matrix(a, b, c, d, tx, ty) // Decompose to extract scaleY with sign, accounting for rotation const [, , c, d] = parts; const scaleY = Math.sign(d || 1) * Math.hypot(c, d); // Set control if scale differs from 1, or is explicitly -1 const notOne = Math.abs(Math.abs(scaleY) - 1) > SCALE_EPS; if (notOne || Math.abs(scaleY + 1) < SCALE_EPS) { flipped = scaleY < 0; hasFlipControl = true; } } else if (parts.length === 16) { // 3D matrix3d(m00, m01, ..., m15) // For 3D, scaleY is second column magnitude: sqrt(m04² + m05² + m06²) const [, , , , m04, m05, m06] = parts; const scaleY = Math.sign(m05 || 1) * Math.hypot(m04, m05, m06); const notOne = Math.abs(Math.abs(scaleY) - 1) > SCALE_EPS; if (notOne || Math.abs(scaleY + 1) < SCALE_EPS) { flipped = scaleY < 0; hasFlipControl = true; } } } } } catch (e) { // Fallback: leave hasFlipControl=false } } } let rotated = 0; if (dataset.rotated) { rotated = parseInt(dataset.rotated) || 0; } else if (typeof videoElement.rotated !== "undefined" && videoElement.rotated !== false) { rotated = parseInt(videoElement.rotated) || 0; } const transforms = []; // Include explicit scaleX/scaleY to preserve: // 1. Dataset flags (director controls) // 2. Inline transforms (from applyMirror) // 3. Computed CSS transforms (from global &mirror) if (hasMirrorControl) { if (mirrored) { transforms.push("scaleX(-1)"); } else { transforms.push("scaleX(1)"); } } if (hasFlipControl) { if (flipped) { transforms.push("scaleY(-1)"); } else { transforms.push("scaleY(1)"); } } if (rotated) { transforms.push("rotate(" + rotated + "deg)"); } videoElement.style.transform = transforms.join(" "); } catch (e) { errorlog(e); } } function cleanupMediaTracks() { getUserMediaRequestID += 1; try { if (session.streamSrcClone) { session.streamSrcClone.getTracks().forEach(function (track) { session.streamSrcClone.removeTrack(track); track.stop(); log("stopping old track"); }); } if (session.streamSrc) { session.streamSrc.getTracks().forEach(function (track) { session.streamSrc.removeTrack(track); track.stop(); log("stopping old track"); }); } else { checkBasicStreamsExist(); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getTracks().forEach(function (track) { session.videoElement.srcObject.removeTrack(track); track.stop(); log("stopping old track"); }); } else { session.videoElement.srcObject = createMediaStream(); } activatedPreview = false; } catch (e) { errorlog(e); } getById("gowebcam").dataset.ready = "false"; getById("gowebcam").dataset.audioready = "false"; getById("gowebcam").disabled = true; } /// Detect system changes; handle change or use for debugging var lastAudioDevice = null; var lastVideoDevice = null; var lastPlaybackDevice = null; var audioReconnectTimeout = null; var videoReconnectTimeout = null; var grabDevicesTimeout = null; var playbackReconnectTimeout = null; function reconnectDevices(event) { /// TODO: Perhaps change this to only if there is a DISCONNECT; rather than ON NEW DEVICE? try { if (session.firstPlayTriggered && session.audioCtx.state == "suspended") { session.audioCtx.resume(); } } catch (e) { warnlog("session.audioCtx.resume(); failed"); } warnlog("A media device has changed"); if (iOS || iPad) { // consider adding this back, but if no problem, whatever. return; } if (document.getElementById("previewWebcam")) { // rest of the code isn't setup to support pre-connection setup. clearTimeout(playbackReconnectTimeout); playbackReconnectTimeout = setTimeout(function () { if (document.getElementById("previewWebcam")) { enumerateDevices() .then(gotDevices) .then(function () { if (document.getElementById("previewWebcam")) { resetupAudioOut(document.getElementById("previewWebcam")); } }); } }, 1000); return; } try { if (!session.streamSrc) { checkBasicStreamsExist(); } else { session.streamSrc.getTracks().forEach(function (track) { if (track.readyState == "ended") { if (track.kind == "audio") { lastAudioDevice = track.label; } else if (track.kind == "video") { lastVideoDevice = track.label; } session.streamSrc.removeTrack(track); log("remove ended old track"); } }); if (session.streamSrcClone) { session.streamSrcClone.getTracks().forEach(function (track) { if (track.readyState == "ended") { log("remove track4"); session.streamSrcClone.removeTrack(track); track.stop(); } }); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getTracks().forEach(function (track) { if (track.readyState == "ended") { session.videoElement.srcObject.removeTrack(track); log("remove ended old track"); } }); } } /* } else { clearTimeout(playbackReconnectTimeout); playbackReconnectTimeout = setTimeout(function() { enumerateDevices().then(gotDevices2).then(function() { resetupAudioOut(); }); }, 1000); return; } */ } catch (e) { errorlog(e); } clearTimeout(audioReconnectTimeout); audioReconnectTimeout = null; if (lastAudioDevice) { audioReconnectTimeout = setTimeout(function () { // only reconnect same audio device. If reconnected, clear the disconnected flag. enumerateDevices() .then(gotDevices2) .then(function () { // TODO: check to see if any audio is connected? var streamConnected = false; var audioSelect = getById("audioSource3").querySelectorAll("input"); for (var i = 0; i < audioSelect.length; i++) { if (audioSelect[i].value == "ZZZ") { continue; } else if (audioSelect[i].checked) { log("checked"); streamConnected = true; break; } } if (!streamConnected) { for (var i = 0; i < audioSelect.length; i++) { if (audioSelect[i].value == "ZZZ") { continue; } const lastNorm = normalizeAudioAliasLabel(lastAudioDevice || ""); const currNorm = normalizeAudioAliasLabel(audioSelect[i].dataset.label || ""); if (lastNorm && lastNorm === currNorm) { // if the last disconnected device matches. audioSelect[i].checked = true; streamConnected = true; lastAudioDevice = null; warnlog("DISCONNECTED AUDIO DEVICE RECONNECTED"); break; } } } activatedPreview = false; grabAudio("#audioSource3"); setTimeout(function () { enumerateDevices() .then(gotDevices2) .then(function () { }); }, 1000); }); }, 2000); } clearTimeout(videoReconnectTimeout); // only reconnect same video device. videoReconnectTimeout = null; if (lastVideoDevice) { videoReconnectTimeout = setTimeout(function () { enumerateDevices() .then(gotDevices2) .then(function () { var streamConnected = false; var videoSelect = getById("videoSource3"); errorlog(videoSelect.value); if (videoSelect.value == "ZZZ") { for (var i = 0; i < videoSelect.options.length; i++) { try { if (videoSelect.options[i].innerHTML == lastVideoDevice) { videoSelect.options[i].selected = "true"; streamConnected = true; lastVideoDevice = null; break; } } catch (e) { errorlog(e); } } } if (streamConnected) { activatedPreview = false; grabVideo(session.quality, "videosource", "select#videoSource3"); setTimeout(function () { enumerateDevices() .then(gotDevices2) .then(function () { }); }, 1000); } }); }, 2000); } clearTimeout(playbackReconnectTimeout); playbackReconnectTimeout = setTimeout(function () { enumerateDevices() .then(gotDevices2) .then(function () { resetupAudioOut(); }); }, 1000); } function handleAudioTrackEnded(event) { errorlog("Audio track ended unexpectedly"); // If there's already a reconnection attempt in progress, don't start another one if (session.audioReconnectInProgress) { return; } let modalID = null; if (!session.cleanOutput) { warnUser("Your microphone disconnected. Attempting to reconnect...", 3200); } session.audioReconnectInProgress = true; // Wait a brief moment to ensure the device has time to be recognized again if it was unplugged/replugged setTimeout(function () { activatedPreview = false; grabAudio("#audioSource3", null, false); // Check if reconnection was successful after a delay setTimeout(function () { session.audioReconnectInProgress = false; closeModal(false, modalID); // Check if there are any active audio tracks after reconnection attempt const hasAudioTracks = session.streamSrc && session.streamSrc.getAudioTracks && session.streamSrc.getAudioTracks().length > 0; if (!hasAudioTracks) { // Reconnection failed, open settings menu if (!session.cleanOutput) { warnUser("Failed to reconnect your microphone. Please select a different device.", 5000); } // Open the settings menu if (typeof toggleSettings === 'function') { toggleSettings(true); // force show the settings } } }, 2000); }, 1000); } var vingesterFixed = false; function resetupAudioOut(ele = false, forceReset = false) { // this re-sets ALL output devices / sources log("resetupAudioOut"); if (iOS || iPad || SafariVersion || (ChromiumVersion && session.mobile)) { // TODO : TEST TO SEE IF THIS WORKS WITH SAFARI? it might. if (ele) { return; } for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement) { try { session.rpcs[UUID].videoElement .pause() .then(() => { setTimeout( function (uuid) { log("win"); try { session.rpcs[uuid].videoElement.play().then(() => { // updateIncomingAudioElement(uuid); // DO NOT DO THIS; it would cause a loop, as this updateIncomingAudioElement calls reseet log("toggle pause/play"); }); } catch (e) { errorlog(e); } }, 0, UUID ); }) .catch(errorlog); } catch (e) { warnlog(e); } } } return; } //if (isVingester){return;} var outputSelect = document.getElementById("outputSource") || document.getElementById("outputSource3") || false; var sinkSet = false; try { // function tries to get vingester working, by listening for its first tweak. if (!session.sink && !vingesterFixed && document.getElementById("testtone") && document.getElementById("testtone").sinkId && isVingester) { vingesterFixed = true; // only set the session.sink one time, as vingester on should be needing to set it one time. session.sink = document.getElementById("testtone").sinkId; } } catch (e) { errorlog(e); } if (outputSelect && outputSelect.options && outputSelect.options.length) { for (var i = 0; i < outputSelect.options.length; i++) { if (outputSelect.options[i].value == session.sink) { outputSelect.options[i].selected = "true"; sinkSet = true; } } if (sinkSet == false) { if (outputSelect.options[0]) { outputSelect.options[0].selected = "true"; sinkSet = outputSelect.value; } } else { sinkSet = session.sink; } } else { sinkSet = session.sink; } if (ele) { try { var eleSink = sinkSet; if (ele.manualSink) { eleSink = ele.manualSink; log("Manual Sink Identified"); } if (eleSink) { if (forceReset) { ele.setSinkId("default") .then(() => { ele.setSinkId(eleSink) .then(() => { log("New Output Device"); }) .catch(error => { if (!Firefox) { warnlog(error); } // TODO: If error, then see if I need to add mic support, and grab it if needed. }); }) .catch(error => { if (!Firefox) { errorlog(error); } ele.setSinkId(eleSink) .then(() => { log("New Output Device"); }) .catch(error => { if (!Firefox) { warnlog(error); } // TODO: If error, then see if I need to add mic support, and grab it if needed. }); }); } ele.setSinkId(eleSink) .then(() => { log("New Output Device for self-preview"); }) .catch(error => { if (!Firefox) { warnlog("Need to add mic support, and grab it if needed."); warnlog(error); } // TODO: If error, then see if I need to add mic support, and grab it if needed. }); } } catch (e) { warnlog("can't use setsink"); } log("audio sink: " + eleSink); return; } if (session.videoElement) { // this would be a preview or videosource try { var eleSink = sinkSet; if (session.videoElement.manualSink) { eleSink = session.videoElement.manualSink; } if (eleSink) { session.videoElement .setSinkId(eleSink) .then(() => { log("New Output Device for self-preview"); }) .catch(error => { if (!Firefox) { warnlog(error); } // TODO: If error, then see if I need to add mic support, and grab it if needed. }); } } catch (e) { warnlog("can't use setsink"); } } for (UUID in session.rpcs) { try { if (session.rpcs[UUID].videoElement) { var eleSink = sinkSet; if (session.rpcs[UUID].videoElement.manualSink) { eleSink = session.rpcs[UUID].videoElement.manualSink; } if (eleSink) { session.rpcs[UUID].videoElement .setSinkId(eleSink) .then(() => { log("New Output Device for: " + UUID); }) .catch(error => { if (!Firefox) { warnlog(error); } // TODO: If error, then see if I need to add mic support, and grab it if needed. }); } } } catch (e) { warnlog(e); } } log("audio sink 2: " + eleSink); } function obfuscateURL(input) { if (input.startsWith("https://obs.ninja/")) { input = input.replace("https://obs.ninja/", "obs.ninja/"); } else if (input.startsWith("http://obs.ninja/")) { input = input.replace("http://obs.ninja/", "obs.ninja/"); } else if (input.startsWith("obs.ninja/")) { input = input.replace("obs.ninja/", "obs.ninja/"); } else if (input.startsWith("https://vdo.ninja/")) { input = input.replace("https://vdo.ninja/", "vdo.ninja/"); } else if (input.startsWith("http://vdo.ninja/")) { input = input.replace("http://vdo.ninja/", "vdo.ninja/"); } else if (input.startsWith("vdo.ninja/")) { input = input.replace("vdo.ninja/", "vdo.ninja/"); } input = input.replace("&view=", "&v="); input = input.replace("&view&", "&v&"); input = input.replace("?view&", "?v&"); input = input.replace("?view=", "?v="); input = input.replace("&videobitrate=", "&vb="); input = input.replace("?videobitrate=", "?vb="); input = input.replace("&bitrate=", "&vb="); input = input.replace("?bitrate=", "?vb="); input = input.replace("?audiodevice=", "?ad="); input = input.replace("&audiodevice=", "&ad="); input = input.replace("?label=", "?l="); input = input.replace("&label=", "&l="); input = input.replace("?stereo=", "?s="); input = input.replace("&stereo=", "&s="); input = input.replace("&stereo&", "&s&"); input = input.replace("?stereo&", "?s&"); input = input.replace("?webcam&", "?wc&"); input = input.replace("&webcam&", "&wc&"); input = input.replace("?remote=", "?rm="); input = input.replace("&remote=", "&rm="); input = input.replace("?password=", "?p="); input = input.replace("&password=", "&p="); input = input.replace("&maxvideobitrate=", "&mvb="); input = input.replace("?maxvideobitrate=", "?mvb="); input = input.replace("&maxbitrate=", "&mvb="); input = input.replace("?maxbitrate=", "?mvb="); input = input.replace("&height=", "&h="); input = input.replace("?height=", "?h="); input = input.replace("&width=", "&w="); input = input.replace("?width=", "?w="); input = input.replace("&quality=", "&q="); input = input.replace("?quality=", "?q="); input = input.replace("&cleanoutput=", "&clean="); input = input.replace("?cleanoutput=", "?clean="); input = input.replace("&maxviewers=", "&clean="); input = input.replace("?maxviewers=", "?clean="); input = input.replace("&frameRate=", "&fr="); input = input.replace("?frameRate=", "?fr="); input = input.replace("&fps=", "&fr="); input = input.replace("?fps=", "?fr="); input = input.replace("&roomid=", "&r="); input = input.replace("?roomid=", "?r="); input = input.replace("&room=", "&r="); input = input.replace("?room=", "?r="); log(input); var key = "OBSNINJAFORLIFE"; var encrypted = CryptoJS.AES.encrypt(input, key); var output = "https://invite.cam/" + encrypted.toString(); return output; } function notifyOfScreenShare() { if (session.notifyScreenShare) { var data = {}; data.screenShareState = session.screenShareState; session.sendMessage(data); } } var beforeScreenShare = null; // video var screenShareAudioTrack = null; async function toggleScreenShare(reload = false) { /// &sstype=1 var quality = session.quality_ss; if (quality === false) { quality = session.roomid ? session.quality_room : session.quality_wb; } if (session.quality !== false) { quality = session.quality; } if (session.screensharequality !== false) { quality = session.screensharequality; } if (reload) { // quality = 0, audio = true, videoOnEnd = false) { await grabScreen(quality, true, true).then(res => { if (res != false) { session.screenShareState = true; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); notifyOfScreenShare(); getById("screensharebutton").classList.add("green"); getById("screensharebutton").ariaPressed = "true"; enumerateDevices() .then(gotDevices2) .then(function () { }); } }); return; } if (session.screenShareState == false) { // adding a screen await grabScreen(quality, true, true).then(res => { if (res != false) { session.screenShareState = true; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); notifyOfScreenShare(); //session.refreshScale(); getById("screensharebutton").classList.add("green"); getById("screensharebutton").ariaPressed = "true"; enumerateDevices() .then(gotDevices2) .then(function () { }); //if (session.videoElement.readyState!==4){ session.videoElement.play().then(() => { log("start play doublecheck"); }); //} updateMixer(); pokeIframeAPI("screen-share-state", true); } }); } else { // removing a screen . session.screenShareState already true true ///////////////////////////////// session.screenShareState = false; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); notifyOfScreenShare(); if (!session.streamSrc) { checkBasicStreamsExist(); } if (screenShareAudioTrack) { if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getAudioTracks().forEach(function (track) { // previous video track; saving it. Must remove the track at some point. if (screenShareAudioTrack.id == track.id) { // since there are more than one audio track, lets see if we can remove JUST the audio track for the screen share. log("remove ss track"); session.videoElement.srcObject.removeTrack(track); track.stop(); } }); } if (session.streamSrcClone) { // session.streamSrcClone.getAudioTracks().forEach(function (track) { if (screenShareAudioTrack.id == track.id) { // since there are more than one audio track, lets see if we can remove JUST the audio track for the screen share. log("remove ss track clone"); session.streamSrcClone.removeTrack(track); track.stop(); } }); } if (session.streamSrc) { session.streamSrc.getAudioTracks().forEach(function (track) { // previous video track; saving it. Must remove the track at some point. if (screenShareAudioTrack.id == track.id) { // since there are more than one audio track, lets see if we can remove JUST the audio track for the screen share. log("remove ss track audio"); session.streamSrc.removeTrack(track); track.stop(); } }); } session.videoElement.srcObject = outboundAudioPipeline(); // updateREnderOoutput is just for video if videoElement is already activated. screenShareAudioTrack = null; senderAudioUpdate(); } var addedAlready = false; if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(function (track) { if (beforeScreenShare && track.id == beforeScreenShare.id) { addedAlready = true; } else { log("remove ss track 44"); session.streamSrc.removeTrack(track); track.stop(); } }); } if (session.streamSrcClone) { session.streamSrcClone.getVideoTracks().forEach(function (track) { if (beforeScreenShare && track.id == beforeScreenShare.id) { // } else { log("remove ss track 45"); session.streamSrcClone.removeTrack(track); track.stop(); } }); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getVideoTracks().forEach(function (track) { if (beforeScreenShare && track.id == beforeScreenShare.id) { addedAlready = true; } else { log("remove ss track 46"); session.videoElement.srcObject.removeTrack(track); track.stop(); } }); } getById("screensharebutton").classList.remove("green"); getById("screensharebutton").ariaPressed = "false"; if (beforeScreenShare) { if (addedAlready == false) { session.streamSrc.addTrack(beforeScreenShare); // add back in the video track we had before we started screen sharing. It should be NULL if we changed the video track else where (such as via the settings). #TODO: } } beforeScreenShare = null; updateRenderOutpipe(); // this syncs the video toggleSettings(true); // forceShow updateMixer(); } } var ipcRenderer = false; var ElectronDesktopCapture = false; var electronAppAudioInstance = null; var electronAppAudioSupportChecked = false; var electronAppAudioSupported = false; function electronSupportsApplicationAudio() { if (electronAppAudioSupportChecked) { return electronAppAudioSupported; } electronAppAudioSupportChecked = true; try { if (typeof window !== "undefined") { if (window.WindowAudioStream) { electronAppAudioSupported = true; } else if (window.electronApi && typeof window.electronApi.isWindowAudioCaptureAvailable === "function") { electronAppAudioSupported = !!window.electronApi.isWindowAudioCaptureAvailable(); } else { electronAppAudioSupported = false; } } } catch (err) { console.warn("Failed to determine application audio support:", err); electronAppAudioSupported = false; } return electronAppAudioSupported; } function ensureElectronAppAudioInstance() { if (!electronSupportsApplicationAudio()) { return null; } if (!electronAppAudioInstance) { try { electronAppAudioInstance = new window.WindowAudioStream(); } catch (err) { console.error("Failed to create WindowAudioStream instance:", err); electronAppAudioSupported = false; electronAppAudioInstance = null; } } return electronAppAudioInstance; } function extractElectronAudioTargetFromSource(source) { if (!source || !source.id) { return null; } const id = String(source.id); if (!id.toLowerCase().startsWith("window:")) { return null; } const match = id.match(/window:(\d+)/i); if (match && match[1]) { const numericId = parseInt(match[1], 10); if (!Number.isNaN(numericId) && numericId > 0) { return numericId; } } return null; } async function attachElectronApplicationAudio(stream, source) { const targetId = extractElectronAudioTargetFromSource(source); if (!targetId) { console.warn("Application audio capture requires a window source."); return false; } const instance = ensureElectronAppAudioInstance(); if (!instance) { return false; } try { const audioStream = await instance.start(targetId); if (!audioStream) { return false; } const clonedTracks = []; audioStream.getAudioTracks().forEach(track => { const clone = track.clone(); clonedTracks.push(clone); stream.addTrack(clone); }); const cleanup = async () => { clonedTracks.forEach(track => { try { track.stop(); } catch (err) { console.warn("Failed to stop application audio track:", err); } }); if (instance.isCapturing()) { try { await instance.stop(); } catch (err) { console.warn("Failed to stop WindowAudioStream:", err); } } }; const onceCleanup = () => { cleanup().catch(err => console.error("Error cleaning up application audio:", err)); }; if (typeof stream.addEventListener === "function") { stream.addEventListener("inactive", onceCleanup, { once: true }); } if (stream && typeof stream.getVideoTracks === "function") { stream.getVideoTracks().forEach(track => track.addEventListener("ended", onceCleanup, { once: true })); } clonedTracks.forEach(track => track.addEventListener("ended", onceCleanup, { once: true })); return true; } catch (err) { console.error("Failed to attach application audio:", err); try { if (instance.isCapturing()) { await instance.stop(); } } catch (stopErr) { console.warn("Failed to stop WindowAudioStream after error:", stopErr); } return false; } } async function createElectronDesktopAudioStream() { const new_constraints = { audio: { mandatory: { chromeMediaSource: "desktop" } }, video: { mandatory: { chromeMediaSource: "desktop" } } }; new_constraints.video.mandatory.maxFrameRate = 1; const stream = await window.navigator.mediaDevices.getUserMedia(new_constraints); if (stream && typeof stream.getVideoTracks === "function" && stream.getVideoTracks().length) { const track = stream.getVideoTracks()[0]; stream.removeTrack(track); track.stop(); } return stream; } if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { // this enables Screen Capture in Electron try { if (!ipcRenderer) { ipcRenderer = require("electron").ipcRenderer; } window.navigator.mediaDevices.getDisplayMedia = (constraints = false) => { return new Promise(async (resolve, reject) => { try { if (session.autostart) { session.autostart = false; if (parseInt(session.screenshare) + "" === session.screenshare) { var sscid = parseInt(session.screenshare) - 1; if (sscid < 0) { sscid = 0; } //ipcRenderer.sendSync('prompt', {title, val}); const sources = await ipcRenderer.sendSync("getSources", { types: ["screen"] }); var new_constraints = { audio: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sources[sscid].id } }, video: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sources[sscid].id } } }; if (session.audioDevice === 0) { new_constraints.audio = false; } try { if (constraints.video.width.ideal) { new_constraints.video.mandatory.maxWidth = constraints.video.width.ideal; } } catch (e) { } try { if (constraints.video.height.ideal) { new_constraints.video.mandatory.maxHeight = constraints.video.height.ideal; } } catch (e) { } try { if (constraints.video.frameRate.ideal) { new_constraints.video.mandatory.maxFrameRate = constraints.video.frameRate.ideal; } } catch (e) { } /// //if (Firefox){ // this is electron // new_constraints = toFirefoxConstraint(new_constraints); //} warnlog("navigator.mediaDevices.getUserMedia starting..."); const stream = await window.navigator.mediaDevices.getUserMedia(new_constraints); resolve(stream); } else if (session.screenshare && session.screenshare !== true) { var sscid = null; const sources = await ipcRenderer.sendSync("getSources", { types: ["window"] }); for (var i = 0; i < sources.length; i++) { if (sources[i].name.toLowerCase().startsWith(session.screenshare.toLowerCase())) { // check if anythign starts with sscid = i; break; } } if (sscid === null) { sscid = 0; // grab first window if nothing. for (var i = 0; i < sources.length; i++) { if (sources[i].name.toLowerCase().includes(session.screenshare.toLowerCase())) { // check if something includes the string; fallback sscid = i; break; } } } /// var new_constraints = { audio: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sources[sscid].id } }, video: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sources[sscid].id } } }; if (session.audioDevice === 0) { new_constraints.audio = false; } try { if (constraints.video.width.ideal) { new_constraints.video.mandatory.maxWidth = constraints.video.width.ideal; } } catch (e) { } try { if (constraints.video.height.ideal) { new_constraints.video.mandatory.maxHeight = constraints.video.height.ideal; } } catch (e) { } try { if (constraints.video.frameRate.ideal) { new_constraints.video.mandatory.maxFrameRate = constraints.video.frameRate.ideal; } } catch (e) { } /// //if (Firefox){ // new_constraints = toFirefoxConstraint(new_constraints); //} warnlog("navigator.mediaDevices.getUserMedia starting..."); const stream = await window.navigator.mediaDevices.getUserMedia(new_constraints); resolve(stream); } else { var sscid = 0; const sources = await ipcRenderer.sendSync("getSources", { types: ["screen"] }); /// var new_constraints = { audio: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sources[sscid].id } }, video: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sources[sscid].id } } }; if (session.audioDevice === 0) { new_constraints.audio = false; } try { if (constraints.video.width.ideal) { new_constraints.video.mandatory.maxWidth = constraints.video.width.ideal; } } catch (e) { } try { if (constraints.video.height.ideal) { new_constraints.video.mandatory.maxHeight = constraints.video.height.ideal; } } catch (e) { } try { if (constraints.video.frameRate.ideal) { new_constraints.video.mandatory.maxFrameRate = constraints.video.frameRate.ideal; } } catch (e) { } warnlog(new_constraints); /// //if (Firefox){ // new_constraints = toFirefoxConstraint(new_constraints); //} warnlog("navigator.mediaDevices.getUserMedia starting..."); const stream = await window.navigator.mediaDevices.getUserMedia(new_constraints); resolve(stream); } } else { const sources = await ipcRenderer.sendSync("getSources", { types: ["screen", "window"] }); const selectionElem = document.createElement("div"); selectionElem.classList = "desktop-capturer-selection"; if (session.screenshareVideoOnly) { selectionElem.innerHTML = `
    `; } else { selectionElem.innerHTML = `
    `; } document.body.appendChild(selectionElem); const systemAudioCheckbox = getById("alsoCaptureAudio"); const appAudioOption = getById("captureAppAudioParent"); const appAudioCheckbox = getById("captureAppAudio"); if (appAudioOption && appAudioCheckbox) { const supportsAppAudio = !macOS && electronSupportsApplicationAudio(); if (supportsAppAudio) { appAudioOption.style.display = "inline-block"; appAudioCheckbox.addEventListener("change", () => { if (!systemAudioCheckbox) { return; } if (appAudioCheckbox.checked) { systemAudioCheckbox.dataset.wasCheckedBeforeAppAudio = systemAudioCheckbox.checked ? "true" : "false"; if (systemAudioCheckbox.checked) { systemAudioCheckbox.checked = false; } } else { if (systemAudioCheckbox.dataset.wasCheckedBeforeAppAudio === "true") { systemAudioCheckbox.checked = true; } delete systemAudioCheckbox.dataset.wasCheckedBeforeAppAudio; } }); } else { appAudioOption.style.display = "none"; appAudioCheckbox.checked = false; } } if (systemAudioCheckbox) { systemAudioCheckbox.addEventListener("change", () => { if (systemAudioCheckbox.checked && appAudioCheckbox) { appAudioCheckbox.checked = false; } delete systemAudioCheckbox.dataset.wasCheckedBeforeAppAudio; }); } if (macOS) { const captureDesktopButton = getById("captureDesktopAudio"); if (captureDesktopButton) { captureDesktopButton.style.display = "none"; } const alsoCaptureAudioCheckbox = getById("alsoCaptureAudio"); if (alsoCaptureAudioCheckbox) { alsoCaptureAudioCheckbox.checked = false; } const alsoCaptureAudioParent1 = getById("alsoCaptureAudioParent1"); if (alsoCaptureAudioParent1) { alsoCaptureAudioParent1.style.display = "none"; } const alsoCaptureAudioParent2 = getById("alsoCaptureAudioParent2"); if (alsoCaptureAudioParent2) { alsoCaptureAudioParent2.style.display = "inline-block"; } if (appAudioOption) { appAudioOption.style.display = "none"; } if (appAudioCheckbox) { appAudioCheckbox.checked = false; } } document.getElementById("cancelscreenshare").addEventListener("click", async () => { selectionElem.remove(); reject(null); }); document.querySelectorAll(".desktop-capturer-click").forEach(button => { button.addEventListener("click", async () => { try { if (button.id == "captureDesktopAudio") { const stream = await createElectronDesktopAudioStream(); resolve(stream); selectionElem.remove(); return; } const id = button.getAttribute("data-id"); const source = sources.find(source => source.id === id); if (!source) { throw new Error(`Source with id ${id} does not exist`); } const systemAudioCheckbox = getById("alsoCaptureAudio"); const appAudioCheckbox = getById("captureAppAudio"); const hadSystemAudioPreference = !!(systemAudioCheckbox && (systemAudioCheckbox.checked || systemAudioCheckbox.dataset.wasCheckedBeforeAppAudio === "true")); const wantsAppAudio = !!(appAudioCheckbox && appAudioCheckbox.checked && electronSupportsApplicationAudio()); const wantsSystemAudio = !wantsAppAudio && !!(systemAudioCheckbox && systemAudioCheckbox.checked); var audioStream = null; if (wantsSystemAudio) { audioStream = await createElectronDesktopAudioStream(); } var new_constraints = { audio: false, video: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: source.id } } }; try { if (constraints.video.width.ideal) { new_constraints.video.mandatory.maxWidth = constraints.video.width.ideal; } } catch (e) { } try { if (constraints.video.height.ideal) { new_constraints.video.mandatory.maxHeight = constraints.video.height.ideal; } } catch (e) { } try { if (constraints.video.frameRate.ideal) { new_constraints.video.mandatory.maxFrameRate = constraints.video.frameRate.ideal; } } catch (e) { } if (typeof warnlog === "function") { warnlog(new_constraints); warnlog("navigator.mediaDevices.getUserMedia starting..."); } const stream = await window.navigator.mediaDevices.getUserMedia(new_constraints); let attachedAppAudio = false; if (wantsAppAudio) { attachedAppAudio = await attachElectronApplicationAudio(stream, source); if (!attachedAppAudio && hadSystemAudioPreference) { if (!audioStream) { try { audioStream = await createElectronDesktopAudioStream(); } catch (audioErr) { console.warn("Failed to fallback to desktop audio:", audioErr); } } if (audioStream) { console.warn("Falling back to desktop audio; application audio capture unavailable for selected source."); if (systemAudioCheckbox) { systemAudioCheckbox.checked = true; delete systemAudioCheckbox.dataset.wasCheckedBeforeAppAudio; } if (appAudioCheckbox) { appAudioCheckbox.checked = false; } } } } if (!attachedAppAudio && audioStream && typeof audioStream.getAudioTracks === "function") { const audioTracks = audioStream.getAudioTracks(); if (audioTracks.length) { stream.addTrack(audioTracks[0]); } } resolve(stream); selectionElem.remove(); } catch (err) { errorlog("Error selecting desktop capture source:", err); reject(err); } }); }); } } catch (err) { errorlog("Error displaying desktop capture sources:", err); reject(err); } }); }; ElectronDesktopCapture = true; } catch (e) { warnlog("Couldn't load electron's screen capture. Elevate the app's permission to allow it (right-click?)"); } } async function grabScreen(quality = 0, audio = true, videoOnEnd = false) { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { if (!session.cleanOutput) { setTimeout(function () { if (iOS || iPad) { warnUser(getTranslation("ios-no-screen-share"), false, false); } else if (session.mobile) { warnUser(getTranslation("mobile-no-screen-share"), false, false); } else if (Firefox && !session.mobile) { warnUser(getTranslation("no-screen-share-supported-firefox"), false, false); } else { warnUser(getTranslation("no-screen-share-supported"), false, false); } }, 1); } return false; } if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (!ElectronDesktopCapture) { if (!session.cleanOutput) { warnUser("Enable Elevated Privileges to allow screen-sharing. (right click this window to see that option)"); } return false; } } var video = {}; if (quality == -1) { // unlocked capture resolution } else if (quality == 0) { video.width = { ideal: 1920 }; video.height = { ideal: 1080 }; } else if (quality == 1) { video.width = { ideal: 1280 }; video.height = { ideal: 720 }; } else if (quality == 2) { video.width = { ideal: 640 }; video.height = { ideal: 360 }; } else if (quality >= 3) { // lowest video.width = { ideal: 320 }; video.height = { ideal: 180 }; } if (session.width) { video.width = { ideal: session.width }; } if (session.height) { video.height = { ideal: session.height }; } var constraints = { // this part is a bit annoying. Do I use the same settings? I can add custom setting controls here later audio: { echoCancellation: false, // For screen sharing, we want it off by default. autoGainControl: false, noiseSuppression: false }, video: video //,cursor: {exact: "none"} }; try { let supportedConstraints = navigator.mediaDevices.getSupportedConstraints(); if (supportedConstraints.cursor) { if (session.screensharecursor) { constraints.video.cursor = ["always", "motion"]; } else { constraints.video.cursor = "never"; } } if (session.suppressLocalAudioPlayback && supportedConstraints.suppressLocalAudioPlayback) { constraints.audio.suppressLocalAudioPlayback = true; } if (session.preferCurrentTab) { constraints.preferCurrentTab = true; } if (session.selfBrowserSurface) { constraints.selfBrowserSurface = session.selfBrowserSurface; // exclude or include } if (session.surfaceSwitching) { constraints.surfaceSwitching = session.surfaceSwitching; // exclude or include } if (session.systemAudio) { constraints.systemAudio = session.systemAudio; // exclude or include } if (session.displaySurface && supportedConstraints.displaySurface) { constraints.video.displaySurface = session.displaySurface; // monitor, window, or browser } } catch (e) { warnlog("navigator.mediaDevices.getSupportedConstraints() not supported"); } if (session.echoCancellation === true) { constraints.audio.echoCancellation = true; } if (session.autoGainControl === true) { constraints.audio.autoGainControl = true; } if (session.noiseSuppression === true) { constraints.audio.noiseSuppression = true; } if (session.voiceIsolation === true) { constraint.audio.voiceIsolation = true; } if (audio == false) { constraints.audio = false; } var overrideFramerate = false; if (session.screensharefps !== false) { constraints.video.frameRate = { ideal: session.screensharefps, max: session.screensharefps }; } else if (session.frameRate !== false && session.maxframeRate != false) { overrideFramerate = session.frameRate; constraints.video.frameRate = { ideal: session.maxframeRate, max: session.maxframeRate }; } else if (session.frameRate !== false) { constraints.video.frameRate = session.frameRate; } else if (session.maxframeRate != false) { constraints.video.frameRate = { ideal: session.maxframeRate, max: session.maxframeRate }; } else { constraints.video.frameRate = { ideal: 60 }; } if (session.screenshareVideoOnly) { constraints.audio = false; } if (session.forceAspectRatio) { // await updateCameraConstraints("aspectRatio", session.forceAspectRatio); if (constraints.video && constraints.video !== true) { constraints.video.aspectRatio = { ideal: parseFloat(session.forceAspectRatio) }; if (constraints.video.width && !session.width) { delete constraints.video.width; } else if (constraints.video.height && !session.height) { delete constraints.video.height; } } } if (constraints.video !== false && Object.keys(constraints.video).length == 0) { constraints.video = true; } var wasDisabled = true; return navigator.mediaDevices .getDisplayMedia(constraints) .then(async function (stream) { log("adding video tracks 2245"); try { var constraint = {}; if (session.forceAspectRatio && session.forceScreenShareAspectRatio === null) { constraint.aspectRatio = parseFloat(session.forceAspectRatio); } else if (session.forceScreenShareAspectRatio) { constraint.aspectRatio = parseFloat(session.forceScreenShareAspectRatio); } if (overrideFramerate) { constraint.frameRate = overrideFramerate; } if (Object.keys(constraint).length) { await stream.getVideoTracks()[0].applyConstraints({ advanced: [constraint] }); log({ advanced: [constraint] }); } } catch (e) { errorlog(e); } try { if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(function (track) { //track.stop(); beforeScreenShare = track; session.streamSrc.removeTrack(track); wasDisabled = false; // log("stopping video track"); }); if (session.streamSrcClone) { session.streamSrcClone.getVideoTracks().forEach(function (track) { log("remove ss track clone 11"); session.streamSrcClone.removeTrack(track); track.stop(); }); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getVideoTracks().forEach(function (track) { //track.stop(); wasDisabled = false; session.videoElement.srcObject.removeTrack(track); log("stopping video track 2"); }); } else { checkBasicStreamsExist(); } } else { checkBasicStreamsExist(); // create srcObject + videoElement } } catch (e) { warnlog(e); } try { stream.getVideoTracks()[0].onended = function (e) { // if screen share stops, warnlog(e); if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(function (track) { session.streamSrc.removeTrack(track); track.stop(); log("stopping video track 3"); if (beforeScreenShare && beforeScreenShare.id == track.id) { beforeScreenShare.stop(); beforeScreenShare = null; } }); } if (session.streamSrcClone) { session.streamSrcClone.getVideoTracks().forEach(function (track) { session.streamSrcClone.removeTrack(track); log("remove ss track clone 14"); track.stop(); }); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getVideoTracks().forEach(function (track) { session.videoElement.srcObject.removeTrack(track); track.stop(); log("stopping video track 4"); }); } else { //session.videoElement.srcObject = createMediaStream(); session.videoElement.srcObject = outboundAudioPipeline(); } if (screenShareAudioTrack) { if (session.streamSrc) { session.streamSrc.getAudioTracks().forEach(function (track) { // previous video track; saving it. Must remove the track at some point. if (screenShareAudioTrack.id == track.id) { // since there are more than one audio track, lets see if we can remove JUST the audio track for the screen share. session.streamSrc.removeTrack(track); track.stop(); } }); } if (session.streamSrcClone) { session.streamSrcClone.getAudioTracks().forEach(function (track) { // previous video track; saving it. Must remove the track at some point. if (screenShareAudioTrack.id == track.id) { // since there are more than one audio track, lets see if we can remove JUST the audio track for the screen share. session.streamSrcClone.removeTrack(track); log("remove ss track 21"); track.stop(); } }); } screenShareAudioTrack = null; senderAudioUpdate(); } session.screenShareState = false; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); notifyOfScreenShare(); getById("screensharebutton").classList.remove("green"); getById("screensharebutton").ariaPressed = "false"; if (videoOnEnd == true) { if (beforeScreenShare) { session.streamSrc.addTrack(beforeScreenShare); // updateRenderOutpipe beforeScreenShare = null; } updateRenderOutpipe(); toggleSettings(true); // forceshow } else { //session.refreshScale(); // since updateREnderOutput already has htis. } updateMixer(); }; } catch (e) { log("No Video selected; screensharing?"); } stream.getTracks().forEach(function (track) { addScreenDevices(track); session.streamSrc.addTrack(track, stream); // Lets not add the audio to this preview; echo can be annoying }); updateRenderOutpipe(); if (wasDisabled && stream.getVideoTracks().length && !session.videoMuted) { var msg = {}; msg.videoMuted = session.videoMuted; session.sendMessage(msg); } if (stream.getAudioTracks().length) { screenShareAudioTrack = stream.getAudioTracks()[0]; senderAudioUpdate(); } session.applySoloChat(); // mute streams that should be muted if a director session.applyIsolatedChat(); applyMirror(true); return true; }) .catch(function (err) { errorlog(err); errorlog(err.name); if (err.name == "NotAllowedError" || err.name == "PermissionDeniedError") { // User Stopped it. if (macOS) { warnUser(getTranslation("screen-permissions-denied"), false, false); } } else { if (audio == true) { if (err.name == "NotReadableError") { if (!session.cleanOutput) { warnUser(getTranslation("change-audio-output-device"), false, false); } setTimeout(function () { grabScreen(quality, false); }, 1); return false; } else { setTimeout(function () { grabScreen(quality, false); }, 1); } } if (!session.cleanOutput) { setTimeout( function (e) { errorlog(e); }, 1, err ); // TypeError: Failed to execute 'getDisplayMedia' on 'MediaDevices': Audio capture is not supported } } return false; }); } function toggleBufferSettings(UUID) { getById("bufferSettings").dataset.UUID = UUID; toggle(getById("bufferSettings")); if (getById("bufferSettings").style.display == "none") { getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); } else { getById("modalBackdrop").innerHTML = ""; // Delete modal getById("modalBackdrop").remove(); zindex = 25; getById("bufferSettings").style.zIndex = 25; var modalTemplate = `
    `; document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end document.getElementById("modalBackdrop").addEventListener("click", toggleBufferSettings); var buffer = session.rpcs[UUID].buffer; if (buffer === false) { buffer = session.buffer || 0; } getById("bufferSettings") .querySelectorAll("input") .forEach(ele => { ele.value = parseInt(buffer); ele.title = ele.value + " ms"; //getById("bufferSliderValue").innerText = ele.title ele.onchange = function (e) { session.rpcs[UUID].buffer = parseInt(e.target.value); //getById("bufferSliderValue").innerText = session.rpcs[UUID].buffer + " ms"; getById("bufferSettings") .querySelectorAll("input") .forEach(ele2 => { if (ele2 !== e.target) { ele2.value = parseInt(e.target.value); } ele2.title = parseInt(e.target.value) + " ms"; }); playoutdelay(UUID); // trigger }; ele.onkeyup = function (e) { if (e.key === "Enter") { session.rpcs[UUID].buffer = parseInt(e.target.value); //getById("bufferSliderValue").innerText = session.rpcs[UUID].buffer + " ms"; getById("bufferSettings") .querySelectorAll("input") .forEach(ele2 => { if (ele2 !== e.target) { ele2.value = parseInt(e.target.value); } ele2.title = parseInt(e.target.value) + " ms"; }); playoutdelay(UUID); // trigger } }; ele.oninput = function (e) { getById("bufferSettings") .querySelectorAll("input") .forEach(ele2 => { if (ele2 !== e.target) { ele2.value = parseInt(e.target.value); } ele2.title = parseInt(e.target.value) + " ms"; }); }; }); } } function togglePTZControls(UUID) { var modal = getById("ptzControlsModal"); if (UUID) { modal.dataset.UUID = UUID; } toggle(modal); if (modal.style.display == "none") { if (getById("modalBackdrop")) { getById("modalBackdrop").innerHTML = ""; getById("modalBackdrop").remove(); } } else { if (getById("modalBackdrop")) { getById("modalBackdrop").innerHTML = ""; getById("modalBackdrop").remove(); } zindex = 25; modal.style.zIndex = 25; var modalTemplate = `
    `; document.body.insertAdjacentHTML("beforeend", modalTemplate); document.getElementById("modalBackdrop").addEventListener("click", function() { togglePTZControls(); }); var targetUUID = modal.dataset.UUID; // Reset sliders to neutral positions getById("ptzPanSlider").value = 0; getById("ptzPanValue").innerText = "0"; getById("ptzTiltSlider").value = 0; getById("ptzTiltValue").innerText = "0"; getById("ptzZoomSlider").value = 50; getById("ptzZoomValue").innerText = "50"; getById("ptzFocusSlider").value = 0; getById("ptzFocusValue").innerText = "0"; // Pan slider handlers getById("ptzPanSlider").oninput = function(e) { getById("ptzPanValue").innerText = e.target.value; }; getById("ptzPanSlider").onchange = function(e) { var normalizedValue = parseInt(e.target.value) / 100; // Convert -100..100 to -1..1 session.requestPanChange(normalizedValue, targetUUID, session.remote, true); }; // Tilt slider handlers getById("ptzTiltSlider").oninput = function(e) { getById("ptzTiltValue").innerText = e.target.value; }; getById("ptzTiltSlider").onchange = function(e) { var normalizedValue = parseInt(e.target.value) / 100; // Convert -100..100 to -1..1 session.requestTiltChange(normalizedValue, targetUUID, session.remote, true); }; // Zoom slider handlers getById("ptzZoomSlider").oninput = function(e) { getById("ptzZoomValue").innerText = e.target.value; }; getById("ptzZoomSlider").onchange = function(e) { var normalizedValue = parseInt(e.target.value) / 100; // Convert 0..100 to 0..1 session.requestZoomChange(normalizedValue, targetUUID, session.remote, true); }; // Focus slider handlers getById("ptzFocusSlider").oninput = function(e) { getById("ptzFocusValue").innerText = e.target.value; }; getById("ptzFocusSlider").onchange = function(e) { var normalizedValue = parseInt(e.target.value) / 100; // Convert -100..100 to -1..1 session.requestFocusChange(normalizedValue, targetUUID, session.remote, true); }; // Reset Autofocus button handler getById("ptzResetAutofocusBtn").onclick = function() { session.requestAutofocusChange(true, targetUUID, session.remote); }; } } function toggleRoomSettings() { toggle(getById("roomSettings")); if (getById("roomSettings").style.display == "none") { //getById("modalBackdrop").innerHTML = ''; // Delete modal //getById("modalBackdrop").remove(); } else { //getById("modalBackdrop").innerHTML = ''; // Delete modal //getById("modalBackdrop").remove(); zindex = 25; getById("roomSettings").style.zIndex = 25; var modalTemplate = `
    `; // document.body.insertAdjacentHTML("beforeend", modalTemplate); // Insert modal at body end // document.getElementById("modalBackdrop").addEventListener("click", toggleRoomSettings); getById("trbSettingInput").value = session.totalRoomBitrate; getById("trbSettingInputManual").value = session.totalRoomBitrate; getById("trbSettingInputFeedback").innerHTML = session.totalRoomBitrate; if (session.limitTotalBitrate !== false) { getById("ltbSettingInputManual").value = session.limitTotalBitrate; getById("ltbSettingInput").value = session.limitTotalBitrate; getById("ltbSettingInputFeedback").innerHTML = session.limitTotalBitrate || "Disabled"; } // Show auth access control if in auth mode and user is director if (session.authMode && session.director && window.vdoAuth) { getById("authAccessControl").style.display = "block"; loadRoomAccessSettings(); } } } function changeLTB(ele) { session.limitTotalBitrate = parseInt(ele.value); getById("ltbSettingInputManual").value = session.limitTotalBitrate; getById("ltbSettingInput").value = session.limitTotalBitrate; getById("ltbSettingInputFeedback").innerHTML = session.limitTotalBitrate || "Disabled"; pokeIframeAPI("limit-total-bitrate", session.limitTotalBitrate); session.limitTotalBitrateGuests(); } function changeTRB(ele) { session.totalRoomBitrate = parseInt(ele.value); var msg = {}; msg.directorSettings = {}; msg.directorSettings.totalRoomBitrate = session.totalRoomBitrate; session.sendMessage(msg); pokeIframeAPI("total-room-bitrate", session.totalRoomBitrate); } function sendMediaDevices(UUID) { enumerateDevices().then(function (deviceInfos) { var data = {}; data.UUID = UUID; data.mediaDevices = deviceInfos; session.sendMessage(data, data.UUID); }); } function changeVideoDevice(index, quality = 0) { enumerateDevices() .then(gotDevices2) .then(function () { activatedPreview = false; document.getElementById("videoSource3").selectedIndex = index + ""; grabVideo(quality, "videosource", "#videoSource3"); }); } function changeAudioDevice(index) { enumerateDevices() .then(gotDevices2) .then(function () { activatedPreview = false; var audioSelect = document.getElementById("audioSource3").querySelectorAll("input"); for (var i = 0; i < audioSelect.length; i++) { audioSelect[i].checked = false; } audioSelect[index - 1].checked = true; grabAudio("#audioSource3"); }); } function changeVideoDeviceById(deviceId, UUID = false) { enumerateDevices() .then(gotDevices2) .then(function () { var opts = document.getElementById("videoSource3").options; var index = false; for (var opt, j = 0; (opt = opts[j]); j++) { if (opt.value == deviceId) { index = j; break; } } if (index !== false) { if (document.getElementById("videoSource3").selectedIndex === index) { // this is just refreshing the device. activatedPreview = false; grabVideo(0, "videosource", "#videoSource3", UUID); } else if (UUID && !session.consent) { window.focus(); confirmAlt("Allow the director to change your video device to:\n\n" + opts[index].text + " ?").then(res => { if (res) { document.getElementById("videoSource3").selectedIndex = index; activatedPreview = false; grabVideo(0, "videosource", "#videoSource3", UUID); } else { try { var data = {}; data.UUID = UUID; data.rejected = "changeCamera"; session.sendMessage(data, data.UUID); } catch (e) { } } }); } else { document.getElementById("videoSource3").selectedIndex = index; activatedPreview = false; grabVideo(0, "videosource", "#videoSource3", UUID); } } }); } function changeAudioDeviceById(deviceId, UUID = false) { enumerateDevices() .then(gotDevices2) .then(function () { var audioSelect = document.getElementById("audioSource3").querySelectorAll("input"); var matched = false; var exists = false; for (var i = 0; i < audioSelect.length; i++) { if (audioSelect[i].value == deviceId) { exists = true; if (audioSelect[i].checked) { matched = true; } } } if (exists) { if (matched) { // this is just refreshing the device. activatedPreview = false; //grabAudio("#audioSource3", UUID); grabAudio("#audioSource3", null, false, UUID); } else if (UUID && !session.consent) { window.focus(); confirmAlt("Allow the director to change your audio mic source?").then(res => { if (res) { // enumerateDevices().then(gotDevices2).then(function() { var audioSelect = document.getElementById("audioSource3").querySelectorAll("input"); for (var i = 0; i < audioSelect.length; i++) { if (audioSelect[i].value == deviceId) { audioSelect[i].checked = true; } else { audioSelect[i].checked = false; } } activatedPreview = false; grabAudio("#audioSource3", null, false, UUID); // }); } else { try { var data = {}; data.UUID = UUID; data.rejected = "changeMicrophone"; session.sendMessage(data, data.UUID); } catch (e) { } } }); } else { //enumerateDevices().then(gotDevices2).then(function() { var audioSelect = document.getElementById("audioSource3").querySelectorAll("input"); for (var i = 0; i < audioSelect.length; i++) { if (audioSelect[i].value == deviceId) { audioSelect[i].checked = true; } else { audioSelect[i].checked = false; } } activatedPreview = false; grabAudio("#audioSource3", null, false, UUID); // }); } } }); } function changeAudioOutputDeviceById(deviceId, UUID = false) { // remote control of the speaker output. warnlog(deviceId); if (document.getElementById("outputSource3")) { enumerateDevices() .then(gotDevices2) .then(function () { var index = false; if (document.getElementById("outputSource3")) { var opts = document.getElementById("outputSource3").options; for (var opt, j = 0; (opt = opts[j]); j++) { if (opt.value == deviceId) { index = j; break; } } } if (index !== false) { if (document.getElementById("outputSource3").selectedIndex === index) { // this is just refreshing the device. session.sink = deviceId; saveSettings(); resetupAudioOut(); } else if (UUID && !session.consent) { // UUID just lets us inform the requester window.focus(); confirmAlt("Allow the director to change your audio's speaker to:\n\n" + opts[index].text + " ?").then(res => { if (res) { if (index !== false) { document.getElementById("outputSource3").selectedIndex = index; } session.sink = deviceId; saveSettings(); resetupAudioOut(); var data = {}; data.UUID = UUID; sendMediaDevices(data.UUID); session.sendMessage(data, data.UUID); } else { try { var data = {}; data.UUID = UUID; data.rejected = "changeSpeaker"; session.sendMessage(data, data.UUID); } catch (e) { } } }); } else { if (index !== false) { document.getElementById("outputSource3").selectedIndex = index; } session.sink = deviceId; saveSettings(); resetupAudioOut(); } } }); } else { session.sink = deviceId; saveSettings(); resetupAudioOut(); } } function checkBasicStreamsExist() { log("checkBasicStreamsExist()"); if (!session.streamSrc) { session.streamSrc = createMediaStream(); } if (!session.videoElement) { if (document.getElementById("videosource")) { session.videoElement = document.getElementById("videosource"); } else if (document.getElementById("previewWebcam")) { session.videoElement = document.getElementById("previewWebcam"); } else { session.videoElement = createVideoElement(); } session.videoElement.addEventListener( "playing", e => { resetupAudioOut(session.videoElement, true); }, { once: true } ); session.videoElement.onpause = event => { // prevent things from pausing; human or other if (!(event.ctrlKey || event.metaKey)) { log("Video paused; auto playing"); event.currentTarget .play() .then(_ => { log("playing 10"); }) .catch(warnlog); } }; session.videoElement.addEventListener("error", function (event) { errorlog("video error"); errorlog(event); setTimeout(function () { if (session.videoElement) { log("Trying to re-load local preview, as it may have crashed"); session.videoElement.load(); } }, 1200); }); //session.videoElement.addEventListener('loadedmetadata', function(event) { // log("loadedmetadata"); // log(event); //}); } session.videoElement.srcObject = outboundAudioPipeline(); toggleMute(true); return session.videoElement; } var getUserMediaRequestID = 0; var getAudioUserMediaRequestID = 0; var grabVideoUserMediaTimeout = null; var grabVideoTimer = null; async function grabVideo(quality = 0, eleName = "previewWebcam", selector = "select#videoSourceSelect", callback = false) { if (activatedPreview == true) { log("activated preview return 2"); return; } if (session.miconly) { return; } activatedPreview = true; log("Grabbing video: " + quality); if (grabVideoTimer) { clearTimeout(grabVideoTimer); } log("element:" + eleName); var wasDisabled = true; try { if (session.streamSrc) { if (session.canvasWebGL) { session.canvasWebGL.remove(); session.canvasWebGL = null; } if (session.canvasSource) { session.canvasSource.srcObject.getTracks().forEach(function (trk) { session.canvasSource.srcObject.removeTrack(trk); trk.stop(); wasDisabled = false; }); } if (session.streamSrc) { session.streamSrc.getVideoTracks().forEach(function (track) { session.streamSrc.removeTrack(track); log("remove ss track 9"); track.stop(); wasDisabled = false; }); } if (session.streamSrcClone) { session.streamSrcClone.getVideoTracks().forEach(function (track) { session.streamSrcClone.removeTrack(track); log("remove ss track s9"); track.stop(); }); } } else { checkBasicStreamsExist(); log("CREATE NEW STREAM"); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getVideoTracks().forEach(function (track) { session.videoElement.srcObject.removeTrack(track); log("remove ss track 98"); track.stop(); session.videoElement.load(); wasDisabled = false; }); } else { checkBasicStreamsExist(); } } catch (e) { errorlog(e); } session.videoElement.controls = session.showControls || false; log("selector: " + selector); var videoSelect = document.querySelector(selector); // document.querySelector("videoSource3").value == "ZZZ" log(videoSelect); var mirror = false; getById("cameraTip1").classList.add("hidden"); if (!videoSelect || videoSelect.value == "ZZZ") { // if there is no video, or if manually set to audio ready, then do this step. clearTimeout(grabVideoUserMediaTimeout); getUserMediaRequestID += 1; warnlog("ZZZ SET - so no VIDEO"); SelectedVideoInputDevices = []; saveSettings(); if (session.avatar && session.avatar.ready) { updateRenderOutpipe(); } else if (session.mobile && needsLegacyWakeLock()) { // OBSOLETE since we now have "WAKE LOCK" API used. startLegacyKeepAliveLoop(); } if (eleName == "previewWebcam" && document.getElementById("previewWebcam")) { if (session.autostart) { publishWebcam(); // no need to mirror as there is no video... return; } else { log("4462"); updateStats(); if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").dataset.ready = "true"; if (document.getElementById("gowebcam").dataset.audioready == "true") { document.getElementById("gowebcam").disabled = false; //document.getElementById("gowebcam").innerHTML = getTranslation("start"); miniTranslate(document.getElementById("gowebcam"), "start"); document.getElementById("gowebcam").focus(); } } } } else { // If they disabled the video but not in preview mode; but actualy live. We will want to remove the stream from the publishing // we don't want to do this otherwise, as we are "replacing" the track in other cases. // this does cause a problem, as previous bitrate settings & resolutions might not be applied if switched back.... must test if (session.avatar && session.avatar.ready) { updateRenderOutpipe(); return; } if (session.chunked) { for (UUID in session.pcs) { session.chunkedStream(UUID); // make sure we check that this connection allows video / audio } // return; } try { var miscSenders = []; if (session.whipOut && session.whipOut.getSenders) { miscSenders.push(session.whipOut); } miscSenders.forEach(dataRTC => { if (dataRTC && dataRTC.getSenders) { dataRTC.getSenders().forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (sender.track && sender.track.kind == "video") { var trk = getWhipOutCanvasTrack(dataRTC); if (session.screenShareState && session.screenshareContentHint && trk.kind === "video") { try { trk.contentHint = session.screenshareContentHint; } catch (e) { errorlog(e); } } else if (session.contentHint && trk.kind === "video") { try { trk.contentHint = session.contentHint; } catch (e) { errorlog(e); } } try { sender.replaceTrack(trk); // replace may not be supported by all browsers. eek. } catch (e) { errorlog(e); } } }); } }); } catch (e) { errorlog(e); } for (UUID in session.pcs) { if ("realUUID" in session.pcs[UUID]) { continue; } // do not apply to screen shares. if (session.chunked && session.pcs[UUID].allowChunked) { continue; } // for any connected peer, update the video they have if connected with a video already. var senders = getSenders2(UUID); senders.forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (sender.track && sender.track.kind == "video") { sender.track.enabled = false; // I'm not entirely sure if I shoudl be doing this to a video stream... but I suppose new connections won't get a stream, and old connections will just replace it? getById("mutevideobutton").classList.add("hidden"); // hide the mute button, so they can't unmute while no video. //session.pcs[UUID].removeTrack(sender); // replace may not be supported by all browsers. eek. //errorlog("DELETED SENDER"); } }); } var msg = {}; msg.videoMuted = true; // doesn;t matter if video is actually muted or not; no video is being sent session.sendMessage(msg); } return; } else { if (videoSelect && videoSelect.value) { SelectedVideoInputDevices = [videoSelect.value]; saveSettings(); } if (session.avatar && session.avatar.timer) { clearInterval(session.avatar.timer); session.avatar.timer = null; } var sq = 0; if (session.quality === false) { sq = session.roomid ? session.quality_room : session.quality_wb; } else if (session.quality > 2) { // 1080, 720, and 360p sq = 2; // hacking my own code. TODO: ugly, so I need to revisit this. } else { sq = session.quality; } if (session.director && quality !== false) { // URL-based quality won't matter if DIRECTOR; // quality = quality; } else if (quality === false || quality < sq) { quality = sq; // override the user's setting } if ((iOS || iPad) && SafariVersion < 15) { // iOS will not work correctly at 1080p; likely a h264 codec issue. if (quality == 0) { quality = 1; } } var constraints = { audio: false, video: getUserMediaVideoParams(quality, iOS || iPad) }; //if (Firefox){ // constraints.video.height = constraints.video.height.ideal; // constraints.video.width = constraints.video.height.ideal; //} log("Quality selected:" + quality); if (session.outboundVideoBitrate_userSet === false) { // default is 2500 if (session.quality == 0) { // 1080p session.outboundVideoBitrate = 4000; } else if (session.quality == -1) { // unlocked session.outboundVideoBitrate = 4000; } else if (session.quality == -2) { // 4k session.outboundVideoBitrate = 8000; } else if (session.quality == -3) { // 2k session.outboundVideoBitrate = 6000; } else { session.outboundVideoBitrate = false; } } if (session.facingMode) { constraints.video.facingMode = { exact: session.facingMode }; // user or environment } else if (iOS || iPad) { constraints.video.deviceId = { exact: videoSelect.value }; // iPhone 6s compatible ? Needs to be exact for iPhone 6s } else if (Firefox) { // is firefox. constraints.video.deviceId = { exact: videoSelect.value }; // Firefox is a dick. Needs it to be exact. } else if (videoSelect.options[videoSelect.selectedIndex].text.includes("NDI Video")) { // NDI does not like "EXACT" constraints.video.deviceId = videoSelect.value; // NDI is fucked up } else { constraints.video.deviceId = { exact: videoSelect.value }; // Default. Should work for Logitech, etc. } if (session.width) { constraints.video.width = { exact: session.width }; // manually specified - so must be exact } if (session.height) { constraints.video.height = { exact: session.height }; } if (session.frameRate) { constraints.video.frameRate = { exact: session.frameRate }; } else if (session.maxframeRate != false) { constraints.video.frameRate = { ideal: session.maxframeRate, max: session.maxframeRate }; } else if ((iOS || iPad) && SafariVersion > 15) { // iOS supports 720p60, but just 1080p30 : iphone 11 on march 2023 if (quality === 1) { // iphone 11 and older if (!constraints.video.frameRate) { constraints.video.frameRate = { ideal: 60, max: 60 }; } } else if (iPhone12Up && quality < 1) { // iphone 12 and up? if (!constraints.video.frameRate) { try { if (videoSelect.options[videoSelect.selectedIndex].innerText.startsWith("Back ")) { // front seems to be limited to 720p60 / 1080p30 constraints.video.frameRate = { ideal: 60, max: 60 }; } } catch (e) { errorlog(e); } } } } if (session.ptz) { if (constraints.video && constraints.video !== true) { if (ChromiumVersion && ChromiumVersion > 80) { constraints.video.pan = true; constraints.video.tilt = true; constraints.video.zoom = true; } } } if (session.forceAspectRatio) { // await updateCameraConstraints("aspectRatio", session.forceAspectRatio); if (constraints.video && constraints.video !== true) { constraints.video.aspectRatio = { ideal: parseFloat(session.forceAspectRatio) }; if (constraints.video.width && !session.width) { delete constraints.video.width; } else if (constraints.video.height && !session.height) { delete constraints.video.height; } } } var obscam = false; var mirrorcheck = false; log(videoSelect.options[videoSelect.selectedIndex].text); if (!videoSelect.options[videoSelect.selectedIndex]) { if (session.mobile) { mirrorcheck = true; mirror = false; } else { mirror = false; } } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("OBS-Camera")) { // OBS Virtualcam mirror = true; obscam = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("OBS Virtual Camera")) { // OBS Virtualcam mirror = true; obscam = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("Streamlabs ")) { // OBS Virtualcam mirror = true; obscam = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("Dummy video device")) { // Linuxv mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("vMix Video")) { // vMix mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("Blackmagic")) { // Blackmagic devices mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("screen-capture-recorder")) { // screen-capture-recorder mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.includes(" back")) { // Android mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.includes(" rear")) { // Android mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.includes("NDI Video")) { // NDI Virtualcam mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.startsWith("Back Camera")) { // iPhone and iOS mirror = true; } else if (videoSelect.options[videoSelect.selectedIndex].text.toLowerCase().includes("c922")) { if (session.quality !== 2 && !session.cleanOutput) { //getById("cameraTipContext1").innerHTML = getTranslation("camera-tip-c922"); miniTranslate(getById("cameraTipContext1"), "camera-tip-c922"); getById("cameraTip1").classList.remove("hidden"); } } else if (videoSelect.options[videoSelect.selectedIndex].text.toLowerCase().includes("cam link")) { if (!session.cleanOutput) { //getById("cameraTipContext1").innerHTML = getTranslation("camera-tip-camlink"); miniTranslate(getById("cameraTipContext1"), "camera-tip-camlink"); getById("cameraTip1").classList.remove("hidden"); } } else if (session.mobile) { mirrorcheck = true; mirror = false; } else { mirror = false; } if (SamsungASeries && ChromiumVersion) { if (!session.cleanOutput) { //getById("cameraTipContext1").innerHTML = getTranslation("samsung-a-series"); miniTranslate(getById("cameraTipContext1"), "samsung-a-series"); getById("cameraTip1").classList.remove("hidden"); } } if (session.nomirror) { // do not have the camera be mirrored by default, unless using &mirror session.mirrorExclude = true; } else { session.mirrorExclude = mirror; } if (constraints.video && constraints.video !== true && Object.keys(constraints.video).length == 0) { constraints.video = true; } else if (constraints.video && constraints.video !== true && Object.keys(constraints.video).length == 1 && "deviceId" in constraints.video && "exact" in constraints.video.deviceId && constraints.video.deviceId.exact === "default") { constraints.video = true; // solves issues with IOS, where no permission yet given - can't request device ID it seems until permissions is given. } log(constraints); clearTimeout(grabVideoUserMediaTimeout); getUserMediaRequestID += 1; var gumMediaID = getUserMediaRequestID; var delayStart = 100; if (ChromiumVersion > 110) { // aded july 16th; speed up camera switching. delayStart = 20; } else if (Firefox) { delayStart = 500; // cause firefox is buggy as crap } grabVideoUserMediaTimeout = setTimeout( function (gumID, callback2) { if (getUserMediaRequestID !== gumID) { return; } // cancel if (Firefox) { constraints = toFirefoxConstraint(constraints); } warnlog("navigator.mediaDevices.getUserMedia starting..."); navigator.mediaDevices .getUserMedia(constraints) .then(function (stream) { if (getUserMediaRequestID !== gumID) { warnlog("GET USER MEDIA CALL HAS EXPIRED"); stream.getTracks().forEach(function (track) { stream.removeTrack(track); track.stop(); log("stopping old track"); }); return; } log("adding video tracks 2412"); stream.getVideoTracks().forEach(async function (track) { try { if (mirrorcheck) { try { var capabilities = track.getCapabilities(); } catch (e) { var capabilities = {}; } if ("facingMode" in capabilities) { if (capabilities.facingMode == "environment") { session.mirrorExclude = true; } } if ("backgroundBlur" in capabilities) { // Chrome original trial, until v117, and then??? query('#effectSelector option[value="13"]').classList.remove("hidden"); query('#effectSelector option[value="13"]').disabled = null; query('#effectSelector3 option[value="13"]').classList.remove("hidden"); query('#effectSelector3 option[value="13"]').disabled = null; } else { query('#effectSelector option[value="13"]').disabled = true; query('#effectSelector3 option[value="13"]').disabled = true; } } } catch (e) { } session.streamSrc.addTrack(track); // tracks previously removed. try { track.onended = function (e) { // hurrah! warnlog(e); refreshVideoDevice(); }; } catch (e) { errorlog(e); } if (session.whiteBalance !== false) { try { await track.applyConstraints({ advanced: [{ whiteBalanceMode: "manual", colorTemperature: parseInt(session.whiteBalance) }] }); } catch (e) { errorlog(e); try { await track.applyConstraints({ advanced: [{ whiteBalanceMode: "manual" }] }); } catch (e) { warnlog(e); } } } if (session.exposure !== false) { try { await track.applyConstraints({ advanced: [{ exposureMode: "manual", exposureTime: parseInt(session.exposure) }] }); } catch (e) { errorlog(e); try { await track.applyConstraints({ advanced: [{ exposureMode: "manual" }] }); } catch (e) { warnlog(e); } } } if (session.zoom !== false) { try { await track.applyConstraints({ advanced: [{ zoom: parseFloat(session.zoom) }] }); } catch (e) { errorlog(e); } } if (session.saturation !== false) { try { await track.applyConstraints({ advanced: [{ saturation: parseInt(session.saturation) }] }); } catch (e) { errorlog(e); } } if (session.sharpness !== false) { try { await track.applyConstraints({ advanced: [{ sharpness: parseInt(session.sharpness) }] }); } catch (e) { errorlog(e); } } if (session.contrast !== false) { try { await track.applyConstraints({ advanced: [{ contrast: parseInt(session.contrast) }] }); } catch (e) { errorlog(e); } } if (session.brightness !== false) { try { await track.applyConstraints({ advanced: [{ brightness: parseInt(session.brightness) }] }); } catch (e) { errorlog(e); } } if (session.focusDistance !== false) { try { await track.applyConstraints({ advanced: [{ focusMode: "manual", focusDistance: parseInt(session.focusDistance) }] }); } catch (e) { errorlog(e); try { await track.applyConstraints({ advanced: [{ focusMode: "manual" }] }); } catch (e) { warnlog(e); } } } if (session.mobile) { if (!(iPad || iOS || Firefox)) { try { applySavedVideoSettings(track); } catch (e) { errorlog(e); } } } }); if (Firefox && !FirefoxEnumerated) { if (session.streamSrc && session.streamSrc.getTracks().length) { FirefoxEnumerated = true; enumerateDevices().then(gotDevices); } } updateRenderOutpipe(); // senderAudioUpdate if (wasDisabled && !session.videoMuted) { var msg = {}; msg.videoMuted = session.videoMuted; session.sendMessage(msg); } applyMirror(session.mirrorExclude); session.videoElement.play().then(() => { log("play doublecheck completed"); }); if (eleName == "previewWebcam" && document.getElementById("previewWebcam")) { if (session.autostart) { publishWebcam(); } else { log("4620"); if (document.getElementById("gear_webcam")) { updateStats(obscam); } if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").dataset.ready = "true"; if (document.getElementById("gowebcam").dataset.audioready == "true") { document.getElementById("gowebcam").disabled = false; //document.getElementById("gowebcam").innerHTML = getTranslation("start"); miniTranslate(document.getElementById("gowebcam"), "start"); document.getElementById("gowebcam").focus(); } } } } else if (getById("gear_webcam3").style.display === "inline-block") { updateStats(obscam); } // Once crbug.com/711524 is fixed, we won't need to wait anymore. This is // currently needed because capabilities can only be retrieved after the // device starts streaming. This happens after and asynchronously w.r.t. // getUserMedia() returns. if (grabVideoTimer) { clearTimeout(grabVideoTimer); if (eleName == "previewWebcam" && document.getElementById("previewWebcam")) { session.videoElement.controls = true; } } if (getById("popupSelector_constraints_video")) { getById("popupSelector_constraints_video").innerHTML = ""; } if (getById("popupSelector_constraints_audio")) { getById("popupSelector_constraints_audio").innerHTML = ""; } if (getById("popupSelector_constraints_loading")) { getById("popupSelector_constraints_loading").style.display = ""; } if (iOS || iPad) { // TEMPORARY: iOS 15.3 beta fix toggleSpeakerMute(true); } if (!(eleName == "previewWebcam" || document.getElementById("previewWebcam"))) { updateMixer(); // not with the preview, but after. } pokeIframeAPI("local-camera-event"); let grabVideoPostTimeoutValue = 1000; if (Firefox || session.mobile) { // wait longer for these; they are more likely to crash if too quick. grabVideoPostTimeoutValue = 2000; } grabVideoTimer = setTimeout( async function (callback3, gumid) { if (getUserMediaRequestID !== gumid) { // new camera selected in this time. return; } makeImages(true); if (getById("popupSelector_constraints_loading")) { getById("popupSelector_constraints_loading").style.display = "none"; } if (eleName == "previewWebcam" && document.getElementById("previewWebcam")) { session.videoElement.controls = true; try { var track0 = session.streamSrc.getVideoTracks(); if (track0.length) { track0 = track0[0]; if (track0.getCapabilities) { session.cameraConstraints = track0.getCapabilities(); } else { session.cameraConstraints = {}; } log(session.cameraConstraints); if (track0.getSettings) { session.currentCameraConstraints = track0.getSettings(); if (screen && screen.orientation && screen.orientation.type) { if (screen.orientation.type.includes("portrait")) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } else if (window.matchMedia("(orientation: portrait)").matches) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } else { session.currentCameraConstraints = {}; } log(session.currentCameraConstraints); } } catch (e) { errorlog(e); } } else if (toggleSettingsState) { log("16047"); updateConstraintSliders(); //listCameraSettings(); } if (callback3) { try { var data = {}; data.UUID = callback3; data.videoOptions = listVideoSettingsPrep(); sendMediaDevices(data.UUID); session.sendMessage(data, data.UUID); } catch (e) { } } if (iOS || iPad) { // TEMPORARY: iOS 15.3 beta fix toggleSpeakerMute(true); } if (session.forceAspectRatio) { await updateCameraConstraints("aspectRatio", session.forceAspectRatio); } updateForceRotate(); // this contains session.setResolution(); if (iOS || iPad) { // if we don't do this, portrait videos may be detected as horizontal if (!document.getElementById("previewWebcam")) { updateMixer(); // not with the preview, but after. } } try { if (session.pip3) { if (!eleName.pip) { eleName.pip = true; toggleSystemPip(session.videoElement, true); } } } catch (e) { } // this will reset scaling for all viewers of this stream. I also call it when aspect ratio, width, or height is changed via applyConstraints dragElement(session.videoElement); }, grabVideoPostTimeoutValue, callback2, gumID ); // focus log("DONE - found stream"); }) .catch(function (e) { if (getUserMediaRequestID !== gumID) { warnlog("the previously selected camera attempted failed, but not a big deal, since its now void"); return; } warnlog(e); if (e.name === "OverconstrainedError") { warnlog(e.message || e); log("Resolution or frameRate didn't work"); } else if (e.name === "NotReadableError") { if (quality <= 10) { activatedPreview = false; grabVideo(quality + 1, eleName, selector); } else if (session.facingMode) { session.facingMode = false; activatedPreview = false; grabVideo(false, eleName, selector); // restart. } else { if (!session.cleanOutput) { if (iOS) { warnUser("An error occured. Closing existing tabs in Safari may solve this issue."); } else { warnUser("Error: Could not start video source.\n\nTypically this means the Camera is already be in use elsewhere. Most webcams can only be accessed by one program at a time.\n\nTry a different camera or perhaps try re-plugging in the device."); } } activatedPreview = true; if (getById("gowebcam")) { getById("gowebcam").innerHTML = "Problem with Camera"; } } return; } else if (e.name === "NavigatorUserMediaError") { if (getById("gowebcam")) { getById("gowebcam").innerHTML = "Problem with Camera"; } if (!session.cleanOutput) { warnUser("Unknown error: 'NavigatorUserMediaError'"); } return; } else if (e.name === "timedOut") { activatedPreview = true; if (getById("gowebcam")) { getById("gowebcam").innerHTML = "Problem with Camera"; } if (!session.cleanOutput) { warnUser(e.message); } return; } else { errorlog("An unknown camera error occured"); } if (quality <= 10) { activatedPreview = false; grabVideo(quality + 1, eleName, selector); } else if (session.facingMode) { session.facingMode = false; activatedPreview = false; grabVideo(false, eleName, selector); // restart. } else { errorlog("********Camera failed to work"); activatedPreview = true; if (getById("gowebcam")) { getById("gowebcam").innerHTML = "Problem with Camera"; } if (!session.cleanOutput) { if (session.width || session.height || session.frameRate) { warnUser(" Camera failed to load.\n\nPlease ensure your camera supports the resolution and frameRate that has been manually specified. Perhaps use &quality=0 instead.", false, false); } else { warnUser(" Camera failed to load.\n\nPlease make sure it is not already in use by another application.\n\nPlease make sure you have accepted the camera permissions.", false, false); } } } }); }, delayStart, gumMediaID, callback ); } } function updateRenderOutpipe() { // video only. log("updateRenderOutpipe()"); if (session.canvasWebGL) { session.canvasWebGL.remove(); session.canvasWebGL = null; } if (session.canvasSource) { session.canvasSource.srcObject.getTracks().forEach(function (trk) { session.canvasSource.srcObject.removeTrack(trk); //trk.stop(); }); } if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getVideoTracks().forEach(function (track) { session.videoElement.srcObject.removeTrack(track); log("remove ss track 84"); //track.stop(); //session.videoElement.load(); }); } else { checkBasicStreamsExist(); } if (session.streamSrc) { var tracks = session.streamSrc.getVideoTracks(); if (!tracks.length || session.videoMuted) { tracks = setAvatarImage(tracks); if (tracks.length) { if (tracks.length && !session.cleanOutput && !session.cleanish) { getById("mutevideobutton").classList.remove("hidden"); } tracks.forEach(function (track) { session.videoElement.srcObject.addTrack(track); if (session.avatar && session.avatar.tracks) { var msg = {}; msg.videoMuted = false; // doesn't matter actual mute state, since its the avatar session.sendMessage(msg); } else { toggleVideoMute(true); } pushOutVideoTrack(track); // video only }); } else { var msg = {}; msg.videoMuted = true; session.sendMessage(msg); session.videoElement.load(); getById("mutevideobutton").classList.add("hidden"); } } else if (tracks.length) { applyMirror(session.mirrorExclude || session.screenShareState); tracks.forEach(function (track) { track = applyEffects(track); // updates with the correct track session.streamSrc session.videoElement.srcObject.addTrack(track); toggleVideoMute(true); pushOutVideoTrack(track); // video only }); if (tracks.length && !session.cleanOutput && !session.cleanish) { getById("mutevideobutton").classList.remove("hidden"); } } } } function pushOutVideoTrack(track) { log("pushOutVideoTrack"); pokeIframeAPI("push-video-track", track.id, false, session.streamID); // (action, value = null, UUID = null, SID=null) if (session.chunked) { for (UUID in session.pcs) { session.chunkedStream(UUID); // I need to update chunkedStream with the current track? If sstype=3, then skip this } } if (session.audioContentHint && track.kind === "audio") { // why am I pushing an audio track? errorlog("this shouldn't occur, since only video tracks are expected"); try { track.contentHint = session.audioContentHint; } catch (e) { errorlog(e); } } if (session.screenShareState && session.screenshareContentHint && track.kind === "video") { // I need to check if this is actually a screenshare before setting the hint (sstype=3) try { track.contentHint = session.screenshareContentHint; } catch (e) { errorlog(e); } } else if (session.contentHint && track.kind === "video") { try { track.contentHint = session.contentHint; } catch (e) { errorlog(e); } } if (session.whipOut && session.whipOut.getSenders) { // should only be 0 or 1 video sender, ever. //var added = false; session.whipOut.getSenders().forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (sender.track && sender.track.kind == "video") { warnlog("Replacing track"); sender.replaceTrack(track); // replace may not be supported by all browsers. eek. //sender.track.enabled = true; //added = true; } }); } for (UUID in session.pcs) { var videoAdded = false; try { if ("realUUID" in session.pcs[UUID]) { continue; } if (session.chunked && session.pcs[UUID].allowChunked) { continue; } if (session.pcs[UUID].guest == true && session.roombitrate === 0) { log("room rate restriction detected. No videos will be published to other guests"); } else if (session.pcs[UUID].allowVideo == true) { // allow // for any connected peer, update the video they have if connected with a video already. var added = false; var senders = getSenders2(UUID); senders.forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (added) { return; } if (sender.track && sender.track.kind == "video") { sender.replaceTrack(track); // replace may not be supported by all browsers. eek. log("Track replaced"); log(track); sender.track.enabled = true; added = true; } }); if (added == false) { videoAdded = true; session.pcs[UUID].addTrack(track, session.videoElement.srcObject); // can't replace, so adding setTimeout( function (uuid) { session.optimizeBitrate(uuid); }, session.rampUpTime, UUID ); // 3 seconds lets us ramp up the quality a bit and figure out the total bandwidth quicker } } } catch (e) { errorlog(e); } if (iOS || iPad) { ///////// THIS IS A FIX FOR iOS 15.4. When a video is loaded (view/push), the bitrate from iOS devices is stuck low, and resolution needs toggle to fix. // videoAdded value needs to be deleted from above also if (SafariVersion && SafariVersion <= 13) { // } else if (videoAdded) { setTimeout( function (uuid) { session.setScale(uuid, null); }, 2000, UUID ); setTimeout( function (uuid) { var scale = 100; session.setScale; if (session.pcs[uuid].scale) { scale = session.pcs[uuid].scale; } session.setScale(uuid, scale); }, 5000, UUID ); } } } if (track.kind === "audio") { session.applyIsolatedChat(); } session.refreshScale(); } async function grabAudio(selector = "#audioSource", trackid = null, override = false, callbackUUID = false, callback = false) { // trackid is the excluded track , callback is UUID if (activatedPreview == true) { log("activated preview return 2"); return; } activatedPreview = true; getAudioUserMediaRequestID += 1; var gumAudioID = getAudioUserMediaRequestID; log("TRACK EXCLUDED:" + trackid); try { var baseTest = document.querySelector(selector); if (!baseTest) { errorlog("No audio source menu"); return; } if (baseTest && baseTest.tagName == "UL") { var audioSelect = baseTest.querySelectorAll("input"); var audioExcludeList = []; for (var i = 0; i < audioSelect.length; i++) { try { if ("screen" == audioSelect[i].dataset.type) { // skip already excluded ---------- !!!!!! DOES THIS MAKE SENSE? TODO: CHECK if (audioSelect[i].checked) { audioExcludeList.push(audioSelect[i]); } } } catch (e) { errorlog(e); } } } else if (baseTest && baseTest.tagName == "SELECT") { var audioExcludeList = []; var audioSelect = baseTest.options; for (var i = 0; i < audioSelect.length; i++) { try { if ("screen" == audioSelect[i].dataset.type) { // skip already excluded ---------- !!!!!! DOES THIS MAKE SENSE? TODO: CHECK if (audioSelect[i].selected) { audioExcludeList.push(audioSelect[i]); } } } catch (e) { errorlog(e); } } } } catch (e) { errorlog(e); } try { if (session.videoElement && session.videoElement.srcObject) { session.videoElement.srcObject.getAudioTracks().forEach(function (track) { // TODO: Confirm that I even need this? for (var i = 0; i < audioExcludeList.length; i++) { try { if (audioExcludeList[i].label == track.label) { warnlog("DONE"); return; } } catch (e) { errorlog(e); } } if (trackid && track.id == trackid) { warnlog("SKIPPED EXCLUDED TRACK?"); return; } session.videoElement.srcObject.removeTrack(track); log("remove ss track67"); track.stop(); // remove then stop. }); } else { // if no stream exists checkBasicStreamsExist(); } } catch (e) { errorlog(e); } try { if (session.streamSrc) { session.streamSrc.getAudioTracks().forEach(function (track) { for (var i = 0; i < audioExcludeList.length; i++) { try { if (audioExcludeList[i].label == track.label) { warnlog("EXCLUDING TRACK; PROBABLY SCREEN SHARE"); return; } } catch (e) { errorlog(e); } } if (trackid && track.id == trackid) { warnlog("SKIPPED EXCLUDED TRACK?"); return; } session.streamSrc.removeTrack(track); track.stop(); }); } else { // if no stream exists checkBasicStreamsExist(); } } catch (e) { errorlog(e); } try { if (session.streamSrcClone) { session.streamSrcClone.getAudioTracks().forEach(function (track) { for (var i = 0; i < audioExcludeList.length; i++) { try { if (audioExcludeList[i].label == track.label) { warnlog("EXCLUDING TRACK; PROBABLY SCREEN SHARE"); return; } } catch (e) { errorlog(e); } } if (trackid && track.id == trackid) { warnlog("SKIPPED EXCLUDED TRACK?"); return; } log("remove ss track 55"); session.streamSrcClone.removeTrack(track); track.stop(); }); } } catch (e) { errorlog(e); } var streams = await getAudioOnly(selector, trackid, override, gumAudioID); // Get audio streams if (gumAudioID !== getAudioUserMediaRequestID) { try { streams.forEach(stream => { if (!stream) { return; } stream.getTracks().forEach(track => track.stop()); }); } catch (e) { } activatedPreview = false; return; } try { log("STREAMS: " + streams.length); for (var i = 0; i < streams.length; i++) { streams[i].getAudioTracks().forEach(function (track) { try { if (gumAudioID !== getAudioUserMediaRequestID) { track.stop(); return; } session.streamSrc.addTrack(track); // add video track to the preview video track.onended = handleAudioTrackEnded; // Add event listener for track end log("ok?"); // applySavedAudioSettings(track); ## this doesn't work as echo-cancellation(+) needs to be applied via getuserMedia only. } catch (e) { errorlog(e); } }); } } catch (e) { errorlog(e); } if (Firefox && !FirefoxEnumerated) { if (session.streamSrc && session.streamSrc.getTracks().length) { FirefoxEnumerated = true; enumerateDevices().then(gotDevices); } } if (callback) { callback(); } senderAudioUpdate(callbackUUID); try { if (session.streamSrc && session.streamSrc.getVideoTracks && session.streamSrc.getVideoTracks().length) { var previewVideoCount = 0; if (session.videoElement && session.videoElement.srcObject && session.videoElement.srcObject.getVideoTracks) { previewVideoCount = session.videoElement.srcObject.getVideoTracks().length; } if (!previewVideoCount && typeof updateRenderOutpipe === "function") { updateRenderOutpipe(); } } } catch (e) { errorlog(e); } } session.toggleSoloChat = function (UUID, event = false) { // ==> applyIsolatedChat -- this should be trigger by the director only I think if (session.director) { if (!session.directorEnabledPPT) { warnUser("Enable the director's microphone first.", 2000); return false; } } if (Firefox) { warnlog("Solo talk support for Firefox is currently experimental"); } var msg = {}; msg.micIsolate = false; if (session.soloChatUUID.includes(UUID)) { // already added, so lets toggle off session.soloChatUUID.splice(session.soloChatUUID.indexOf(UUID), 1); // Toggles. Adds target to soloChatUUID list msg.lowerVolume = false; } else { session.soloChatUUID.push(UUID); //not added, so lets toggle on msg.lowerVolume = true; } if (event) { if (event.ctrlKey || event.metaKey) { if (session.soloChatUUID.includes(UUID)) { msg.micIsolate = 1; } } } session.sendRequest(msg, UUID); log(session.soloChatUUID); var ele = document.querySelector('[data-action-type="solo-chat"][data--u-u-i-d="' + UUID + '"]'); // [data--u-u-i-d="'+UUID+'"] // this all just updates the buttons log(ele); var ret = 0; if (session.soloChatUUID.includes(UUID)) { if (msg.micIsolate) { ret = 2; ele.classList.add("altpress"); // we will do this later. ele.value = 2; } else { ret = 1; ele.value = 1; } } else { ele.classList.remove("pressed"); ele.ariaPressed = "false"; ele.classList.remove("altpress"); ele.value = 0; } session.applySoloChat(false); return ret; }; /////////////////////// session.togglePrivateChat = function (ele) { var msg = {}; warnlog(ele); if (ele.value == 0) { msg.micIsolate = true; ele.value = 1; ele.classList.add("pressed"); ele.ariaPressed = "true"; } else { msg.micIsolate = false; ele.value = 0; ele.classList.remove("pressed"); ele.ariaPressed = "false"; } session.sendRequest(msg, ele.dataset.UUID); warnlog(msg); }; // we call this via session.applyIsolatedChat, just in case session.applyIsolatedVolume = function () { // mutes outbound mic audio; for guests, and not the director var i = session.lowerVolume.length; while (i--) { if (!(session.lowerVolume[i] in session.rpcs)) { // clean up dead connections session.lowerVolume.splice(i, 1); } } var soloMode = false; /* if (!(session.cleanOutput)){ if (session.lowerVolume.length){ getById("header").classList.add('orange'); getById("head6").classList.remove('hidden'); } else if (session.audioGain === 0){ // do nothing. } else { getById("header").classList.remove('orange'); getById("head6").classList.add('hidden'); } } */ if (session.lowerVolume.length) { soloMode = true; } if (soloMode) { for (var UUID in session.rpcs) { if (session.lowerVolume.includes(UUID)) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].savedVolume !== false) { // isolated session.rpcs[UUID].videoElement.volume = session.rpcs[UUID].savedVolume; session.rpcs[UUID].savedVolume = false; } continue; } if (session.rpcs[UUID].videoElement && session.rpcs[UUID].savedVolume == false) { // not isolated session.rpcs[UUID].savedVolume = session.rpcs[UUID].videoElement.volume; session.rpcs[UUID].videoElement.volume = session.rpcs[UUID].savedVolume * 0.25; } } } else { for (var UUID in session.rpcs) { if (session.rpcs[UUID].videoElement && session.rpcs[UUID].savedVolume !== false) { // isolated session.rpcs[UUID].videoElement.volume = session.rpcs[UUID].savedVolume; session.rpcs[UUID].savedVolume = false; } } } }; session.applyIsolatedChat = function (UUID = false) { // mutes outbound mic audio; for guests, and not the director log("applyIsolatedChat"); session.applyIsolatedVolume(); // this toggle the speaker output var i = session.micIsolated.length; while (i--) { if (!(session.micIsolated[i] in session.pcs) && !(session.micIsolated[i] in session.rpcs)) { session.micIsolated.splice(i, 1); } } var muteList = [...session.micIsolated]; // one thing I hate about Javascript. Doesn't actually copy arrays. var soloMode = false; if (session.micIsolatedAutoMute) { // session.micIsolatedAutoMute soloMode = true; session.micIsolatedAutoMute.forEach(uid => { if (!muteList.includes(uid) && (uid in session.rpcs || uid in session.pcs)) { muteList.push(uid); } }); } if (muteList.length) { soloMode = true; } if (!session.cleanOutput) { if (soloMode) { getById("header").classList.add("orange"); getById("head6").classList.remove("hidden"); } else if (session.audioGain === 0) { // do nothing. } else { getById("header").classList.remove("orange"); getById("head6").classList.add("hidden"); } } ///// if (session.directorSpeakerMuted !== null) { for (var uuid in session.rpcs) { try { var receivers = getReceivers2(uuid); //session.rpcs[uuid].getReceivers(); for (var i = 0; i < receivers.length; i++) { if (receivers[i].track.kind == "audio") { receivers[i].track.enabled = true; // Chrome 133+ fix: must enable before disabling receivers[i].track.enabled = !session.directorSpeakerMuted; } } } catch (e) { errorlog(e); } } if (session.directorSpeakerMuted) { getById("videosource").muted = true; } } ////////////// if (UUID) { try { var senders = getSenders2(UUID); senders.forEach(sender => { if (!sender.track) { return; } if (sender.track.kind !== "audio") { return; } var settings = {}; if (!soloMode) { settings.active = true; session.pcs[UUID].audioMutedOverride = false; } else if (muteList.indexOf(UUID) >= 0) { settings.active = true; session.pcs[UUID].audioMutedOverride = false; } else { log("MUTING via session.applyIsolatedChat"); settings.active = false; session.pcs[UUID].audioMutedOverride = true; } setEncodings(sender, settings); }); } catch (e) { errorlog(e); } } else { for (var UUID in session.pcs) { try { var senders = getSenders2(UUID); senders.forEach(sender => { if (!sender.track) { return; } if (sender.track.kind !== "audio") { return; } var settings = {}; if (!soloMode) { settings.active = true; session.pcs[UUID].audioMutedOverride = false; } else if (muteList.indexOf(UUID) >= 0) { settings.active = true; session.pcs[UUID].audioMutedOverride = false; } else { log("MUTING via session.applyIsolatedChat"); settings.active = false; session.pcs[UUID].audioMutedOverride = true; } setEncodings(sender, settings); }); } catch (e) { errorlog(e); } } } }; var FirefoxSenders = {}; function setEncodings(sender, settings = null, callback = null, cbarg = null) { if (!settings) { if (!sender.encodingsQueue) { // not set return; } else if (!sender.encodingsQueue.length) { // none left return; } } else if (!("encodingsQueue" in sender)) { sender.encodingsQueue = [[settings, callback, cbarg]]; } else { sender.encodingsQueue.push([settings, callback, cbarg]); } if (sender.encodingsQueueActive) { return; } try { sender.encodingsQueueActive = true; // we're now busy. var options = sender.encodingsQueue.shift(); settings = options[0]; callback = options[1]; cbarg = options[2]; const params = sender.getParameters(); if (!params.encodings || params.encodings.length == 0) { params.encodings = [{}]; } var changed = false; for (var setting in settings) { if (settings[setting] === null) { if (setting in params.encodings[0]) { delete params.encodings[0][setting]; changed = true; } } else { if (setting in params.encodings[0]) { if (params.encodings[0][setting] !== settings[setting]) { changed = true; } } else { changed = true; } params.encodings[0][setting] = settings[setting]; } } log(settings); // if old Firefox, see if I can do something other than Active? if (!changed && !Firefox && !SafariVersion) { log("SET ENCODINGS MATCH INPUT; skipping"); if (callback) { if (cbarg) { setTimeout(function () { callback(cbarg); }, 0); } else { setTimeout(function () { callback(); }, 0); } } sender.encodingsQueueActive = false; setEncodings(sender); return; } if (Firefox && !(Firefox >= 110)) { // https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpEncodingParameters now supported in v110, but old versions will need this function still if ("active" in settings) { warnlog("Firefox does not support track active state. We will use enable/disable for that instead."); if (FirefoxSenders.sender) { if (FirefoxSenders.sender.lastState === false) { FirefoxSenders.sender.activeState = settings.active; // already set to false, so should stay disabled } else { FirefoxSenders.sender.activeState = settings.active; sender.track.enabled = settings.active; // either true or false } } else { FirefoxSenders.sender = { lastState: sender.track.enabled, activeState: settings.active }; sender.track.enabled = settings.active; } delete settings.active; if (!Object.keys(settings).length) { if (callback) { if (cbarg) { setTimeout(function () { callback(cbarg); }, 0); } else { setTimeout(function () { callback(); }, 0); } } log("COMPELTED FIREFOX SET ENCODINGS"); sender.encodingsQueueActive = false; setEncodings(sender); return; } } } else if (Firefox) { // Firefox , all versions, don't support active state with audio yet?? GAhhhhhhhh! if ("track" in sender && "kind" in sender.track && sender.track.kind == "audio") { if ("active" in settings) { warnlog("Firefox does not support track active state with AUDIO yet... We will use enable/disable for that instead."); if (FirefoxSenders.sender) { if (FirefoxSenders.sender.lastState === false) { FirefoxSenders.sender.activeState = settings.active; // already set to false, so should stay disabled } else { FirefoxSenders.sender.activeState = settings.active; sender.track.enabled = settings.active; // either true or false } } else { FirefoxSenders.sender = { lastState: sender.track.enabled, activeState: settings.active }; sender.track.enabled = settings.active; } delete settings.active; if (!Object.keys(settings).length) { if (callback) { if (cbarg) { setTimeout(function () { callback(cbarg); }, 0); } else { setTimeout(function () { callback(); }, 0); } } log("COMPELTED FIREFOX SET ENCODINGS"); sender.encodingsQueueActive = false; setEncodings(sender); return; } } } } sender .setParameters(params) .then(() => { if (callback) { if (cbarg) { setTimeout(function () { callback(cbarg); }, 0); } else { setTimeout(function () { callback(); }, 0); } } sender.encodingsQueueActive = false; setEncodings(sender); }) .catch(e => { errorlog(e); sender.encodingsQueueActive = false; setEncodings(sender); }); } catch (e) { errorlog(e); sender.encodingsQueueActive = false; } } session.applySoloChat = function (apply = true) { // mutes outbound mic audio; ;; does the actual solo chat muting for the director if (session.director === false) { session.applyIsolatedChat(); return; } else if (!session.directorEnabledPPT) { return; } log("applySoloChat()"); var i = session.soloChatUUID.length; while (i--) { if (!(session.soloChatUUID[i] in session.pcs)) { session.soloChatUUID.splice(i, 1); log("splicing out: " + i); } } for (var uuid in session.pcs) { // not sure what to do here wrt to screen tracks try { var senders = getSenders2(uuid); senders.forEach(sender => { if (!sender.track) { return; } if (sender.track.kind !== "audio") { return; } var settings = {}; if (session.soloChatUUID.length && session.soloChatUUID.includes(uuid)) { settings.active = true; setEncodings( sender, settings, function (uid) { log("2: " + uid); var button = document.querySelector('[data-action-type="solo-chat"][data--u-u-i-d="' + uid + '"]'); if (button) { button.classList.add("pressed"); button.ariaPressed = "true"; button.classList.remove("hint"); } }, uuid ); } else if (session.soloChatUUID.length == 0) { settings.active = true; setEncodings( sender, settings, function (uid) { log(uid); var button = document.querySelector('[data-action-type="solo-chat"][data--u-u-i-d="' + uid + '"]'); if (button) { button.classList.remove("pressed"); button.ariaPressed = "false"; button.classList.remove("hint"); } }, uuid ); } else { settings.active = false; setEncodings( sender, settings, function (uid) { warnlog("muted the output to:" + uid); var button = document.querySelector('[data-action-type="solo-chat"][data--u-u-i-d="' + uid + '"]'); if (button) { button.classList.remove("pressed"); button.ariaPressed = "false"; button.classList.add("hint"); } }, uuid ); } }); } catch (e) { errorlog(e); } } if (apply == false) { if (session.soloChatUUID.length) { session.muted_savedState = session.muted; session.muted = false; data = {}; data.muteState = session.muted; for (var i = 0; i < session.soloChatUUID.length; i++) { session.sendMessage(data, session.soloChatUUID[i]); } } else { session.muted = session.muted_savedState; } toggleMute(true); } }; function senderAudioUpdate(callbackUUID = false, videoSource = null) { try { let tracks = []; if (!videoSource) { checkBasicStreamsExist(); videoSource = session.videoElement.srcObject; } tracks = videoSource.getAudioTracks(); if (session.audioContentHint && tracks.length) { tracks.forEach(trk => { try { trk.contentHint = session.audioContentHint; } catch (e) { errorlog(e); } }); } if (session.whipOut && session.whipOut.getSenders && tracks.length) { // mixMinus won't work with meshcast, so don't bother. session.whipOut.getSenders().forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (sender.track && sender.track.kind == "audio") { tracks.forEach(trk => { sender.replaceTrack(trk); }); } }); } for (UUID in session.pcs) { if ("realUUID" in session.pcs[UUID]) { continue; } // do not process the screen share audio if (session.chunked && session.pcs[UUID].allowChunked && (session.pcs[UUID].allowChunked !== 2)) { continue; } if (session.pcs[UUID].allowAudio == true) { var senders = getSenders2(UUID); if (session.mixMinus) { log("mixMinus START .."); var STRM = mixMinusAudio(UUID); if (!STRM) { continue; } STRM.getAudioTracks().forEach(trk => { if (session.audioContentHint) { trk.contentHint = session.audioContentHint; } var added = false; senders.forEach(sender => { if (added) { if (sender.track && sender.track.kind == "audio") { sender.track.enabled = false; } return; } if (sender.track && sender.track.kind == "audio") { sender.replaceTrack(trk); sender.track.enabled = true; added = true; warnlog("ADDED 5"); } }); if (added) { return; } session.pcs[UUID].addTrack(trk, STRM); }); continue; } senders.forEach(sender => { var good = false; if (sender.track && sender.track.id && sender.track.kind == "audio") { tracks.forEach(function (track) { // audio also if (track.id == sender.track.id) { good = true; } }); } else { // video or something else; ignore it. return; } if (good) { return; } sender.track.enabled = false; //session.pcs[UUID].removeTrack(sender); // Apparently removeTrack causes renogiation; also kills send/recv. }); if (tracks.length) { tracks.forEach(function (track) { var matched = false; var senders = getSenders2(UUID); senders.forEach(sender => { if (sender.track && sender.track.id && sender.track.kind == "audio") { warnlog(sender.track.id + " " + track.id); if (sender.track.id == track.id) { warnlog("MATCHED 1"); matched = true; } } }); if (matched) { return; } var added = false; var senders = getSenders2(UUID); senders.forEach(sender => { if (added) { return; } if (sender.track && sender.track.kind == "audio" && sender.track.enabled == false) { sender.replaceTrack(track); sender.track.enabled = true; added = true; warnlog("ADDED 2"); } }); if (added) { return; } var sender = session.pcs[UUID].addTrack(track, videoSource); }); } else { var senders = getSenders2(UUID); senders.forEach(sender => { if (sender.track && sender.track.kind == "audio") { sender.track.enabled = false; // (trying this instead) //session.pcs[UUID].removeTrack(sender); // Apparently removeTrack causes renogiation; also kills send/recv. } }); } } } if (session.director !== false) { session.applySoloChat(); // mute streams that should be muted if a director } session.applyIsolatedChat(); try { if (toggleSettingsState) { updateConstraintSliders(); } } catch (e) { } if (callbackUUID) { try { var data = {}; data.UUID = callbackUUID; data.audioOptions = listAudioSettingsPrep(); sendMediaDevices(data.UUID); session.sendMessage(data, data.UUID); } catch (e) { } } if (session.twilio) { session.twilio.updateMixer(); } } catch (e) { errorlog(e); } if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").dataset.audioready = true; if (document.getElementById("gowebcam").dataset.ready && document.getElementById("gowebcam").dataset.ready == "true") { document.getElementById("gowebcam").disabled = false; miniTranslate(document.getElementById("gowebcam"), "start"); document.getElementById("gowebcam").focus(); } } } async function press2talk(clean = false) { var ele = getById("press2talk"); ele.style.minWidth = "127px"; ele.style.padding = "7px"; getById("settingsbutton").classList.remove("hidden"); if (!document.getElementById("controls_director") && session.showDirector) { createDirectorOnlyBox(); } if (session.taintedSession) { var msg = {}; msg.virtualHangup = false; session.sendMessage(msg); } log("DIRECTOR STREAM SETUP"); if (getById("press2talk").dataset.enabled == true) { log("already enabled"); return; } getById("press2talk").dataset.enabled = true; if (session.transcript) { setTimeout(function () { setupClosedCaptions(); }, 1000); } getById("press2talk").outerHTML = ""; getById("mutebutton").classList.remove("hidden"); getById("hangupbutton2").classList.remove("hidden"); if (!session.showDirector && session.recordLocal !== false) { getById("recordLocalbutton").classList.remove("hidden"); } if (session.screenshareType === 3) { getById("screenshare3button").className = "float"; getById("screensharebutton").className = "float hidden"; getById("screenshare2button").className = "float hidden"; } else if (session.screenshareType === 1) { getById("screensharebutton").className = "float"; getById("screenshare3button").className = "float hidden"; getById("screenshare2button").className = "float hidden"; } else if (session.screenshareType === 2) { getById("screenshare2button").className = "float"; getById("screensharebutton").className = "float hidden"; getById("screenshare3button").className = "float hidden"; } else if (session.broadcast === null) { // sstype=1, since in self-broadcast mode getById("screensharebutton").className = "float"; getById("screenshare2button").className = "float hidden"; getById("screenshare3button").className = "float hidden"; } else { // sstype=3, since not in broadcast mode getById("screensharebutton").className = "float hidden"; getById("screenshare2button").className = "float hidden"; getById("screenshare3button").className = "float"; } checkBasicStreamsExist(); session.videoElement.id = "videosource"; // could be set to UUID in the future session.videoElement.dataset.menu = "context-menu-video"; if (session.streamID) { session.videoElement.dataset.sid = session.streamID; } // videosource session.videoElement.muted = true; session.videoElement.autoplay = true; session.videoElement.controls = session.showControls || false; session.videoElement.setAttribute("playsinline", ""); if (document.getElementById("videoContainer_director")) { getById("videoContainer_director").appendChild(session.videoElement); } else { getById("miniPerformer").appendChild(session.videoElement); } if (session.screenShareElement && document.getElementById("videoScreenContainer_director")) { getById("videoScreenContainer_director").appendChild(session.screenShareElement); } else if (session.screenShareElement) { getById("miniPerformer").appendChild(session.screenShareElement); } session.videoElement.title = "This is the preview of the Director's audio and video output."; session.videoElement.onpause = event => { // prevent things from pausing; human or other if (!(event.ctrlKey || event.metaKey)) { log("Video paused; auto playing"); event.currentTarget .play() .then(_ => { log("playing 9"); }) .catch(warnlog); } }; session.videoElement.addEventListener("click", function (e) { // show stats of video if double clicked log("click"); try { if (e.ctrlKey || e.metaKey) { e.preventDefault(); //////////////////////// var [menu, innerMenu] = statsMenuCreator(); ////////////////////////////////// menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); e.stopPropagation(); return false; } } catch (e) { errorlog(e); } }); updatePushId(); /* if (session.directorEnabledPPT){ enumerateDevices().then(gotDevices).then(async function() { console.log("done"); toggleSettings(); }); return; } */ //await toggleSettings(); var constraint = { video: false, audio: true }; if (session.videoDevice) { constraint.video = true; } if (session.audioDevice === 0) { constraint.audio = false; } requestBasicPermissions(constraint, function () { log("requestBasicPermissions done"); enumerateDevices() .then(gotDevices) .then(async function () { log("enumerateDevices+gotDevices complete"); pokeIframeAPI("director-share", true, false, session.streamID); // director has started publishing; even if no audio/video. log("session.directorEnabledPPT: " + session.directorEnabledPPT); if (session.directorEnabledPPT) { return; } if (session.audioDevice !== 0) { // change from Auto to Selected Audio Device log("SETTING AUDIO DEVICE!!"); activatedPreview = false; await grabAudio("#audioSource3"); } if (session.videoDevice !== 0) { activatedPreview = false; if (session.quality !== false) { await grabVideo(session.quality, "videosource", "#videoSource3"); } else { //session.quality_wb = parseInt(getById("webcamquality").elements.namedItem("resolution").value); await grabVideo(session.roomid ? session.quality_room : session.quality_wb, "videosource", "#videoSource3"); } } if (session.videoMutedFlag) { session.videoMuted = true; toggleVideoMute(true); } session.directorEnabledPPT = true; toggleMute(true); //await toggleSettings(); if (session.autorecord || session.autorecordlocal) { log("AUTO RECORD START"); setTimeout( function (v) { var videoKbps = session.recordDefault; if (session.recordLocal !== false) { videoKbps = session.recordLocal; } if (document.querySelector("[data-action-type='recorder-local'][data-sid='" + session.streamID + "']")) { recordLocalVideoToggle(true); } else if (v.stopWriter || v.recording) { } else if (v.startWriter) { v.startWriter(); } else { recordLocalVideo(null, videoKbps, v); } }, 2000, session.videoElement ); } log("session.seeding: " + session.seeding); if (session.seeding) { setTimeout(function () { if (session.meshcast2) { meshcast2(); } else if (session.meshcast) { meshcast(); } else if (session.whipOutput) { whipOut(); } else if (session.whepHost) { whepOut(); } }, 1000); return; } if (session.meshcast2) { meshcast2(); } else if (session.meshcast) { meshcast(); } else if (session.whipOutput) { whipOut(); } else if (session.whepHost) { whepOut(); } session.seeding = true; await session.seedStream(); }); }); } // publishdirector function statsMenuCreator() { if (getById("menuStatsBox")) { clearInterval(getById("menuStatsBox").interval); getById("menuStatsBox").remove(); } var menu = document.createElement("div"); menu.id = "menuStatsBox"; menu.className = "debugStats remotestats"; getById("main").appendChild(menu); menu.style.left = parseInt(Math.random() * 10) + 15 + "px"; menu.style.top = parseInt(Math.random() * 10) + "px"; menu.innerHTML = "

    Statistics

    "; var menuCloseBtn = document.createElement("button"); menuCloseBtn.className = "close"; menuCloseBtn.innerHTML = "×"; menu.appendChild(menuCloseBtn); var innerMenu = document.createElement("div"); menu.appendChild(innerMenu); menuCloseBtn.addEventListener("click", function (eve) { clearInterval(menu.interval); eve.currentTarget.parentNode.remove(); eve.preventDefault(); eve.stopPropagation(); }); return [menu, innerMenu]; } // WEBCAM session.publishStream = function (v) { // stream is used to generated an SDP log("STREAM SETUP"); if (session.transcript) { setTimeout(function () { setupClosedCaptions(); }, 1000); } if (!session.streamSrc) { checkBasicStreamsExist(); } session.streamSrc.oninactive = function streamoninactive() { warnlog("Stream inactive"); if (session.videoElement.recording) { session.videoElement.recorder.stop(); } }; if (session.streamSrc.getVideoTracks().length == 0) { warnlog("NO VIDEO TRACK INCLUDED"); } if (session.streamSrc.getAudioTracks().length == 0) { warnlog("NO AUDIO TRACK INCLUDED"); } var container = document.createElement("div"); v.container = container; container.id = "container"; if (session.cleanOutput) { container.style.height = "100%"; v.style.maxWidth = "100%"; v.style.boxShadow = "none"; } if (session.cover) { container.style.setProperty("height", "100%", "important"); } //container.className = "vidcon"; getById("gridlayout").appendChild(container); v.className = "tile"; //"tile task"; TODO: get working (will add task later on instead) v.muted = true; v.autoplay = true; if (session.showControls !== null) { v.controls = session.showControls; } else if (session.mobile) { v.controls = true; } else { v.controls = session.showControls || false; } v.setAttribute("playsinline", ""); v.id = "videosource"; // could be set to UUID in the future v.oncanplay = null; session.videoElement = v; container.appendChild(v); if (session.audioGain !== false) { changeMainGain(session.audioGain); // just in case we don't mute things in time via the draw / audioMeter interval } toggleMute(true); if (session.nopreview) { v.style.display = "none"; container.style.display = "none"; } if (((session.roomid === false || session.roomid === "") && session.quality === false) || session.forceMediaSettings) { try { if (session.quality_wb !== false && session.quality === false) { getById("webcamquality3").elements.namedItem("resolution").value = (session.roomid ? (session.quality_room || 0) : session.quality_wb); } else if (session.quality !== false) { getById("webcamquality3").elements.namedItem("resolution").value = session.quality; } getById("gear_webcam3").style.display = "inline-block"; getById("webcamquality3").onchange = function (event) { if (parseInt(getById("webcamquality3").elements.namedItem("resolution").value) == 2) { if (session.maxframeRate === false) { session.maxframeRate = 30; session.maxframeRate_q2 = true; } } else if (session.maxframeRate_q2) { session.maxframeRate = false; session.maxframeRate_q2 = false; } activatedPreview = false; session.quality_wb = parseInt(getById("webcamquality3").elements.namedItem("resolution").value); session.quality_room = session.quality_wb; grabVideo(session.quality_wb, "videosource", "select#videoSource3"); }; } catch (e) { errorlog(e); } } var bigPlayButton = document.getElementById("bigPlayButton"); if (bigPlayButton) { bigPlayButton.parentNode.removeChild(bigPlayButton); } if (session.streamID) { session.videoElement.dataset.sid = session.streamID; } if (session.statsMenu) { var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); } if (session.director) { // the director doesn't load a webcam by default anyways. // audio is not mucked with } else if (session.scene !== false) { // it's a scene, and there are no previews in a scene. //setTimeout(function(){updateMixer();},10); } else if (session.roomid !== false) { if (session.roomid === "") { if (!session.view || session.view === "") { if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else if (session.minipreview) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (session.windowed) { v.className = "myVideo"; //"myVideo task"; TODO: get working container.classList.add("vidcon"); } getById("mutespeakerbutton").classList.add("hidden"); applyMirror(session.mirrorExclude); container.style.width = "100%"; //container.style.height="100%"; container.style.alignItems = "center"; container.backgroundColor = "#666"; setTimeout(function () { dragElement(v); }, 1000); play(); } else { session.windowed = session.windowed === null ? false : session.windowed; applyMirror(session.mirrorExclude); play(); //setTimeout(function(){updateMixer();},10); } } else { //session.cbr=0; // we're just going to override it if (session.stereo == 5) { // not a scene or director, so we will assume its a guest. changing to stereo=3 session.stereo = 3; } session.windowed = session.windowed === null ? false : session.windowed; applyMirror(session.mirrorExclude); if (session.include.length) { play(); } //setTimeout(function(){updateMixer();},10); } } else { if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else if (session.minipreview) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (session.windowed) { v.className = "myVideo"; //"myVideo task"; TODO: get working container.classList.add("vidcon"); } getById("mutespeakerbutton").classList.add("hidden"); applyMirror(session.mirrorExclude); container.style.width = "100%"; //container.style.height="100%"; //container.style.display = "flex"; container.style.alignItems = "center"; container.backgroundColor = "#666"; setTimeout(function () { dragElement(v); }, 1000); } v.addEventListener("click", function (e) { log("click"); try { if (e.ctrlKey || e.metaKey) { e.preventDefault(); var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); e.stopPropagation(); return false; } } catch (e) { errorlog(e); } }); v.touchTimeOut = null; v.touchLastTap = 0; v.touchCount = 0; v.addEventListener("touchend", function (event) { if (session.disableMouseEvents) { return; } }); updateReshareLink(); pokeIframeAPI("started-camera"); // depreciated pokeIframeAPI("camera-share", true); if (session.videoMutedFlag) { session.videoMuted = true; toggleVideoMute(true); } if (!gotDevices2AlreadyRan) { enumerateDevices().then(gotDevices2); // this is needed for iOS; was previous set to timeout at 100ms, but would be useful everywhere I think } v.dataset.menu = "context-menu-video"; if (!session.cleanOutput) { v.classList.add("task"); // this adds the right-click menu } session.postPublish(); if (session.autorecord || session.autorecordlocal) { log("AUTO RECORD START"); setTimeout( function (v) { var videoKbps = session.recordDefault; if (session.recordLocal !== false) { videoKbps = session.recordLocal; } if (session.director) { recordVideo(document.querySelector("[data-action-type='recorder-local'][data-sid='" + session.streamID + "']"), null, videoKbps); } else if (v.stopWriter || v.recording) { } else if (v.startWriter) { v.startWriter(); } else { recordLocalVideo(null, videoKbps, v); } }, 2000, v ); } setTimeout(function () { updateMixer(); }, 10); }; // publishStream function stickyMessage(message) { var textOverlay = getById("stickyMsgs"); if (textOverlay) { var spanOverlay = document.createElement("span"); spanOverlay.innerHTML = message; var closeBtn = document.createElement("button"); closeBtn.className = "overlayCloseBtn red"; closeBtn.innerHTML = "❌"; closeBtn.title = "Close this message"; closeBtn.onclick = function () { this.parentNode.remove(); }; textOverlay.appendChild(spanOverlay); spanOverlay.appendChild(closeBtn); textOverlay.classList.remove("hidden"); setTimeout( function (spanOverlay) { if (spanOverlay) { spanOverlay.style.animation = "fadeout 1s"; spanOverlay.style.opacity = "0"; setTimeout( function (spanOverlay) { spanOverlay.remove(); }, 900, spanOverlay ); } }, 30000, spanOverlay ); } } session.postPublish = async function () { log("Post publish"); if (session.welcomeMessage) { stickyMessage(session.welcomeMessage); // getChatMessage(session.welcomeMessage, false, true, true); } if (session.queue && (session.queueType == 3 || session.queueType == 4) && !session.director) { youreWaitingToBeActivated(); } if (session.welcomeImage) { let welcomeoverlay = document.createElement("img"); welcomeoverlay.src = session.welcomeImage; welcomeoverlay.className = "welcomeOverlay"; document.body.appendChild(welcomeoverlay); await sleep(2000); setTimeout( function (welcomeoverlay) { welcomeoverlay.style = "animation: fadeout 1s;"; setTimeout( function (welcomeoverlay) { welcomeoverlay.remove(); }, 990, welcomeoverlay ); }, 1000, welcomeoverlay ); } if (session.welcomeHTML) { let welcomeHTML = document.createElement("div"); welcomeHTML.innerHTML = session.welcomeHTML; welcomeHTML.className = "welcomeOverlay"; document.body.appendChild(welcomeHTML); setTimeout( function (welcomeHTML) { welcomeHTML.style = "animation: fadeout 1s;"; setTimeout( function (welcomeHTML) { welcomeHTML.remove(); }, 990, welcomeHTML ); }, 3000, welcomeHTML ); } if (session.waitPage && session.iFramesAllowed) { let waitPageIFrame = parseURL4Iframe(session.waitPage); if (waitPageIFrame) { session.layout = combinedLayout([{ "w": 100, "h": 100, "x": 0, "y": 0, "z": 1, "slot": 1, "cover": false, "borderThickness": 20, "animated": 1000, "borderColor": "#0000", "backgroundMedia": "", "foregroundMedia": "", "iframeSrc": waitPageIFrame, "defaultStreamID": "", "margin": 50, "rounded": 30, "muted": false }]); updateMixer(); } } clearInterval(session.updateLocalStatsInterval); session.updateLocalStatsInterval = setInterval(function () { updateLocalStats(); }, session.statsInterval); pokeIframeAPI("screen-share-state", false); session.seeding = true; session.seedStream(); if (session.meshcast2) { await meshcast2(); } else if (session.whipOutput) { // was handling these functions within session.seedStream(); doing it here now instead. 8-08-2024 whipOut(); } else if (session.meshcast) { await meshcast(); } else if (session.whepHost) { whepOut(); } if (session.chunkcast) { session.chunkedStream(null); } if (session.motionRecord && session.videoElement && !session.motionDetectionInterval) { session.motionDetectionInterval = setTimeout(function () { setInterval(function () { motionDetection(session.videoElement, session.motionRecord); }, 400); }, 2000); } if (session.poke) { if (session.poke === true) { let topic = await generateTopic(session.roomid, session.streamID, false, false, session.hash, window.location.hostname); await triggerNotification(topic) } else { await triggerNotification(session.poke); } } if (session.autoEnd) { log("Auto-end timer started: " + session.autoEnd + "ms"); // Create countdown display const countdownDiv = document.createElement("div"); countdownDiv.id = "autoEndCountdown"; countdownDiv.style.cssText = "position: fixed; top: 10px; right: 10px; background: rgba(0,0,0,0.7); color: white; padding: 10px 15px; border-radius: 5px; font-size: 16px; z-index: 9999; display: flex; align-items: center; gap: 8px;"; countdownDiv.innerHTML = '⏱️--:--'; document.body.appendChild(countdownDiv); // Update countdown every second let remainingTime = session.autoEnd; const updateCountdown = () => { const minutes = Math.floor(remainingTime / 60000); const seconds = Math.floor((remainingTime % 60000) / 1000); document.getElementById("autoEndTime").textContent = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0'); remainingTime -= 1000; if (remainingTime < 0) { clearInterval(session.autoEndInterval); } }; updateCountdown(); // Initial update session.autoEndInterval = setInterval(updateCountdown, 1000); // Set timer to end stream session.autoEndTimer = setTimeout(() => { log("Auto-end timer expired, ending stream"); clearInterval(session.autoEndInterval); session.hangup(); }, session.autoEnd); } }; function triggerNotification(topic, customMessage = null) { if (!topic) return false; const message = customMessage || ((session.label ? session.label : 'Someone') + (session.roomid ? ' joined your room' : ' joined your stream')); const notifyUrl = `https://notify.vdo.ninja/?notify=${topic}&message=${encodeURIComponent(message)}`; console.log('Sending notification to:', notifyUrl); return fetch(notifyUrl) .then(response => { console.log('Notification response status:', response.status); if (!response.ok) { return response.text().then(text => { try { const errorData = JSON.parse(text); console.error('Notification server error:', errorData); return false; } catch (e) { console.error('Notification error response:', text); return false; } }); } return response.json(); }) .then(data => { if (data === false) return false; console.log('Notification result:', data); // Check push results to diagnose issues if (data.pushResults && Array.isArray(data.pushResults)) { data.pushResults.forEach(result => { if (!result.success) { console.warn('Push notification failed:', result); } }); } return data.success === true; }) .catch(error => { console.error('Error sending notification:', error); return false; }); } function hashTopic(text) { const salt1 = "abc12345ASB234ASD1116"; const salt2 = "xyzJKL789MNO567PQR890"; const salt3 = "9843kasdjfh234jhk234j"; let saltedText = salt1 + text + salt2 + text.split('').reverse().join('') + salt3; let hash = 0; if (saltedText.length === 0) return "0"; for (let i = 0; i < saltedText.length; i++) { const char = saltedText.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } let hash2 = 0; for (let i = 0; i < saltedText.length; i++) { hash2 = ((hash2 << 7) + hash2) + saltedText.charCodeAt(i); hash2 = hash2 & hash2; } const combinedHash = Math.abs(hash).toString(36) + Math.abs(hash2).toString(36); if (combinedHash.length < 10) { return combinedHash + Math.random().toString(36).substring(2, 12); } return combinedHash; } async function generateTopic(roomId, pushId, viewId, password, hash, domain) { domain = domain || 'vdo.ninja'; if (!roomId && !viewId && !pushId) { console.error('At least one of roomId, viewId or pushId is required'); return null; } const components = { room: roomId || viewId || pushId, domain: domain.replace(/\./g, '_') }; let sensitiveData = Object.values(components).filter(Boolean).join('_'); if (hash) { sensitiveData += `_${hash}`; } else if (password) { const passwordHash = await generateHash(password); sensitiveData += `_${passwordHash}`; } const secureTopicHash = hashTopic(sensitiveData); const finalPrefix = components.domain; const finalTopic = `${finalPrefix}_${secureTopicHash}`; return finalTopic; } async function publishScreen2(constraints, audioList = [], audio = true, overrideFramerate = false) { // webcam stream is used to generated an SDP log("SCREEN SHARE SETUP - publishScreen2"); if (!navigator.mediaDevices.getDisplayMedia) { setTimeout(function () { if (iOS || iPad) { warnUser("Sorry, but your iOS browser does not support screen-sharing.\n\nPlease see this guide for an alternative method to do so.", false, false); } else if (session.mobile) { warnUser("Sorry, your browser does not support screen-sharing.\n\nThe Android native app should support it though.", false, false); } else { warnUser("Sorry, your browser does not support screen-sharing.\n\nPlease use the desktop versions of Firefox or Chrome instead."); } }, 1); return false; } if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (!ElectronDesktopCapture) { if (!(session.cleanOutput && session.cleanish == false)) { warnUser("Enable Elevated Privileges to allow screen-sharing. (right click this window to see that option)"); } return false; } } var streams = []; log(audioList); for (var i = 0; i < audioList.length; i++) { // mic sources; not screen . let constraintAudio = { video: false, audio: { deviceId: { exact: audioList[i] } } }; if (session.echoCancellation === false) { // default should be ON. we won't even add it since deviceId is specified and Browser defaults to on already constraintAudio.audio.echoCancellation = false; } else { constraintAudio.audio.echoCancellation = true; } if (session.autoGainControl === false) { constraintAudio.audio.autoGainControl = false; } else { constraintAudio.audio.autoGainControl = true; } if (session.noiseSuppression === false) { constraintAudio.audio.noiseSuppression = false; } else { constraintAudio.audio.noiseSuppression = true; } if (session.voiceIsolation === true) { constraintAudio.audio.voiceIsolation = true; } if (session.audioInputChannels) { if (constraintAudio.audio === true) { constraintAudio.audio = {}; constraintAudio.audio.channelCount = session.audioInputChannels; } else if (constraintAudio.audio) { constraintAudio.audio.channelCount = session.audioInputChannels; } } if (session.micSampleRate) { if (constraintAudio.audio === true) { constraintAudio.audio = {}; constraintAudio.audio.sampleRate = parseInt(session.micSampleRate); } else if (constraintAudio.audio) { constraintAudio.audio.sampleRate = parseInt(session.micSampleRate); } } if (session.micSampleSize) { if (constraintAudio.audio === true) { constraintAudio.audio = {}; constraintAudio.audio.sampleSize = parseInt(session.micSampleSize); } else if (constraintAudio.audio) { constraintAudio.audio.sampleSize = parseInt(session.micSampleSize); } } getUserMediaRequestID += 1; var gumID = getUserMediaRequestID; if (Firefox) { constraintAudio = toFirefoxConstraint(constraintAudio); } log(constraintAudio); warnlog("navigator.mediaDevices.getUserMedia starting..."); await navigator.mediaDevices .getUserMedia(constraintAudio) .then(stream => { if (getUserMediaRequestID !== gumID) { warnlog("GET USER MEDIA CALL HAS EXPIRED 3"); stream.getTracks().forEach(function (track) { stream.removeTrack(track); track.stop(); log("stopping old track"); }); return; } streams.push(stream); }) .catch(errorlog); } if (session.audioDevice === 0) { constraints.audio = false; } if (session.screenshareVideoOnly) { constraints.audio = false; } if (constraints.video !== false && Object.keys(constraints.video).length == 0) { constraints.video = true; } log(constraints); getUserMediaRequestID += 1; var gumID = getUserMediaRequestID; return navigator.mediaDevices .getDisplayMedia(constraints) .then(async function (stream) { if (getUserMediaRequestID !== gumID) { warnlog("GET USER MEDIA CALL HAS EXPIRED 3"); stream.getTracks().forEach(function (track) { stream.removeTrack(track); track.stop(); log("stopping old track"); }); return; } try { var constraint = {}; if (session.forceAspectRatio && session.forceScreenShareAspectRatio === null) { constraint.aspectRatio = parseFloat(session.forceAspectRatio); } else if (session.forceScreenShareAspectRatio) { constraint.aspectRatio = parseFloat(session.forceScreenShareAspectRatio); } if (overrideFramerate) { constraint.frameRate = overrideFramerate; } if (Object.keys(constraint).length) { await stream.getVideoTracks()[0].applyConstraints({ advanced: [constraint] }); log({ advanced: [constraint] }); } } catch (e) { errorlog(e); } /// RETURN stream for preview? rather than jumping right in. session.screenShareState = true; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); notifyOfScreenShare(); try { stream.getVideoTracks()[0].onended = function () { toggleScreenShare(); }; } catch (e) { log("No Video selected; screensharing?"); } // OR, jump right in, and let user change from there if (session.roomid !== false) { if (session.roomid === "" && (!session.view || session.view === "")) { if (session.manual === null) { session.manual = session.manual === null ? true : session.manual; } if (!session.cleanOutput) { var showReshare = getStorage("showReshare"); if (showReshare) { generateHash(session.streamID + session.salt + "bca321", 4) .then(function (hash) { // million to one error. if (showReshare === hash) { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); } else if (session.permaid === null) { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); } }) .catch(errorlog); } } } else { getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); log("ROOMID EANBLED"); log("Update Mixer Event on REsize SET"); window.onresize = updateMixer; window.onorientationchange = function () { if (Firefox) { updateForceRotate(true); } setTimeout(async function () { if (session.forceAspectRatio) { await updateCameraConstraints("aspectRatio", session.forceAspectRatio); } if (session.effect && session.effect === "7") { digitalZoom(); } updateForceRotate(); updateMixer(); }, 200); }; joinRoom(session.roomid); } } else { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); getById("logoname").style.display = "none"; } updatePushId(); if (stream.getAudioTracks().length) { screenShareAudioTrack = stream.getAudioTracks()[0]; } log("adding tracks"); for (var i = 0; i < streams.length; i++) { streams[i].getAudioTracks().forEach(track => { stream.addTrack(track); }); } streams = null; if (!session.screenshareVideoOnly && session.audioDevice !== 0) { if (stream.getAudioTracks().length == 0) { if (!session.cleanOutput) { if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { // Electron has no audio. } else if (!getStorage("leaveInPeaceSSWarning")) { setStorage("leaveInPeaceSSWarning", "true", 720); setTimeout(function () { warnUser(getTranslation("no-audio-source-detected"), 10000, false); }, 300); } } } } try { session.streamSrc = stream; } catch (e) { errorlog(e); } toggleMute(true); var v = createVideoElement(); session.videoElement = v; if (session.streamID) { session.videoElement.dataset.sid = session.streamID; } var container = document.createElement("div"); v.container = container; container.id = "container_screen"; container.style.height = "100%"; if (session.cleanOutput) { v.style.maxWidth = "100%"; v.style.boxShadow = "none"; } //container.className = "vidcon"; getById("gridlayout").appendChild(container); if (session.nopreview) { v.style.display = "none"; container.style.display = "none"; } //if (session.cover){ // container.style.setProperty('height', '100%', 'important'); //} container.appendChild(v); v.className = "tile"; if (session.director) { } else if (session.scene !== false) { setTimeout(function () { updateMixer(); }, 1); } else if (session.roomid !== false) { if (session.roomid === "") { if (!session.view || session.view === "") { getById("mutespeakerbutton").classList.add("hidden"); if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (!session.windowed) { if (session.mirrored && session.flipped) { v.style.transform = " scaleX(-1) scaleY(-1)"; v.classList.add("mirrorControl"); } else if (session.mirrored) { v.style.transform = "scaleX(-1)"; v.classList.add("mirrorControl"); } else if (session.flipped) { v.style.transform = "scaleY(-1)"; v.classList.remove("mirrorControl"); } else { v.style.transform = ""; v.classList.remove("mirrorControl"); } } else { v.className = "myVideo"; if (session.mirrored && session.flipped) { v.style.transform = " scaleX(-1) scaleY(-1) translate(0, 50%)"; v.classList.add("mirrorControl"); } else if (session.mirrored) { v.style.transform = "scaleX(-1) translate(0, -50%)"; v.classList.add("mirrorControl"); } else if (session.flipped) { v.style.transform = "scaleY(-1) translate(0, 50%)"; v.classList.remove("mirrorControl"); } else { v.style.transform = " translate(0, -50%)"; v.classList.remove("mirrorControl"); } } container.style.width = "100%"; //container.style.height="100%"; container.style.alignItems = "center"; container.backgroundColor = "#666"; setTimeout(function () { dragElement(v); }, 1000); play(); } else { play(); setTimeout(function () { updateMixer(); }, 1); } } else { setTimeout(function () { updateMixer(); }, 1); } } else { getById("mutespeakerbutton").classList.add("hidden"); if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (!session.windowed) { if (session.mirrored && session.flipped) { v.style.transform = " scaleX(-1) scaleY(-1)"; v.classList.add("mirrorControl"); } else if (session.mirrored) { v.style.transform = "scaleX(-1)"; v.classList.add("mirrorControl"); } else if (session.flipped) { v.style.transform = "scaleY(-1)"; v.classList.remove("mirrorControl"); } else { v.style.transform = ""; v.classList.remove("mirrorControl"); } } else { v.className = "myVideo"; container.classList.add("vidcon"); if (session.mirrored && session.flipped) { v.style.transform = " scaleX(-1) scaleY(-1) translate(0, 50%)"; v.classList.add("mirrorControl"); } else if (session.mirrored) { v.style.transform = "scaleX(-1) translate(0, -50%)"; v.classList.add("mirrorControl"); } else if (session.flipped) { v.style.transform = "scaleY(-1) translate(0, 50%)"; v.classList.remove("mirrorControl"); } else { v.style.transform = " translate(0, -50%)"; v.classList.remove("mirrorControl"); } } container.style.width = "100%"; //container.style.height="100%"; container.style.alignItems = "center"; container.backgroundColor = "#666"; } if (!session.windowed) { window.onresize = updateMixer; window.onorientationchange = function () { if (Firefox) { updateForceRotate(true); } setTimeout(async function () { if (session.forceAspectRatio) { await updateCameraConstraints("aspectRatio", session.forceAspectRatio); } if (session.effect && session.effect === "7") { digitalZoom(); } updateForceRotate(); updateMixer(); }, 200); }; } v.autoplay = true; v.controls = session.showControls || false; v.setAttribute("playsinline", ""); v.muted = true; v.id = "videosource"; v.dataset.menu = "context-menu-video"; if (!session.cleanOutput) { v.classList.add("task"); // this adds the right-click menu } //if (!v.srcObject || v.srcObject.id !== stream.id) { // v.srcObject = stream; v.srcObject = outboundAudioPipeline(); //} v.onpause = event => { // prevent things from pausing; human or other if (!(event.ctrlKey || event.metaKey)) { log("Video paused; auto playing"); event.currentTarget .play() .then(_ => { log("playing 11"); }) .catch(warnlog); } }; v.addEventListener("click", function (e) { // show stats of video if double clicked log("click"); try { if (e.ctrlKey || e.metaKey) { e.preventDefault(); var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); e.stopPropagation(); return false; } } catch (e) { errorlog(e); } }); updateReshareLink(); if (session.videoMutedFlag) { session.videoMuted = true; toggleVideoMute(true); } clearInterval(session.updateLocalStatsInterval); session.updateLocalStatsInterval = setInterval(function () { updateLocalStats(); }, session.statsInterval); if (session.meshcast2) { await meshcast2(); } else if (session.whipOutput) { // was handling these functions within session.seedStream(); doing it here now instead. 8-08-2024 whipOut(); } else if (session.meshcast) { await meshcast(); } else if (session.whepHost) { whepOut(); } session.seeding = true; session.seedStream(); //pokeIframeAPI('started-screenshare'); // depreciated pokeIframeAPI("screen-share-state", true, null, session.streamID); // (action, value = null, UUID = null, SID=null) if (session.autorecord || session.autorecordlocal) { log("AUTO RECORD START"); setTimeout( function (v) { var videoKbps = session.recordDefault; if (session.recordLocal !== false) { videoKbps = session.recordLocal; } if (session.director) { recordVideo(document.querySelector("[data-action-type='recorder-local'][data-sid='" + session.streamID + "']"), null, videoKbps); } else if (v.stopWriter || v.recording) { } else if (v.startWriter) { v.startWriter(); } else { recordLocalVideo(null, videoKbps, v); } }, 2000, v ); } return true; }) .catch(function (err) { errorlog(err); errorlog(err.name); if (err.name == "NotAllowedError" || err.name == "PermissionDeniedError") { // User Stopped it. (is this next part needed??) session.screenShareState = false; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); notifyOfScreenShare(); if (macOS) { warnUser(getTranslation("screen-permissions-denied"), false, false); } return false; } else { if (audio == true) { if (err.name == "NotReadableError") { if (!session.cleanOutput) { warnUser(getTranslation("change-audio-output-device"), false, false); } constraints.audio = false; return publishScreen2(constraints, audioList, false); } else { constraints.audio = false; if (!session.cleanOutput) { setTimeout(function () { warnUser(err); }, 1); // TypeError: Failed to execute 'getDisplayMedia' on 'MediaDevices': Audio capture is not supported } return publishScreen2(constraints, audioList, false); } } else { if (!session.cleanOutput) { setTimeout(function () { warnUser(err); }, 1); // TypeError: Failed to execute 'getDisplayMedia' on 'MediaDevices': Audio capture is not supported } return false; } } }); } // publishStream2 var transferList = []; var msgTransferList = []; function cancelFile(ele) { var idx = ele.dataset.tid; try { transferList[idx].dc.close(); } catch (e) { } transferList[idx].status = 5; updateDownloadLink(idx); } function requestFile(ele) { var idx = ele.dataset.tid; transferList[idx].status = 1; var fid = ele.dataset.fid; var UUID = ele.dataset.uuid; var msg = {}; msg.requestFile = fid; msg.UUID = UUID; session.sendRequest(msg, msg.UUID); updateDownloadLink(idx); pokeIframeAPI("request-file", fid, UUID); } function clearDownloadFile(ele) { var idx = ele.dataset.tid; transferList[idx].status = 6; updateDownloadLink(idx); } function addDownloadLink(fileList, UUID, pc) { if (session.nodownloads) { return; } // downloads are blocked log(fileList); if (!fileList || !fileList.length) { return; } for (var i = 0; i < fileList.length; i++) { fileList[i].UUID = UUID; fileList[i].completed = 0; fileList[i].status = 0; fileList[i].time = Date.now(); fileList[i].pc = pc[UUID]; transferList.push(fileList[i]); } if (session.chatbutton === false) { return; } // messages can still appear as overlays updateMessages(); if (session.beepToNotify) { playtone(); } if (session.chat == false) { getById("chattoggle").className = "las la-comments toggleSize pulsate"; getById("chatbutton").className = "float"; if (getById("chatNotification").value) { getById("chatNotification").value = getById("chatNotification").value + 1; } else { getById("chatNotification").value = 1; } getById("chatNotification").classList.add("notification", "red"); } //if (session.broadcastChannel !== false) { // session.broadcastChannel.postMessage(data); /* send */ //} } function updateDownloadLink(idx) { idx = parseInt(idx); var elements = document.querySelectorAll('[data-tid="' + idx + '"]'); if (elements[0]) { if (transferList[idx].status === 0) { elements[0].innerHTML = "Download it here"; } else if (transferList[idx].status === 1) { elements[0].innerHTML = "Requested"; //elements[0].onclick='cancelFile(this);' } else if (transferList[idx].status === 2) { elements[0].innerHTML = "Downloading: " + parseInt(transferList[idx].completed * 100) + "%"; elements[0].onclick = function () { cancelFile(this); }; } else if (transferList[idx].status === 3) { elements[0].innerHTML = "Completed"; elements[0].onclick = null; elements[0].disabled = true; } else if (transferList[idx].status === 4) { elements[0].innerHTML = "No longer available"; elements[0].onclick = null; elements[0].disabled = true; } else if (transferList[idx].status === 5) { elements[0].innerHTML = "Cancelled"; elements[0].onclick = null; elements[0].disabled = true; } else if (transferList[idx].status === 6) { getById("transfer_" + idx).style.display = "none"; //delete(transferList[idx]); } } } function showDownloadLinks() { if (session.nodownloads) { return; } // downloads are blocked msgTransferList = []; if (!transferList || !transferList.length) { return; } for (var i = 0; i < transferList.length; i++) { fileShareMessage(transferList[i], i); } } function fileShareMessage(fileinfo, idx) { fileinfo.name = sanitizeChat(fileinfo.name); // keep it clean. var label = false; if (fileinfo.pc) { if (fileinfo.pc.label) { label = sanitizeLabel(fileinfo.pc.label); } } var data = {}; data.idx = idx; if (fileinfo.status === 0) { data.msg = " has a shared a file with you:
    " + fileinfo.name + "
    Do you trust them? "; } else if (fileinfo.status === 1) { data.msg = " has a shared a file with you:
    " + fileinfo.name + "
    "; } else if (fileinfo.status === 2) { data.msg = " has a shared a file with you:
    " + fileinfo.name + "
    "; } else if (fileinfo.status === 3) { data.msg = " has a shared a file with you:
    " + fileinfo.name + "
    "; transferList[idx].status = 6; } else if (fileinfo.status === 4) { data.msg = " has a shared a file with you:
    " + fileinfo.name + "
    "; } else if (fileinfo.status === 5) { data.msg = " has a shared a file with you:
    " + fileinfo.name + "
    "; transferList[idx].status = 6; } else if (fileinfo.status === 6) { return; } var director = false; // add back in later. if (session.directorList.indexOf(fileinfo.UUID) >= 0) { director = true; } if (label) { data.label = label; if (director) { data.label = "" + data.label + ""; } else { data.label = "" + data.label + ""; } } else if (director) { data.label = "Director"; } else { const identifier = getPeerDisplayName(fileinfo.UUID, false); if (identifier) { data.label = "" + identifier + ""; } else { data.label = "Someone"; } } data.type = "action"; msgTransferList.push(data); } session.shareFile = function (ele, UUID = false, event = false) { const file = ele.files[0]; if (!file) return; try { const fileId = session.generateStreamID(7); session.hostedFiles.push({ id: fileId, name: file.name, size: file.size, state: 1, restricted: UUID || false, file: file // Store the actual File object }); log(session.hostedFiles); enhancedFileTransfers.initTransfer(fileId, file.size); // Provide file list to appropriate peers if (UUID === false) { for (let peerUUID in session.pcs) { session.provideFileList(peerUUID); } for (let peerUUID in session.rpcs) { if (!(peerUUID in session.pcs)) { session.provideFileList(peerUUID); } } } else { session.provideFileList(UUID); } pokeIframeAPI("file-share", true); // Update the file share display updateFileShare(); closeModal(); } catch (e) { errorlog(e); } finally { // Clear the file input ele.value = ''; } }; function arrayBufferToString(buffer, encoding, callback) { var blob = new Blob([buffer], { type: "text/plain" }); var reader = new FileReader(); reader.onload = function (evt) { callback(evt.target.result); }; reader.readAsText(blob, encoding); } session.hostFile = function (ele, event = false) { // webcam stream is used to generated an SDP log("FILE TRANSFER SETUP"); session.hostedFiles = []; for (var i = 0; i < ele.files.length; i++) { session.hostedFiles.push({ id: session.generateStreamID(7), name: ele.files[i].name, size: ele.files[i].size, state: 1, restricted: false, file: ele.files[i] // Store the actual File object }); } log(session.hostedFiles); var container = document.createElement("div"); container.id = "container_host"; getById("gridlayout").appendChild(container); if (session.cover) { container.style.setProperty("height", "100%", "important"); } if (session.roomid !== false) { if (session.roomid === "" && (!session.view || session.view === "")) { } else { log("ROOMID EANBLED"); //log("Update Mixer Event on REsize SET"); //window.addEventListener("resize", updateMixer);// TODO FIX //window.addEventListener("orientationchange", updateMixer);// TODO FIX getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); joinRoom(session.roomid); } } else { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); getById("logoname").style.display = "none"; } getById("head1").className = "hidden"; getById("head1").className = "hidden"; getById("head2").className = "hidden"; if (!session.cleanOutput) { getById("chatbutton").className = "float"; getById("sharefilebutton").classList.remove("hidden"); // we won't override "display:none", if set, though. // getById("mediafileshare").classList.remove("hidden"); getById("hangupbutton").className = "float"; getById("controlButtons").classList.remove("hidden"); // getById("legal").classList.remove("hidden"); //getById("helpbutton").style.display = "inherit"; //getById("reportbutton").style.display = ""; } else { getById("controlButtons").classList.add("hidden"); // getById("legal").classList.add("hidden"); } updatePushId(); updateReshareLink(); updateFileShare(); pokeIframeAPI("file-share", true); pokeIframeAPI("started-fileshare"); // deprecated clearInterval(session.updateLocalStatsInterval); session.updateLocalStatsInterval = setInterval(function () { updateLocalStats(); }, session.statsInterval); session.seeding = true; session.seedStream(); }; function updateReshareLink() { try { var m = getById("mainmenu"); m.remove(); document.querySelectorAll(".hidden2").forEach(ele2 => { ele2.classList.remove("hidden2"); }); } catch (e) { } var added = ""; if (session.defaultPassword === false) { if (session.password !== false) { added = "&pw=" + session.password; } else { added = "&pw=false"; } } var wss = ""; if (session.wssSetViaUrl) { if (session.customWSS && session.customWSS !== true) { wss = "&pie=" + session.customWSS; } else if (session.customWSS == true) { wss = "&wss=" + session.wss; } else { wss = "&wss2=" + session.wss; } } if (session.audience && !session.audienceToken) { if (document.getElementById("reshare")) { if (!session.cleanOutput) { getById("copythisurl").innerHTML = ""; getById("head3a").classList.remove("hidden"); } document.getElementById("reshare").href = null; document.getElementById("reshare").text = "loading public view link..."; document.getElementById("reshare").style.width = (document.getElementById("reshare").text.length + 1) * 1.15 * 8 + "px"; } return; } else if (session.audience) { var shareLink = "https://" + location.host + location.pathname + "?view=" + session.streamID + added + wss + "&audience=" + session.audienceToken; if (document.getElementById("reshare")) { if (!session.cleanOutput) { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); getById("copythisurl").innerHTML = 'This is your public audience link   '; } document.getElementById("reshare").href = shareLink; document.getElementById("reshare").text = shareLink; document.getElementById("reshare").style.width = (document.getElementById("reshare").text.length + 1) * 1.15 * 8 + "px"; } pokeIframeAPI("share-link", shareLink); return; } var shareLink = "https://" + location.host + location.pathname + "?view=" + session.streamID + added + wss; if (document.getElementById("reshare")) { document.getElementById("reshare").href = shareLink; document.getElementById("reshare").text = shareLink; document.getElementById("reshare").style.width = (document.getElementById("reshare").text.length + 1) * 1.15 * 8 + "px"; } if (session.whipOutput) { getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); } pokeIframeAPI("share-link", shareLink); } function cleanupMediaState(vid) { if (session.canvasSource) { session.canvasSource.destroy(); session.canvasSource = null; } if (vid) { if (vid.srcObject) { const tracks = vid.srcObject.getTracks(); tracks.forEach(track => track.stop()); vid.srcObject = null; } if (vid.currentObjectURL) { URL.revokeObjectURL(vid.currentObjectURL); vid.currentObjectURL = null; } vid.src = ''; } if (session.streamSrc) { const tracks = session.streamSrc.getTracks(); tracks.forEach(track => track.stop()); session.streamSrc = null; } } session.changePublishFile = function (ele, event) { log("FILE STREAM CHANGE"); var files = Array.from(ele.files); var vid = getById("videosource"); // Clean up existing state first cleanupMediaState(vid); if (files[0].type.startsWith('image/')) { const objectURL = URL.createObjectURL(files[0]); session.canvasSource = new CanvasStreamSource(); session.canvasSource.imgSrc = objectURL; window.postMessage({ type: 'canvas-frame', frame: session.canvasSource.imgSrc }, '*'); session.streamSrc = session.canvasSource.getStream(); // Process the stream like we do for video session.streamSrc = outboundAudioPipeline(session.streamSrc); var tracks = session.streamSrc.getVideoTracks(); if (tracks.length) { pushOutVideoTrack(tracks[0]); } senderAudioUpdate(); vid.play(); // We still need play() but don't set srcObjec } else { log("FILE VIDEO STREAM CHANGE"); vid.playlist = files; nextFilePlaylist(vid); } session.applySoloChat(); session.applyIsolatedChat(); toggleMute(true); }; function nextFilePlaylist(vid) { log("nextFilePlaylistD"); var filenext = vid.playlist.shift(); cleanupMediaState(vid); if (!filenext) { if (session.blankStream) { session.streamSrc = session.blankStream; pushOutVideoTrack(session.blankStream.getVideoTracks()[0]); } return; } vid.pause(); vid.currentObjectURL = URL.createObjectURL(filenext); vid.src = vid.currentObjectURL; // Wrap the onloadeddata in error handling vid.onloadeddata = function () { try { if (Firefox) { session.streamSrc = vid.mozCaptureStream(); } else { session.streamSrc = vid.captureStream(); } updateMixer(); var tracks = session.streamSrc.getVideoTracks(); if (tracks.length) { pushOutVideoTrack(tracks[0]); // video only } senderAudioUpdate(false, session.streamSrc); } catch (e) { errorlog(e); if (session.blankStream) { session.streamSrc = session.blankStream; pushOutVideoTrack(session.blankStream.getVideoTracks()[0]); } } }; vid.onerror = (e) => { errorlog(e); cleanupMediaState(vid); if (session.blankStream) { session.streamSrc = session.blankStream; pushOutVideoTrack(session.blankStream.getVideoTracks()[0]); } // Try next file if available if (vid.playlist.length) { setTimeout(() => nextFilePlaylist(vid), 1000); } }; vid.load(); vid.play() .then(_ => { log("playing 2"); }) .catch(e => { warnlog(e); if (session.blankStream) { session.streamSrc = session.blankStream; pushOutVideoTrack(session.blankStream.getVideoTracks()[0]); } // Try next file if available if (vid.playlist.length) { setTimeout(() => nextFilePlaylist(vid), 1000); } }); } session.publishFile = function (ele, event) { log("FILE STREAM SETUP"); if (!session.blankStream) { const canvas = document.createElement('canvas'); canvas.width = 1280; canvas.height = 720; const ctx = canvas.getContext('2d'); ctx.fillStyle = 'black'; ctx.fillRect(0, 0, canvas.width, canvas.height); session.blankStream = canvas.captureStream(30); session.streamSrc = session.blankStream; } if (session.transcript) { setTimeout(function () { setupClosedCaptions(); }, 1000); } const files = Array.from(ele.files); log(files); const file = files[0]; const isImage = file.type.startsWith('image/'); var fileURL = URL.createObjectURL(file); var container = document.createElement("div"); container.id = "container"; if (session.cover) { container.style.setProperty("height", "100%", "important"); } var v = createVideoElement(); v.container = container; if (session.cleanOutput) { container.style.height = "100%"; v.style.maxWidth = "100%"; v.style.boxShadow = "none"; } container.appendChild(v); if (session.streamID) { v.dataset.sid = session.streamID; } v.autoplay = false; if (session.showControls !== null) { v.controls = session.showControls; } else { v.controls = true; } v.muted = false; v.loop = files.length == 1; v.id = "videosource"; v.dataset.menu = "context-menu-video"; v.setAttribute("playsinline", ""); if (isImage) { session.canvasSource = new CanvasStreamSource(); session.canvasSource.imgSrc = fileURL; window.postMessage({ type: 'canvas-frame', frame: session.canvasSource.imgSrc }, '*'); session.streamSrc = session.canvasSource.getStream(); v.srcObject = session.streamSrc; v.play(); handleUIAndStream(); } else { v.src = fileURL; } v.playlist = files; v.addEventListener("ended", function (e) { log("MY HANDLER TRIGGERED"); var vid = getById("videosource"); nextFilePlaylist(vid); }, false); v.className = "tile clean fileshare"; session.videoElement = v; session.mirrorExclude = true; v.addEventListener("click", (e) => { log("click"); try { if (e.ctrlKey || e.metaKey) { e.preventDefault(); var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); e.stopPropagation(); return false; } } catch (e) { errorlog(e); } }); v.touchTimeOut = null; v.touchLastTap = 0; v.touchCount = 0; v.addEventListener("touchend", (event) => { if (session.disableMouseEvents) { return; } log("touched"); document.onmousemove = null; document.ontouchmove = null; var currentTime = new Date().getTime(); var tapLength = currentTime - v.touchLastTap; clearTimeout(v.touchTimeOut); if (tapLength < 500 && tapLength > 0) { log("double touched"); v.touchCount += 1; event.preventDefault(); if (v.touchCount < 5) { v.touchLastTap = currentTime; return false; } v.touchLastTap = 0; v.touchCount = 0; var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); event.stopPropagation(); return false; } else { v.touchCount = 1; v.touchTimeOut = setTimeout( function (vv) { clearTimeout(vv.touchTimeOut); vv.touchLastTap = 0; vv.touchCount = 0; }, 5000, v ); v.touchLastTap = currentTime; } }); v.onerror = () => { if (session.cleanOutput) { errorlog("File failed to load.\n\nSelect a compatible media file."); } else { warnUser("File failed to load.\n\nSelect a compatible media file."); } }; v.onloadeddata = async () => { session.mediafileShare = true; getById("mainmenu").remove(); if (Firefox) { session.streamSrc = v.mozCaptureStream(); } else { session.streamSrc = v.captureStream(); } if (session.framegrab && session.framegrabAudioRequested && session.pendingFramegrabAudioSettings) { try { const maybePromise = session.startFramegrabAudio(session.pendingFramegrabAudioSettings); if (maybePromise && typeof maybePromise.then === "function") { maybePromise.catch(errorlog); } } catch (err) { errorlog(err); } } handleUIAndStream(); }; getById("gridlayout").appendChild(container); function handleUIAndStream() { if (session.roomid !== false) { if (session.roomid === "" && (!session.view || session.view === "")) { } else { log("ROOMID ENABLED"); log("Update Mixer Event on REsize SET"); getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); joinRoom(session.roomid); } } else { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); getById("logoname").style.display = "none"; } updatePushId(); getById("head1").className = "hidden"; getById("head2").className = "hidden"; if (!session.cleanOutput) { getById("chatbutton").className = "float"; getById("mediafileshare").classList.remove("hidden"); getById("hangupbutton").className = "float"; getById("controlButtons").classList.remove("hidden"); // getById("legal").classList.remove("hidden"); } else { getById("controlButtons").classList.add("hidden"); // getById("legal").classList.add("hidden"); } toggleMute(true); if (session.director) { } else if (session.scene !== false) { } else if (session.roomid !== false) { if (session.roomid === "") { if (!session.view || session.view === "") { if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else if (session.minipreview) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (session.windowed) { session.videoElement.className = "myVideo clean fileshare"; container.classList.add("vidcon"); } getById("mutespeakerbutton").classList.add("hidden"); container.style.width = "100%"; container.style.alignItems = "center"; container.backgroundColor = "#666"; play(); } else { session.windowed = session.windowed === null ? false : session.windowed; play(); } } else { if (session.stereo == 5) { session.stereo = 3; } session.windowed = session.windowed === null ? false : session.windowed; } } else { if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else if (session.minipreview) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (session.windowed) { session.videoElement.className = "myVideo clean fileshare"; container.classList.add("vidcon"); } getById("mutespeakerbutton").classList.add("hidden"); container.style.width = "100%"; container.style.alignItems = "center"; container.backgroundColor = "#666"; } applyMirror(session.mirrorExclude); updateReshareLink(); pokeIframeAPI("started-fileshare"); pokeIframeAPI("file-share", true); clearInterval(session.updateLocalStatsInterval); session.updateLocalStatsInterval = setInterval(function () { updateLocalStats(); }, session.statsInterval); if (session.meshcast2) { meshcast2(); } else if (session.whipOutput) { whipOut(); } else if (session.meshcast) { meshcast(); } else if (session.whepHost) { whepOut(); } session.seeding = true; if (session.videoMutedFlag) { session.videoMuted = true; toggleVideoMute(true); } session.seedStream(); } }; // publishFile class CanvasStreamSource { constructor() { this.canvas = document.createElement('canvas'); this.ctx = this.canvas.getContext('2d', { willReadFrequently: true }); this.stream = this.canvas.captureStream(30); this.initialized = false; this.boundHandleFrame = this.handleFrame.bind(this); this.lastFrameTime = Date.now(); this.indicatorRotation = 0; this.frameCheckInterval = setInterval(() => { this.drawKeyframeTrigger(); }, 100); window.addEventListener('message', this.boundHandleFrame); } drawKeyframeTrigger() { if (!this.canvas || !this.ctx || Date.now() - this.lastFrameTime < 500) return; const state = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); this.ctx.save(); this.ctx.translate(this.canvas.width - 15, this.canvas.height - 15); this.ctx.rotate(this.indicatorRotation); const opacity = 0.15 + Math.sin(this.indicatorRotation) * 0.05; this.ctx.strokeStyle = `rgba(200, 200, 200, ${opacity})`; this.ctx.lineWidth = 1; const indicator = new Path2D(); indicator.arc(0, 0, 3, 0, Math.PI * 2); indicator.moveTo(5, 0); indicator.arc(0, 0, 5, 0, Math.PI * 2); this.ctx.stroke(indicator); this.ctx.restore(); this.ctx.putImageData(state, 0, 0); this.indicatorRotation += 0.01; } initializeForFrame(frame) { if (this.initialized) return; try { if (typeof frame !== "string") { const videoTrack = new MediaStreamTrackGenerator({ kind: 'video' }); this.writer = videoTrack.writable.getWriter(); const newStream = new MediaStream([videoTrack]); this.stream.getVideoTracks().forEach(track => { this.stream.removeTrack(track); track.stop(); }); newStream.getVideoTracks().forEach(track => { this.stream.addTrack(track); }); } this.initialized = true; } catch (e) { console.error("Failed to initialize MediaStreamTrackGenerator, keeping canvas:", e); this.initialized = true; } } handleFrame(event) { if (!event.data || !event.data.frame || (event.data.type != "canvas-frame")) return; const frame = event.data.frame; this.initializeForFrame(frame); if (this.writer && typeof frame !== "string") { this.writer.write(frame).catch(e => { console.error("Error writing frame:", e); if (frame.close) frame.close(); }); } else if (this.canvas) { const img = new Image(); img.onload = () => { this.canvas.width = img.width; this.canvas.height = img.height; this.ctx.drawImage(img, 0, 0); this.lastFrameTime = Date.now(); img.remove(); }; img.src = frame; } } getStream() { return this.stream; } destroy() { if (this.frameCheckInterval) { clearInterval(this.frameCheckInterval); } if (this.writer) { this.writer.close(); } if (this.stream) { this.stream.getTracks().forEach(track => { track.stop(); this.stream.removeTrack(track); }); } if (this.imgSrc) { URL.revokeObjectURL(this.imgSrc); } window.removeEventListener('message', this.boundHandleFrame); this.canvas = null; this.ctx = null; this.stream = null; this.writer = null; this.initialized = false; } } session.publishFrameSource = function (ele, event) { var container = document.createElement("div"); container.id = "container"; if (session.cover) { container.style.setProperty("height", "100%", "important"); } var v = createVideoElement(); v.container = container; if (session.cleanOutput) { container.style.height = "100%"; v.style.maxWidth = "100%"; v.style.boxShadow = "none"; } container.appendChild(v); if (session.streamID) { v.dataset.sid = session.streamID; } getById("gridlayout").appendChild(container); if (session.roomid !== false) { if (session.roomid === "" && (!session.view || session.view === "")) { } else { log("ROOMID EANBLED"); log("Update Mixer Event on REsize SET"); //window.addEventListener("resize", updateMixer);// TODO FIX //window.addEventListener("orientationchange", updateMixer);// TODO FIX getById("head3").classList.add("hidden"); getById("head3a").classList.add("hidden"); joinRoom(session.roomid); } } else { getById("head3").classList.remove("hidden"); getById("head3a").classList.remove("hidden"); getById("logoname").style.display = "none"; } updatePushId(); getById("head1").className = "hidden"; getById("head2").className = "hidden"; if (!session.cleanOutput) { getById("chatbutton").className = "float"; getById("sharefilebutton").classList.remove("hidden"); // we won't override "display:none", if set, though. getById("hangupbutton").className = "float"; getById("controlButtons").classList.remove("hidden"); // getById("legal").classList.remove("hidden"); //getById("helpbutton").style.display = "inherit"; //getById("reportbutton").style.display = ""; } else { getById("controlButtons").classList.add("hidden"); // getById("legal").classList.add("hidden"); } var bigPlayButton = document.getElementById("bigPlayButton"); if (bigPlayButton) { bigPlayButton.parentNode.removeChild(bigPlayButton); } v.autoplay = false; if (session.showControls !== null) { v.controls = session.showControls; } else { v.controls = true; } v.muted = false; v.id = "videosource"; // could be set to UUID in the future v.dataset.menu = "context-menu-video"; v.setAttribute("playsinline", ""); session.canvasSource = new CanvasStreamSource(); session.streamSrc = session.canvasSource.getStream(); v.srcObject = session.streamSrc; v.className = "tile clean"; session.videoElement = v; session.mirrorExclude = true; v.addEventListener("click", (e) => { log("click"); try { if (e.ctrlKey || e.metaKey) { e.preventDefault(); var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); e.stopPropagation(); return false; } } catch (e) { errorlog(e); } }); v.touchTimeOut = null; v.touchLastTap = 0; v.touchCount = 0; v.addEventListener("touchend", (event) => { if (session.disableMouseEvents) { return; } log("touched"); //document.ontouchup = null; //document.onmouseup = null; document.onmousemove = null; document.ontouchmove = null; var currentTime = new Date().getTime(); var tapLength = currentTime - v.touchLastTap; clearTimeout(v.touchTimeOut); if (tapLength < 500 && tapLength > 0) { /// log("double touched"); v.touchCount += 1; event.preventDefault(); if (v.touchCount < 5) { v.touchLastTap = currentTime; return false; } v.touchLastTap = 0; v.touchCount = 0; var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); event.stopPropagation(); return false; ////// } else { v.touchCount = 1; v.touchTimeOut = setTimeout( function (vv) { clearTimeout(vv.touchTimeOut); vv.touchLastTap = 0; vv.touchCount = 0; }, 5000, v ); v.touchLastTap = currentTime; } }); v.onerror = (e) => { errorlog(e); }; v.onloadeddata = async () => { session.mediafileShare = true; getById("mainmenu").remove(); if (Firefox) { session.streamSrc = v.mozCaptureStream(); } else { session.streamSrc = v.captureStream(); // gaaaaaaaaaaaahhhhhhhh! } if (session.framegrab && session.framegrabAudioRequested && session.pendingFramegrabAudioSettings) { try { const maybePromise = session.startFramegrabAudio(session.pendingFramegrabAudioSettings); if (maybePromise && typeof maybePromise.then === "function") { maybePromise.catch(errorlog); } } catch (err) { errorlog(err); } } toggleMute(true); if (session.director) { } else if (session.scene !== false) { } else if (session.roomid !== false) { if (session.roomid === "") { if (!session.view || session.view === "") { if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else if (session.minipreview) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (session.windowed) { v.className = "myVideo clean"; container.classList.add("vidcon"); } getById("mutespeakerbutton").classList.add("hidden"); container.style.width = "100%"; container.style.alignItems = "center"; container.backgroundColor = "#666"; play(); } else { session.windowed = session.windowed === null ? false : session.windowed; play(); } } else { //session.cbr=0; // we're just going to override it if (session.stereo == 5) { session.stereo = 3; } session.windowed = session.windowed === null ? false : session.windowed; } applyMirror(session.mirrorExclude); } else { if (session.fullscreen) { session.windowed = session.windowed === null ? false : session.windowed; } else if (session.minipreview) { session.windowed = session.windowed === null ? false : session.windowed; } else { session.windowed = session.windowed === null ? true : session.windowed; } if (session.windowed) { v.className = "myVideo clean "; container.classList.add("vidcon"); } getById("mutespeakerbutton").classList.add("hidden"); container.style.width = "100%"; container.style.alignItems = "center"; container.backgroundColor = "#666"; applyMirror(session.mirrorExclude); } updateReshareLink(); pokeIframeAPI("started-frameshare"); // depreciated pokeIframeAPI("frame-share", true); clearInterval(session.updateLocalStatsInterval); session.updateLocalStatsInterval = setInterval(function () { updateLocalStats(); }, session.statsInterval); if (session.meshcast2) { await meshcast2(); } else if (session.whipOutput) { // was handling these functions within session.seedStream(); doing it here now instead. 8-08-2024 whipOut(); } else if (session.meshcast) { await meshcast(); } else if (session.whepHost) { whepOut(); } session.seeding = true; if (session.videoMutedFlag) { session.videoMuted = true; toggleVideoMute(true); } session.seedStream(); }; }; // publishFrameSource function tryAgain(event) { // audio or video agnostic track reconnect ------------not actually in use,. maybe out of date log("TRY AGAIN TRIGGERED"); warnlog(event); } function enterPressedClick(event, ele) { if (event.keyCode === 13) { event.preventDefault(); ele.click(); } } function enterPressed(event, callback) { // Number 13 is the "Enter" key on the keyboard if (event.keyCode === 13) { event.preventDefault(); callback(); } } function dragElement(elmnt) { if (session.disableMouseEvents) { return; } log("dragElement started"); function onvideoclick() { log("onvideoclick"); log(pos3 + " " + pos4); //log(pos3o + " " + pos4o); tapToFocus(parseInt((pos3 * 100) / elmnt.clientWidth), parseInt((pos4 / elmnt.clientHeight) * 100)); return false; } function elementDrag(e) { e = e || window.event; e.preventDefault(); // calculate the new cursor position: log("dragging"); log(e); if (Date.now() - millis < 100) { return; } dragged = true; millis = Date.now(); if (e.type == "touchstart" || e.type == "touchmove" || e.type == "touchend" || e.type == "touchcancel") { var touch = e.touches[0] || e.originalEvent.touches[0] || e.originalEvent.changedTouches[0]; pos1 = touch.clientX; pos2 = touch.clientY; } else if (e.type == "mousedown" || e.type == "mouseup" || e.type == "mousemove" || e.type == "mouseover" || e.type == "mouseout" || e.type == "mouseenter" || e.type == "mouseleave") { pos1 = e.clientX; pos2 = e.clientY; } if (!zoomable) { return; } var zoom = parseFloat(((pos4 - pos2) * 2) / elmnt.offsetHeight); if (zoom > 1) { zoom = 1.0; } else if (zoom < -1) { zoom = -1.0; } input.value = zoom * (input.max - input.min) + input.min; updateCameraConstraints("zoom", input.value, false, false); } function closeDragElement(e) { log("closeDragElement"); log(e); // focusable if (!dragged) { log("dragged: " + dragged); onvideoclick(); } dragged = false; elmnt.removeEventListener("touchend", closeDragElement); elmnt.removeEventListener("mouseup", closeDragElement); /* stop moving when mouse button is released:*/ //document.ontouchend = null; //document.onmouseup = null; document.onmousemove = null; document.ontouchmove = null; } function dragMouseDown(e) { log("dragMouseDown"); log(e); dragged = false; millis = Date.now(); e = e || window.event; e.preventDefault(); pos0 = input.value; if (e.type == "touchstart" || e.type == "touchmove" || e.type == "touchend" || e.type == "touchcancel") { var touch = e.touches[0] || e.originalEvent.touches[0] || e.originalEvent.changedTouches[0]; pos3 = touch.clientX; pos4 = touch.clientY; //pos3o = touch.offsetX; //pos4o = touch.offsetX; } else if (e.type == "mousedown" || e.type == "mouseup" || e.type == "mousemove" || e.type == "mouseover" || e.type == "mouseout" || e.type == "mouseenter" || e.type == "mouseleave") { pos3 = e.clientX; pos4 = e.clientY; //pos3o = e.offsetX; //pos4o = e.offsetX; } elmnt.addEventListener("touchend", closeDragElement); elmnt.addEventListener("mouseup", closeDragElement); document.ontouchmove = elementDrag; document.onmousemove = elementDrag; } try { var stream = elmnt.srcObject; try { var track0 = stream.getVideoTracks(); } catch (e) { return; } if (!track0.length) { return; } var focusable = false; var zoomable = false; var dragged = false; var input = getById("zoomSlider"); track0 = track0[0]; if (track0.getCapabilities) { var capabilities = track0.getCapabilities(); var settings = track0.getSettings(); if ("focusDistance" in capabilities) { log("focusable"); focusable = true; } if ("zoom" in capabilities) { if (capabilities.zoom.min !== capabilities.zoom.max) { log("zoomable;"); zoomable = true; input.min = capabilities.zoom.min; input.max = capabilities.zoom.max; input.step = capabilities.zoom.step; input.value = settings.zoom; } } } var millis = Date.now(); var pos0 = 1; var pos3 = 0; var pos4 = 0; var pos1 = 0; var pos2 = 0; //var pos3o = 0; //var pos4o = 0; } catch (e) { errorlog(e); return; } if (!focusable && !zoomable) { return; } // can't be zoomed or focused. log("drag on"); elmnt.onmousedown = dragMouseDown; elmnt.ontouchstart = dragMouseDown; } function previewIframe(iframeSrc) { // this is pretty important if you want to avoid camera permission popup problems. You can also call it automatically via: loadIframe();"> , but don't call it before the page loads. if (!session.iFramesAllowed) { warnUser("Can't create iFRAME - security is tainted due to possible CSS injection"); errorlog("Can't create iFRAME - security is tainted due to possible CSS injection"); return; } var iframe = document.createElement("iframe"); iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;midi;screen-wake-lock;"; // do not allow location iframe.style.width = "100%"; iframe.style.height = "100%"; iframe.style.border = "10px dashed rgb(64 65 62)"; iframe.classList.add("insecure"); iframe.setAttribute("allowtransparency", "true"); iframe.setAttribute("crossorigin", "anonymous"); iframe.setAttribute("credentialless", "true"); iframeSrc = parseURL4Iframe(iframeSrc); /* if (typeof iframeSrc == "object"){ // special handler. iframeSrc = iframeSrc.parsedSrc; } */ iframe.src = iframeSrc; getById("previewIframe").innerHTML = ""; getById("previewIframe").style.width = "640px"; getById("previewIframe").style.height = "360px"; getById("previewIframe").style.margin = "auto"; getById("previewIframe").appendChild(iframe); } function loadIframe(iframesrc, target) { // this is pretty important if you want to avoid camera permission popup problems. You can also call it automatically via: loadIframe();"> , but don't call it before the page loads. /* if (document.getElementById("mainmenu")) { var m = getById("mainmenu"); m.remove(); } */ if (!session.iFramesAllowed) { return false; } if (!target) { return false; } if (typeof target == "string") { let UUID = target; var iframe = document.createElement("iframe"); iframe.style.width = "100%"; iframe.style.height = "100%"; iframe.id = "iframe_" + UUID; iframe.dataset.UUID = UUID; iframe.loadedYoutubeListen = false; if (session.director) { // } else if (session.scene !== false) { if (session.view) { // specific video to be played iframe.style.display = "block"; } else if (session.scene === "0") { iframe.style.display = "block"; } else { // group scene I guess; needs to be added manually iframe.style.display = "none"; } } else if (session.roomid !== false) { // } else { iframe.style.display = "block"; } } else { var iframe = target; } iframe.classList.add("insecure"); iframe.setAttribute("allowtransparency", "true"); iframe.setAttribute("crossorigin", "anonymous"); iframe.setAttribute("credentialless", "true"); iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;midi;screen-wake-lock;"; // do not allow location if (iframesrc == "") { iframesrc = "./"; iframe.classList.remove("insecure"); } // trusted domains var ipsafe = false; if (iframesrc.startsWith("https://www.youtube.com/") || iframesrc.startsWith("https://youtube.com/")) { iframe.classList.remove("insecure"); setTimeout( function (iframe_id) { YoutubeListen(iframe_id); }, 1000, iframe.id ); // create stats feedback for the director; syncing. if (session.noaudio) { if (iframesrc.includes("?")) { iframesrc += "&mute=1"; } else { iframesrc += "?mute=1"; } } ipsafe = true; } else if (iframesrc.includes("vdo.ninja/")) { iframe.classList.remove("insecure"); ipsafe = false; if (isIFrame) { console.warn("You're not allow to put this domain inside an iframe of an iframe."); return false; } } else if (iframesrc.includes("obs.ninja/")) { iframe.classList.remove("insecure"); ipsafe = false; if (isIFrame) { console.warn("You're not allow to put this domain inside an iframe of an iframe."); return false; } } else if (iframesrc.includes("versus.cam/")) { iframe.classList.remove("insecure"); ipsafe = false; if (isIFrame) { console.warn("You're not allow to put this domain inside an iframe of an iframe."); return false; } } else if (iframesrc.includes("invite.cam/")) { ipsafe = false; if (isIFrame) { console.warn("You're not allow to put this domain inside an iframe of an iframe."); return false; } } else if (iframesrc.startsWith("https://player.twitch.tv/")) { iframe.classList.remove("insecure"); ipsafe = true; } else if (iframesrc.startsWith("https://x.com/")) { iframe.classList.remove("insecure"); ipsafe = false; } else if (iframesrc.startsWith("https://twitch.tv/")) { iframe.classList.remove("insecure"); ipsafe = true; } else if (iframesrc.startsWith("https://caption.ninja/")) { iframe.classList.remove("insecure"); ipsafe = true; } else if (iframesrc.startsWith("https://www.twitch.tv/")) { iframe.classList.remove("insecure"); ipsafe = true; } else if (iframesrc.startsWith("https://vimeo.com/")) { iframe.classList.remove("insecure"); ipsafe = true; } else if (iframesrc.startsWith("https://player.vimeo.com/")) { iframe.classList.remove("insecure"); ipsafe = true; } else if (iframesrc.startsWith("https://meshcast.io/")) { iframe.classList.remove("insecure"); try { if (document.domain.endsWith(".vdo.ninja")) { document.domain = "vdo.ninja"; } } catch (e) { errorlog(e); } ipsafe = true; } else if (iframesrc.startsWith("https://app.stageten.tv/")) { iframe.classList.remove("insecure"); ipsafe = true; } else if (iframesrc.startsWith("https://socialstream.ninja/")) { iframe.classList.remove("insecure"); ipsafe = false; } else if (session.cleanOutput && window.obsstudio) { iframe.classList.remove("insecure"); } if (!ipsafe) { iframe.title = "⚠️ This section is an iframe that may be of untrusted origin. Use caution."; } if (!ipsafe && (urlParams.has("privacy") || urlParams.has("private"))) { if (session.cleanOutput || window.obsstudio) { iframesrc = "./confirm.html?clean&url=" + encodeURI(iframesrc); } else { iframesrc = "./confirm.html?url=" + encodeURI(iframesrc); } } else if (!ipsafe && (typeof target == "object")) { // likely widget if (session.widgetwidth <= 28) { // 28 for marcus iframe.classList.remove("insecure"); } else if (isIFrame && (session.widgetwidth <= 50)) { iframe.classList.remove("insecure"); } else { if (session.cleanOutput || window.obsstudio) { iframesrc = "./confirm.html?clean&url=" + encodeURI(iframesrc); } else { iframesrc = "./confirm.html?url=" + encodeURI(iframesrc); } } } if (isIFrame && ["invite.cam", "invitecamera.com", "vdo.ninja", "versus.cam", "dev.versus.cam", "backup.vdo.ninja", "proxy.vdo.ninia", "proxy.obs.ninja", "insecure.vdo.ninja", "insecure.obs.ninja", "rtc.ninja"].includes(getParentHostname())) { iframe.classList.add("insecure"); } iframe.src = iframesrc; pokeIframeAPI("iframe-loaded", iframesrc); return iframe; } function dropDownButtonAction(ele) { var ele = getById("dropButton"); if (ele) { ele.parentNode.removeChild(ele); //getById('container-5').classList.remove('hidden'); //getById('container-8').classList.remove('hidden'); //getById('container-6').classList.remove('hidden'); document.querySelectorAll("div.column.card").forEach(child => { child.classList.remove("hidden"); }); } } function updateConstraintSliders() { log("updateConstraintSliders"); if (session.roomid !== false && session.roomid !== "" && session.director !== true && session.forceMediaSettings == false) { if (session.controlRoomBitrate !== false) { listCameraSettings(); } if (session.effect !== false) { //if ((iOS) || (iPad)){ //} else { getById("effectsDiv3").style.display = "block"; getById("effectSelector3").value = session.effect || "0"; //} } } else { listAudioSettings(); listCameraSettings(); //if ((iOS) || (iPad)){ // } else { if (session.effect !== false) { getById("effectsDiv3").style.display = "block"; try { getById("effectSelector3").value = session.effect || "0"; } catch (E) { } } //} } //checkIfPIP(); // this doesn't actually work on iOS still, so whatever. } function checkIfPIP() { try { if (session.videoElement && ((session.videoElement.webkitSupportsPresentationMode && typeof session.videoElement.webkitSetPresentationMode === "function") || document.pictureInPictureEnabled || !videoElement.disablePictureInPicture)) { // Toggle PiP when the user clicks the button. getById("pIpStartButton").addEventListener("click", function (event) { // if ( (document.pictureInPictureEnabled || !videoElement.disablePictureInPicture)){ //session.videoElement.requestPictureInPicture(); // } else { session.videoElement.webkitSetPresentationMode(session.videoElement.webkitPresentationMode === "picture-in-picture" ? "inline" : "picture-in-picture"); // } }); getById("pIpStartButton").style.display = "inline-block"; } } catch (e) { errorlog(e); } } function togglePictureInPicture(videoElement) { if (document.pictureInPictureElement) { if (document.pictureInPictureElement.id == videoElement.id) { document.exitPictureInPicture(); pokeIframeAPI("picture-in-picture", false); return false; } else { document.exitPictureInPicture(); pokeIframeAPI("picture-in-picture", false); videoElement.requestPictureInPicture(); pokeIframeAPI("picture-in-picture", true); } } else if (document.pictureInPictureEnabled) { videoElement.requestPictureInPicture(); pokeIframeAPI("picture-in-picture", true); } return true; } function mixMinusAudio(uid = false) { if (session.stereo === false) { var merger = session.audioCtx.createChannelMerger(1); } else { var merger = session.audioCtx.createChannelMerger(2); } if (session.videoElement && session.videoElement.srcObject) { var tracks = session.videoElement.srcObject.getAudioTracks(); for (var i = 0; i < tracks.length; i++) { try { var tempStream = createMediaStream(); tempStream.addTrack(tracks[i]); trackStream = session.audioCtx.createMediaStreamSource(tempStream); if (session.stereo !== false) { var splitter = session.audioCtx.createChannelSplitter(2); trackStream.connect(splitter); splitter.connect(merger, 0, 0); try { splitter.connect(merger, 1, 1); } catch (e) { errorlog(e); try { splitter.connect(merger, 0, 1); // hack. } catch (e) { errorlog(e); } } } else { trackStream.connect(merger, 0, 0); } } catch (e) { errorlog(e); } } } for (var UUID in session.rpcs) { if (uid && UUID === uid) { continue; } if (!session.rpcs[UUID].videoElement) { continue; } else if (!session.rpcs[UUID].videoElement.srcObject) { continue; } var tracks = session.rpcs[UUID].videoElement.srcObject.getAudioTracks(); for (var i = 0; i < tracks.length; i++) { try { var tempStream = createMediaStream(); tempStream.addTrack(tracks[i]); trackStream = session.audioCtx.createMediaStreamSource(tempStream); if (session.stereo !== false) { var splitter = session.audioCtx.createChannelSplitter(2); trackStream.connect(splitter); splitter.connect(merger, 0, 0); try { splitter.connect(merger, 1, 1); } catch (e) { errorlog(e); try { splitter.connect(merger, 0, 1); // hack. } catch (e) { errorlog(e); } } } else { trackStream.connect(merger, 0, 0); } } catch (e) { errorlog(e); } } } var destination = session.audioCtx.createMediaStreamDestination(); merger.connect(destination); return destination.stream; } // Cleanup audio nodes for a guest's mix-minus to prevent memory leaks function cleanupMixMinusAudioNodes(uuid) { if (!session.mixMinusState || !session.mixMinusState[uuid]) { return; } var nodes = session.mixMinusState[uuid].audioNodes; if (!nodes) { return; } try { // Disconnect all source nodes if (nodes.sources) { for (var i = 0; i < nodes.sources.length; i++) { try { nodes.sources[i].disconnect(); } catch (e) { } } nodes.sources = []; } // Disconnect all splitter nodes if (nodes.splitters) { for (var i = 0; i < nodes.splitters.length; i++) { try { nodes.splitters[i].disconnect(); } catch (e) { } } nodes.splitters = []; } // Disconnect merger if (nodes.merger) { try { nodes.merger.disconnect(); } catch (e) { } nodes.merger = null; } // Destination doesn't need explicit disconnect nodes.destination = null; } catch (e) { warnlog("Error cleaning up mix-minus audio nodes: " + e); } } // Director mix-minus: Creates a custom audio mix for a specific guest // Includes all other guests' audio + director's audio, excluding the target guest's own audio function createDirectorMixMinusForGuest(targetUUID) { // Check if this guest has mix enabled (either via &mixminus or via UI) // Allow if directorMixMinus is set OR if guest-specific state is enabled if (!session.directorMixMinus && (!session.mixMinusState || !session.mixMinusState[targetUUID] || !session.mixMinusState[targetUUID].enabled)) { return null; } if (!session.mixMinusState) { session.mixMinusState = {}; } if (!session.audioCtx) { warnlog("Audio context not initialized for mix-minus"); return null; } // Initialize state for this guest if not exists if (!session.mixMinusState[targetUUID]) { initMixMinusStateForGuest(targetUUID); } var guestState = session.mixMinusState[targetUUID]; // Cleanup previous audio nodes before creating new ones (or if disabled) cleanupMixMinusAudioNodes(targetUUID); if (!guestState || !guestState.enabled) { return null; } // Ensure audioNodes object exists if (!guestState.audioNodes) { guestState.audioNodes = { merger: null, destination: null, sources: [], splitters: [] }; } // Create channel merger based on stereo setting var merger; try { if (session.stereo === false) { merger = session.audioCtx.createChannelMerger(1); } else { merger = session.audioCtx.createChannelMerger(2); } guestState.audioNodes.merger = merger; } catch (e) { errorlog("Failed to create audio merger for mix-minus: " + e); return null; } // Helper function to connect audio track to merger function connectTrackToMerger(track) { try { var tempStream = createMediaStream(); tempStream.addTrack(track); var trackStream = session.audioCtx.createMediaStreamSource(tempStream); guestState.audioNodes.sources.push(trackStream); if (session.stereo !== false) { var splitter = session.audioCtx.createChannelSplitter(2); guestState.audioNodes.splitters.push(splitter); trackStream.connect(splitter); splitter.connect(merger, 0, 0); try { splitter.connect(merger, 1, 1); } catch (e) { errorlog(e); try { splitter.connect(merger, 0, 1); } catch (e) { errorlog(e); } } } else { trackStream.connect(merger, 0, 0); } } catch (e) { errorlog(e); } } // Add director's processed audio mix (if enabled) if (guestState.useDirectorMix !== false && session.videoElement && session.videoElement.srcObject) { var mixTracks = session.videoElement.srcObject.getAudioTracks(); for (var i = 0; i < mixTracks.length; i++) { connectTrackToMerger(mixTracks[i]); } } // Add raw input devices (if any are enabled) if (guestState.rawDevices && session.streamSrc) { var inputTracks = session.streamSrc.getAudioTracks(); for (var i = 0; i < inputTracks.length; i++) { var track = inputTracks[i]; var deviceId = track.getSettings().deviceId || track.id; if (guestState.rawDevices[deviceId] === true) { connectTrackToMerger(track); } } } // Add all other guests' audio (from session.rpcs) for (var UUID in session.rpcs) { // Skip the target guest (they don't need to hear themselves) if (UUID === targetUUID) { continue; } // Check if this source is excluded for this guest if (guestState.excludeSources && guestState.excludeSources.includes(UUID)) { continue; } // If using include mode, check if source is explicitly included if (guestState.includeSources && guestState.includeSources.length > 0) { if (!guestState.includeSources.includes(UUID)) { continue; } } // Skip if rpcs entry doesn't exist or has no audio if (!session.rpcs[UUID] || !session.rpcs[UUID].videoElement || !session.rpcs[UUID].videoElement.srcObject) { continue; } var guestTracks = session.rpcs[UUID].videoElement.srcObject.getAudioTracks(); for (var i = 0; i < guestTracks.length; i++) { connectTrackToMerger(guestTracks[i]); } } // Create destination stream var destination = session.audioCtx.createMediaStreamDestination(); merger.connect(destination); guestState.audioNodes.destination = destination; return destination.stream; } // Initialize mix-minus state for a guest function initMixMinusStateForGuest(uuid) { if (!session.mixMinusState) { session.mixMinusState = {}; } if (!session.mixMinusDefaults) { session.mixMinusDefaults = { allGuestsEnabled: true, includeDirectorAudio: true, includeAllGuests: session.directorMixMinus ? true : false // Only include guests by default if &mixminus is set }; } // Don't overwrite existing state (audio nodes may already be stored) if (session.mixMinusState[uuid]) { return; } session.mixMinusState[uuid] = { enabled: session.mixMinusDefaults.allGuestsEnabled, excludeSources: [], includeSources: [], // Director audio options useDirectorMix: true, // Use processed WebAudio output (with effects) rawDevices: {}, // { deviceId: true/false } for raw input devices directorAudioDevices: {}, // Legacy - kept for backwards compatibility // Audio node references for cleanup audioNodes: { merger: null, destination: null, sources: [], // MediaStreamSource nodes splitters: [] // ChannelSplitter nodes } }; // Initialize raw input devices (from session.streamSrc) - disabled by default if (session.streamSrc) { var inputTracks = session.streamSrc.getAudioTracks(); for (var i = 0; i < inputTracks.length; i++) { var deviceId = inputTracks[i].getSettings().deviceId || inputTracks[i].id; session.mixMinusState[uuid].rawDevices[deviceId] = false; // Disabled by default } } // Legacy: also track processed WebAudio output devices if (session.videoElement && session.videoElement.srcObject) { var directorTracks = session.videoElement.srcObject.getAudioTracks(); for (var i = 0; i < directorTracks.length; i++) { var deviceId = directorTracks[i].getSettings().deviceId || directorTracks[i].id; session.mixMinusState[uuid].directorAudioDevices[deviceId] = true; } } // Without &mixminus, exclude other guests by default (director audio only) // With &mixminus, include all guests by default (mix-minus behavior) if (!session.mixMinusDefaults.includeAllGuests) { // Add all current guests (except target) to excludeSources for (var guestUUID in session.rpcs) { if (guestUUID !== uuid) { session.mixMinusState[uuid].excludeSources.push(guestUUID); } } } } // Toggle mix-minus enabled/disabled for a specific guest function toggleMixMinusForGuest(uuid) { if (!session.mixMinusState) { session.mixMinusState = {}; } if (!session.mixMinusState[uuid]) { initMixMinusStateForGuest(uuid); } session.mixMinusState[uuid].enabled = !session.mixMinusState[uuid].enabled; updateMixMinusForGuest(uuid); return session.mixMinusState[uuid].enabled; } // Toggle a specific audio source in the mix for a guest function toggleSourceInMixForGuest(sourceUUID, targetUUID) { if (!session.mixMinusState) { session.mixMinusState = {}; } if (!session.mixMinusState[targetUUID]) { initMixMinusStateForGuest(targetUUID); } var state = session.mixMinusState[targetUUID]; var idx = state.excludeSources.indexOf(sourceUUID); if (idx > -1) { state.excludeSources.splice(idx, 1); // Remove from exclude list (enable) } else { state.excludeSources.push(sourceUUID); // Add to exclude list (disable) } updateMixMinusForGuest(targetUUID); return idx > -1; // Returns true if source is now enabled } // Toggle a director audio device in the mix for a guest function toggleDirectorDeviceInMix(deviceId, targetUUID) { if (!session.mixMinusState) { session.mixMinusState = {}; } if (!session.mixMinusState[targetUUID]) { initMixMinusStateForGuest(targetUUID); } var state = session.mixMinusState[targetUUID]; state.directorAudioDevices[deviceId] = !state.directorAudioDevices[deviceId]; updateMixMinusForGuest(targetUUID); return state.directorAudioDevices[deviceId]; } // Set mix-minus state for all guests function setMixMinusForAll(enabled) { if (!session.mixMinusDefaults) { session.mixMinusDefaults = { allGuestsEnabled: enabled, includeDirectorAudio: true, includeAllGuests: true }; } else { session.mixMinusDefaults.allGuestsEnabled = enabled; } if (!session.mixMinusState) { session.mixMinusState = {}; return; } for (var uuid in session.mixMinusState) { session.mixMinusState[uuid].enabled = enabled; updateMixMinusForGuest(uuid); } } // Update/rebuild the mix-minus stream for a guest // This should replace the audio track being sent to the guest function updateMixMinusForGuest(uuid) { if (!session.pcs[uuid]) { return; } var mixStream = createDirectorMixMinusForGuest(uuid); if (!mixStream) { return; } var mixTracks = mixStream.getAudioTracks(); if (!mixTracks.length) { return; } // Replace ALL audio tracks being sent to this guest try { var senders = session.pcs[uuid].getSenders(); var replacedCount = 0; for (var i = 0; i < senders.length; i++) { if (senders[i].track && senders[i].track.kind === "audio") { senders[i].replaceTrack(mixTracks[0]); replacedCount++; } } if (replacedCount > 0) { log("Updated mix-minus audio for guest: " + uuid + " (replaced " + replacedCount + " audio track(s))"); } } catch (e) { errorlog("Error updating mix-minus for guest " + uuid + ": " + e); } } // Called when a new guest joins - initialize their mix-minus state and send mix function onGuestJoinedMixMinus(uuid) { if (!session.directorMixMinus) { return; } initMixMinusStateForGuest(uuid); // Also update existing guests' mixes to include the new guest for (var existingUUID in session.mixMinusState) { if (existingUUID !== uuid && session.mixMinusState[existingUUID].enabled) { updateMixMinusForGuest(existingUUID); } } } // Called when a guest leaves - cleanup and update other guests' mixes function onGuestLeftMixMinus(uuid) { // Allow cleanup if directorMixMinus is set OR if there's any mix state if (!session.directorMixMinus && !session.mixMinusState) { return; } // Cleanup audio nodes before removing state cleanupMixMinusAudioNodes(uuid); // Remove from state delete session.mixMinusState[uuid]; // Remove from exclude/include lists of other guests for (var otherUUID in session.mixMinusState) { var state = session.mixMinusState[otherUUID]; var idx = state.excludeSources.indexOf(uuid); if (idx > -1) { state.excludeSources.splice(idx, 1); } idx = state.includeSources.indexOf(uuid); if (idx > -1) { state.includeSources.splice(idx, 1); } // Update their mix since a source left updateMixMinusForGuest(otherUUID); } } // UI handler for mix-minus toggle button in director panel function directToggleMixMinus(ele, event) { if (!session.directorMixMinus) { warnUser("Mix-minus not enabled. Add &mixminus to your director URL."); return; } var UUID = ele.dataset.UUID; if (!UUID) { // Try to find UUID from parent element try { UUID = ele.closest("[data-UUID]").dataset.UUID; } catch (e) { errorlog("Could not find guest UUID for mix-minus toggle"); return; } } var enabled = toggleMixMinusForGuest(UUID); // Update button appearance if (enabled) { ele.classList.add("pressed"); ele.title = "Mix-minus enabled - this guest hears all other audio"; } else { ele.classList.remove("pressed"); ele.title = "Mix-minus disabled for this guest"; } log("Mix-minus for " + UUID + " is now: " + (enabled ? "enabled" : "disabled")); } // Global variable to track open dropdown var activeMixDropdown = null; // Toggle mix dropdown visibility and populate sources function toggleMixDropdown(UUID, buttonEle, event) { if (event) { event.stopPropagation(); } // Find or get UUID from button if (!UUID && buttonEle) { UUID = buttonEle.dataset.UUID; if (!UUID) { try { UUID = buttonEle.closest("[data-UUID]").dataset.UUID; } catch (e) { errorlog("Could not find guest UUID for mix dropdown"); return; } } } // Find the dropdown container (sibling to PGM/Mic row) var container = buttonEle.closest(".row").nextElementSibling; if (!container || !container.classList.contains("mix-dropdown-container")) { errorlog("Could not find mix dropdown container"); return; } var dropdown = container.querySelector(".mix-dropdown"); if (!dropdown) { errorlog("Could not find mix dropdown element"); return; } // Close any other open dropdown if (activeMixDropdown && activeMixDropdown !== dropdown) { activeMixDropdown.style.display = "none"; activeMixDropdown.closest(".mix-dropdown-container").style.display = "none"; } // Toggle visibility if (dropdown.style.display === "none" || dropdown.style.display === "") { // Initialize state if needed if (!session.mixMinusState) { session.mixMinusState = {}; } if (!session.mixMinusState[UUID]) { initMixMinusStateForGuest(UUID); } // Enable mix-minus for this guest if not already if (!session.mixMinusState[UUID].enabled) { session.mixMinusState[UUID].enabled = true; updateMixMinusForGuest(UUID); } // Populate and show dropdown populateMixDropdown(UUID, dropdown); container.style.display = "block"; dropdown.style.display = "block"; activeMixDropdown = dropdown; buttonEle.classList.add("pressed"); // Add click outside listener to close dropdown setTimeout(function() { document.addEventListener("click", closeMixDropdownOnClickOutside); }, 10); } else { // Hide dropdown dropdown.style.display = "none"; container.style.display = "none"; activeMixDropdown = null; buttonEle.classList.remove("pressed"); document.removeEventListener("click", closeMixDropdownOnClickOutside); } } // Close dropdown when clicking outside function closeMixDropdownOnClickOutside(event) { if (activeMixDropdown && !activeMixDropdown.contains(event.target)) { var container = activeMixDropdown.closest(".mix-dropdown-container"); activeMixDropdown.style.display = "none"; if (container) { container.style.display = "none"; } // Find and un-press the Mix button var row = container ? container.previousElementSibling : null; if (row) { var mixBtn = row.querySelector('[data-action-type="custom-mix"]'); if (mixBtn) { mixBtn.classList.remove("pressed"); } } activeMixDropdown = null; document.removeEventListener("click", closeMixDropdownOnClickOutside); } } // Populate dropdown with available audio sources function populateMixDropdown(targetUUID, dropdown) { if (!dropdown) { return; } var html = '
    Audio Sources
    '; var state = session.mixMinusState[targetUUID]; // Section 1: Director Mix (processed WebAudio output with effects) html += '
    '; html += '
    Director Mix
    '; if (session.videoElement && session.videoElement.srcObject) { var mixTracks = session.videoElement.srcObject.getAudioTracks(); if (mixTracks.length > 0) { var checked = state.useDirectorMix !== false; html += '
    '; html += ''; html += ''; html += '
    '; } else { html += '
    No director mix available
    '; } } else { html += '
    No director mix available
    '; } html += '
    '; // Section 2: Director Input Devices (raw, unprocessed) html += '
    '; html += '
    Director Input Devices
    '; if (session.streamSrc) { var inputTracks = session.streamSrc.getAudioTracks(); if (inputTracks.length === 0) { html += '
    No input devices
    '; } else { for (var i = 0; i < inputTracks.length; i++) { var track = inputTracks[i]; var deviceId = track.getSettings().deviceId || track.id; var label = track.label || ("Mic " + (i + 1)); var checked = state.rawDevices && state.rawDevices[deviceId] === true; html += '
    '; html += ''; html += ''; html += '
    '; } } } else { html += '
    No input devices
    '; } html += '
    '; // Other guests section html += '
    '; html += '
    Guests
    '; var guestCount = 0; for (var UUID in session.rpcs) { // Skip the target guest (they don't hear themselves) if (UUID === targetUUID) { continue; } if (!session.rpcs[UUID] || !session.rpcs[UUID].videoElement || !session.rpcs[UUID].videoElement.srcObject) { continue; } var guestTracks = session.rpcs[UUID].videoElement.srcObject.getAudioTracks(); if (guestTracks.length === 0) { continue; } guestCount++; var guestLabel = session.rpcs[UUID].label || "Guest"; var streamID = session.rpcs[UUID].streamID || UUID.substring(0, 6); var displayLabel = guestLabel + " - " + streamID; // Check if source is excluded var checked = state.excludeSources.indexOf(UUID) === -1; html += '
    '; html += ''; html += ''; html += '
    '; } if (guestCount === 0) { html += '
    No other guests
    '; } html += '
    '; dropdown.innerHTML = html; } // Helper to sanitize labels for HTML display function sanitizeLabel(str) { if (!str) return ""; return str.replace(//g, ">").replace(/"/g, """); } // Toggle a source in the mix and update the mix function toggleMixSource(targetUUID, sourceId, sourceType, checkbox) { if (!session.mixMinusState || !session.mixMinusState[targetUUID]) { return; } var state = session.mixMinusState[targetUUID]; if (sourceType === 'mix') { // Toggle Director Mix (processed WebAudio output) state.useDirectorMix = !state.useDirectorMix; updateMixMinusForGuest(targetUUID); } else if (sourceType === 'raw') { // Toggle raw input device if (!state.rawDevices) { state.rawDevices = {}; } state.rawDevices[sourceId] = !state.rawDevices[sourceId]; updateMixMinusForGuest(targetUUID); } else if (sourceType === true) { // Legacy: Toggle director audio device (backwards compatibility) toggleDirectorDeviceInMix(sourceId, targetUUID); } else { // Toggle guest audio source (sourceType === false or undefined) toggleSourceInMixForGuest(sourceId, targetUUID); } } function listAudioSettingsPrep() { try { var tracks = session.streamSrc.getAudioTracks(); if (!tracks.length) { warnlog("session.streamSrc contains no audio tracks"); //return; } } catch (e) { warnlog(e); return; } var data = []; for (var i = 0; i < tracks.length; i += 1) { track0 = tracks[i]; var trackSet = {}; if (track0.getCapabilities) { trackSet.audioConstraints = track0.getCapabilities(); } else if (Firefox) { // let's pretend like Firefox doesn't actually suck trackSet.audioConstraints = { autoGainControl: [true, false], // "channelCount": { // "max": 2, // "min": 1 // }, // "deviceId": "default", echoCancellation: [true, false], // "groupId": "a3cbdec54a9b6ed473fd950415626f7e76f9d1b90f8c768faab572175a355a17", // "latency": { // "max": 0.01, // "min": 0.01 // }, noiseSuppression: [true, false] // "sampleRate": { // "max": 48000, // "min": 48000 // }, // "sampleSize": { // "max": 16, // "min": 16 /// } }; } if (track0.getSettings) { trackSet.currentAudioConstraints = track0.getSettings(); if (!session.stereo) { try { delete trackSet.currentAudioConstraints.channelCount; delete trackSet.audioConstraints.channelCount; } catch (e) { } } else if (session.audioInputChannels && session.audioInputChannels == 1) { // this is pretty hacky, but it gets around not being able to actually set 1-channel. Not sure why. trackSet.currentAudioConstraints.channelCount = 1; } } trackSet.trackLabel = "unknown or none"; if (track0.label) { trackSet.trackLabel = track0.label; } if (track0.id) { trackSet.deviceId = track0.id; } if (i == 0) { trackSet.equalizer = session.equalizer; // only supporting the first track at the moment. for (var waid in session.webAudios) { // TODO: EXCLUDE CURRENT TRACK IF ALREADY EXISTS ... if (track.id === wa.id){.. try { trackSet.lowEQ = session.webAudios[waid].lowEQ.gain.value; trackSet.midEQ = session.webAudios[waid].midEQ.gain.value; trackSet.highEQ = session.webAudios[waid].highEQ.gain.value; } catch (e) { } break; } } else { trackSet.equalizer = false; } if (i == 0) { trackSet.lowcut = session.lowcut; // only supporting the first track at the moment. if (session.lowcut) { for (var waid in session.webAudios) { // TODO: EXCLUDE CURRENT TRACK IF ALREADY EXISTS ... if (track.id === wa.id){.. try { trackSet.lowcut = session.webAudios[waid].lowcut1.frequency.value; } catch (e) { } break; } } } else { trackSet.lowcut = false; } trackSet.subGain = false; for (var waid in session.webAudios) { // TODO: EXCLUDE CURRENT TRACK IF ALREADY EXISTS ... if (track.id === wa.id){.. try { if (session.webAudios[waid].subGainNodes && track0.id in session.webAudios[waid].subGainNodes) { trackSet.subGain = session.webAudios[waid].subGainNodes[track0.id].gain.value; } break; } catch (e) { } } if (i == 0 && !session.disableWebAudio) { // if web audio is disabled, don't show them trackSet.gating = session.noisegate; trackSet.compressor = session.compressor; trackSet.micDelay = session.micDelay; trackSet.micPanning = session.micPanning !== false ? session.micPanning : false; } data.push(trackSet); } pokeIframeAPI("listing-audio-settings", data); return data; } function listVideoSettingsPrep() { try { var track0 = session.streamSrc.getVideoTracks(); if (track0.length) { track0 = track0[0]; if (track0.getCapabilities) { session.cameraConstraints = track0.getCapabilities(); } log(session.cameraConstraints); } } catch (e) { warnlog(e); return; } try { if (track0.getSettings) { session.currentCameraConstraints = track0.getSettings(); if (screen && screen.orientation && screen.orientation.type) { if (screen.orientation.type.includes("portrait")) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } else if (window.matchMedia("(orientation: portrait)").matches) { // legacy if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } } catch (e) { warnlog(e); return; } var msg = {}; msg.trackLabel = "unknown or none"; if (track0.label) { msg.trackLabel = track0.label; } msg.currentCameraConstraints = session.currentCameraConstraints; msg.cameraConstraints = session.cameraConstraints; pokeIframeAPI("listing-video-settings", msg); return msg; } var Final_transcript = ""; var Interim_transcript = ""; var Recognition = null; if ("webkitSpeechRecognition" in window) { var SpeechRecognition = webkitSpeechRecognition; } else if ("SpeechRecognition" in window) { var SpeechRecognition = window.SpeechRecognition; } else { var SpeechRecognition = false; } var TranscriptionCounter = 0; var retriesRecognition = 0; var activeRecognition = false; var timeoutRecognition = null; function setupClosedCaptions() { if (activeRecognition) { return; } activeRecognition = true; log("CLOSED CAPTIONING SETUP"); if (SpeechRecognition) { Recognition = new SpeechRecognition(); Recognition.lang = session.transcript; Recognition.continuous = true; Recognition.interimResults = true; Recognition.maxAlternatives = 0; Recognition.onstart = function () { log("started transcription: " + Date.now()); clearTimeout(timeoutRecognition); timeoutRecognition = setTimeout(function () { retriesRecognition = 0; }, 10000); }; Recognition.onerror = function (event) { if (retriesRecognition <= 3) { console.error(event); } errorlog(event); }; Recognition.onend = function (e) { warnlog(e); log("Stopped transcription " + Date.now()); clearTimeout(timeoutRecognition); timeoutRecognition = setTimeout(function () { Recognition.start(); }, parseInt(500 * retriesRecognition * retriesRecognition)); // restart it if it fails. retriesRecognition += 1; if (retriesRecognition == 3) { console.error("Captioning service is having a problem connecting"); } }; Recognition.onresult = function (event) { Interim_transcript = ""; if (typeof event.results == "undefined") { log(event); return; } for (var i = event.resultIndex; i < event.results.length; ++i) { if (event.results[i].isFinal) { Final_transcript += event.results[i][0].transcript; } else { Interim_transcript += event.results[i][0].transcript; } } if (Final_transcript.length > 0) { log("FINAL:" + Final_transcript); try { var data = {}; data.isFinal = true; data.transcript = Final_transcript; data.counter = TranscriptionCounter; session.sendMessage(data); TranscriptionCounter += 1; Final_transcript = ""; Interim_transcript = ""; pokeIframeAPI("transcription-text", Final_transcript); } catch (e) { errorlog(e); } } else { try { var data = {}; data.isFinal = false; data.transcript = Interim_transcript; data.counter = TranscriptionCounter; session.sendMessage(data); } catch (e) { errorlog(e); Interim_transcript = ""; } } }; Recognition.start(); } else if (!session.cleanOutput) { warnUser(getTranslation("speech-not-suppoted"), false, false); } } async function requestGoogleDriveRecord(ele, state = null, bitrate = null, event = null) { var UUID = ele.dataset.UUID || null; // Handle CTRL+click for selection if (event && (event.ctrlKey || event.metaKey)) { ele.classList.toggle("armed"); ele.ariaPressed = ele.classList.contains("armed") ? "true" : "false"; // Add callback only once for all armed buttons if (document.querySelectorAll('[data-action-type="recorder-google-drive-remote"].armed').length === 1 && ele.classList.contains("armed")) { Callbacks.push([multiGdriveRecord]); } return; } // Single button normal operation if (!state && ele.classList.contains("pressed")) { var msg = {}; msg.requestVideoRecord = false; msg.googleDriveRecord = false; msg.UUID = UUID; session.sendRequest(msg, msg.UUID); ele.classList.remove("pressed"); ele.ariaPressed = "false"; } else if (state == null || state) { if (!(session.gdrive && session.gdrive.accessToken)) { session.gdrive = setupGoogleDriveUploader(); if (session.gdrive.promise) { log("AWAITING PROMISE"); try { // Make sure we're initialized before requesting a token await session.gdrive.ensureInitialized(); session.gdrive.requestAccessToken(); await session.gdrive.promise; console.log("Promise resolved with token"); } catch (e) { console.error("Error getting token:", e); ele.classList.remove("armed"); return; } } } var filename = UUID; if (session.rpcs[UUID]) { filename = session.rpcs[UUID].label || session.rpcs[UUID].streamID || UUID; } filename = filename.replace(/[\W]+/g, "_"); filename = filename.substring(0, 55); filename += "_" + Date.now().toString(); if (SafariVersion) { filename += ".mp4"; } else { filename += ".webm"; } log("PROMISE DONE"); var uploadLink = await session.gdrive.startResumableUpload(filename); var msg = {}; msg.requestVideoRecord = true; msg.googleDriveRecord = uploadLink; msg.UUID = UUID; if (bitrate === null) { window.focus(); let response = await promptRecordingOptions(getTranslation("what-bitrate-gdrive")); if (response) { msg.value = response.bitrate; msg.recordConfig = response; session.sendRequest(msg, msg.UUID); ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.classList.remove("armed"); } else { ele.classList.remove("armed"); return; } } else { msg.value = bitrate; session.sendRequest(msg, msg.UUID); ele.classList.add("pressed"); ele.ariaPressed = "true"; ele.classList.remove("armed"); } pokeIframeAPI("request-video-record", msg.requestVideoRecord, UUID); } } async function multiGdriveRecord() { const armedButtons = document.querySelectorAll('[data-action-type="recorder-google-drive-remote"].armed'); if (!armedButtons.length) return; armedButtons.forEach(button => { button.classList.remove("armed"); button.ariaPressed = "false"; }); // Get recording settings once for all buttons window.focus(); let response = await promptRecordingOptions(getTranslation("what-bitrate-gdrive")); if (!response) { return; } // Set up Google Drive authentication once if (!(session.gdrive && session.gdrive.accessToken)) { session.gdrive = setupGoogleDriveUploader(); if (session.gdrive.promise) { try { if (typeof session.gdrive.ensureInitialized === "function") { await session.gdrive.ensureInitialized(); } if (typeof session.gdrive.requestAccessToken === "function") { session.gdrive.requestAccessToken(); } await session.gdrive.promise; } catch (e) { // Auth failed, clean up armed buttons armedButtons.forEach(button => { button.classList.remove("armed"); button.ariaPressed = "false"; }); return; } } } // Process each armed button with the same settings for (const button of armedButtons) { const UUID = button.dataset.UUID || null; // Generate unique filename for each recording const filename = ((session.rpcs[UUID] && (session.rpcs[UUID].label || session.rpcs[UUID].streamID)) || UUID) .replace(/[\W]+/g, "_") .substring(0, 55) + "_" + Date.now().toString() + (SafariVersion ? ".mp4" : ".webm"); // Get upload link for each recording const uploadLink = await session.gdrive.startResumableUpload(filename); // Create message with shared settings const msg = { requestVideoRecord: true, googleDriveRecord: uploadLink, UUID: UUID, value: response.bitrate, recordConfig: response }; // Send request and update button state session.sendRequest(msg, msg.UUID); button.classList.add("pressed"); button.classList.remove("armed"); button.ariaPressed = "true"; pokeIframeAPI("request-video-record", true, UUID); } } async function requestVideoRecord(ele, state = null, bitrate = null) { var UUID = ele.dataset.UUID || null; if (!state && ele.classList.contains("pressed")) { var msg = {}; msg.requestVideoRecord = false; msg.UUID = UUID; session.sendRequest(msg, msg.UUID); ele.classList.remove("pressed"); ele.ariaPressed = "false"; } else if (state == null || state) { var msg = {}; msg.requestVideoRecord = true; msg.UUID = UUID; if (bitrate === null) { window.focus(); let response = await promptRecordingOptions(getTranslation("what-bitrate")); if (response) { msg.value = response.bitrate; msg.recordConfig = response; session.sendRequest(msg, msg.UUID); ele.classList.add("pressed"); ele.ariaPressed = "true"; // "btn-HL-green" } else { return; } } else { msg.value = bitrate; session.sendRequest(msg, msg.UUID); ele.classList.add("pressed"); ele.ariaPressed = "true"; } } pokeIframeAPI("request-video-record", msg.requestVideoRecord, UUID); } function changeOrderDirector(value) { if (session.order == false) { session.order = 0; } session.order += parseInt(value) || 0; var elements = document.querySelectorAll('[data-action-type="order-value-director"]'); //log(elements); if (elements[0]) { elements[0].innerText = parseInt(session.order) || 0; } var data = {}; data = {}; data.order = session.order; session.sendPeers(data); pokeIframeAPI("director-order", data.order); } function changeOrder(value, UUID) { var msg = {}; msg.changeOrder = value; msg.UUID = UUID; session.sendRequest(msg, msg.UUID); pokeIframeAPI("change-order", value, UUID); } function requestVideoHack(keyname, value, UUID, ctrl = false) { var msg = {}; msg.requestVideoHack = true; msg.keyname = keyname; msg.value = value; msg.UUID = UUID; msg.ctrl = ctrl; session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-video-setting", { value: value, keyname: keyname, ctrl: ctrl }, UUID); } function requestAudioHack(keyname, value, UUID, deviceId = "default") { var msg = {}; msg.requestAudioHack = true; msg.keyname = keyname; msg.value = value; msg.UUID = UUID; msg.deviceId = deviceId; session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-audio-setting", { value: value, keyname: keyname, deviceId: deviceId }, UUID); } function requestChangeEQ(keyname, value, UUID, track = 0) { var msg = {}; msg.requestChangeEQ = true; msg.keyname = keyname; msg.value = value; msg.UUID = UUID; msg.track = track; // pointless atm session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-change-eq", { value: value, keyname: keyname, track: track }, UUID); } function requestChangeGating(keyname, value, UUID, track = 0) { var msg = {}; msg.requestChangeGating = true; msg.keyname = keyname; msg.value = value; msg.UUID = UUID; msg.track = track; // pointless atm session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-change-gating", { value: value, keyname: keyname, track: track }, UUID); } function requestChangeCompressor(keyname, value, UUID, track = 0) { var msg = {}; msg.requestChangeCompressor = true; msg.keyname = keyname; msg.value = value; msg.UUID = UUID; msg.track = track; // pointless atm session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-change-compressor", { value: value, keyname: keyname, track: track }, UUID); } function requestChangeMicDelay(value, UUID, track = 0) { var msg = {}; msg.requestChangeMicDelay = true; msg.value = value; msg.UUID = UUID; msg.track = track; // pointless atm session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-change-mic-delay", { value: value, track: track }, UUID); } function requestChangeSubGain(value, UUID, deviceId) { var msg = {}; msg.requestChangeSubGain = true; msg.value = value; msg.UUID = UUID; msg.deviceId = deviceId; // pointless atm log(msg); session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-sub-gain", { value: value, deviceId: deviceId }, UUID); } function requestChangeLowcut(value, UUID, track = 0) { var msg = {}; msg.requestChangeLowcut = true; msg.value = value; msg.UUID = UUID; msg.track = track; // pointless atm session.sendRequest(msg, msg.UUID); pokeIframeAPI("request-low-cut", value, UUID); } function toggleSystemPip(vid, autoRetry = false) { if (!vid) { return Promise.resolve(false); } try { if (vid.webkitSupportsPresentationMode && typeof vid.webkitSetPresentationMode === "function") { vid.webkitSetPresentationMode(vid.webkitPresentationMode === "picture-in-picture" ? "inline" : "picture-in-picture"); clearAutoPiPPrompt(); return Promise.resolve(true); } else if (!document.pictureInPictureEnabled) { return Promise.resolve(false); } var pipPromise = null; if (document.pictureInPictureElement) { if (document.pictureInPictureElement === vid) { pipPromise = document.exitPictureInPicture(); } else { pipPromise = document.exitPictureInPicture().catch(errorlog).then(() => vid.requestPictureInPicture()); } } else { pipPromise = vid.requestPictureInPicture(); } if (!pipPromise || typeof pipPromise.then !== "function") { clearAutoPiPPrompt(); return Promise.resolve(true); } return pipPromise .then(() => { clearAutoPiPPrompt(); return true; }) .catch(err => { if (autoRetry && err && (err.name === "NotAllowedError" || err.name === "InvalidStateError")) { showAutoPiPPrompt(vid); } errorlog(err); return false; }); } catch (e) { if (autoRetry && e && (e.name === "NotAllowedError" || e.name === "InvalidStateError")) { showAutoPiPPrompt(vid); } errorlog(e); return Promise.resolve(false); } } function showAutoPiPPrompt(vid) { if (!vid) { return; } session.autoPiPPromptVideo = vid; if (session.autoPiPPrompt) { return; } var prompt = document.createElement("div"); prompt.id = "pipPrompt"; prompt.style.position = "fixed"; prompt.style.bottom = "16px"; prompt.style.right = "16px"; prompt.style.zIndex = "12000"; prompt.style.background = "rgba(20,20,24,0.95)"; prompt.style.border = "1px solid rgba(255,255,255,0.2)"; prompt.style.borderRadius = "8px"; prompt.style.padding = "12px"; prompt.style.maxWidth = "280px"; prompt.style.display = "flex"; prompt.style.flexDirection = "column"; prompt.style.gap = "8px"; prompt.style.boxShadow = "0 4px 12px rgba(0,0,0,0.35)"; var message = document.createElement("div"); message.innerText = "Click to enable picture-in-picture"; message.style.fontSize = "14px"; message.style.lineHeight = "18px"; var controls = document.createElement("div"); controls.style.display = "flex"; controls.style.justifyContent = "space-between"; controls.style.gap = "8px"; var confirmBtn = document.createElement("button"); confirmBtn.type = "button"; confirmBtn.innerText = "Open PiP"; confirmBtn.style.flex = "1"; confirmBtn.style.padding = "6px 10px"; confirmBtn.style.borderRadius = "6px"; confirmBtn.style.border = "1px solid rgba(255,255,255,0.2)"; confirmBtn.style.background = "var(--accent-color, #3a7afe)"; confirmBtn.style.color = "#fff"; confirmBtn.style.cursor = "pointer"; var dismissBtn = document.createElement("button"); dismissBtn.type = "button"; dismissBtn.innerText = "Not now"; dismissBtn.style.flex = "1"; dismissBtn.style.padding = "6px 10px"; dismissBtn.style.borderRadius = "6px"; dismissBtn.style.border = "1px solid rgba(255,255,255,0.2)"; dismissBtn.style.background = "rgba(255,255,255,0.08)"; dismissBtn.style.color = "#fff"; dismissBtn.style.cursor = "pointer"; confirmBtn.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); var target = session.autoPiPPromptVideo; clearAutoPiPPrompt(); if (target) { toggleSystemPip(target); } }); dismissBtn.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); clearAutoPiPPrompt(); }); controls.appendChild(confirmBtn); controls.appendChild(dismissBtn); prompt.appendChild(message); prompt.appendChild(controls); document.body.appendChild(prompt); session.autoPiPPrompt = prompt; } function clearAutoPiPPrompt() { if (session.autoPiPPrompt) { try { session.autoPiPPrompt.remove(); } catch (e) { } session.autoPiPPrompt = false; } session.autoPiPPromptVideo = false; } function updateDirectorsAudio(dataN, UUID) { var audioEle = document.createElement("div"); query("#container_" + UUID + " .advancedAudioSettings").innerHTML = ""; if (query('[data-action-type="advanced-audio-settings"][data--u-u-i-d="' + UUID + '"]').classList.contains("pressed")) { query("#container_" + UUID + " .advancedAudioSettings").classList.remove("hidden"); } //query('[data-action-type="advanced-audio-settings"][data--u-u-i-d="' + UUID + '"]').classList.add("pressed"); //query('[data-action-type="advanced-audio-settings"][data--u-u-i-d="' + UUID + '"]').ariaPressed = "true"; //log(dataN); if (!dataN.length) { var label = document.createElement("label"); label.innerText = "No microphone selected"; label.style.display = "block"; label.id = "remoteAudioLabel_" + UUID; label.dataset.nomic = true; label.classList.add("settingsLabel"); label.dataset.UUID = UUID; audioEle.appendChild(label); query('[data-action-type="refresh-mic"][data--u-u-i-d="' + UUID + '"]').disabled = true; query("#container_" + UUID + " .advancedAudioSettings").appendChild(audioEle); return; } query('[data-action-type="refresh-mic"][data--u-u-i-d="' + UUID + '"]').disabled = false; for (var n = 0; n < dataN.length; n += 1) { var data = dataN[n]; if (dataN.length == 1) { if (data.trackLabel) { var label = document.createElement("label"); label.innerText = data.trackLabel; label.style.display = "block"; label.id = "remoteAudioLabel_" + UUID; label.classList.add("settingsLabel"); label.dataset.UUID = UUID; audioEle.appendChild(label); } } //if (n !== 0) { //var label = document.createElement("span"); //label.innerText = "Coming Soon"; //audioEle.appendChild(label); // continue; // remove to more than one audio device (assuming other fixes are applied) //} if ("micDelay" in data && n == 0) { var label = document.createElement("label"); var i = "micDelay"; var div = document.createElement("div"); label.id = "label_" + i + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + UUID; var input = document.createElement("input"); input.min = 0; input.max = 500; input.value = data.micDelay || 0; input.title = "Previously was: " + input.value; input.type = "range"; input.dataset.keyname = i; //input.dataset.labelname = "mic delay (ms):"; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + " (ms):"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.value = data.micDelay || 0; manualInput.className = "manualInput"; manualInput.id = "constraints_manual_" + i + "_" + UUID; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; input.dataset.track = n; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + UUID; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.style.margin = "2px 0px 5px"; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeMicDelay(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.onchange = function (e) { //e.target.title = e.target.value; getById("constraints_manual_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeMicDelay(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.oninput = function (e) { getById("constraints_manual_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); requestChangeMicDelay(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); } }; audioEle.appendChild(div); div.appendChild(label); div.appendChild(manualInput); audioEle.appendChild(input); } if (data.micPanning !== false && n == 0) { // Director-side control: Mic Panning (0..180, 90=center) var label = document.createElement("label"); var i = "micPanning"; var div = document.createElement("div"); label.id = "label_" + i + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + UUID; label.innerText = "Mic Pan:"; var input = document.createElement("input"); input.min = 0; input.max = 180; input.value = data.micPanning || 90; input.title = "0=L, 90=C, 180=R"; input.type = "range"; input.dataset.keyname = i; input.dataset.track = n; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + UUID; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.style.margin = "2px 0px 5px"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.value = data.micPanning || 90; manualInput.className = "manualInput"; manualInput.id = "constraints_manual_" + i + "_" + UUID; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeMicPanning(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.onchange = function (e) { getById("constraints_manual_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeMicPanning(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.oninput = function (e) { getById("constraints_manual_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); requestChangeMicPanning(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); } }; audioEle.appendChild(div); div.appendChild(label); div.appendChild(manualInput); audioEle.appendChild(input); } if (data.lowcut !== false && n == 0) { var label = document.createElement("label"); var i = "lowCut"; label.id = "label_" + i + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + UUID; var input = document.createElement("input"); input.min = 50; input.max = 150; input.value = data.lowcut; input.title = "Previously was: " + input.value; input.type = "range"; input.dataset.keyname = i; //input.dataset.labelname = "low cut:"; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.value = data.lowcut; manualInput.className = "manualInput"; manualInput.id = "constraints_manual_" + i + "_" + UUID; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; input.dataset.track = n; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + UUID; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.style.margin = "2px 0px 5px"; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeLowcut(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.onchange = function (e) { //e.target.title = e.target.value; getById("constraints_manual_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeLowcut(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.oninput = function (e) { getById("constraints_manual_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); requestChangeLowcut(parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); } }; audioEle.appendChild(label); audioEle.appendChild(manualInput); audioEle.appendChild(input); } if (data.equalizer && n == 0) { var label = document.createElement("label"); var i = "Low_EQ"; //label.id = "label_" + i + "_"+UUID; label.htmlFor = "constraints_" + i + "_" + UUID; var input = document.createElement("input"); input.min = -50; input.max = 50; input.value = data.lowEQ; input.title = "Previously was: " + input.value; input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = "low EQ:"; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.value = data.lowEQ; manualInput.className = "manualInput"; manualInput.id = "label_" + i + "_" + UUID; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; input.dataset.track = n; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + UUID; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.style.margin = "2px 0px 5px"; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeEQ("low", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeEQ("low", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); requestChangeEQ("low", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); } }; audioEle.appendChild(label); audioEle.appendChild(manualInput); audioEle.appendChild(input); var label = document.createElement("label"); var i = "midEQ"; //label.id = "label_" + i + "_"+UUID; label.htmlFor = "constraints_" + i + "_" + UUID; var input = document.createElement("input"); input.min = -50; input.max = 50; input.value = data.midEQ; input.title = "Previously was: " + input.value; input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = "mid EQ:"; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.value = data.midEQ; manualInput.className = "manualInput"; manualInput.id = "label_" + i + "_" + UUID; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; input.dataset.track = n; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + UUID; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.style.margin = "2px 0px 5px"; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeEQ("mid", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeEQ("mid", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); requestChangeEQ("mid", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); } }; audioEle.appendChild(label); audioEle.appendChild(manualInput); audioEle.appendChild(input); var label = document.createElement("label"); var i = "highEQ"; //label.id = "label_" + i + "_"+UUID; label.htmlFor = "constraints_" + i + "_" + UUID; var input = document.createElement("input"); input.min = -50; input.max = 50; input.value = data.highEQ; input.title = "Previously was: " + input.value; input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = "high EQ:"; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.value = data.highEQ; manualInput.className = "manualInput"; manualInput.id = "label_" + i + "_" + UUID; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; input.dataset.track = n; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + UUID; input.classList.add("inputConstraint"); input.name = "constraints_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeEQ("high", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeEQ("high", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); requestChangeEQ("high", parseInt(e.target.value), e.target.dataset.UUID, parseInt(e.target.dataset.track)); } }; audioEle.appendChild(label); audioEle.appendChild(manualInput); audioEle.appendChild(input); } if ("gating" in data && n == 0) { // only show once. var label = document.createElement("label"); var i = "noiseGate"; var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + n + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + n + "_" + UUID; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block; padding:0;"; label.dataset.keyname = i; label.dataset.track = n; var input = document.createElement("select"); var c = document.createElement("option"); var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); if (data.gating) { opt.selected = "true"; } input.dataset.deviceId = data.deviceId; input.id = "constraints_" + i + "_" + n + "_" + UUID; input.className = "constraintCameraInput"; input.name = "constraints_" + i + "_" + n; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.track = n; input.dataset.UUID = UUID; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname).innerText =e.target.dataset.keyname+": "+e.target.value; requestChangeGating("gating", e.target.value, e.target.dataset.UUID, parseInt(e.target.dataset.track)); log(e.target.dataset.keyname, e.target.value); }; audioEle.appendChild(div); div.appendChild(label); div.appendChild(input); } if ("compressor" in data && n == 0) { var label = document.createElement("label"); var i = "compressor"; var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + n + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + n + "_" + UUID; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block; padding:0;"; label.dataset.keyname = i; label.dataset.track = n; var input = document.createElement("select"); var c = document.createElement("option"); var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", 1); input.options.add(opt); if (data.compressor == 1) { opt.selected = "true"; } opt = new Option("Limiter", 2); input.options.add(opt); if (data.compressor == 2) { opt.selected = "true"; } input.dataset.deviceId = data.deviceId; input.id = "constraints_" + i + "_" + n + "_" + UUID; input.className = "constraintCameraInput"; input.name = "constraints_" + i + "_" + n; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.track = n; input.dataset.UUID = UUID; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname).innerText =e.target.dataset.keyname+": "+e.target.value; requestChangeCompressor("compressor", e.target.value, e.target.dataset.UUID, parseInt(e.target.dataset.track)); log(e.target.dataset.keyname, e.target.value); }; audioEle.appendChild(div); div.appendChild(label); div.appendChild(input); } if (dataN.length > 1) { if (data.trackLabel) { var label = document.createElement("label"); label.innerText = data.trackLabel; label.style.display = "block"; label.id = "remoteAudioLabel_" + UUID + "_" + n + "_" + UUID; label.classList.add("settingsLabel"); audioEle.appendChild(label); } } for (var i in data.audioConstraints) { try { log(i); log(data.audioConstraints[i]); if (typeof data.audioConstraints[i] === "object" && data.audioConstraints[i] !== null && "max" in data.audioConstraints[i] && "min" in data.audioConstraints[i]) { if (i === "aspectRatio") { continue; } else if (i === "width") { continue; } else if (i === "height") { continue; } else if (i === "frameRate") { continue; } else if (i === "latency") { // continue; } else if (i === "sampleRate") { continue; } else if (i === "channelCount") { // continue; } else if (i === "volume") { continue; } if (!("deviceId" in data.audioConstraints)) { continue; } // not going to support older versions. var label = document.createElement("label"); //label.id = "label_" + i + "_"+n+ "_"+UUID; label.htmlFor = "constraints_" + i + "_" + n + "_" + UUID; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var input = document.createElement("input"); input.min = data.audioConstraints[i].min; input.max = data.audioConstraints[i].max; if (parseFloat(input.min) == parseFloat(input.max)) { continue; } var manualInput = document.createElement("input"); manualInput.type = "number"; if ("step" in data.audioConstraints[i]) { input.step = data.audioConstraints[i].step; manualInput.step = data.audioConstraints[i].step; } else if ("volume" == i) { input.step = 0.01; manualInput.step = 0.01; } manualInput.dataset.keyname = i; manualInput.className = "manualInput"; manualInput.id = "label_" + i + "_" + n + "_" + UUID; manualInput.max = data.audioConstraints[i].max; manualInput.min = data.audioConstraints[i].min; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; manualInput.dataset.keyname = i; if (i in data.currentAudioConstraints) { input.value = data.currentAudioConstraints[i]; manualInput.value = parseFloat(input.value); //label.innerText = i + ": " + data.currentAudioConstraints[i]; label.title = "Previously was: " + data.currentAudioConstraints[i]; input.title = "Previously was: " + data.currentAudioConstraints[i]; } else { label.innerText = i; } if (i === "height" || i === "width") { input.title = "Hold CTRL (or cmd) to lock width and height together when changing them"; input.min = 16; } input.type = "range"; input.dataset.keyname = i; input.dataset.track = n; input.dataset.deviceId = data.deviceId; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + n + "_" + UUID; input.style = "display:block; width:100%;"; input.name = "constraints_" + i + "_" + n + "_" + UUID; if (i == "channelCount") { input.style.display = "none"; manualInput.style.margin = "5px 0px 9px 10px"; } manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.track + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, e.target.dataset.deviceId); }; input.onchange = function (e) { //e.target.title = e.target.value; getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.track + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, e.target.dataset.deviceId); }; audioEle.appendChild(label); audioEle.appendChild(manualInput); audioEle.appendChild(input); } else if (typeof data.audioConstraints[i] === "object" && data.audioConstraints[i] !== null) { if (i == "resizeMode") { continue; } var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + n + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + n + "_" + UUID; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block; padding:0;"; var input = document.createElement("select"); var c = document.createElement("option"); if (data.audioConstraints[i].length > 1) { for (var opts in data.audioConstraints[i]) { log(opts); if (data.audioConstraints[i][opts] === false) { var opt = new Option("Off", data.audioConstraints[i][opts]); } else if (data.audioConstraints[i][opts] === true) { var opt = new Option("On", data.audioConstraints[i][opts]); } else { var opt = new Option(data.audioConstraints[i][opts], data.audioConstraints[i][opts]); } input.options.add(opt); if (i in data.currentAudioConstraints) { if (data.audioConstraints[i][opts] == data.currentAudioConstraints[i]) { opt.selected = "true"; } } } } else if (i.toLowerCase() == "torch") { var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); try { if (i in data.currentAudioConstraints) { if (data.audioConstraints[i]["torch"] == true) { opt.selected = "true"; } } } catch (e) { } } else { continue; } input.id = "constraints_" + i + "_" + n + "_" + UUID; input.className = "constraintCameraInput"; input.name = input.id; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.track = n; input.dataset.deviceId = data.deviceId; input.dataset.UUID = UUID; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname).innerText =e.target.dataset.keyname+": "+e.target.value; requestAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, e.target.dataset.deviceId); log(e.target.dataset.keyname, e.target.value); }; audioEle.appendChild(div); div.appendChild(label); div.appendChild(input); } else if (typeof data.audioConstraints[i] === "boolean") { var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + n + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + n + "_" + UUID; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block; padding:0;"; label.dataset.keyname = i; label.dataset.track = n; var input = document.createElement("select"); var c = document.createElement("option"); var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); try { if (data.audioConstraints[i] === true) { opt.selected = "true"; } } catch (e) { } input.dataset.deviceId = data.deviceId; input.id = "constraints_" + i + "_" + n + "_" + UUID; input.className = "constraintCameraInput"; input.name = input.id; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.track = n; input.dataset.UUID = UUID; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname).innerText =e.target.dataset.keyname+": "+e.target.value; requestAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, e.target.dataset.deviceId); log(e.target.dataset.keyname, e.target.value); }; audioEle.appendChild(div); div.appendChild(label); div.appendChild(input); } } catch (e) { errorlog(e); } } if (data.subGain !== false) { var label = document.createElement("label"); var i = "Gain"; var div = document.createElement("div"); label.id = "label_" + i + "_" + n + "_" + UUID; label.htmlFor = "constraints_" + i + "_" + n + "_" + UUID; var input = document.createElement("input"); input.min = 0; input.max = 200; input.value = data.subGain * 100; input.title = "Previously was: " + parseInt(input.value); input.type = "range"; input.dataset.keyname = i; input.dataset.track = n; input.dataset.labelname = "Gain:"; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.value = data.subGain * 100; manualInput.className = "manualInput"; manualInput.id = "label_" + i + "_" + n + "_" + UUID; manualInput.dataset.UUID = UUID; manualInput.dataset.track = n; input.dataset.track = data.deviceId; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + n + "_" + UUID; input.style = "display:block; width:100%;"; input.name = input.id; input.style.margin = "2px 0px 5px"; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.track + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeSubGain(parseInt(e.target.value), e.target.dataset.UUID, e.target.dataset.track); }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.track + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestChangeSubGain(parseInt(e.target.value), e.target.dataset.UUID, e.target.dataset.track); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.track + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); requestChangeSubGain(parseInt(e.target.value), e.target.dataset.UUID, e.target.dataset.track); } }; audioEle.appendChild(div); div.appendChild(label); div.appendChild(manualInput); audioEle.appendChild(input); } query("#container_" + UUID + " .advancedAudioSettings").appendChild(audioEle); } if (fixScrollReset) { clearTimeout(fixScrollReset); fixScrollReset = null; getById("directorlayout").scrollTop = fixScrollResetValue; } } var remoteSliderTimeout = 0; function updateDirectorsVideo(data, UUID) { var videoEle = document.createElement("div"); if (data.trackLabel) { var label = document.createElement("label"); label.innerText = data.trackLabel; label.style.display = "block"; label.id = "remoteVideoLabel_" + UUID; label.dataset.UUID = UUID; label.classList.add("settingsLabel"); videoEle.appendChild(label); } for (var i in data.cameraConstraints) { try { log(i); log(data.cameraConstraints[i]); if (i === "focusMode") { continue; // I'll handle this with FocusDistance instead } else if (i === "whiteBalanceMode") { continue; // I'll handle this elsewhere } else if (i === "exposureMode") { continue; // I'll handle this elsewhere } if (typeof data.cameraConstraints[i] === "object" && data.cameraConstraints[i] !== null && "max" in data.cameraConstraints[i] && "min" in data.cameraConstraints[i]) { if (i === "aspectRatio") { // continue; } else if (i === "width") { // continue; } else if (i === "height") { // continue; } else if (i === "frameRate") { // continue; } else if (i === "latency") { // continue; } else if (i === "sampleRate") { continue; } else if (i === "channelCount") { // continue; } var manualMode = false; var manualLabel = false; if (i === "exposureTime") { if (data.currentCameraConstraints["exposureMode"]) { manualMode = document.createElement("input"); manualMode.type = "checkbox"; manualMode.id = "manual_" + i + "_" + UUID; manualMode.dataset.UUID = UUID; manualMode.dataset.keyname = "exposureMode"; manualMode.onchange = function (e) { var value = "manual"; if (e.target.checked) { value = "continuous"; } requestVideoHack(e.target.dataset.keyname, value, e.target.dataset.UUID, true); //getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); //getById("label_" + e.target.dataset.keyname+ "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); }; manualLabel = document.createElement("label"); manualLabel.htmlFor = manualMode.id; manualLabel.innerHTML = "Auto: "; manualLabel.style.marginLeft = "20px"; if (data.currentCameraConstraints["exposureMode"] == "continuous") { manualMode.checked = true; } } } else if (i === "focusDistance") { if (data.currentCameraConstraints["focusMode"]) { manualMode = document.createElement("input"); manualMode.type = "checkbox"; manualMode.id = "manual_" + i + "_" + UUID; manualMode.dataset.UUID = UUID; manualMode.dataset.keyname = "focusMode"; manualMode.onchange = function (e) { var value = "manual"; if (e.target.checked) { value = "continuous"; } requestVideoHack(e.target.dataset.keyname, value, e.target.dataset.UUID, true); }; manualLabel = document.createElement("label"); manualLabel.htmlFor = manualMode.id; manualLabel.innerHTML = "Auto: "; manualLabel.style.marginLeft = "20px"; if (data.currentCameraConstraints["focusMode"] == "continuous") { manualMode.checked = true; } } } else if (i === "colorTemperature") { if (data.currentCameraConstraints["whiteBalanceMode"]) { manualMode = document.createElement("input"); manualMode.type = "checkbox"; manualMode.id = "manual_" + i + "_" + UUID; manualMode.dataset.UUID = UUID; manualMode.dataset.keyname = "whiteBalanceMode"; manualMode.onchange = function (e) { var value = "manual"; if (e.target.checked) { value = "continuous"; } requestVideoHack(e.target.dataset.keyname, value, e.target.dataset.UUID, true); }; manualLabel = document.createElement("label"); manualLabel.htmlFor = manualMode.id; manualLabel.innerHTML = "Auto: "; manualLabel.style.marginLeft = "20px"; if (data.currentCameraConstraints["whiteBalanceMode"] == "continuous") { manualMode.checked = true; } } } var label = document.createElement("label"); //label.id = "label_" + i; label.htmlFor = "constraints_" + i + " _" + UUID; if (i === "colorTemperature") { label.innerText = "Color Temp:"; } else if (i === "exposureCompensation") { label.innerText = "Exposure Comp:"; } else if (i === "exposureTime") { label.innerText = "Exposure:"; } else if (i === "focusDistance") { label.innerText = "Focus:"; } else { label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; } if (i === "zoom" || i === "pan" || i === "til") { label.innerHTML = " " + label.innerText; } var input = document.createElement("input"); if (i === "aspectRatio") { input.max = 5; input.min = 0.2; input.step = 0.00001; } else if (i === "exposureTime") { input.min = data.cameraConstraints[i].min; input.max = Math.min(data.cameraConstraints[i].max, 2000); } else { input.min = data.cameraConstraints[i].min; input.max = data.cameraConstraints[i].max; } if (parseFloat(input.min) == parseFloat(input.max)) { continue; } if (i in data.currentCameraConstraints) { input.value = data.currentCameraConstraints[i]; label.title = "Previously was: " + data.currentCameraConstraints[i]; input.title = "Previously was: " + data.currentCameraConstraints[i]; } input.type = "range"; input.dataset.keyname = i; input.dataset.UUID = UUID; input.id = "constraints_" + i + "_" + UUID; input.name = input.id; input.classList.add("inputConstraint"); input.manualMode = manualMode; if (i === "height" || i === "width") { input.title = "Hold CTRL (or cmd) to lock width and height together when changing them"; input.min = 16; } var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.value = parseFloat(input.value); manualInput.className = "manualInput"; manualInput.id = "label_" + i + "_" + UUID; manualInput.name = manualInput.id; manualInput.dataset.keyname = i; manualInput.dataset.UUID = UUID; manualInput.manualMode = manualMode; if ("step" in data.cameraConstraints[i]) { manualInput.step = data.cameraConstraints[i].step; input.step = data.cameraConstraints[i].step; } else if (i === "aspectRatio") { input.step = 0.000001; manualInput.step = 0.005; } manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (e.target.manualMode) { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, true); } else { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, false); } }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); //updateVideoConstraints(e.target.dataset.keyname, e.target.value); if (CtrlPressed || e.target.manualMode) { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, true); } else { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, false); } }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); if (Date.now() - remoteSliderTimeout > 100) { remoteSliderTimeout = Date.now(); if (CtrlPressed) { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, true); } else { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, false); } } }; videoEle.appendChild(label); videoEle.appendChild(manualInput); if (manualMode && manualLabel) { videoEle.appendChild(manualLabel); videoEle.appendChild(manualMode); } if (i === "aspectRatio") { var preSelectButton = document.createElement("button"); preSelectButton.value = 16 / 9.0; preSelectButton.innerText = "16:9"; preSelectButton.dataset.keyname = i; preSelectButton.dataset.UUID = UUID; preSelectButton.className = "preSelectButton"; preSelectButton.onclick = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, false); }; videoEle.appendChild(preSelectButton); var preSelectButton = document.createElement("button"); preSelectButton.value = 9 / 16.0; preSelectButton.innerText = "9:16"; preSelectButton.dataset.UUID = UUID; preSelectButton.className = "preSelectButton"; preSelectButton.dataset.keyname = i; preSelectButton.onclick = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.UUID).value = parseFloat(e.target.value); requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, false); }; videoEle.appendChild(preSelectButton); } videoEle.appendChild(input); } else if (typeof data.cameraConstraints[i] === "object" && data.cameraConstraints[i] !== null) { if (i == "resizeMode") { continue; } var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + UUID; label.name = label.id; label.htmlFor = "constraints_" + i + "_" + UUID; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block; padding:0;"; label.dataset.keyname = i; label.dataset.UUID = UUID; var input = document.createElement("select"); var c = document.createElement("option"); if (data.cameraConstraints[i].length > 1) { for (var opts in data.cameraConstraints[i]) { log(opts); if (data.cameraConstraints[i][opts] === false) { var opt = new Option("Off", data.cameraConstraints[i][opts]); } else if (data.cameraConstraints[i][opts] === true) { var opt = new Option("On", data.cameraConstraints[i][opts]); } else { var opt = new Option(data.cameraConstraints[i][opts], data.cameraConstraints[i][opts]); } input.options.add(opt); if (i in data.currentCameraConstraints) { if (data.cameraConstraints[i][opts] == data.currentCameraConstraints[i]) { opt.selected = "true"; } } } } else if (i.toLowerCase() == "torch") { var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); try { if (i in data.currentCameraConstraints) { if (data.cameraConstraints[i]["torch"] == true) { opt.selected = "true"; } } } catch (e) { } } else { continue; } input.id = "constraints_" + i + "_" + UUID; input.className = "constraintCameraInput"; input.name = input.id; input.dataset.UUID = UUID; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname+ "_" + e.target.dataset.UUID).innerText =e.target.dataset.keyname+": "+e.target.value; //updateVideoConstraints(e.target.dataset.keyname, e.target.value); if (CtrlPressed) { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, true); } else { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, false); } log(e.target.dataset.keyname, e.target.value); }; videoEle.appendChild(div); div.appendChild(label); div.appendChild(input); } else if (typeof data.cameraConstraints[i] === "boolean") { var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + UUID; label.name = label.id; label.htmlFor = "constraints_" + i + "_" + UUID; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block; padding:0;"; label.dataset.keyname = i; label.dataset.UUID = UUID; var input = document.createElement("select"); var c = document.createElement("option"); var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); try { if (data.audioConstraints[i] === true) { opt.selected = "true"; } } catch (e) { } input.id = "constraints_" + i + "_" + UUID; input.className = "constraintCameraInput"; input.name = input.id; input.style = "display:inline; padding:2px;"; input.dataset.UUID = UUID; input.dataset.keyname = i; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname+ "_" + e.target.dataset.UUID).innerText =e.target.dataset.keyname+": "+e.target.value; //updateVideoConstraints(e.target.dataset.keyname, e.target.value); if (CtrlPressed) { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, true); } else { requestVideoHack(e.target.dataset.keyname, e.target.value, e.target.dataset.UUID, false); } log(e.target.dataset.keyname, e.target.value); }; videoEle.appendChild(div); div.appendChild(label); div.appendChild(input); } } catch (e) { errorlog(e); } } query("#container_" + UUID + " .advancedVideoSettings").innerHTML = ""; query("#container_" + UUID + " .advancedVideoSettings").appendChild(videoEle); query("#container_" + UUID + " .advancedVideoSettings").classList.remove("hidden"); if (fixScrollReset) { clearTimeout(fixScrollReset); fixScrollReset = null; getById("directorlayout").scrollTop = fixScrollResetValue; } } /////// function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } function listAudioSettings() { getById("popupSelector_constraints_audio").innerHTML = ""; var tracks = session.streamSrc.getAudioTracks(); if (!tracks.length) { warnlog("session.streamSrc contains no audio tracks"); return; } for (var ii = 0; ii < tracks.length; ii++) { track0 = tracks[ii]; if (track0.getCapabilities) { session.audioConstraints = track0.getCapabilities(); } else if (Firefox) { // let's pretend like Firefox doesn't actually suck session.audioConstraints = { autoGainControl: [true, false], // "channelCount": { // "max": 2, // "min": 1 // }, // "deviceId": "default", echoCancellation: [true, false], // "groupId": "a3cbdec54a9b6ed473fd950415626f7e76f9d1b90f8c768faab572175a355a17", // "latency": { // "max": 0.01, // "min": 0.01 // }, noiseSuppression: [true, false] // "sampleRate": { // "max": 48000, // "min": 48000 // }, // "sampleSize": { // "max": 16, // "min": 16 /// } }; } try { if (track0.getSettings) { session.currentAudioConstraints = track0.getSettings(); if (!session.stereo) { try { delete session.currentAudioConstraints.channelCount; delete session.audioConstraints.channelCount; } catch (e) { } } else if (session.audioInputChannels && session.audioInputChannels == 1) { // this is pretty hacky, but it gets around not being able to actually set 1-channel. Not sure why. session.currentAudioConstraints.channelCount = 1; } } } catch (e) { errorlog(e); } ////// if (ii == 0) { for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].gainNode) { if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var div = document.createElement("div"); var label = document.createElement("label"); var i = "masterGain"; //label.id = "label_" + i; label.htmlFor = "constraints_" + i; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block;"; var input = document.createElement("input"); input.min = 0; input.max = 200; input.dataset.deviceid = track0.id; // pointless input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.value = session.webAudios[webAudio].gainNode.gain.value * 100; //label.innerHTML += " " + parseInt(session.webAudios[webAudio].gainNode.gain.value * 100); input.title = parseInt(input.value); var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.deviceid = track0.id; manualInput.dataset.labelname = label.innerHTML; manualInput.value = session.webAudios[webAudio].gainNode.gain.value * 100; manualInput.className = "manualInput"; manualInput.id = "label_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMainGain(e.target.value); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMainGain(e.target.value); e.target.title = e.target.value; }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMainGain(e.target.value); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(div); div.appendChild(label); div.appendChild(manualInput); div.appendChild(input); break; } } } if (session.micDelay !== false && ii == 0) { // ii==0 implies only track0 is supported by the web audio pipeline currently (or everything after the mixer node) if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var label = document.createElement("label"); var i = "micDelay"; label.htmlFor = "constraints_" + i; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + " (ms):"; var input = document.createElement("input"); input.min = 0; input.max = 500; input.dataset.deviceid = track0.id; // pointless, for now input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].micDelay) { // session.webAudios[waid].micDelay.delayTime.setValueAtTime input.value = session.webAudios[webAudio].micDelay.delayTime.value * 1000; label.innerHTML += " " + parseInt(session.webAudios[webAudio].micDelay.delayTime.value * 1000); input.title = input.value; break; } } var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.labelname = label.innerHTML; manualInput.value = parseFloat(input.value); manualInput.className = "manualInput"; manualInput.id = "label_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMicDelay(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMicDelay(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMicDelay(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(label); getById("popupSelector_constraints_audio").appendChild(manualInput); getById("popupSelector_constraints_audio").appendChild(input); } // Mic Panning - local settings UI if (session.micPanning !== false && ii == 0) { if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var label = document.createElement("label"); var i = "micPanning"; label.htmlFor = "constraints_" + i; label.innerText = "Mic Pan:"; var input = document.createElement("input"); input.min = 0; input.max = 180; input.dataset.deviceid = track0.id; input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.value = session.micPanning !== false ? session.micPanning : 90; label.innerHTML += " " + parseInt(input.value); input.title = input.value + " (0=L, 90=C, 180=R)"; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.deviceid = track0.id; manualInput.dataset.labelname = label.innerHTML; manualInput.value = parseInt(input.value); manualInput.className = "manualInput"; manualInput.id = "label_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMicPanning(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMicPanning(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMicPanning(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(label); getById("popupSelector_constraints_audio").appendChild(manualInput); getById("popupSelector_constraints_audio").appendChild(input); } if (session.lowcut && ii == 0) { // ii==0 implies only track0 is supported by the web audio pipeline currently (or everything after the mixer node) if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var label = document.createElement("label"); var i = "Low_Cut"; label.htmlFor = "constraints_" + i; label.innerText = "Low Cut:"; var input = document.createElement("input"); input.min = 50; input.max = 400; input.dataset.deviceid = track0.id; // pointless input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].lowcut1) { input.value = session.webAudios[webAudio].lowcut1.frequency.value; label.innerHTML += " " + session.webAudios[webAudio].lowcut1.frequency.value; input.title = input.value; break; } } var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.labelname = label.innerHTML; manualInput.value = parseFloat(input.value); manualInput.className = "manualInput"; manualInput.id = "label_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeLowCut(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeLowCut(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeLowCut(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(label); getById("popupSelector_constraints_audio").appendChild(manualInput); getById("popupSelector_constraints_audio").appendChild(input); } if (session.equalizer && ii == 0) { // ii==0 implies only track0 is supported by the web audio pipeline currently (or everything after the mixer node) if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var label = document.createElement("label"); var i = "Low_EQ"; //label.id = "label_" + i; label.htmlFor = "constraints_" + i; label.innerHTML = "Low EQ:"; var input = document.createElement("input"); input.min = -50; input.max = 50; input.dataset.deviceid = track0.id; // pointless input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].lowEQ) { input.value = session.webAudios[webAudio].lowEQ.gain.value; label.innerHTML += " " + session.webAudios[webAudio].lowEQ.gain.value; input.title = input.value; } } var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.labelname = label.innerHTML; manualInput.value = parseFloat(input.value); manualInput.className = "manualInput"; manualInput.id = "label_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeLowEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeLowEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeLowEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(label); getById("popupSelector_constraints_audio").appendChild(manualInput); getById("popupSelector_constraints_audio").appendChild(input); // if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var label = document.createElement("label"); var i = "Mid_EQ"; //label.id = "label_" + i; label.htmlFor = "constraints_" + i; label.innerHTML = "Mid EQ:"; var input = document.createElement("input"); input.min = -50; input.max = 50; input.dataset.deviceid = track0.id; // pointless input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].midEQ) { input.value = session.webAudios[webAudio].midEQ.gain.value; label.innerHTML += " " + session.webAudios[webAudio].midEQ.gain.value; input.title = input.value; } } var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.labelname = label.innerHTML; manualInput.value = parseFloat(input.value); manualInput.className = "manualInput"; manualInput.id = "label_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMidEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMidEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeMidEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(label); getById("popupSelector_constraints_audio").appendChild(manualInput); getById("popupSelector_constraints_audio").appendChild(input); // if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var label = document.createElement("label"); var i = "High_EQ"; //label.id = "label_" + i; label.htmlFor = "constraints_" + i; label.innerHTML = "High EQ:"; var input = document.createElement("input"); input.min = -50; input.max = 50; input.dataset.deviceid = track0.id; // pointless input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].highEQ) { input.value = session.webAudios[webAudio].highEQ.gain.value; label.innerHTML += " " + session.webAudios[webAudio].highEQ.gain.value; input.title = input.value; } } var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.labelname = label.innerHTML; manualInput.value = parseFloat(input.value); manualInput.className = "manualInput"; manualInput.id = "label_" + i; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeHighEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeHighEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); changeHighEQ(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(label); getById("popupSelector_constraints_audio").appendChild(manualInput); getById("popupSelector_constraints_audio").appendChild(input); } if (session.noisegate !== false && ii == 0) { for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].gatingNode) { var div = document.createElement("div"); var label = document.createElement("label"); var i = "noiseGating"; label.id = "label_" + i + "_" + ii; label.htmlFor = "constraints_" + i + "_" + ii; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block;"; label.dataset.keyname = i; label.title = "This will reduce the gain ~80% when there is no one talking loudly"; var input = document.createElement("select"); var c = document.createElement("option"); input.dataset.deviceid = track0.id; var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); if (session.noisegate) { opt.selected = "true"; } input.options.add(opt); if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } input.id = "constraints_" + i + "_" + ii; input.className = "constraintCameraInput"; input.name = "constraints_" + i + "_" + ii; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; if (e.target.value == "false") { session.noisegate = null; } else if (e.target.value == "true") { session.noisegate = true; } else { session.noisegate = e.target.value; } if (!session.noisegate) { changeGatingGain(100); changeGatingGain(100, 3100); } pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(div); div.appendChild(label); div.appendChild(input); break; } } } //////// if (tracks.length > 1) { var label = document.createElement("h4"); label.innerHTML = track0.label; label.style = "text-shadow: 0 0 10px #fff3;margin:0px 0 10px 0"; if (ii > 0) { label.style = "text-shadow: 0 0 10px #fff3;margin:40px 0 10px 0"; } getById("popupSelector_constraints_audio").appendChild(label); } for (var i in session.audioConstraints) { try { log(i); log(session.audioConstraints[i]); if (typeof session.audioConstraints[i] === "object" && session.audioConstraints[i] !== null && "max" in session.audioConstraints[i] && "min" in session.audioConstraints[i]) { if (i === "aspectRatio") { continue; } else if (i === "width") { continue; } else if (i === "height") { continue; } else if (i === "frameRate") { continue; } else if (i === "latency") { // continue; } else if (i === "sampleRate") { //continue; } else if (i === "sampleSize") { //continue; } else if (i === "channelCount") { if (!(session.stereo && session.stereo != 3)) { // not stereo continue; } } else if (!session.disableWebAudio && i === "volume") { continue; } var label = document.createElement("label"); //label.id = "label_" + i + "_"+ii; label.htmlFor = "constraints_" + i + "_" + ii; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var input = document.createElement("input"); input.min = session.audioConstraints[i].min; input.max = session.audioConstraints[i].max; input.dataset.deviceid = track0.id; if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var manualInput = document.createElement("input"); manualInput.type = "number"; if ("step" in session.audioConstraints[i]) { input.step = session.audioConstraints[i].step; manualInput.step = session.audioConstraints[i].step; } else if ("volume" == i) { input.step = 0.01; manualInput.step = 0.01; } if (i in session.currentAudioConstraints) { input.value = parseFloat(session.currentAudioConstraints[i]); label.title = "Previously was: " + session.currentAudioConstraints[i]; input.title = "Previously was: " + session.currentAudioConstraints[i]; } if (i === "height" || i === "width") { input.title = "Hold CTRL (or cmd) to lock width and height together when changing them"; input.min = 16; } else if (i == "sampleRate") { label.title = "Audio typically gets resampled to 48-kHz"; } input.type = "range"; input.dataset.keyname = i; input.dataset.track = ii; input.id = "constraints_" + i + "_" + ii; input.style = "display:block; width:100%;"; input.name = "constraints_" + i + "_" + ii; manualInput.dataset.keyname = i; manualInput.dataset.track = ii; manualInput.dataset.deviceid = track0.id; manualInput.className = "manualInput"; manualInput.id = "label_" + i + "_" + ii; manualInput.max = session.audioConstraints[i].max; manualInput.min = session.audioConstraints[i].min; manualInput.value = parseFloat(session.currentAudioConstraints[i]); manualInput.onchange = function (e) { try { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.track).value = parseFloat(e.target.value); applyAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); } catch (e) { errorlog(e); } }; input.onchange = function (e) { try { getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.track).value = parseFloat(e.target.value); applyAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); } catch (e) { errorlog(e); } }; // not sure if I should include "oninput" as well? Probably not needed. var div = document.createElement("div"); if (parseFloat(input.min) == parseFloat(input.max)) { manualInput.disabled = true; manualInput.title = "Only one option available, so can't be changed"; label.title = "Only one option available, so can't be changed"; div.appendChild(label); div.appendChild(manualInput); getById("popupSelector_constraints_audio").appendChild(div); } else { div.appendChild(label); div.appendChild(manualInput); div.appendChild(input); getById("popupSelector_constraints_audio").appendChild(div); } } else if (typeof session.audioConstraints[i] === "object" && session.audioConstraints[i] !== null) { if (i == "resizeMode") { continue; } var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + ii; label.htmlFor = "constraints_" + i + "_" + ii; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block;"; label.dataset.keyname = i; var input = document.createElement("select"); var c = document.createElement("option"); if (session.audioConstraints[i].length == 2) { for (var opts in session.audioConstraints[i]) { log(opts); if (session.audioConstraints[i][opts] === true) { var opt = new Option("On", session.audioConstraints[i][opts]); } else if (session.audioConstraints[i][opts] === false) { var opt = new Option("Off", session.audioConstraints[i][opts]); } else { var opt = new Option(session.audioConstraints[i][opts], session.audioConstraints[i][opts]); } input.options.add(opt); if (i in session.currentAudioConstraints) { if (session.audioConstraints[i][opts] == session.currentAudioConstraints[i]) { opt.selected = "true"; } } } } else if (session.audioConstraints[i].length > 1) { for (var opts in session.audioConstraints[i]) { log(opts); var opt = new Option(session.audioConstraints[i][opts], session.audioConstraints[i][opts]); input.options.add(opt); if (i in session.currentAudioConstraints) { if (session.audioConstraints[i][opts] == session.currentAudioConstraints[i]) { opt.selected = "true"; } } } } else if (i.toLowerCase() == "torch") { var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); } else { continue; } if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } input.id = "constraints_" + i + "_" + ii; input.className = "constraintCameraInput"; input.name = "constraints_" + i + "_" + ii; input.dataset.deviceid = track0.id; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; applyAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.deviceid); pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(div); div.appendChild(label); div.appendChild(input); } else if (typeof session.audioConstraints[i] === "boolean") { var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i + "_" + ii; label.htmlFor = "constraints_" + i + "_" + ii; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block;"; label.dataset.keyname = i; var input = document.createElement("select"); var c = document.createElement("option"); input.dataset.deviceid = track0.id; var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } input.id = "constraints_" + i + "_" + ii; input.className = "constraintCameraInput"; input.name = "constraints_" + i + "_" + ii; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname).innerHTML =e.target.dataset.keyname+": "+e.target.value; applyAudioHack(e.target.dataset.keyname, e.target.value, e.target.dataset.deviceid); pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(div); div.appendChild(label); div.appendChild(input); } } catch (e) { errorlog(e); } } if (tracks.length > 1) { for (var webAudio in session.webAudios) { if (session.webAudios[webAudio].subGainNodes && track0.id in session.webAudios[webAudio].subGainNodes) { if (getById("popupSelector_constraints_audio").style.display == "none") { getById("advancedOptionsAudio").style.display = "inline-flex"; } var div = document.createElement("div"); var label = document.createElement("label"); var i = "Gain"; label.id = "label_" + i + "_" + track0.id; label.htmlFor = "constraints_" + i + "_" + track0.id; label.innerText = "Gain:"; label.style = "display:inline-block; padding:0;margin-top: 15px"; var input = document.createElement("input"); input.min = 0; input.max = 200; input.dataset.deviceid = track0.id; // pointless input.type = "range"; input.dataset.keyname = i; input.dataset.labelname = label.innerHTML; input.id = "constraints_" + i + "_" + track0.id; input.style = "display:block; width:100%;"; input.name = "constraints_" + i + "_" + track0.id; input.value = session.webAudios[webAudio].subGainNodes[track0.id].gain.value * 100; //label.innerText += " " + parseInt(session.webAudios[webAudio].subGainNodes[track0.id].gain.value * 100); input.title = parseInt(input.value); var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.dataset.deviceid = track0.id; manualInput.dataset.labelname = label.innerHTML; manualInput.value = session.webAudios[webAudio].subGainNodes[track0.id].gain.value * 100; manualInput.className = "manualInput"; manualInput.id = "manualInput_" + i + "_" + track0.id; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname + "_" + e.target.dataset.deviceid).value = parseFloat(e.target.value); //getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.deviceid).innerText = "Gain: " + parseInt(e.target.value); getById("manualInput_" + e.target.dataset.keyname + "_" + e.target.dataset.deviceid).value = parseFloat(e.target.value); changeSubGain(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { //getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.deviceid).innerText = "Gain: " + parseInt(e.target.value); getById("manualInput_" + e.target.dataset.keyname + "_" + e.target.dataset.deviceid).value = parseFloat(e.target.value); changeSubGain(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; }; input.onchange = function (e) { //getById("label_" + e.target.dataset.keyname + "_" + e.target.dataset.deviceid).innerText = "Gain: " + parseInt(e.target.value); getById("manualInput_" + e.target.dataset.keyname + "_" + e.target.dataset.deviceid).value = parseFloat(e.target.value); changeSubGain(e.target.value, e.target.dataset.deviceid); e.target.title = e.target.value; pokeIframeAPI("mic-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_audio").appendChild(div); div.appendChild(label); div.appendChild(manualInput); div.appendChild(input); break; } } } } } function applyAudioHack(constraint, value = null, deviceid = "default") { if (value == parseFloat(value)) { value = parseFloat(value); if (constraint == "channelCount") { session.audioInputChannels = value; } value = { exact: value }; } else if (value == "true") { value = true; } else if (value == "false") { value = false; } try { var tracks = session.streamSrc.getAudioTracks(); if (!tracks.length) { warnlog("session.streamSrc contains no audio tracks"); return; } var track0 = tracks[0]; for (var ii = 0; ii < tracks.length; ii++) { if (tracks[ii].id == deviceid) { track0 = tracks[ii]; break; } } if (track0.getCapabilities) { session.audioConstraints = track0.getCapabilities(); } else if (Firefox) { // Firefox fallback session.audioConstraints = { autoGainControl: [true, false], deviceId: deviceid, echoCancellation: [true, false], noiseSuppression: [true, false] }; } log(session.audioConstraints); if (track0.getSettings) { session.currentAudioConstraints = track0.getSettings(); } } catch (e) { warnlog("Error getting audio track info"); errorlog(e); return; } var new_constraints = Object.assign({}, session.currentAudioConstraints, { [constraint]: value }); new_constraints = { audio: new_constraints, video: false }; log("new constraints"); log(new_constraints); activatedPreview = false; enumerateDevices() .then(gotDevices2) .then(function () { grabAudio("#audioSource3", null, new_constraints, false, saveAudioResult); }); } // saveAudioResult is disabled but keeping structure for potential future use function saveAudioResult() { return false; // DISABLED: we can't load audio settings, so no point in saving them /* Future implementation when audio settings can be loaded: if (!session.streamSrc) { return; } var tracks = session.streamSrc.getAudioTracks(); if (!tracks.length) { return; } var track0 = tracks[0]; session.currentAudioConstraints = track0.getSettings(); if (session.currentAudioConstraints.deviceId) { setStorage("audio_" + session.currentAudioConstraints.deviceId, session.currentAudioConstraints); } */ } function listCameraSettings() { getById("popupSelector_constraints_video").innerHTML = ""; if (session.controlRoomBitrate === true) { session.controlRoomBitrate = session.totalRoomBitrate; } if (session.roomid && session.view !== "" && session.controlRoomBitrate !== false) { log("LISTING OPTION FOR BITRATE CONTROL"); var i = "Room Video Bitrate (kbps)"; var label = document.createElement("label"); label.htmlFor = "constraints_" + i; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.title = "If you're on a slow network, you can improve frame rate and audio quality by reducing the amount of video data that others send you"; var input = document.createElement("input"); input.min = 0; input.max = parseInt(session.totalRoomBitrate * 1.5); if (getById("popupSelector_constraints_video").style.display == "none") { getById("advancedOptionsCamera").style.display = "inline-flex"; } input.value = parseInt(session.controlRoomBitrate); label.innerHTML = i + ": "; var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.value = session.controlRoomBitrate; manualInput.className = "manualInput"; manualInput.id = "label_" + i; input.type = "range"; input.dataset.keyname = i; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; input.title = "If you're on a slow network, you can improve frame rate and audio quality by reducing the amount of video data that others send you"; manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); if (e.target.value > session.totalRoomBitrate) { return; } else { session.controlRoomBitrate = parseInt(e.target.value); } updateMixer(); pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); if (e.target.value > session.totalRoomBitrate) { return; } else { session.controlRoomBitrate = parseInt(e.target.value); } updateMixer(); pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_video").appendChild(label); getById("popupSelector_constraints_video").appendChild(manualInput); getById("popupSelector_constraints_video").appendChild(input); } try { var track0 = session.streamSrc.getVideoTracks(); if (track0.length) { track0 = track0[0]; if (track0.getCapabilities) { session.cameraConstraints = track0.getCapabilities(); } else { session.cameraConstraints = {}; // probably firefox... } log(session.cameraConstraints); } } catch (e) { errorlog(e); return; } try { if (track0.getSettings) { session.currentCameraConstraints = track0.getSettings(); if (session.mobile) { if (screen && screen.orientation && screen.orientation.type) { if (!screen.orientation.type.includes("portrait")) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } else if (!window.matchMedia("(orientation: portrait)").matches) { if (session.currentCameraConstraints && session.currentCameraConstraints.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } } } else { session.currentCameraConstraints = {}; } } catch (e) { errorlog(e); } var orderedConstraints = {}; if (session.cameraConstraints.torch) { orderedConstraints.torch = session.cameraConstraints.torch; } if (session.cameraConstraints.aspectRatio) { orderedConstraints.aspectRatio = session.cameraConstraints.aspectRatio; } if (session.cameraConstraints.width) { orderedConstraints.width = session.cameraConstraints.width; } if (session.cameraConstraints.height) { orderedConstraints.height = session.cameraConstraints.height; } if (session.cameraConstraints.zoom) { orderedConstraints.zoom = session.cameraConstraints.zoom; } for (var key in session.cameraConstraints) { if (session.cameraConstraints.hasOwnProperty(key) && key !== "width" && key !== "height") { orderedConstraints[key] = session.cameraConstraints[key]; } } session.cameraConstraints = orderedConstraints; for (var i in session.cameraConstraints) { try { log(i); log(session.cameraConstraints[i]); if (i === "focusMode") { continue; // I'll handle this with FocusDistance instead } else if (i === "whiteBalanceMode") { continue; // I'll handle this elsewhere } else if (i === "exposureMode") { continue; // I'll handle this elsewhere } if (typeof session.cameraConstraints[i] === "object" && session.cameraConstraints[i] !== null && "max" in session.cameraConstraints[i] && "min" in session.cameraConstraints[i]) { var manualMode = false; var manualLabel = false; if (i === "exposureTime") { if (session.currentCameraConstraints["exposureMode"]) { manualMode = document.createElement("input"); manualMode.type = "checkbox"; manualMode.id = "manual_" + i; manualMode.dataset.keyname = "exposureMode"; manualMode.onchange = function (e) { var value = "manual"; if (e.target.checked) { value = "continuous"; } if (CtrlPressed) { updateCameraConstraints(e.target.dataset.keyname, value, true, false); } else { updateCameraConstraints(e.target.dataset.keyname, value, false, false); } pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: value }); }; manualLabel = document.createElement("label"); manualLabel.htmlFor = manualMode.id; manualLabel.innerHTML = "Auto: "; manualLabel.style.marginLeft = "20px"; if (session.currentCameraConstraints["exposureMode"] == "continuous") { manualMode.checked = true; } } } else if (i === "focusDistance") { if (session.currentCameraConstraints["focusMode"]) { manualMode = document.createElement("input"); manualMode.type = "checkbox"; manualMode.id = "manual_" + i; manualMode.dataset.keyname = "focusMode"; manualMode.onchange = function (e) { var value = "manual"; if (e.target.checked) { value = "continuous"; } if (CtrlPressed) { updateCameraConstraints(e.target.dataset.keyname, value, true, false); } else { updateCameraConstraints(e.target.dataset.keyname, value, false, false); } pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: value }); }; manualLabel = document.createElement("label"); manualLabel.htmlFor = manualMode.id; manualLabel.innerHTML = "Auto: "; manualLabel.style.marginLeft = "20px"; if (session.currentCameraConstraints["focusMode"] == "continuous") { manualMode.checked = true; } } } else if (i === "colorTemperature") { if (session.currentCameraConstraints["whiteBalanceMode"]) { manualMode = document.createElement("input"); manualMode.type = "checkbox"; manualMode.id = "manual_" + i; manualMode.dataset.keyname = "whiteBalanceMode"; manualMode.onchange = function (e) { var value = "manual"; if (e.target.checked) { value = "continuous"; } if (CtrlPressed) { updateCameraConstraints(e.target.dataset.keyname, value, true, false); } else { updateCameraConstraints(e.target.dataset.keyname, value, false, false); } pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: value }); }; manualLabel = document.createElement("label"); manualLabel.htmlFor = manualMode.id; manualLabel.innerHTML = "Auto: "; manualLabel.style.marginLeft = "20px"; if (session.currentCameraConstraints["whiteBalanceMode"] == "continuous") { manualMode.checked = true; } } } var label = document.createElement("label"); label.htmlFor = "constraints_" + i; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; var input = document.createElement("input"); input.min = parseFloat(session.cameraConstraints[i].min); if (i === "aspectRatio") { input.max = 5; input.min = 0.2; } else if (i === "exposureTime") { input.min = parseFloat(session.cameraConstraints[i].min); input.max = Math.min(parseFloat(session.cameraConstraints[i].max), 2000); } else { input.min = parseFloat(session.cameraConstraints[i].min); input.max = parseFloat(session.cameraConstraints[i].max); } if (parseFloat(input.min) == parseFloat(input.max)) { continue; } if (getById("popupSelector_constraints_video").style.display == "none") { getById("advancedOptionsCamera").style.display = "inline-flex"; } var manualInput = document.createElement("input"); manualInput.type = "number"; manualInput.dataset.keyname = i; manualInput.className = "manualInput"; manualInput.id = "label_" + i; if ("step" in session.cameraConstraints[i]) { input.step = session.cameraConstraints[i].step; manualInput.step = session.cameraConstraints[i].step; } else if (i === "aspectRatio") { input.step = 0.000001; manualInput.step = 0.005; } if (i in session.currentCameraConstraints) { input.value = parseFloat(session.currentCameraConstraints[i]); //label.innerHTML = i + ": " + session.currentCameraConstraints[i]; manualInput.value = parseFloat(session.currentCameraConstraints[i]); label.title = "Previously was: " + session.currentCameraConstraints[i]; input.title = "Previously was: " + session.currentCameraConstraints[i]; } else { label.innerHTML = i; } if (i === "height" || i === "width") { input.title = "Hold CTRL (or cmd) to lock width and height together when changing them"; input.min = 16; } input.type = "range"; input.dataset.keyname = i; input.id = "constraints_" + i; input.style = "display:block; width:100%;"; input.name = "constraints_" + i; // on manualInput.change = .. update the input field! gotta riprocate manualInput.onchange = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); updateCameraConstraints(e.target.dataset.keyname, e.target.value, false, false); pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; input.oninput = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); if (CtrlPressed) { updateCameraConstraints(e.target.dataset.keyname, e.target.value, true, false, false); } else { updateCameraConstraints(e.target.dataset.keyname, e.target.value, false, false, false); } }; input.onchange = function (e) { getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); if (CtrlPressed) { updateCameraConstraints(e.target.dataset.keyname, e.target.value, true, false); } else { updateCameraConstraints(e.target.dataset.keyname, e.target.value, false, false); } pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_video").appendChild(label); getById("popupSelector_constraints_video").appendChild(manualInput); if (manualMode && manualLabel) { getById("popupSelector_constraints_video").appendChild(manualLabel); getById("popupSelector_constraints_video").appendChild(manualMode); } if (i === "aspectRatio") { var preSelectButton = document.createElement("button"); preSelectButton.value = 16 / 9.0; preSelectButton.innerText = "16:9"; preSelectButton.dataset.keyname = i; preSelectButton.className = "preSelectButton"; preSelectButton.onclick = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); updateCameraConstraints(e.target.dataset.keyname, e.target.value, false, false); }; getById("popupSelector_constraints_video").appendChild(preSelectButton); var preSelectButton = document.createElement("button"); preSelectButton.value = 9 / 16.0; preSelectButton.innerText = "9:16"; preSelectButton.className = "preSelectButton"; preSelectButton.dataset.keyname = i; preSelectButton.onclick = function (e) { getById("constraints_" + e.target.dataset.keyname).value = parseFloat(e.target.value); getById("label_" + e.target.dataset.keyname).value = parseFloat(e.target.value); updateCameraConstraints(e.target.dataset.keyname, e.target.value, false, false); }; getById("popupSelector_constraints_video").appendChild(preSelectButton); } getById("popupSelector_constraints_video").appendChild(input); } else if (typeof session.cameraConstraints[i] === "object" && session.cameraConstraints[i] !== null) { if (i == "resizeMode") { continue; } var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i; label.htmlFor = "constraints_" + i; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block;"; label.dataset.keyname = i; var input = document.createElement("select"); if (session.cameraConstraints[i].length > 1) { var included = false; for (var opts in session.cameraConstraints[i]) { log(opts); var opt = new Option(session.cameraConstraints[i][opts], session.cameraConstraints[i][opts]); input.options.add(opt); if (i in session.currentCameraConstraints) { if (session.cameraConstraints[i][opts] == session.currentCameraConstraints[i]) { opt.selected = "true"; included = true; } } } if (!included) { if (i in session.currentCameraConstraints) { var opt = new Option(session.currentCameraConstraints[i], session.currentCameraConstraints[i]); input.options.add(opt); opt.selected = "true"; } } } else if (i.toLowerCase() == "torch") { warnlog("TORCH"); var opt = new Option("Off", false); input.options.add(opt); opt = new Option("On", true); input.options.add(opt); try { if (session.currentCameraConstraints[i]) { opt.selected = "selected"; } } catch (e) { } } else if (session.cameraConstraints[i].length && "continuous" == session.cameraConstraints[i][0]) { var opt = new Option("continuous", "continuous"); input.options.add(opt); if (i in session.currentCameraConstraints) { if ("continuous" == session.currentCameraConstraints[i]) { opt.selected = "true"; var opt = new Option("manual", "manual"); input.options.add(opt); var opt = new Option("none", "none"); input.options.add(opt); } else { var opt = new Option(session.currentCameraConstraints[i], session.currentCameraConstraints[i]); input.options.add(opt); opt.selected = "true"; if (session.currentCameraConstraints[i] == "none") { var opt = new Option("manual", "manual"); input.options.add(opt); } else { var opt = new Option("none", "none"); input.options.add(opt); } } } else { opt.selected = "true"; var opt = new Option("manual", "manual"); input.options.add(opt); var opt = new Option("none", "none"); input.options.add(opt); } } else if (session.cameraConstraints[i].length && "manual" == session.cameraConstraints[i][0]) { var opt = new Option("manual", "manual"); input.options.add(opt); if (i in session.currentCameraConstraints) { if ("manual" == session.currentCameraConstraints[i]) { opt.selected = "true"; var opt = new Option("continuous", "continuous"); input.options.add(opt); var opt = new Option("none", "none"); input.options.add(opt); } else { var opt = new Option(session.currentCameraConstraints[i], session.currentCameraConstraints[i]); input.options.add(opt); opt.selected = "true"; if (session.currentCameraConstraints[i] == "none") { var opt = new Option("continuous", "continuous"); input.options.add(opt); } else { var opt = new Option("none", "none"); input.options.add(opt); } } } else { opt.selected = "true"; var opt = new Option("continuous", "continuous"); input.options.add(opt); var opt = new Option("none", "none"); input.options.add(opt); } } else { continue; } if (getById("popupSelector_constraints_video").style.display == "none") { getById("advancedOptionsCamera").style.display = "inline-flex"; } input.id = "constraints_" + i; input.className = "constraintCameraInput"; input.name = "constraints_" + i; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname).innerHTML =e.target.dataset.keyname+": "+e.target.value; if (CtrlPressed) { updateCameraConstraints(e.target.dataset.keyname, e.target.value, true, false, false); } else { updateCameraConstraints(e.target.dataset.keyname, e.target.value, false, false, false); } pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_video").appendChild(div); div.appendChild(label); div.appendChild(input); } else if (typeof session.cameraConstraints[i] === "boolean") { var div = document.createElement("div"); var label = document.createElement("label"); label.id = "label_" + i; label.htmlFor = "constraints_" + i; label.innerText = capitalizeFirstLetter(i).replace(/([a-z])([A-Z])/g, "$1 $2") + ":"; label.style = "display:inline-block;"; label.dataset.keyname = i; var input = document.createElement("select"); var opt = new Option("Off", "false"); input.options.add(opt); opt = new Option("On", "true"); input.options.add(opt); if (session.currentCameraConstraints[i]) { opt.selected = "true"; } if (getById("popupSelector_constraints_video").style.display == "none") { getById("advancedOptionsCamera").style.display = "inline-flex"; } input.id = "constraints_" + i; input.className = "constraintCameraInput"; input.name = "constraints_" + i; input.style = "display:inline; padding:2px;"; input.dataset.keyname = i; input.dataset.chosen = input.value; input.onchange = function (e) { this.dataset.chosen = this.value; //getById("label_"+e.target.dataset.keyname).innerHTML =e.target.dataset.keyname+": "+e.target.value; if (CtrlPressed) { updateCameraConstraints(e.target.dataset.keyname, e.target.value, true, false, false); } else { updateCameraConstraints(e.target.dataset.keyname, e.target.value, false, false, false); } pokeIframeAPI("camera-constraint-changed", { name: e.target.dataset.keyname, value: e.target.value }); }; getById("popupSelector_constraints_video").appendChild(div); div.appendChild(label); div.appendChild(input); } } catch (e) { errorlog(e); } } if (session.currentCameraConstraints.deviceId) { if (getStorage("camera_" + session.currentCameraConstraints.deviceId)) { var button = document.createElement("button"); button.innerHTML = "Reset video settings to default"; button.style.display = "block"; button.style.padding = "10px 5px"; button.dataset.deviceId = session.currentCameraConstraints.deviceId; button.onclick = function () { var deviceId = this.dataset.deviceId; var cameraSettings = getStorage("camera_" + deviceId); var constraints = {}; var resetResolution = false; var failed = false; if (cameraSettings["default"]) { if (cameraSettings["current"]) { for (var i in cameraSettings["default"]) { if (i == "groupId") { continue; } else if (i === "aspectRatio") { // do not load from storage; causes issues continue; } else if (i === "width") { // continue; } else if (i === "height") { // continue; } else { // if I include any of these, it will complain about mixing types and fail if (i in cameraSettings["current"]) { if (cameraSettings["current"][i] != cameraSettings["default"][i]) { track0 .applyConstraints({ advanced: [{ [i]: cameraSettings["default"][i] }] }) .then(() => { }) .catch(e => { errorlog("Failed to reset to defaults"); failed = true; }); } } continue; } if (i in cameraSettings["current"]) { if (cameraSettings["current"][i] != cameraSettings["default"][i]) { if (i in session.cameraConstraints) { if ("min" in session.cameraConstraints[i]) { if (session.cameraConstraints[i].min > cameraSettings["default"][i]) { continue; } } if ("max" in session.cameraConstraints[i]) { if (session.cameraConstraints[i].max < cameraSettings["default"][i]) { continue; } } } constraints[i] = cameraSettings["default"][i]; if (i == "width" || i == "height" || i == "aspectRatio") { resetResolution = true; } } } } } } warnlog(constraints); if (Object.keys(constraints).length) { track0 .applyConstraints({ advanced: [constraints] }) .then(() => { if (!failed) { removeStorage("camera_" + deviceId); } listCameraSettings(); if (resetResolution) { session.setResolution(); // this will reset scaling for all viewers of this stream } }) .catch(e => { errorlog("Failed to reset to defaults"); errorlog(e); }); } else if (!failed) { removeStorage("camera_" + deviceId); listCameraSettings(); } }; getById("popupSelector_constraints_video").appendChild(button); } } } // Audio settings application function applySavedAudioSettings(track0) { if (!track0?.getSettings) return; log("applySavedAudioSettings"); session.currentAudioConstraints = track0.getSettings(); const deviceId = session.currentAudioConstraints.deviceId; if (!deviceId) return; const audioSettings = getStorage("audio_" + deviceId); if (!audioSettings?.deviceId) return; const constraints = {}; const allowedProps = ["autoGainControl", "echoCancellation", "noiseSuppression"]; for (const prop in session.currentAudioConstraints) { if (audioSettings[prop] !== undefined && audioSettings[prop] !== session.currentAudioConstraints[prop] && allowedProps.includes(prop)) { constraints[prop] = audioSettings[prop]; warnlog("DIFF: " + prop); } } warnlog(constraints); if (!Object.keys(constraints).length) return; track0.applyConstraints({ advanced: [constraints] }) .then(() => warnlog("audio settings updated for deviceId:" + deviceId)) .catch(e => errorlog("Failed to reset to audio defaults")); } // Video settings application function applySavedVideoSettings(track0) { if (!track0?.getSettings) return; session.currentCameraConstraints = track0.getSettings(); // Handle mobile orientation if (session.mobile) { const isPortrait = (screen?.orientation?.type?.includes("portrait")) || window.matchMedia("(orientation: portrait)").matches; if (!isPortrait && session.currentCameraConstraints?.aspectRatio) { session.currentCameraConstraints.aspectRatio = 1 / session.currentCameraConstraints.aspectRatio; } } const deviceId = session.currentCameraConstraints.deviceId; if (!deviceId) return; const cameraSettings = getStorage("camera_" + deviceId); if (!cameraSettings?.current) return; const constraints = {}; const skipProps = ["groupId"]; const urlOverrides = { aspectRatio: session.forceAspectRatio, whiteBalanceMode: session.whiteBalance, colorTemperature: session.whiteBalance, exposureTime: session.exposure, exposureMode: session.exposure, zoom: session.zoom, saturation: session.saturation, sharpness: session.sharpness, contrast: session.contrast, brightness: session.brightness }; for (const prop in session.currentCameraConstraints) { if (!cameraSettings.current[prop] || cameraSettings.current[prop] === session.currentCameraConstraints[prop] || skipProps.includes(prop)) continue; if (urlOverrides[prop]) { log(`${prop} is manually set via URL`); continue; } constraints[prop] = cameraSettings.current[prop]; warnlog("DIFF: " + prop); } warnlog(constraints); if (!Object.keys(constraints).length) return; track0.applyConstraints({ advanced: [constraints] }) .then(() => warnlog("video settings updated for deviceId:" + deviceId)) .catch(e => errorlog("Failed to reset to defaults")); } // Camera constraints update state var updateCameraConstraintsBusy = false; var updateCameraConstraintsNext = false; // Main camera constraints update function async function updateCameraConstraints(constraint, value = null, ctrl = false, UUID = false, save = true) { if (constraint === "zoom" && value === 0) { log("can't zoom to zero"); return; } log("updateCameraConstraintsBusy?"); if (updateCameraConstraintsBusy) { updateCameraConstraintsNext = [constraint, value, ctrl, UUID, save]; return; } updateCameraConstraintsBusy = true; updateCameraConstraintsNext = false; try { const track0 = session.streamSrc?.getVideoTracks()?.[0]; if (!track0 || track0.readyState !== "live" || !track0.enabled) { if (!save) { errorlog("TRACK IS NOT ENABLED"); updateCameraConstraintsBusy = false; updateCameraConstraintsNext = false; } return; } // Parse value if (value == parseFloat(value)) { value = parseFloat(value); } else if (value === "true") { value = true; } else if (value === "false") { value = false; } log({ advanced: [{ [constraint]: value }] }); // Get current settings and prepare storage let cameraSettings = {}; if (track0.getSettings) { session.currentCameraConstraints = track0.getSettings(); if (session.currentCameraConstraints.deviceId) { const storageKey = "camera_" + session.currentCameraConstraints.deviceId; const stored = getStorage(storageKey); if (!stored) { cameraSettings.default = JSON.parse(JSON.stringify(session.currentCameraConstraints)); log(cameraSettings.default); } else { cameraSettings = stored; } } } // Build constraints const constraints = await buildConstraints(constraint, value, ctrl, track0); // Handle mobile orientation for constraints if (session.mobile) { adjustConstraintsForMobileOrientation(constraints); } log("20788"); log(constraints); // Apply constraints await track0.applyConstraints({ advanced: [constraints] }) .then(() => { log("applied constraint"); if (save) { saveConstraintSettings(track0, cameraSettings, constraint, UUID); } if (updateCameraConstraintsNext) { setTimeout(() => { updateCameraConstraintsBusy = false; updateCameraConstraints(...updateCameraConstraintsNext); }, 30); } else { updateCameraConstraintsBusy = false; } }) .catch(e => { errorlog(e.message); errorlog("couldn't save defaults"); window.focus(); updateCameraConstraintsBusy = false; updateCameraConstraintsNext = false; }); } catch (e) { errorlog(e); updateCameraConstraintsBusy = false; updateCameraConstraintsNext = false; return e; } } // Helper to build constraints based on type async function buildConstraints(constraint, value, ctrl, track0) { const current = session.currentCameraConstraints; let constraints = {}; switch (constraint) { case "width": constraints.width = value; if (current?.frameRate) constraints.frameRate = current.frameRate; if (!ctrl && current?.height) constraints.height = current.height; break; case "height": constraints.height = value; if (current?.frameRate) constraints.frameRate = current.frameRate; if (!ctrl && current?.width) constraints.width = current.width; break; case "frameRate": if (!ctrl) { constraints.frameRate = value; if (current?.height && current?.width) { constraints.height = current.height; constraints.width = current.width; } } else { constraints.frameRate = value; } break; case "exposureMode": if (value === "manual") { await applyCurrentSetting(track0, "exposureTime", current); constraints = buildManualModeConstraints(constraint, value, "exposureTime", current); } else { constraints[constraint] = value; } break; case "exposureTime": constraints[constraint] = value; constraints.exposureMode = "manual"; break; case "focusMode": if (value === "manual") { await applyCurrentSetting(track0, "focusDistance", current); constraints = buildManualModeConstraints(constraint, value, "focusDistance", current); } else { constraints[constraint] = value; } break; case "focusDistance": constraints[constraint] = value; constraints.focusMode = "manual"; break; case "whiteBalanceMode": if (value === "manual") { await applyCurrentSetting(track0, "colorTemperature", current); constraints = buildWhiteBalanceConstraints(constraint, value, current); } else if (value === "continuous") { constraints[constraint] = value; if (session.mobile && ChromiumVersion) { constraints.colorTemperature = 5000; } } else { constraints[constraint] = value; } break; case "colorTemperature": constraints[constraint] = value; constraints.whiteBalanceMode = "manual"; break; case "aspectRatio": constraints[constraint] = value; if (current?.frameRate) constraints.frameRate = current.frameRate; if (session.mobile) { const isPortrait = (screen?.orientation?.type?.includes("portrait")) || window.matchMedia("(orientation: portrait)").matches; if (isPortrait && constraints.aspectRatio) { constraints.aspectRatio = 1 / constraints.aspectRatio; } } break; default: constraints[constraint] = value; } return constraints; } // Helper for manual mode constraints function buildManualModeConstraints(constraint, value, dependentProp, current) { const constraints = { [constraint]: value }; if (current?.height && current?.width) { constraints.height = current.height; constraints.width = current.width; } if (current?.[dependentProp]) { constraints[dependentProp] = current[dependentProp]; } return constraints; } // Helper for white balance constraints function buildWhiteBalanceConstraints(constraint, value, current) { const constraints = { [constraint]: value }; if (current?.height && current?.width) { constraints.height = current.height; constraints.width = current.width; } const colorTempConstraints = session.cameraConstraints?.colorTemperature; if (colorTempConstraints?.max && colorTempConstraints?.min) { if (current?.colorTemperature) { constraints.colorTemperature = current.colorTemperature; } else if (5000 >= colorTempConstraints.min && 5000 <= colorTempConstraints.max) { constraints.colorTemperature = 5000; } else { constraints.colorTemperature = colorTempConstraints.max; } } return constraints; } // Helper to apply current setting async function applyCurrentSetting(track0, prop, current) { if (!current?.[prop]) return; const tempConstraints = { [prop]: current[prop] }; await track0.applyConstraints({ advanced: [tempConstraints] }); session.currentCameraConstraints = track0.getSettings(); } // Helper to adjust constraints for mobile orientation function adjustConstraintsForMobileOrientation(constraints) { const isPortrait = (screen?.orientation?.type?.includes("portrait")) || window.matchMedia("(orientation: portrait)").matches; if (!isPortrait) return; if (constraints.width && constraints.height) { [constraints.width, constraints.height] = [constraints.height, constraints.width]; } else if (constraints.width) { constraints.height = constraints.width; delete constraints.width; if (!constraints.aspectRatio && session.currentCameraConstraints?.height) { constraints.width = session.currentCameraConstraints.height; } } else if (constraints.height) { constraints.width = constraints.height; delete constraints.height; if (!constraints.aspectRatio && session.currentCameraConstraints?.width) { constraints.height = session.currentCameraConstraints.width; } } } // Helper to save constraint settings function saveConstraintSettings(track0, cameraSettings, constraint, UUID) { if (!track0.getSettings || !session.currentCameraConstraints.deviceId) return; session.currentCameraConstraints = track0.getSettings(); cameraSettings.current = session.currentCameraConstraints; setStorage("camera_" + session.currentCameraConstraints.deviceId, cameraSettings); if (toggleSettingsState === true) { listCameraSettings(); } if (UUID) { const data = { UUID: UUID, videoOptions: listVideoSettingsPrep() }; sendMediaDevices(data.UUID); session.sendMessage(data, data.UUID); } if (["width", "height", "aspectRatio"].includes(constraint)) { session.setResolution(); } } function setupSharpnessTool() { var promise; const worker = new Worker("./thirdparty/focus_worker.js", { type: "module" }); worker.onerror = event => { errorlog(event); promise.reject(event); }; worker.onmessage = messageEvent => { log("Sharpness score: " + messageEvent.data.score.avg_edge_width_perc); promise.resolve(messageEvent.data.score.avg_edge_width_perc); }; measureBlur = imageData => { worker.postMessage({ imageData }); }; const canvas = document.createElement("canvas"); // document.getElementById("header").appendChild(canvas); async function getSharpness(x = 50, y = 50) { if (session.videoElement) { log("XY"); log(x + " : " + y); canvas.width = session.videoElement.videoWidth / 5; canvas.height = session.videoElement.videoHeight / 5; if (x < 10) { x = 10; } if (y < 10) { y = 10; } if (x > 90) { x = 90; } if (y > 90) { y = 90; } var sx = (session.videoElement.videoWidth / 100) * (x - 10); var sy = (session.videoElement.videoHeight / 100) * (y - 10); var sw = session.videoElement.videoWidth * 0.2; var sh = session.videoElement.videoHeight * 0.2; canvas.getContext("2d").filter = "blur(3px)"; // denoise canvas.getContext("2d").drawImage(session.videoElement, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); // for drawing the video element on the canvas const canvasData = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height); var res, rej; promise = new Promise((resolve, reject) => { res = resolve; rej = reject; }); promise.resolve = res; promise.reject = rej; measureBlur(canvasData); return promise; } return null; } return getSharpness; } var sharpnessToolActive = false; var sharpnessTool = false; async function tapToFocus(x, y, force = false) { if (isNaN(x) || isNaN(y)) { return; } if (sharpnessToolActive) { return; } if (!session.streamSrc) { checkBasicStreamsExist(); return; } //var bestFocus = -1; var track0 = session.streamSrc.getVideoTracks(); if (!track0.length) { log("No video tracks"); return; } track0 = track0[0]; if (!track0.getCapabilities) { log("Track lacks advanced features. Firefox?"); return; } var capabilities = track0.getCapabilities(); if (!("focusDistance" in capabilities)) { log("Track doesn't support focusing"); return; } var settings = track0.getSettings(); if ("focusMode" in settings) { if (!force && settings.focusMode !== "manual") { log("Need to be in manual focus mode"); return; } } if (!sharpnessTool) { sharpnessTool = setupSharpnessTool(); } var bestFocus = -1; var bestSharpness = 999; sharpnessToolActive = true; try { log("Current focus distance: " + capabilities.focusDistance); await track0.applyConstraints({ advanced: [{ focusMode: "manual", focusDistance: capabilities.focusDistance.min }] }); await sleep(250); var stepping = capabilities.focusDistance.step || 0.1; if ((capabilities.focusDistance.max - capabilities.focusDistance.min) / stepping > 100) { stepping = parseInt((capabilities.focusDistance.max - capabilities.focusDistance.min) / 100); } if (!stepping) { stepping = 0.1; } for (var i = capabilities.focusDistance.min; i <= capabilities.focusDistance.max; i += stepping) { await track0.applyConstraints({ advanced: [{ focusMode: "manual", focusDistance: i }] }); await sleep(120); // wait long enough for a new frame and focus to adjust. log("focus: " + i + ", " + x + "x" + y); var response = await sharpnessTool(x, y); if (response && response < bestSharpness) { bestSharpness = response; bestFocus = i; } else if (response === null) { return; } log(response + " " + bestSharpness + " " + bestFocus + " " + i + " " + capabilities.focusDistance.max); } if (bestFocus !== -1) { log("Setting focus now to: " + bestFocus); await track0.applyConstraints({ advanced: [{ focusMode: "manual", focusDistance: bestFocus }] }); } } catch (e) { errorlog(e); } sharpnessToolActive = false; } session.remoteFocus = async function (focusDistance, absolute = false) { try { var track0 = session.streamSrc.getVideoTracks()[0]; if (!track0?.getCapabilities) return; var capabilities = track0.getCapabilities(); if (!capabilities.focusDistance) { warnlog("No Focus supported on this device"); return; } const focusRange = capabilities.focusDistance; if (!("min" in focusRange)) return; if (session.focusDistance === false || session.focusDistance === undefined) { const settings = track0.getSettings(); session.focusDistance = settings.focusDistance || focusRange.min; } let newFocusDistance; if (absolute) { newFocusDistance = focusRange.min + focusDistance * (focusRange.max - focusRange.min); } else { const range = focusRange.max - focusRange.min; const step = focusRange.step || 0.01; const change = Math.max(Math.abs(range * focusDistance), step); newFocusDistance = session.focusDistance + (focusDistance > 0 ? change : -change); } newFocusDistance = Math.min(Math.max(newFocusDistance, focusRange.min), focusRange.max); const step = focusRange.step || 0.01; const steps = Math.round((newFocusDistance - focusRange.min) / step); newFocusDistance = focusRange.min + (steps * step); // Use updateCameraConstraints with save=false to avoid debouncing await updateCameraConstraints("focusDistance", newFocusDistance, false, false, false); session.focusDistance = newFocusDistance; return session.focusDistance; } catch (e) { errorlog(e); return null; } }; session.setRemoteAutofocus = async function (enabled) { try { var mode = enabled ? "continuous" : "manual"; await updateCameraConstraints("focusMode", mode, false, false, false); session.focusDistance = false; // Reset stored focus distance log("Autofocus set to: " + mode); } catch (e) { errorlog(e); } }; session.remoteZoom = async function (zoom, absolute = false) { try { var track0 = session.streamSrc.getVideoTracks()[0]; if (!track0?.getCapabilities) return; var capabilities = track0.getCapabilities(); if (!capabilities.zoom) { warnlog("No zoom supported on this device"); return; } const zoomRange = capabilities.zoom; if (!("min" in zoomRange) || !("max" in zoomRange) || zoomRange.max === zoomRange.min) { warnlog("Zoom not adjustable on this device"); return; } if (session.zoom === false || session.zoom === undefined) { const settings = track0.getSettings(); session.zoom = settings.zoom || zoomRange.min; } let newZoom; if (absolute) { newZoom = zoomRange.min + zoom * (zoomRange.max - zoomRange.min); } else { const range = zoomRange.max - zoomRange.min; const step = zoomRange.step || 1; const change = Math.max(Math.abs(range * zoom), step); newZoom = session.zoom + (zoom > 0 ? change : -change); } newZoom = Math.min(Math.max(newZoom, zoomRange.min), zoomRange.max); const step = zoomRange.step || 1; const steps = Math.round((newZoom - zoomRange.min) / step); newZoom = zoomRange.min + (steps * step); // Use updateCameraConstraints with save=false await updateCameraConstraints("zoom", newZoom, false, false, false); session.zoom = newZoom; return session.zoom; } catch (e) { errorlog(e); return null; } }; session.remotePan = async function (pan, absolute = false) { try { var track0 = session.streamSrc.getVideoTracks()[0]; if (!track0?.getCapabilities) return; var capabilities = track0.getCapabilities(); if (!capabilities.pan) { warnlog("No pan supported on this device"); return; } const panRange = capabilities.pan; if (!("min" in panRange) || !("max" in panRange) || panRange.max === panRange.min) { warnlog("Pan not adjustable on this device"); return; } if (session.pan === false || session.pan === undefined) { const settings = track0.getSettings(); session.pan = settings.pan || (panRange.min + panRange.max) / 2; } let newPan; if (absolute) { const range = panRange.max - panRange.min; newPan = panRange.min + ((pan + 1) / 2) * range; } else { const range = panRange.max - panRange.min; const step = panRange.step || 1; const change = Math.max(Math.abs(range * pan), step); newPan = session.pan + (pan > 0 ? change : -change); } newPan = Math.min(Math.max(newPan, panRange.min), panRange.max); const step = panRange.step || 1; const steps = Math.round((newPan - panRange.min) / step); newPan = panRange.min + (steps * step); // Use updateCameraConstraints with save=false await updateCameraConstraints("pan", newPan, false, false, false); session.pan = newPan; return session.pan; } catch (e) { errorlog(e); return null; } }; session.remoteTilt = async function (tilt, absolute = false) { try { var track0 = session.streamSrc.getVideoTracks()[0]; if (!track0?.getCapabilities) return; var capabilities = track0.getCapabilities(); if (!capabilities.tilt) { warnlog("No tilt supported on this device"); return; } const tiltRange = capabilities.tilt; if (!("min" in tiltRange) || !("max" in tiltRange) || tiltRange.max === tiltRange.min) { warnlog("Tilt not adjustable on this device"); return; } if (session.tilt === false || session.tilt === undefined) { const settings = track0.getSettings(); session.tilt = settings.tilt || (tiltRange.min + tiltRange.max) / 2; } let newTilt; if (absolute) { const range = tiltRange.max - tiltRange.min; newTilt = tiltRange.min + ((tilt + 1) / 2) * range; } else { const range = tiltRange.max - tiltRange.min; const step = tiltRange.step || 1; const change = Math.max(Math.abs(range * tilt), step); newTilt = session.tilt + (tilt > 0 ? change : -change); } newTilt = Math.min(Math.max(newTilt, tiltRange.min), tiltRange.max); const step = tiltRange.step || 1; const steps = Math.round((newTilt - tiltRange.min) / step); newTilt = tiltRange.min + (steps * step); // Use updateCameraConstraints with save=false await updateCameraConstraints("tilt", newTilt, false, false, false); session.tilt = newTilt; return session.tilt; } catch (e) { errorlog(e); return null; } }; session.remoteExposure = async function (exposure, absolute = false) { try { var track0 = session.streamSrc.getVideoTracks()[0]; if (!track0?.getCapabilities) return; var capabilities = track0.getCapabilities(); var settings = track0.getSettings(); if (!capabilities.exposureMode || !capabilities.exposureTime) { warnlog("Exposure control not supported on this device"); return; } // Ensure manual mode if (settings.exposureMode !== 'manual') { await updateCameraConstraints("exposureMode", "manual", false, false, false); } const exposureRange = capabilities.exposureTime; if (session.exposure === false || session.exposure === undefined) { session.exposure = settings.exposureTime || exposureRange.min; } let newExposure; if (absolute) { newExposure = exposureRange.min + exposure * (exposureRange.max - exposureRange.min); } else { const range = exposureRange.max - exposureRange.min; const step = exposureRange.step || 1; const change = Math.max(Math.abs(range * exposure), step); newExposure = session.exposure + (exposure > 0 ? change : -change); } newExposure = Math.min(Math.max(newExposure, exposureRange.min), exposureRange.max); // Use updateCameraConstraints with save=false await updateCameraConstraints("exposureTime", newExposure, false, false, false); session.exposure = newExposure; log(`Applied new exposure time: ${session.exposure}`); return session.exposure; } catch (e) { errorlog(e); return null; } }; function toggleAudioUser(ele) { if (!ele) { ele = ele || getById("advancedOptionsAudio"); ele.style.display = "inline-flex"; if (getById("popupSelector_constraints_audio").style.display == "block") { toggleSettings(); } else { getById("popupSelector_constraints_audio").style.display = "block"; ele.classList.add("highlight"); if (!toggleSettingsState) { toggleSettings(); } } } else { ele = ele || getById("advancedOptionsAudio"); toggle(getById("popupSelector_constraints_audio"), false, false); ele.classList.toggle("highlight"); } getById("popupSelector_constraints_loading").style.visibility = "visible"; getById("popupSelector_constraints_video").style.display = "none"; getById("popupSelector_user_settings").style.display = "none"; } function toggleVideoUser(ele) { if (!ele) { ele = ele || getById("advancedOptionsCamera"); ele.style.display = "inline-flex"; if (getById("popupSelector_constraints_video").style.display == "block") { toggleSettings(); } else { getById("popupSelector_constraints_video").style.display = "block"; ele.classList.add("highlight"); if (!toggleSettingsState) { toggleSettings(); } } } else { ele = ele || getById("advancedOptionsCamera"); toggle(getById("popupSelector_constraints_video"), false, false); ele.classList.toggle("highlight"); } getById("popupSelector_constraints_loading").style.visibility = "visible"; getById("popupSelector_constraints_audio").style.display = "none"; getById("popupSelector_user_settings").style.display = "none"; } function toggleUserUser(ele) { ele = ele || getById("advancedOptionsGeneral"); if (!toggleSettingsState) { toggleSettings(); } ele.classList.toggle("highlight"); toggle(getById("popupSelector_user_settings"), false, false); getById("popupSelector_user_settings").style.visibility = "visible"; getById("popupSelector_constraints_video").style.display = "none"; getById("popupSelector_constraints_audio").style.display = "none"; } async function requestBasicPermissions(constraint = { video: true, audio: true }, callback = setupWebcamSelection, miconly = false) { if (session.taintedSession === null) { log("STILL WAITING ON HASH TO VALIDATE"); setTimeout( function (constraint, callback, miconly) { requestBasicPermissions(constraint, callback, miconly); }, 1000, constraint, callback, miconly ); return null; } else if (session.taintedSession === true) { warnlog("HASH FAILED; PASSWORD NOT VALID"); return false; } else { log("NOT TAINTED 1"); } setTimeout(function () { getById("getPermissions").style.display = "none"; getById("gowebcam").style.display = ""; }, 0); log("REQUESTING BASIC PERMISSIONS"); try { if (!navigator.mediaDevices) { throw new Error("navigator.mediaDevices not found - check your security / browser settings."); } var timerBasicCheck = null; if (!session.cleanOutput) { log("Setting Timer for getUserMedia"); timerBasicCheck = setTimeout(function () { if (!session.cleanOutput) { if (session.mobile) { warnUser("Notice: Camera timed out\n\nDid you accept the camera permissions?\n\nThis error may also appear if you are in a phone call or another app is already using the camera or microphone."); } else { warnUser("Camera Access Request Timed Out\n\nDid you accept camera permissions? Please do so first.\n\nIf you have NDI Tools installed, try uninstalling that.\n\nPlease also ensure that your camera and audio devices are correctly connected and not already in use. Bypassing USB hubs or using different USB cables can sometimes help.\n\nYou may also just need to restart the computer"); } } }, 10000); } let modifiedConstraint = { ...constraint }; try { const videoPermission = await navigator.permissions.query({ name: "camera" }); const audioPermission = await navigator.permissions.query({ name: "microphone" }); // If video is denied but audio is allowed, adjust the constraint if (videoPermission.state === "denied" && constraint.video) { warnlog("Video permissions are denied"); if (constraint.audio) { // Keep audio if it was originally requested modifiedConstraint.video = false; } else { // If no audio was requested, this will likely fail throw new Error("Video permissions denied"); } } // If audio is denied but video is allowed, adjust the constraint if (audioPermission.state === "denied" && constraint.audio) { warnlog("Audio permissions are denied"); if (constraint.video) { // Keep video if it was originally requested modifiedConstraint.audio = false; } else { // If no video was requested, this will likely fail throw new Error("Audio permissions denied"); } } } catch (permissionError) { log("Permissions API check failed:", permissionError); } if (session.audioInputChannels) { if (modifiedConstraint.audio === true) { modifiedConstraint.audio = {}; modifiedConstraint.audio.channelCount = session.audioInputChannels; } else if (modifiedConstraint.audio) { modifiedConstraint.audio.channelCount = session.audioInputChannels; } } if (session.micSampleRate) { if (modifiedConstraint.audio === true) { modifiedConstraint.audio = {}; modifiedConstraint.audio.sampleRate = parseInt(session.micSampleRate); } else if (modifiedConstraint.audio) { modifiedConstraint.audio.sampleRate = parseInt(session.micSampleRate); } } if (session.micSampleSize) { if (modifiedConstraint.audio === true) { modifiedConstraint.audio = {}; modifiedConstraint.audio.sampleSize = parseInt(session.micSampleSize); } else if (modifiedConstraint.audio) { modifiedConstraint.audio.sampleSize = parseInt(session.micSampleSize); } } if (!modifiedConstraint.audio && !modifiedConstraint.video) { if (miconly) { warnUser("We couldn't find a microphone.\n\nPlease ensure you have granted the microphone permissions."); } else { warnUser("We couldn't find a microphone or camera.\n\nPlease ensure you have granted the microphone and camera permissions."); } // return null; } if (session.safemode) { if (modifiedConstraint.video) { modifiedConstraint.video = true; } if (modifiedConstraint.audio) { modifiedConstraint.audio = true; } } getUserMediaRequestID += 1; var gumID = getUserMediaRequestID; log("CONSTRAINT"); log(modifiedConstraint); var timeoutStart = 0; if (Firefox) { timeoutStart = 500; } log("timeoutStart :" + timeoutStart); setTimeout( async function (gumID, constraint, timerBasicCheck, callback, miconly) { log("gumID: " + gumID); log(constraint); var removeAudio = false; if (!constraint.audio && !constraint.video) { constraint.audio = true; removeAudio = true; } // Permissions API is not supported in all browsers, so we use a try-catch block let videoPermission = "prompt"; let audioPermission = "prompt"; if (Firefox && Firefox >= 132) { console.warn("😱 see: https://bugzilla.mozilla.org/show_bug.cgi?id=1924572#c1"); } else { try { const videoStatus = await navigator.permissions.query({ name: "camera" }); videoPermission = videoStatus.state; const audioStatus = await navigator.permissions.query({ name: "microphone" }); audioPermission = audioStatus.state; log("audioPermission: " + audioPermission); } catch (e) { warnlog("Permissions API is not fully supported in this browser."); } const safariPermissionBug = SafariVersion && SafariVersion > 18 && (iOS || iPad); if (videoPermission === "granted" && !safariPermissionBug) { constraint.video = false; } if (audioPermission === "granted" && !safariPermissionBug) { constraint.audio = false; } } if (!constraint.audio && !constraint.video) { warnlog("bypassing navigator.mediaDevices.getUserMedia; permissions granted already?"); clearTimeout(timerBasicCheck); if (getUserMediaRequestID !== gumID) { warnlog("GET USER MEDIA CALL HAS EXPIRED 3a"); return; } closeModal(); if (callback) { callback(miconly); } return; } if (Firefox) { constraint = toFirefoxConstraint(constraint); } warnlog("navigator.mediaDevices.getUserMedia starting..."); navigator.mediaDevices .getUserMedia(constraint) .then(function (stream) { // Apple needs thi to happen before I can access EnumerateDevices. if (removeAudio) { constraint.audio = false; // this seeems pointless? stream.getTracks().forEach(function (track) { stream.removeTrack(track); track.stop(); log("stopping old track"); }); } log("got first stream"); clearTimeout(timerBasicCheck); if (getUserMediaRequestID !== gumID) { warnlog("GET USER MEDIA CALL HAS EXPIRED 3"); stream.getTracks().forEach(function (track) { stream.removeTrack(track); track.stop(); log("stopping old track"); }); return; } closeModal(); log(stream.getTracks()); session.streamSrc = stream; checkBasicStreamsExist(); updateRenderOutpipe(); if (callback) { callback(miconly); } }) .catch(function (err) { clearTimeout(timerBasicCheck); warnlog("some error with GetUSERMEDIA"); console.warn(err); /* handle the error */ if (err.name == "NotFoundError" || err.name == "DevicesNotFoundError") { //required track is missing } else if (err.name == "NotReadableError" || err.name == "TrackStartError") { //webcam or mic are already in use } else if (err.name == "OverconstrainedError" || err.name == "ConstraintNotSatisfiedError") { //constraints can not be satisfied by avb. devices } else if (err.name == "NotAllowedError" || err.name == "PermissionDeniedError") { //permission denied in browser if (isIFrame) { console.error('Make sure that this IFRAME has the correct permissions allowed, ie:\n\niframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;midi;geolocation;screen-wake-lock;";'); } if (!session.cleanOutput) { setTimeout(function () { if (window.obsstudio) { warnUser("Permissions denied.\n\nTo access the camera or microphone from within OBS, please refer to:\ndocs.vdo.ninja/guides/share-webcam-from-inside-obs.", false, false); } else if (ChromiumVersion && !session.mobile) { warnUser("

    Camera/mic permissions denied

    \nPlease ensure you have allowed the mic/camera permissions in your browser, such as like:\n\n\n\nFor further help on how to resolve this issue, please refer to:\n\nhttps://docs.vdo.ninja/common-errors-and-known-issues/enable-camera-microphone-permissions.", false, false); } else if (Firefox && session.mobile) { warnUser( "

    Camera/mic permission denied

    \nPlease allow mic/camera access.\n\n\ If not prompted, go to Settings -> Site permissions -> exceptions (at bottom) -> vdo.ninja, and then manually enable the permissions.\n\n\ If Firefox still gives you issues, try in incognito mode or a different browser.\ For further help, please refer to:\n\nhttps://docs.vdo.ninja/common-errors-and-known-issues/enable-camera-microphone-permissions.", false, false ); } else { warnUser("Permission access to the camera or microphone was denied.\n\nPlease ensure you have allowed the mic/camera permissions in your browser.\n\nFor guides on how to resolve this issue, please refer to:\n\nhttps://docs.vdo.ninja/common-errors-and-known-issues/enable-camera-microphone-permissions.", false, false); } }, 1); } return; } else if (err.name == "TypeError" || err.name == "TypeError") { //empty constraints object } else { //permission denied in browser if (!session.cleanOutput) { setTimeout( function (err) { warnUser(err); }, 1, err ); } } warnlog("trying to list webcam again"); if (callback) { callback(miconly); } }); }, timeoutStart, gumID, modifiedConstraint, timerBasicCheck, callback, miconly ); } catch (e) { console.warn(e); if (!session.cleanOutput) { if (window.isSecureContext) { warnUser("An error has occured when trying to access the webcam or microphone. The reason is not known."); } else if (iOS || iPad) { warnUser("iOS version 13.4 and up is generally recommended; older than iOS 11 is not supported."); } else { warnUser("Error acessing camera or microphone.\n\nThe website may be loaded in an insecure context.\n\nPlease see: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia"); } } } return null; } function awaitInboundCall() { if (session.twilio) { return; } loadScript("./thirdparty/twilio.min.js?v1", async function () { if (!session.twilio) { session.twilio = {}; } class MyAudioProcessor { constructor(audioContext) { console.log("constructor"); this.audioContext = session.audioCtxOutbound; session.twilio.mixer = session.audioCtxOutbound.createGain(); // This serves as our mixer node this.destination = session.twilio.destination = session.audioCtxOutbound.createMediaStreamDestination(); // Destination node this.background = this.audioContext.createMediaElementSource(document.createElement("video")); } async createProcessedStream(stream) { const source = this.audioContext.createMediaStreamSource(stream); const gain = this.audioContext.createGain(); gain.gain.value = 0; source.connect(gain); gain.connect(session.twilio.mixer); // session.twilio.micSources.push(stream); session.twilio.mixer.connect(this.destination); return this.destination.stream; } async destroyProcessedStream(stream) { console.log("destroyProcessedStream called"); console.log(stream); } } const response = await fetch("https://call.vdo.ninja:8443/token2"); session.twilio.data = await response.json(); session.twilio.device = new Twilio.Device(session.twilio.data.token); const audioProcessor = new MyAudioProcessor(); await session.twilio.device.audio.addProcessor(audioProcessor); session.twilio.device.register(); warnUser("Dial in access code: " + session.twilio.data.dialInNumber); session.twilio.device.on("registered", function () { console.log("Twilio.Device is ready to receive incoming calls!"); }); async function refresh() { console.log("refreshing token"); const response = await fetch("https://call.vdo.ninja:8443/refresh"); session.twilio.data = await response.json(); session.twilio.device.updateToken(session.twilio.data.token); } session.twilio.refreshInterval = setInterval(refresh, 50 * 60 * 1000); session.twilio.micSources = []; session.twilio.updateMixer = async function (UUID = false) { try { console.log("update mixer"); session.twilio.micSources.forEach(src => { src.disconnect(); }); if (session.videoElement && session.videoElement.srcObject && session.videoElement.srcObject.getAudioTracks().length) { var micSource = session.audioCtxOutbound.createMediaStreamSource(session.videoElement.srcObject); } else if (session.streamSrc && session.streamSrc.getAudioTracks().length) { var micSource = session.audioCtxOutbound.createMediaStreamSource(session.streamSrc); } else { var micSource = false; } for (var uuid in session.rpcs) { try { if (session.rpcs[uuid].videoElement && session.rpcs[uuid].videoElement.srcObject) { var guestSource = session.audioCtxOutbound.createMediaStreamSource(session.rpcs[uuid].videoElement.srcObject); if (guestSource) { guestSource.connect(session.twilio.mixer); session.twilio.micSources.push(guestSource); console.log(uuid + " added to twiliio"); } } } catch (e) { errorlog(e); } } if (micSource) { micSource.connect(session.twilio.mixer); session.twilio.micSources.push(micSource); console.log("micSource added to twiliio"); } } catch (err) { console.error("Could not get microphone audio:", err); } }; session.twilio.device.on("incoming", call => { session.twilio.call = call; console.log("Incoming call from: " + session.twilio.call.parameters.From); session.twilio.call.on("reject", () => { console.log("rejected"); }); session.twilio.call.on("disconnect", () => { console.log("disconnected"); }); session.twilio.call.on("cancel", () => { console.log("cancelled"); }); session.twilio.call.on("accept", () => { console.log("accpted"); }); session.twilio.call.on("audio", e => { session.twilio.element = e; console.log("audio stream available"); outboundAudioPipeline(session.streamSrc); }); window.focus(); confirmAlt("Incoming call from: " + session.twilio.call.parameters.From + "\n" + getTranslation("accept-inbound-caller")).then(res => { if (res) { if (session.audioCtxOutbound.state === "suspended") { session.audioCtxOutbound.resume().then(() => { console.log("AudioContext resumed"); }); } session.twilio.updateMixer(); session.twilio.call.accept(); } else { session.twilio.call.reject(); } }); }); }); } function joinConference(roomid, mute = true) { // not used loadScript("./thirdparty/twilio.min.js", function () { fetch("https://call.vdo.ninja:8443/token") .then(response => response.json()) .then(async data => { const device = new Twilio.Device(data.token); call = await device.connect({ params: { To: roomid } }); if (mute) { const checkForRemoteStream = setInterval(() => { if (call.getRemoteStream && call.getRemoteStream()) { if (call.getRemoteStream().getTracks && call.getRemoteStream().getTracks().length) { clearInterval(checkForRemoteStream); call.getRemoteStream().getTracks()[0].enabled = false; console.log("call muted"); } } }, 500); } }) .catch(error => { console.error("Error fetching token: ", error); }); }); } function listenWebsocket(roomid) { // not used var callSocket = null; var connecting = false; var streams = {}; function connectAudioInbound() { clearTimeout(connecting); if (callSocket) { if (callSocket.readyState === callSocket.OPEN) { return; } try { callSocket.close(); } catch (e) { } } callSocket = new WebSocket("wss://call.vdo.ninja:8443/" + roomid); callSocket.onclose = function () { clearTimeout(connecting); connecting = setTimeout(function () { connectAudioInbound(); }, 100); }; callSocket.onopen = function () { send({ join: true }); }; callSocket.addEventListener("message", function (event) { var msg = JSON.parse(event.data); if (msg.event && msg.event === "media" && msg.media && msg.media.payload && msg.streamSid) { playAudioChunk(msg.media.payload, msg.streamSid); if (!streams[msg.streamSid]) { console.log("stream starting"); streams[msg.streamSid] = true; //audioStreams.innerHTML = ""; //for (stream in streams){ // audioStreams.innerHTML += stream+"
    "; //} console.log(streams); } } else if (msg.event && msg.event === "stop" && msg.streamSid) { console.log("stream stopping"); delete streams[msg.streamSid]; //audioStreams.innerHTML = ""; //for (stream in streams){ // audioStreams.innerHTML += stream+"
    "; //} console.log(streams); } // else { // console.log(msg); // console.log("stream misc"); // console.log(streams); //} }); } function base64ToUint8Array(base64) { const binaryString = window.atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } function muLawToPCM(muLawData, gainFactor = 2.0, threshold = 30000, ratio = 2.0) { const pcmData = new Int16Array(muLawData.length); for (let i = 0; i < muLawData.length; i++) { let muLawByte = muLawData[i]; muLawByte = ~muLawByte; let sign = muLawByte & 0x80; muLawByte &= ~0x80; let exponent = (muLawByte >> 4) & 0x07; let data = muLawByte & 0x0f; data |= 0x10; data <<= 1; data += 1; data <<= exponent + 2; data -= 0x84; if (sign === 0) data = -data; let amplifiedData = data * gainFactor; if (Math.abs(amplifiedData) > threshold) { const overThreshold = Math.abs(amplifiedData) - threshold; const compressedData = threshold + overThreshold / ratio; amplifiedData = amplifiedData > 0 ? compressedData : -compressedData; } pcmData[i] = amplifiedData; } return pcmData; } function playAudioChunk(base64Chunk, sid) { const muLawData = base64ToUint8Array(base64Chunk); const pcmData = muLawToPCM(muLawData); const audioBuffer = session.audioCtx.createBuffer(1, pcmData.length, 8000); const bufferSource = session.audioCtx.createBufferSource(); const channelData = audioBuffer.getChannelData(0); for (let i = 0; i < pcmData.length; i++) { channelData[i] = pcmData[i] / 32768.0; } bufferSource.buffer = audioBuffer; bufferSource.connect(session.audioCtx.destination); bufferSource.start(); } function send(cmd) { callSocket.send(JSON.stringify(cmd)); } connectAudioInbound(); } function setupWebcamSelection(miconly = false) { log("setupWebcamSelection();"); checkBasicStreamsExist(); try { return enumerateDevices() .then(function (dInfo) { return gotDevices(dInfo, miconly); }) .then(function () { if (getById("webcamquality").elements && parseInt(getById("webcamquality").elements.namedItem("resolution").value) == 3) { // this is junk?? if (session.maxframeRate === false) { session.maxframeRate = 30; session.maxframeRate_q2 = true; } } else if (session.maxframeRate_q2) { session.maxframeRate = false; session.maxframeRate_q2 = false; } var audioSelect = getById("audioSource"); var videoSelect = getById("videoSourceSelect"); var outputSelect = getById("outputSource"); if (audioSelect.tagName == "UL") { audioSelect.onchange = function () { if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").disabled = true; document.getElementById("gowebcam").dataset.audioready = "false"; //document.getElementById("gowebcam").style.backgroundColor = "#DDDDDD"; document.getElementById("gowebcam").style.fontWeight = "normal"; document.getElementById("gowebcam").innerHTML = "Waiting for mic to load"; miniTranslate(document.getElementById("gowebcam"), "waiting-for-mic-to-load"); } activatedPreview = false; grabAudio(); }; } videoSelect.onchange = function () { if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").disabled = true; document.getElementById("gowebcam").dataset.ready = "false"; //document.getElementById("gowebcam").style.backgroundColor = "#DDDDDD"; document.getElementById("gowebcam").style.fontWeight = "normal"; document.getElementById("gowebcam").innerHTML = "Waiting for Camera to load"; miniTranslate(document.getElementById("gowebcam"), "waiting-for-camera-to-load"); } warnlog("video source changed"); activatedPreview = false; if (session.quality !== false) { grabVideo(session.quality); } else if (document.getElementById("webcamquality")) { session.quality_wb = parseInt(document.getElementById("webcamquality").elements.namedItem("resolution").value); session.quality_room = session.quality_wb; grabVideo(session.quality_wb); } }; if (Firefox && !session.mobile) { outputSelect.onclick = function () { log("on click"); if (outputSelect.options[outputSelect.selectedIndex].value === "others") { navigator.mediaDevices.selectAudioOutput().then(device => { if (device.kind == "audiooutput") { session.sink = device.deviceId; try { var matched = false; outputSelect.childNodes.forEach(ele => { if (ele.value === device.deviceId) { matched = true; ele.selected = true; } }); if (!matched) { var option = document.createElement("option"); option.value = device.deviceId; option.text = device.label; outputSelect.appendChild(option); option.selected = true; } saveSettings(); // we're saving because there was an explicit action to change devices } catch (e) { errorlog(e); } if (!session.sink) { return; } // Not sure this would ever happen, but whatever. resetupAudioOut(); // we'll probalby use session.sink, since outputSelect3 doesn't exist. } }); } }; } outputSelect.onchange = function () { if (iOS || iPad) { return; } if (Firefox && !session.mobile) { if (outputSelect.options[outputSelect.selectedIndex].value === "others") { return; } } try { session.sink = outputSelect.options[outputSelect.selectedIndex].value; saveSettings(); // we're saving because there was an explicit action to change devices } catch (e) { errorlog(e); } if (!session.sink) { return; } // Not sure this would ever happen, but whatever. resetupAudioOut(); // we'll probalby use session.sink, since outputSelect3 doesn't exist. }; getById("webcamquality").onchange = function () { if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").disabled = true; document.getElementById("gowebcam").dataset.ready = "false"; // document.getElementById("gowebcam").style.backgroundColor = "#DDDDDD"; document.getElementById("gowebcam").style.fontWeight = "normal"; document.getElementById("gowebcam").innerHTML = "Waiting for Camera to load"; miniTranslate(document.getElementById("gowebcam"), "waiting-for-camera-to-load"); } if (parseInt(getById("webcamquality").elements.namedItem("resolution").value) == 2) { if (session.maxframeRate === false) { session.maxframeRate = 30; session.maxframeRate_q2 = true; } } else if (session.maxframeRate_q2) { session.maxframeRate = false; session.maxframeRate_q2 = false; } activatedPreview = false; session.quality_wb = parseInt(getById("webcamquality").elements.namedItem("resolution").value); session.quality_room = session.quality_wb; grabVideo(session.quality_wb); }; if (session.safemode) { if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").disabled = false; //document.getElementById("gowebcam").innerHTML = getTranslation("start"); miniTranslate(document.getElementById("gowebcam"), "start"); document.getElementById("gowebcam").dataset.audioready = "true"; document.getElementById("gowebcam").dataset.ready = "true"; document.getElementById("gowebcam").focus(); setTimeout(function () { updateForceRotate(); }, 1000); if (session.autostart) { publishWebcam(); // no need to mirror as there is no video... } return; } } if (session.audioDevice !== 0) { // change from Auto to Selected Audio Device log("SETTING AUDIO DEVICE!!"); activatedPreview = false; grabAudio(); } else if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").dataset.audioready = "true"; } if (session.videoDevice === 0 || miconly) { if (session.autostart) { publishWebcam(); // no need to mirror as there is no video... return; } else if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").dataset.ready = "true"; if (document.getElementById("gowebcam").dataset.audioready == "true") { document.getElementById("gowebcam").disabled = false; miniTranslate(document.getElementById("gowebcam"), "start"); document.getElementById("gowebcam").focus(); //document.getElementById("gowebcam").innerHTML = getTranslation("start"); } } } else { log("GRabbing video: " + session.quality); activatedPreview = false; if (session.quality !== false) { grabVideo(session.quality); } else if (document.getElementById("webcamquality")) { session.quality_wb = parseInt(getById("webcamquality").elements.namedItem("resolution").value); session.quality_room = session.quality_wb; grabVideo(session.quality_wb); } } if (!(iOS || iPad || session.mobile)) { try { if (outputSelect.selectedIndex >= 0) { session.sink = outputSelect.options[outputSelect.selectedIndex].value; saveSettings(); if (session.videoElement && !session.videoElement.paused) { resetupAudioOut(); } } } catch (e) { errorlog(e); } } }) .catch(e => { errorlog(e); }); } catch (e) { errorlog(e); } } Promise.wait = function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }; Promise.prototype.timeout = function (ms) { return Promise.race([ this, Promise.wait(ms).then(function () { if (iOS || iPad) { var errormsg = new Error("Time Out\nDid you accept camera permissions in time? Please do so first.\n\nIf using an iPhone or iPad, try fully closing your browser and open it again; Safari sometimes jams up the camera."); errormsg.name = "timedOut"; errormsg.message = "Time Out\nDid you accept camera permissions in time? Please do so first.\n\nIf using an iPhone or iPad, try fully closing your browser and open it again; Safari sometimes jams up the camera."; throw errormsg; } else if (session.mobile) { var errormsg = new Error("Time Out\nDid you accept camera permissions in time? Please do so first.\n\nMake sure no other application is using the camera already and that you are using a compatible browser. If issues persist, maybe try the official native mobile app."); errormsg.name = "timedOut"; errormsg.message = "Time Out\nDid you accept camera permissions in time? Please do so first.\n\nMake sure no other application is using the camera already and that you are using a compatible browser. If issues persist, maybe try the official native mobile app."; throw errormsg; } else { var errormsg = new Error("Time Out\nDid you accept camera permissions in time? Please do so first.\n\nOtherwise, do you have NDI Tools installed? Maybe try uninstalling it.\n\nPlease also ensure your camera and audio device are correctly connected and not already in use. You may also need to refresh the page."); errormsg.name = "timedOut"; errormsg.message = "Time Out\nDid you accept camera permissions in time? Please do so first.\n\nOtherwise, do you have NDI Tools installed? Maybe try uninstalling it.\n\nPlease also ensure your camera and audio device are correctly connected and not already in use. You may also need to refresh the page."; throw errormsg; } }) ]); }; async function shareWebsite(autostart = false, evt = false) { if (session.iframeSrc) { if (!session.cleanOutput) { getById("websitesharebutton2").classList.remove("hidden"); } if (evt && (evt.ctrlKey || evt.metaKey)) { if (getById("websitesharebutton2").classList.contains("green")) { var actionMsg = {}; actionMsg.infocus = false; for (var UUID in session.pcs) { if (session.pcs[UUID].allowIframe === true) { session.sendMessage(actionMsg, UUID); } } getById("websitesharebutton2").classList.remove("green"); getById("websitesharebutton2").ariaPressed = "false"; getById("websitesharebutton2").title = "Hold CTRL (or CMD) and click to spotlight this shared website"; } else { if (session.streamID) { var actionMsg = {}; actionMsg.infocus = session.streamID; for (var UUID in session.pcs) { if (session.pcs[UUID].allowIframe === true) { session.sendMessage(actionMsg, UUID); } } getById("websitesharebutton2").classList.add("green"); getById("websitesharebutton2").ariaPressed = "true"; getById("websitesharebutton2").title = "Video is currently spotlighted"; } } return; } getById("websitesharebutton2").classList.remove("green"); getById("websitesharebutton2").ariaPressed = "false"; session.iframeSrc = false; if (session.director) { clearDirectorSettings(); //setStorage("directorWebsiteShare", {"website":session.iframeSrc, "roomid":session.roomid}); } else if (document.getElementById("container_iframe") || session.iframeEle) { if (session.iframeEle) { session.iframeEle.remove(); session.iframeEle = false; } if (document.getElementById("container_iframe")) { document.getElementById("container_iframe").remove(); } updateMixer(); } getById("websitesharebutton2").classList.add("hidden"); getById("websitesharebutton").classList.remove("hidden"); var data = {}; data.iframeSrc = false; for (var UUID in session.pcs) { if (session.pcs[UUID].allowIframe === true) { session.sendMessage(data, UUID); } } getById("websitesharebutton2").title = "Hold CTRL (or CMD) and click to spotlight this shared website"; return; } getById("websitesharebutton2").classList.remove("green"); getById("websitesharebutton2").ariaPressed = "false"; if (autostart === false) { window.focus(); var iframeURL = await promptAlt(getTranslation("enter-website"), false, false, session.defaultIframeSrc); } else { var iframeURL = autostart; } if (!iframeURL) { return; } if (iframeURL == session.iframeSrc) { return; } session.defaultIframeSrc = iframeURL; warnlog(iframeURL); session.iframeSrc = parseURL4Iframe(iframeURL); if (session.director && !autostart) { setStorage("directorWebsiteShare", { website: session.iframeSrc, roomid: session.roomid }); } else if (session.iframeEle) { session.iframeEle.src = session.iframeSrc; if (session.iframeSrc.startsWith("https://www.youtube.com/")) { // special handler. setTimeout( function (iframe_id) { YoutubeListen(iframe_id); }, 1000, iframe.id ); } } else if (session.iFramesAllowed) { var iframe = document.createElement("iframe"); iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;midi;screen-wake-lock;"; // do not allow location iframe.src = session.iframeSrc; iframe.id = "iframe_source"; iframe.setAttribute("allowtransparency", "true"); iframe.setAttribute("crossorigin", "anonymous"); iframe.setAttribute("credentialless", "true"); iframe.loadedYoutubeListen = false; session.iframeEle = iframe; var container = document.createElement("div"); iframe.container = container; container.id = "container_iframe"; if (session.iframeSrc.startsWith("https://www.youtube.com/")) { // special handler. setTimeout( function (iframe_id) { YoutubeListen(iframe_id); }, 1000, iframe.id ); } updateMixer(); } getById("websitesharebutton2").classList.remove("hidden"); getById("websitesharebutton").classList.add("hidden"); var data = {}; data.iframeSrc = session.iframeSrc; for (var UUID in session.pcs) { if (session.pcs[UUID].allowIframe === true) { session.sendMessage(data, UUID); } } } function screenshareTypeDecider(sstype = 1) { if (session.screenshareType) { sstype = session.screenshareType; } if (sstype == 1) { toggleScreenShare(); } else if (sstype == 2) { createIframePopup(); } else if (sstype == 3) { createSecondStream(); } } function createScreenShareURL(transparent = true) { // iframe.src = if (session.screenShareElement) { var iFrameID = session.streamID.substring(0, 12) + "_" + session.generateStreamID(5); } else if (session.screenshareid) { var iFrameID = session.screenshareid; } else { var iFrameID = session.streamID.substring(0, 12) + "_" + session.generateStreamID(5); } if (session.exclude) { session.exclude.push(iFrameID); } else { session.exclude = []; session.exclude.push(iFrameID); } var extras = ""; if (session.password) { extras += "&password=" + session.password; // encodeURIComponent( } if (session.privacy) { extras += "&privacy"; } if (session.meshcast) { extras += "&meshcast"; } if (session.token) { extras += "&token=" + session.token; } if (session.remote) { if (session.remote === true) { extras += "&remote"; } else { extras += "&remote=" + session.remote; } } if (session.salt !== location.hostname) { // this is default. extras += "&salt=" + session.salt; } if (session.whipOutVideoBitrate) { extras += "&wovb=" + session.whipOutVideoBitrate; } if (session.whipOutScreenShareBitrate) { extras += "&wossbitrate=" + session.whipOutScreenShareBitrate; } if (!session.notifyScreenShare) { extras += "&smallshare"; } if (session.screenshareContentHint) { extras += "&sshint=" + session.screenshareContentHint; } else if (session.contentHint) { extras += "&sshint=" + session.contentHint; } if (session.audioContentHint) { extras += "&audiohint=" + session.audioContentHint; } if (session.whipOutScreenShareCodec) { extras += "&whipoutcodec=" + session.whipOutScreenShareCodec; } else if (session.whipOutCodec) { extras += "&whipoutcodec=" + session.whipOutCodec; } if (session.screensharequality !== false) { extras += "&q=" + session.screensharequality; // &quality works here, since only thing we are doing } else if (session.quality !== false) { extras += "&q=" + session.quality; } else if (session.quality_ss !== false) { extras += "&q=" + session.quality_ss; } else { extras += "&q=0"; } if (session.screenShareLabel !== false) { if (session.screenShareLabel) { extras += "&label=" + encodeURIComponent(session.screenShareLabel); } else if (session.label) { extras += "&label=" + encodeURIComponent(session.label); } } if (session.screensharefps !== false) { extras += "&maxframeRate=" + parseInt(session.screensharefps * 100) / 100.0; } if (session.screenshareAEC) { extras += "&aec=1"; } if (session.screenshareDenoise) { extras += "&denoise=1"; } if (session.screenshareAutogain) { extras += "&autogain=1"; } if (session.screenshareStereo !== false) { extras += "&stereo=" + session.screenshareStereo; } if (session.forceAspectRatio && session.forceScreenShareAspectRatio === null) { extras += "&ar=" + session.forceAspectRatio; } else if (session.forceScreenShareAspectRatio) { extras += "&ar=" + session.forceScreenShareAspectRatio; } if (session.muted) { extras += "&muted"; } if (session.recordLocal) { extras += "&record=" + session.recordLocal; } if (session.autorecordlocal) { extras += "&autorecordlocal"; } if (transparent) { extras += "&transparent&cleanish"; } // manual, since I don't want to use the auto-mixer. return "?manual&audiodevice=1&screenshare&cb=0&nvb&nsb&autostart&view&room=" + session.roomid + "&push=" + iFrameID + extras; } function createIframePopup() { if (session.screenShareElement) { postMessageIframe(session.screenShareElement, { close: true }); session.screenShareElement.parentNode.removeChild(session.screenShareElement); session.screenShareElement = false; updateMixer(); getById("screenshare2button").classList.remove("green"); getById("screenshare2button").ariaPressed = "false"; return; } if ((session.queue && !session.transferred) || (session.screenShareState && !session.queue && session.transferred)) { // if (session.queue || session.transferred){ //getById("screenshare2button").classList.add("hidden"); //getById("screensharebutton").classList.remove("hidden"); toggleScreenShare(); return; } // can't secondary-screen share if in a queue. //if (!session.iFramesAllowed){errorlog("Can't create iFRAME - security is tainted due to possible CSS injection");return;} // allow because we are doing &sstype=2; not anything else. var iframe = document.createElement("iframe"); iframe.allow = "autoplay;camera;microphone;fullscreen;picture-in-picture;display-capture;midi;screen-wake-lock;"; // do not allow location iframe.src = "./" + createScreenShareURL(); iframe.setAttribute("allowtransparency", "true"); iframe.setAttribute("crossorigin", "anonymous"); iframe.setAttribute("credentialless", "true"); iframe.style.width = "100%"; iframe.style.height = "100%"; iframe.style.overflow = "hidden"; iframe.id = "screensharesource"; iframe.dataset.sid = "#screensharesource"; iframe.style.zIndex = "0"; session.screenShareElement = iframe; session.screenShareElement.dataset.doNotMove = true; document.getElementById("main").appendChild(iframe); if (session.screenShareElementHidden) { session.screenShareElement.style.display = "none"; } updateMixer(); getById("screenshare2button").classList.add("green"); getById("screenshare2button").ariaPressed = "true"; return; // ignore the rest. } function previewWebcam(miconly = false) { if (session.taintedSession === null) { log("STILL WAITING ON HASH TO VALIDATE"); setTimeout( function (miconly) { previewWebcam(miconly); }, 1000, miconly ); return; } else if (session.taintedSession === true) { warnlog("HASH FAILED; PASSWORD NOT VALID"); return; } else { log("NOT TAINTED"); } if (activatedPreview == true) { log("activeated preview return 1"); return; } activatedPreview = true; if (miconly) { // this just shares the preview section with the mic-only and video+mic modes if (!getById("add_camera_inner").cloned) { getById("add_camera_inner").cloned = true; insertAfter(getById("add_camera_inner"), getById("add_microphone")); document.getElementById("videoSourceSelect").innerHTML = ""; } } else if (getById("add_camera_inner").cloned) { getById("add_camera_inner").cloned = false; insertAfter(getById("add_camera_inner"), getById("add_camera")); document.getElementById("videoSourceSelect").innerHTML = ""; } if (session.audioDevice === 0) { // OFF var constraint = { audio: false }; } else if ((session.echoCancellation !== false) && (session.autoGainControl !== false) && (session.noiseSuppression !== false) && (session.voiceIsolation !== true)) { // AUTO var constraint = { audio: true }; } else { // Disable Echo Cancellation and stuff for the PREVIEW (DEFAULT CAM/MIC) var constraint = { audio: {} }; if (session.echoCancellation !== false) { // if not disabled, we assume it's on constraint.audio.echoCancellation = true; } else { constraint.audio.echoCancellation = false; if (!session.cleanoutput) { getById("headphoneTip1").classList.remove("hidden"); miniTranslate(getById("headphoneTipContext1"), "headphones-tip"); //getById("headphoneTipContext1").innerHTML = getTranslation("headphones-tip"); } } if (session.autoGainControl !== false) { constraint.audio.autoGainControl = true; } else { constraint.audio.autoGainControl = false; } if (session.noiseSuppression !== false) { constraint.audio.noiseSuppression = true; } else { constraint.audio.noiseSuppression = false; } if (session.voiceIsolation === true) { constraint.audio.voiceIsolation = true; } } if (session.videoDevice === 0 || miconly) { constraint.video = false; } else { constraint.video = true; } window.onorientationchange = function () { if (Firefox) { updateForceRotate(true); } setTimeout(async function () { if (session.forceAspectRatio) { await updateCameraConstraints("aspectRatio", session.forceAspectRatio); } if (session.effect && session.effect === "7") { digitalZoom(); } updateForceRotate(); }, 200); }; if (constraint.video === false && constraint.audio === false) { if (session.autostart) { publishWebcam(false, miconly); // no need to mirror as there is no video... return; } else { getById("getPermissions").style.display = "none"; if (document.getElementById("gowebcam")) { document.getElementById("gowebcam").dataset.ready = "true"; document.getElementById("gowebcam").dataset.audioready = "true"; document.getElementById("gowebcam").disabled = false; //document.getElementById("gowebcam").innerHTML = getTranslation("start"); miniTranslate(document.getElementById("gowebcam"), "start"); document.getElementById("gowebcam").focus(); } } return; } enumerateDevices() .then(function (devices) { log("enumeratated"); log(devices); var vtrue = false; var atrue = false; devices.forEach(function (device) { if (device.kind === "audioinput") { atrue = true; } else if (device.kind === "videoinput") { vtrue = true; } }); if (atrue === false) { constraint.audio = false; } if (vtrue === false) { constraint.video = false; } setTimeout( function (constraint, miconly) { requestBasicPermissions(constraint, setupWebcamSelection, miconly); }, 0, constraint, miconly ); }) .catch(error => { log("enumeratated failed. Seeking permissions."); setTimeout( function (constraint, miconly) { requestBasicPermissions(constraint, setupWebcamSelection, miconly); }, 0, constraint, miconly ); }); } function copyFunction(copyText, evt = false) { if (evt) { if ("buttons" in evt) { if (evt.buttons !== 0) { return; } } else if ("which" in evt) { if (evt.which !== 0) { return; } } popupMessage(evt); evt.preventDefault(); evt.stopPropagation(); } try { copyText.select(); copyText.setSelectionRange(0, 99999); document.execCommand("copy"); } catch (e) { var dummy = document.createElement("input"); document.body.appendChild(dummy); dummy.value = copyText; dummy.select(); document.execCommand("copy"); document.body.removeChild(dummy); } return false; } function generateQRPage() { var pass = sanitizePassword(getById("invite_password").value); if (pass.length) { return generateHash(pass + session.salt, 4) .then(function (hash) { generateQRPageCallback(hash); }) .catch(errorlog); } else { generateQRPageCallback(""); } } async function updateLinkWelcome(arg, input) { if (input.checked) { var response = await promptAlt("Enter the message you'd like the guests to see"); response = encodeURIComponent(response); var param = input.dataset.param.split("=")[0]; input.dataset.param = param + "=" + response; } updateLink(arg, input); } function updateLinkWebP(arg, input) { if (input.checked) { if (!(getById("director_block_" + arg).dataset.raw.includes("&broadcast") || getById("director_block_" + arg).dataset.raw.includes("?broadcast"))) { getById("broadcastSlider").checked = true; updateLink(arg, getById("broadcastSlider")); } } updateLink(arg, input); } var soloLinkAppended = ""; function updateLink(arg, input, solo = false) { log("updateLink: " + input.dataset.param); if (input.checked) { getById("director_block_" + arg).dataset.raw += input.dataset.param; if (solo) { soloLinkAppended += input.dataset.param; } var string = getById("director_block_" + arg).dataset.raw; if (arg == 1 && getById("obfuscate_director_" + arg).checked) { string = obfuscateURL(string); } getById("director_block_" + arg).href = string; getById("director_block_" + arg).innerText = string; } else { var string = getById("director_block_" + arg).dataset.raw + "&"; string = string.replace(input.dataset.param + "&", "&"); string = string.substring(0, string.length - 1); getById("director_block_" + arg).dataset.raw = string; if (solo) { soloLinkAppended += "&"; soloLinkAppended = soloLinkAppended.replace(input.dataset.param + "&", "&"); soloLinkAppended = soloLinkAppended.substring(0, soloLinkAppended.length - 1); } if (arg == 1 && getById("obfuscate_director_" + arg).checked) { string = obfuscateURL(string); } // document.querySelector("soloLink") // soloLink getById("director_block_" + arg).href = string; getById("director_block_" + arg).innerText = string; } if (solo) { document.querySelectorAll("a.soloLink").forEach(ele => { try { var href = ele.getAttribute("value") + soloLinkAppended; ele.href = href; ele.innerHTML = href; } catch (e) { errorlog(e); } }); } // Update all solo links with universal token if in auth mode if (session.authMode && session.universalViewToken) { updateAllSoloLinks(); } saveDirectorSettings(); } function changeURL(changeURL) { window.focus(); if (session.consent) { hangup(false); window.location.href = changeURL; } else { confirmAlt(getTranslation("director-redirect-1") + changeURL + getTranslation("director-redirect-2")).then(res => { if (res) { hangup(false); window.location.href = changeURL; } }); } } function updateLinkInverse(arg, input) { log("updateLinkInverse"); log(input.dataset.param); if (!input.checked) { getById("director_block_" + arg).dataset.raw += input.dataset.param; var string = getById("director_block_" + arg).dataset.raw; if (arg == 1 && getById("obfuscate_director_" + arg).checked) { string = obfuscateURL(string); } getById("director_block_" + arg).href = string; getById("director_block_" + arg).innerText = string; } else { var string = getById("director_block_" + arg).dataset.raw + "&"; string = string.replace(input.dataset.param + "&", "&"); string = string.substring(0, string.length - 1); getById("director_block_" + arg).dataset.raw = string; if (arg == 1 && getById("obfuscate_director_" + arg).checked) { string = obfuscateURL(string); } getById("director_block_" + arg).href = string; getById("director_block_" + arg).innerText = string; } } function updateLinkScene(arg, input) { log("updateLinkScene"); var string = getById("director_block_" + arg).dataset.raw || ""; if (input.checked) { string = changeParam(string, "scene", "0"); } else { string = changeParam(string, "scene", "1"); } getById("director_block_" + arg).dataset.raw = string; if (arg == 1 && getById("obfuscate_director_" + arg).checked) { string = obfuscateURL(string); } getById("director_block_" + arg).href = string; getById("director_block_" + arg).innerText = string; } function fullscreenPageToggle(state = null) { try { if (!document.fullscreenElement) { // not currently full screen if (state !== false) { // if state is false, we are already not full screen if (document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen(); } else if (document.documentElement.webkitRequestFullscreen) { document.documentElement.webkitRequestFullscreen(); } } } else if (document.exitFullscreen) { if (!state) { // if toggle mode or state=false document.exitFullscreen(); } } else if (document.webkitExitFullscreen) { if (!state) { // if toggle mode or state=false document.webkitExitFullscreen(); } } //updateMixer(); // we will do this on the event for this instead } catch (e) { errorlog(e); } } session.pipWindow = false; async function PictureInPicturePageToggle(state = null) { try { if (typeof documentPictureInPicture === "undefined") { return; } if (session.pipWindow) { getById("testtone").parentNode.insertBefore(session.pipWindow.document.getElementById("gridlayout"), getById("testtone")); session.pipWindow.close(); session.pipWindow = null; updateMixer(); getById("PictureInPicturePage").classList.remove("green"); } else { session.pipWindow = await documentPictureInPicture.requestWindow({ width: 614, height: 344 }); // 360 + 30px for the window header session.pipWindow.addEventListener("pagehide", event => { if (session.pipWindow) { getById("testtone").parentNode.insertBefore(session.pipWindow.document.getElementById("gridlayout"), getById("testtone")); session.pipWindow.close(); session.pipWindow = null; updateMixer(); getById("PictureInPicturePage").classList.remove("green"); } }); var pipWindowHead = 'Pop-out Window'; pipWindowHead += ''; session.pipWindow.document.body.className = "main"; session.pipWindow.document.head.innerHTML = pipWindowHead; session.pipWindow.document.body.style = document.body.style; session.pipWindow.document.title = "Pop-out Window"; session.pipWindow.document.body.append(getById("gridlayout")); session.pipWindow.onresize = updateMixer; updateMixer(); // just in case onresize doesn't trigger getById("PictureInPicturePage").classList.add("green"); } } catch (e) { errorlog(e); } } function resetGen() { getById("gencontent").style.display = "block"; getById("gencontent2").style.display = "none"; getById("gencontent2").className = ""; //container-inner getById("gencontent").className = "container-inner"; // getById("gencontent2").innerHTML = ""; getById("videoname4").focus(); } function generateQRPageCallback(hash) { try { var title = getById("videoname4").value; if (title.length) { title = title.replace(/[\W]+/g, "_").replace(/_+/g, "_"); // but not what others might get. TODO: allow for non-alphanumeric characters; santitize, then URL encode instead, title = "&label=" + title; } var sid = session.generateStreamID(); var viewstr = ""; var sendstr = ""; if (getById("invite_bitrate").checked) { viewstr += "&bitrate=20000"; } if (getById("invite_vp9").checked) { viewstr += "&codec=vp9"; } if (getById("invite_stereo").checked) { viewstr += "&stereo"; sendstr += "&stereo"; } if (getById("invite_automic").checked) { sendstr += "&audiodevice=1"; } if (getById("invite_automic").checked) { sendstr += "&audiodevice=1"; } if (getById("invite_effects").checked) { sendstr += "&effects"; } if (getById("invite_remotecontrol").checked) { // var remote_gen_id = session.generateStreamID(); sendstr += "&remote=" + remote_gen_id; // security viewstr += "&remote=" + remote_gen_id; } if (getById("invite_joinroom").value.trim().length) { sendstr += "&ssid&room=" + getById("invite_joinroom").value.trim(); viewstr += "&solo&room=" + getById("invite_joinroom").value.trim(); } if (getById("invite_password").value.trim().length) { sendstr += "&hash=" + hash; viewstr += "&password=" + sanitizePassword(getById("invite_password").value.trim()); } if (session.token) { sendstr += "&token=" + session.token; viewstr += "&token=" + session.token; } if (getById("invite_group_chat_type").value) { // 0 is default if (getById("invite_group_chat_type").value == 1) { // no video sendstr += "&novideo"; } else if (getById("invite_group_chat_type").value == 2) { // no view or audio sendstr += "&view"; } } if (getById("invite_quality").value) { if (getById("invite_quality").value == 0) { sendstr += "&quality=0"; } else if (getById("invite_quality").value == 1) { sendstr += "&quality=1"; } else if (getById("invite_quality").value == 2) { sendstr += "&quality=2"; } } var wss = ""; if (session.wssSetViaUrl) { if (session.customWSS && session.customWSS !== true) { wss = "&pie=" + session.customWSS; } else if (session.customWSS == true) { wss = "&wss=" + session.wss; } else { wss = "&wss2=" + session.wss; } } var hoststr = ""; if (getById("invite_hostlink").checked) { hoststr = "https://" + location.host + location.pathname + "?push=" + sid + "_hostlink" + "&view=" + sid + sendstr + "&bitrate=500" + title + wss; sendstr = "https://" + location.host + location.pathname + "?push=" + sid + "&view=" + sid + "_hostlink" + sendstr + "&bitrate=1200" + title + wss; } else { sendstr = "https://" + location.host + location.pathname + "?push=" + sid + sendstr + title + wss; } if (getById("invite_obfuscate").checked) { sendstr = obfuscateURL(sendstr); } viewstr = "https://" + location.host + location.pathname + "?view=" + sid + viewstr + title + wss; getById("gencontent").style.display = "none"; getById("gencontent").className = ""; // getById("gencontent2").style.display = "block"; getById("gencontent2").className = "container-inner"; getById("gencontent2").innerHTML = '
    \

    Guest Invite Link:

    \ ' + sendstr + '

    \

    and don\'t forget the

    OBS Browser Source Link:

    ' + viewstr + ' \ '; if (hoststr) { getById("gencontent2").innerHTML += '

    Host Chat Link:

    ' + hoststr + ' '; } getById("gencontent2").innerHTML += '

    \ \
  • This invite link and OBS ingestion link are reusable.
  • \
  • Only one person may use a specific invite at a time.
  • \
  • The stream ID can be changed manually to something else; keep it unique and alphanumeric.
  • \
  • Nothing is stored server-side; links do not expire, nor is there anything to delete.
  • \


    \ '; var qrcode = new QRCode(getById("qrcode"), { width: 300, height: 300, colorDark: "#000000", colorLight: "#FFFFFF", useSVG: false }); qrcode.makeCode(sendstr); getById("qrcode").title = ""; setTimeout(function () { getById("qrcode").title = ""; if (getById("qrcode").getElementsByTagName("img").length) { getById("qrcode").getElementsByTagName("img")[0].style.cursor = "none"; getById("qrcode").getElementsByTagName("img")[0].style.margin = "0 auto"; } }, 100); // i really hate the title overlay that the qrcode function makes } catch (e) { errorlog(e); } } function initSceneList(UUID) { Object.keys(session.sceneList).forEach((scene, index) => { if (getById("container_" + UUID).querySelectorAll('[data-scene="' + scene + '"]').length) { return; } // already exists. var newScene = document.createElement("div"); newScene.innerHTML = '"; newScene.classList.add("customScene"); var added = false; getById("container_" + UUID) .querySelectorAll(".customScene>[data-scene]") .forEach(ele => { log(ele); if (!added && ele.dataset.scene > scene + "") { ele.parentNode.parentNode.insertBefore(newScene, ele.parentNode); added = true; } }); if (!added) { getById("container_" + UUID).appendChild(newScene); } }); } function updateSceneList(scene) { // custom scenes only. if (!session.director) { return; } if (scene in session.sceneList) { return; } if (parseInt(scene) + "" === scene) { if (parseInt(scene) >= 0 && parseInt(scene) <= session.maxScene) { return; } } session.sceneList[scene] = true; for (var UUID in session.rpcs) { var newScene = document.createElement("div"); newScene.innerHTML = '"; newScene.classList.add("customScene"); var added = false; getById("container_" + UUID) .querySelectorAll(".customScene>[data-scene]") .forEach(ele => { log(ele); if (!added && ele.dataset.scene > scene + "") { ele.parentNode.parentNode.insertBefore(newScene, ele.parentNode); added = true; } }); if (!added) { getById("container_" + UUID).appendChild(newScene); } } if (session.showDirector) { if (document.getElementById("container_director")) { var newScene = document.createElement("div"); newScene.innerHTML = '"; newScene.classList.add("customScene"); //getById("container_director").appendChild(newScene); var added = false; getById("container_director") .querySelectorAll(".customScene>[data-scene]") .forEach(ele => { if (!added && ele.dataset.scene > scene + "") { ele.parentNode.parentNode.insertBefore(newScene, ele.parentNode); added = true; } }); if (!added) { getById("container_director").appendChild(newScene); } } } } var vis = (function () { var stateKey, eventKey, keys = { hidden: "visibilitychange", webkitHidden: "webkitvisibilitychange", mozHidden: "mozvisibilitychange", msHidden: "msvisibilitychange" }; for (stateKey in keys) { if (stateKey in document) { eventKey = keys[stateKey]; break; } } return function (c) { if (c) { document.addEventListener(eventKey, c); //document.addEventListener("blur", c); //document.addEventListener("focus", c); } return !document[stateKey]; }; })(); function unPauseVideo(videoEle, update = true) { try { if (!videoEle) { return; } else if (!(videoEle.dataset.UUID in session.rpcs)) { return; } else if (!("prePausedBandwidth" in session.rpcs[videoEle.dataset.UUID])) { return; } // not paused; useless to have, but might as well session.rpcs[videoEle.dataset.UUID].manualBandwidth = false; //session.rpcs[videoEle.dataset.UUID].manualAudioBandwidth = false; if (session.rpcs[videoEle.dataset.UUID].videoElement) { session.rpcs[videoEle.dataset.UUID].videoElement.play(); } delete session.rpcs[videoEle.dataset.UUID].prePausedBandwidth; session.requestRateLimit(false, videoEle.dataset.UUID, false); // passing a bitrate of false forces the saved existing bitrate to be requested. videoEle.classList.remove("paused"); videoEle.classList.remove("partialFadeout"); if (update) { updateMixer(); } } catch (e) { errorlog(e); } } function pauseVideo(videoEle, update = true) { if (!videoEle) { return; } else if (!(videoEle.dataset.UUID in session.rpcs)) { return; } session.rpcs[videoEle.dataset.UUID].prePausedBandwidth = session.rpcs[videoEle.dataset.UUID].manualBandwidth; // useless, but whatever session.rpcs[videoEle.dataset.UUID].manualBandwidth = 0; if (session.rpcs[videoEle.dataset.UUID].videoElement) { session.rpcs[videoEle.dataset.UUID].videoElement.pause(); } //session.rpcs[videoEle.dataset.UUID].manualAudioBandwidth = 0; session.requestRateLimit(false, videoEle.dataset.UUID, true); // passing a bitrate of false forces the saved existing bitrate to be requested. videoEle.classList.add("paused"); videoEle.classList.add("partialFadeout"); if (update) { updateMixer(); } } (function rightclickmenuthing() { // right click menu "use strict"; function clickInsideElement(e, value = "menu") { var el = e.srcElement || e.target; if (el.dataset && value in el.dataset) { return el; } else { while ((el = el.parentNode)) { if (el.dataset && value in el.dataset) { return el; } } } return false; } function getPosition(event2) { var posx = 0; var posy = 0; if (!event2) { var event = window.event; } if (event2.pageX || event2.pageY) { posx = event2.pageX; posy = event2.pageY; } else if (event2.clientX || event2.clientY) { posx = event2.clientX + document.body.scrollLeft + document.documentElement.scrollLeft; posy = event2.clientY + document.body.scrollTop + document.documentElement.scrollTop; } return { x: posx, y: posy }; } var taskItemInContext; var clickCoordsX; var clickCoordsY; var menu; var menuState = 0; var lastMenu = false; var menuWidth; var menuHeight; var windowWidth; var windowHeight; function contextListener() { document.addEventListener("contextmenu", function (e) { if (!session.cleanish && session.cleanOutput) { e.preventDefault(); e.stopPropagation(); return; } if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (e && !e.ctrlKey && !e.metaKey) { return; } } else if (e && (e.ctrlKey || e.metaKey)) { return; } // allow for development ease taskItemInContext = clickInsideElement(e, "menu"); if (taskItemInContext) { e.preventDefault(); e.stopPropagation(); if (taskItemInContext.dataset && taskItemInContext.dataset.menu) { toggleMenuOn(taskItemInContext.dataset.menu, taskItemInContext); } else { toggleMenuOn(); } positionMenu(e); return false; } else { taskItemInContext = null; toggleMenuOff(); } }); } function menuClickListener(e) { var clickeElIsLink = clickInsideElement(e, "action"); if (clickeElIsLink) { e.preventDefault(); e.stopPropagation(); menuItemListener(clickeElIsLink, false, e); return false; } else { var button = e.which || e.button; if (button === 1) { toggleMenuOff(); } } } function handleInputElement(e) { // for the input range slider version var clickeElIsLink = clickInsideElement(e, "action"); if (clickeElIsLink) { e.preventDefault(); e.stopPropagation(); menuItemListener(clickeElIsLink, e.srcElement, e); return false; } else { var button = e.which || e.button; if (button === 1) { toggleMenuOff(); } } } function toggleMenuOn(menutype = false, ele = false) { if (lastMenu && lastMenu !== menutype) { try { menuState = 0; getById(lastMenu).classList.remove("context-menu--active"); document.removeEventListener("click", menuClickListener); menu.querySelectorAll("input").forEach(ele => { ele.removeEventListener("input", handleInputElement); }); } catch (e) { } } menu = getById(menutype || "context-menu"); menuItemSyncState(menu); if (menuState !== 1) { menuState = 1; menu.classList.add("context-menu--active"); document.addEventListener("click", menuClickListener); menu.querySelectorAll("input").forEach(ele => { ele.addEventListener("input", handleInputElement); }); } if (ele && ele.classOptions) { menu.classList.add(ele.classOptions); } lastMenu = menutype || "context-menu"; } function toggleMenuOff() { if (menuState !== 0) { menuState = 0; menu.classList.remove("context-menu--active"); document.removeEventListener("click", menuClickListener); menu.querySelectorAll("input").forEach(ele => { ele.removeEventListener("input", handleInputElement); }); } lastMenu = false; } function positionMenu(e) { try { var clickCoords = getPosition(e); clickCoordsX = clickCoords.x; clickCoordsY = clickCoords.y; } catch (e) { errorlog(e); return; } menuWidth = menu.offsetWidth + 4; menuHeight = menu.offsetHeight + 4; windowWidth = window.innerWidth; windowHeight = window.innerHeight; if (windowWidth - clickCoordsX < menuWidth) { menu.style.left = windowWidth - menuWidth + "px"; } else { menu.style.left = clickCoordsX + "px"; } if (windowHeight - clickCoordsY < menuHeight) { menu.style.top = windowHeight - menuHeight + "px"; } else { menu.style.top = clickCoordsY + "px"; } // Handle submenu edge positioning var submenus = menu.querySelectorAll('.context-menu__submenu'); submenus.forEach(function(submenu) { submenu.classList.remove('context-menu__submenu--left'); var parentRect = submenu.parentElement.getBoundingClientRect(); var submenuWidth = 200; // Width defined in CSS if (parentRect.right + submenuWidth > windowWidth) { submenu.classList.add('context-menu__submenu--left'); } }); } async function menuItemListener(link, inputElement = false, e = false) { if (link.getAttribute("data-action") === "Open") { window.open(taskItemInContext.href); } else if (link.getAttribute("data-action") === "Copy") { copyFunction(taskItemInContext.href); } else if (link.getAttribute("data-action") === "Mirror") { if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { session.mirrored = !session.mirrored; applyMirror(session.mirrorExclude); log("session.mirrored"); } else { if ("mirror" in taskItemInContext) { taskItemInContext.mirror = !taskItemInContext.mirror; applyMirrorGuest(taskItemInContext.mirror, taskItemInContext); } else { taskItemInContext.mirror = true; applyMirrorGuest(taskItemInContext.mirror, taskItemInContext); } } } else if (link.getAttribute("data-action") === "Rotate") { if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { session.rotate = ((session.rotate || 0) + 90) % 360; if (Firefox && session.mobile) { updateForceRotate(true); } else { updateForceRotate(false); } log("session.rotate"); setTimeout(function () { updateMixer(); }, 1); } else { if ("manualRotate" in taskItemInContext) { taskItemInContext.manualRotate = ((taskItemInContext.manualRotate || 0) + 90) % 360; taskItemInContext.rotated = taskItemInContext.manualRotate; } else { taskItemInContext.manualRotate = ((taskItemInContext.rotated || 0) + 90) % 360; taskItemInContext.rotated = taskItemInContext.manualRotate; } if (taskItemInContext.dataset) { taskItemInContext.dataset.rotated = taskItemInContext.rotated || 0; } updateVideoTransform(taskItemInContext); setTimeout(function () { updateMixer(); }, 1); } } else if (link.getAttribute("data-action") === "FullWindow") { if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { session.infocus = true; } else { session.infocus = taskItemInContext.dataset.UUID; } updateMixer(); } else if (link.getAttribute("data-action") === "ShrinkWindow") { session.infocus = false; updateMixer(); } else if (link.getAttribute("data-action") === "Pause") { pauseVideo(taskItemInContext); } else if (link.getAttribute("data-action") === "UnPause") { unPauseVideo(taskItemInContext); } else if (link.getAttribute("data-action") === "PiP") { togglePictureInPicture(taskItemInContext); } else if (link.getAttribute("data-action") === "PiP2") { PictureInPicturePageToggle(); } else if (link.getAttribute("data-action") === "Record") { if (taskItemInContext.stopWriter || taskItemInContext.recording) { } else if (taskItemInContext.startWriter) { taskItemInContext.startWriter(); } else { var videoKbps = session.recordDefault; if (session.recordLocal !== false) { videoKbps = session.recordLocal; } recordLocalVideo(null, videoKbps, taskItemInContext); } } else if (link.getAttribute("data-action") === "StopRecording") { if (taskItemInContext.stopWriter) { taskItemInContext.stopWriter(); } else if (taskItemInContext.recording) { recordLocalVideo("stop", null, taskItemInContext); } } else if (link.getAttribute("data-action") === "CopyFrameAsImage") { copyVideoFrameToClipboard(taskItemInContext, e); } else if (link.getAttribute("data-action") === "SaveFrameToDisk") { saveVideoFrameToDisk(taskItemInContext, e); } else if (link.getAttribute("data-action") === "DrawOnVideo") { if (taskItemInContext.clearDrawOnVideo) { taskItemInContext.clearDrawOnVideo(); taskItemInContext.clearDrawOnVideo = null; } else { taskItemInContext.clearDrawOnVideo = drawOnThis(taskItemInContext); } } else if (link.getAttribute("data-action") === "ChangeBuffer") { toggleBufferSettings(taskItemInContext.dataset.UUID); } else if (link.getAttribute("data-action") === "Cast") { //copyFunction(taskItemInContext.href); } else if (link.getAttribute("data-action") === "Controls") { //getById("main").classList.add("forcecontrols"); // adds an annoying shadow to the bar area //taskItemInContext.showControlBar = true; //checkVideoControlBar(taskItemInContext); //taskItemInContext.controls = false; //ele.focus(); taskItemInContext.removeAttribute("controls"); taskItemInContext.setAttribute("controls", ""); taskItemInContext.controls = true; } else if (link.getAttribute("data-action") === "HideControls") { //taskItemInContext.showControlBar = false; taskItemInContext.controls = false; taskItemInContext.removeAttribute("controls"); } else if (link.getAttribute("data-action") === "Edit") { //copyFunction(taskItemInContext.href); var response = await promptAlt("Please note, manual edits to the URL may conflict with the toggles", false, false, taskItemInContext.href); if (response) { taskItemInContext.href = response; taskItemInContext.dataset.raw = response; taskItemInContext.innerHTML = response; } } else if (link.getAttribute("data-action") === "QRCode") { warnUser("Loading QR Code"); loadQR(function tt(url) { getById("alertModalMessage").innerHTML = ""; var qrcode = new QRCode(getById("alertModalMessage"), { width: 300, height: 300, colorDark: "#000000", colorLight: "#FFFFFF", useSVG: false }); qrcode.makeCode(url); getById("alertModalMessage").title = ""; setTimeout(function () { getById("alertModalMessage").title = ""; if (getById("alertModalMessage").getElementsByTagName("img").length) { getById("alertModalMessage").getElementsByTagName("img")[0].style.cursor = "none"; } }, 100); }, taskItemInContext.href); } else if (link.getAttribute("data-action") === "ShowStats") { if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu); printMyStats(innerMenu); } else if (taskItemInContext.dataset.UUID && taskItemInContext.dataset.UUID in session.rpcs) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, taskItemInContext.dataset.UUID); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, taskItemInContext.dataset.UUID); } } else if (link.getAttribute("data-action") === "OutputAudio") { enumerateDevices().then(function (deviceInfo) { var ele = getById(taskItemInContext.id); var deviceListElement = gotDevices3(deviceInfo, ele); if (deviceListElement) { warnUser("Select the audio playback destination for this media:\n\n"); getById("alertModalMessage").appendChild(deviceListElement); } else { warnUser("No output devices available"); } }); // } else if (link.getAttribute("data-action") === "RemoteHangup") { if (session.rpcs[taskItemInContext.dataset.UUID] && session.rpcs[taskItemInContext.dataset.UUID].stats.info && "remote" in session.rpcs[taskItemInContext.dataset.UUID].stats.info && session.rpcs[taskItemInContext.dataset.UUID].stats.info.remote) { var confirmHangup = confirm(getTranslation("confirm-disconnect-user")); if (confirmHangup) { var msg = {}; msg.hangup = true; msg.remote = session.remote; msg = await session.encodeRemote(msg); session.sendRequest(msg, taskItemInContext.dataset.UUID); pokeIframeAPI("hungup", "remote", taskItemInContext.dataset.UUID); } } } else if (link.getAttribute("data-action") === "RemoteReload") { if (session.rpcs[taskItemInContext.dataset.UUID] && session.rpcs[taskItemInContext.dataset.UUID].stats.info && "remote" in session.rpcs[taskItemInContext.dataset.UUID].stats.info && session.rpcs[taskItemInContext.dataset.UUID].stats.info.remote) { var confirmReload = confirm(getTranslation("confirm-reload-user")); if (confirmReload) { var msg = {}; msg.reload = true; msg.remote = session.remote; msg = await session.encodeRemote(msg); session.sendRequest(msg, taskItemInContext.dataset.UUID); pokeIframeAPI("reload", "remote", taskItemInContext.dataset.UUID); } } } else if (link.getAttribute("data-action") === "PTZControls") { // Requires MUTUAL remote: both local viewer AND remote peer must have &remote if (session.remote && session.rpcs[taskItemInContext.dataset.UUID] && session.rpcs[taskItemInContext.dataset.UUID].stats.info && "remote" in session.rpcs[taskItemInContext.dataset.UUID].stats.info && session.rpcs[taskItemInContext.dataset.UUID].stats.info.remote) { togglePTZControls(taskItemInContext.dataset.UUID); } } else if (link.getAttribute("data-action") === "ResetAutofocus") { // Requires MUTUAL remote: both local viewer AND remote peer must have &remote if (session.remote && session.rpcs[taskItemInContext.dataset.UUID] && session.rpcs[taskItemInContext.dataset.UUID].stats.info && "remote" in session.rpcs[taskItemInContext.dataset.UUID].stats.info && session.rpcs[taskItemInContext.dataset.UUID].stats.info.remote) { session.requestAutofocusChange(true, taskItemInContext.dataset.UUID, session.remote); } } else if (link.getAttribute("data-action") === "RemoteControlsParent") { return; // Don't close menu on submenu parent click } else if (link.getAttribute("data-action") === "SSNewTab") { var URL = "https://" + window.location.hostname + location.pathname + createScreenShareURL(false); log(URL); window.open(URL, "_blank").focus(); } else if (link.getAttribute("data-action") === "pip-clock") { popOutClock(taskItemInContext.children[0]); } else if (link.getAttribute("data-action") === "Publish") { var URL = taskItemInContext.href; URL += "&clean&chroma=000&ssar=landscape&nosettings&prefercurrenttab&selfbrowsersurface=include&displaysurface=browser&np&nopush&publish&whippush&whippushtoken&q=1"; var win = window.open(URL, "targetWindow", "toolbar=no,location=no,status=no,scaling=no,menubar=no,scrollbars=no,resizable=no,width=1280,height=720"); win.focus(); win.resizeTo(1280, 720); } else if (link.getAttribute("data-action") === "RecordWindow") { var URL = taskItemInContext.href; URL += "&clean&chroma=000&ssar=landscape&nosettings&prefercurrenttab&selfbrowsersurface=include&displaysurface=browser&np&nopush&publish&autorecordlocal"; var win = window.open(URL, "targetWindow", "toolbar=no,location=no,status=no,scaling=no,menubar=no,scrollbars=no,resizable=no,width=1280,height=720"); win.focus(); win.resizeTo(1280, 720); } else if (link.getAttribute("data-action") === "SendTip") { var UUID = taskItemInContext.dataset.UUID; if (UUID && session.rpcs[UUID] && session.rpcs[UUID].acceptsTips) { if (typeof openTipModal === 'function') { openTipModal(UUID); } } else if (session.pcs && session.pcs[UUID] && session.pcs[UUID].acceptsTips) { if (typeof openTipModal === 'function') { openTipModal(UUID); } } } if (inputElement === false) { log("Task ID - " + taskItemInContext + ", Task action - " + link.getAttribute("data-action")); toggleMenuOff(); } } function menuItemSyncState(menu) { var items = menu.querySelectorAll("[data-action]"); for (var i = 0; i < items.length; i++) { if (items[i].getAttribute("data-action") === "FullWindow") { if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { if (session.infocus === true) { items[i].parentNode.classList.add("hidden"); } else { items[i].parentNode.classList.remove("hidden"); } } else if (taskItemInContext.dataset.UUID === session.infocus) { items[i].parentNode.classList.add("hidden"); } else { items[i].parentNode.classList.remove("hidden"); } } else if (items[i].getAttribute("data-action") === "ShrinkWindow") { if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { if (session.infocus === true) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (taskItemInContext.dataset.UUID === session.infocus) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "Pause") { if (taskItemInContext.dataset.UUID && taskItemInContext.dataset.UUID in session.rpcs) { if ("prePausedBandwidth" in session.rpcs[taskItemInContext.dataset.UUID]) { items[i].parentNode.classList.add("hidden"); } else { items[i].parentNode.classList.remove("hidden"); } } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "UnPause") { if (taskItemInContext.dataset.UUID && taskItemInContext.dataset.UUID in session.rpcs) { if ("prePausedBandwidth" in session.rpcs[taskItemInContext.dataset.UUID]) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "Record") { if (taskItemInContext.stopWriter || taskItemInContext.recording) { items[i].parentNode.classList.add("hidden"); } else { items[i].parentNode.classList.remove("hidden"); } } else if (items[i].getAttribute("data-action") === "StopRecording") { if (taskItemInContext.stopWriter || taskItemInContext.recording) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "CopyFrameAsImage") { if (taskItemInContext.srcObject && taskItemInContext.srcObject.getVideoTracks().length) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "SaveFrameToDisk") { if (taskItemInContext.srcObject && taskItemInContext.srcObject.getVideoTracks().length) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "Controls") { if (taskItemInContext.controls) { items[i].parentNode.classList.add("hidden"); } else { items[i].parentNode.classList.remove("hidden"); } } else if (items[i].getAttribute("data-action") === "HideControls") { if (taskItemInContext.controls) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "PiP2") { if (typeof documentPictureInPicture !== "undefined") { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "RemoteControlsParent") { // Show/hide the entire Remote Controls submenu // Requires MUTUAL remote: both local viewer AND remote peer must have &remote if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { items[i].parentNode.classList.add("hidden"); } else if (session.remote && session.rpcs[taskItemInContext.dataset.UUID] && session.rpcs[taskItemInContext.dataset.UUID].stats.info && "remote" in session.rpcs[taskItemInContext.dataset.UUID].stats.info && session.rpcs[taskItemInContext.dataset.UUID].stats.info.remote) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "ChangeBuffer") { if (taskItemInContext.id == "videosource" || taskItemInContext.id == "previewWebcam") { items[i].parentNode.classList.add("hidden"); } else if (session.rpcs[taskItemInContext.dataset.UUID]) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "TipRightClick") { if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { items[i].parentNode.classList.add("hidden"); } else { items[i].parentNode.classList.remove("hidden"); } } else if (items[i].getAttribute("data-action") === "SendTip") { // Show tip option only if: // 1. Video source accepts tips (publisher opt-in) // 2. Viewer has opted in with &showtips (viewer opt-in) // 3. Not in clean mode // 4. Not in Electron // 5. Performer has completed Stripe setup (validated) var UUID = taskItemInContext.dataset.UUID; var acceptsTips = false; var tipId = null; var tipServer = null; if (UUID) { if (session.rpcs && session.rpcs[UUID] && session.rpcs[UUID].acceptsTips) { acceptsTips = true; tipId = session.rpcs[UUID].tipId; tipServer = session.rpcs[UUID].tipServer; } else if (session.pcs && session.pcs[UUID] && session.pcs[UUID].acceptsTips) { acceptsTips = true; tipId = session.pcs[UUID].tipId; tipServer = session.pcs[UUID].tipServer; } } // Check if performer is validated (use cache if available) var performerValid = false; if (tipId) { tipServer = tipServer || session.tipServer || "https://ninjabacker.com"; var cacheKey = tipServer + "/" + tipId; performerValid = tipPerformerCache[cacheKey] === true; } if (acceptsTips && performerValid && session.showTips && !session.cleanOutput && navigator.userAgent.toLowerCase().indexOf(" electron/") === -1) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "Publish") { if (taskItemInContext.classList.contains("publish")) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } else if (items[i].getAttribute("data-action") === "RecordWindow") { if (taskItemInContext.classList.contains("publish")) { items[i].parentNode.classList.remove("hidden"); } else { items[i].parentNode.classList.add("hidden"); } } } } contextListener(); })(); function checkVideoControlBar(ele) { // this is aggressive. Lets not use it unless required. if (ele) { if (ele.showControlBar) { if (ele.showControlBarInterval) { clearTimeout(ele.showControlBarInterval); } ele.focus(); ele.removeAttribute("controls"); ele.setAttribute("controls", ""); ele.focus(); ele.showControlBarInterval = setTimeout(function (ele) { checkVideoControlBar(ele); }, 100, ele); } } } function gotDevices3(deviceInfos, vid) { var audioEle = document.createElement("select"); log(deviceInfos); if (!deviceInfos.length) { return false; } for (let i = 0; i !== deviceInfos.length; ++i) { if (deviceInfos[i].kind === "audiooutput") { var opt = document.createElement("option"); opt.innerText = deviceInfos[i].label; opt.value = deviceInfos[i].deviceId; audioEle.appendChild(opt); audioEle.videoTarget = vid; if (vid.sinkId) { if (vid.sinkId == deviceInfos[i].deviceId) { opt.selected = true; } } else if (vid.manualSink) { if (vid.manualSink == deviceInfos[i].deviceId) { opt.selected = true; } } else if (session.sink) { if (session.sink == deviceInfos[i].deviceId) { opt.selected = true; } } } } audioEle.onchange = function () { vid.manualSink = this.options[this.selectedIndex].value; if (this.videoTarget && this.videoTarget.dataset.UUID) { session.audioEffects = true; updateIncomingAudioElement(this.videoTarget.dataset.UUID); } resetupAudioOut(this.videoTarget); }; return audioEle; } function popupMessage(e, message = "Copied to Clipboard") { // right click menu var posx = 0; var posy = 0; if (!e) var e = window.event; if (e.pageX || e.pageY) { posx = e.pageX; posy = e.pageY; } else if (e.clientX || e.clientY) { posx = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft; posy = e.clientY + document.body.scrollTop + document.documentElement.scrollTop; } posx += 10; var menu = getById("messagePopup"); menu.innerHTML = "
    " + message + "
    "; var menuState = 0; var menuWidth; var menuHeight; var menuPosition; var menuPositionX; var menuPositionY; var windowWidth; var windowHeight; if (menuState !== 1) { menuState = 1; menu.classList.add("context-menu--active"); } menuWidth = menu.offsetWidth + 4; menuHeight = menu.offsetHeight + 4; windowWidth = window.innerWidth; windowHeight = window.innerHeight; if (windowWidth - posx < menuWidth) { menu.style.left = windowWidth - menuWidth + "px"; } else { menu.style.left = posx + "px"; } if (windowHeight - posy < menuHeight) { menu.style.top = windowHeight - menuHeight + "px"; } else { menu.style.top = posy + "px"; } function toggleMenuOff() { if (menuState !== 0) { menuState = 0; menu.classList.remove("context-menu--active"); } } menu.classList.remove("fadeout"); var showlength = message.length * 50 || 500; setTimeout(function () { menu.classList.add("fadeout"); }, showlength); setTimeout(function () { toggleMenuOff(); }, showlength + 1000); } function timeSince(date) { var seconds = Math.floor((new Date() - date) / 1000); var interval = seconds / 31536000; if (interval > 1) { return Math.floor(interval) + " years"; } interval = seconds / 2592000; if (interval > 1) { return Math.floor(interval) + " months"; } interval = seconds / 86400; if (interval > 1) { return Math.floor(interval) + " days"; } interval = seconds / 3600; if (interval > 1) { return Math.floor(interval) + " hours"; } interval = seconds / 60; if (interval > 1) { return Math.floor(interval) + " minutes"; } return "Seconds ago"; } var messageList = []; function sendChatMessage(chatMsg = false, bc = false) { // filtered + visual var data = {}; if (chatMsg === false) { var msg = document.getElementById("chatInput").value; } else { var msg = chatMsg; } //msg = sanitizeChat(msg); if (msg == "") { return false; } msg = convertShortcodes(msg); var label = ""; if (session.label) { if (session.director) { label = "" + session.label + ": "; } else { label = "" + session.label + ": "; } } else if (session.director) { label = "Director: "; } if (msg.trim() === "/list") { var listMsg = null; for (var UUID in session.rpcs) { if (session.rpcs[UUID].label) { listMsg = UUID + ": " + session.rpcs[UUID].label; } else if (session.directorList.indexOf(UUID) >= 0) { listMsg = UUID + ": Director"; } else { listMsg = UUID + ": Unknown User"; } var data = {}; data.msg = listMsg; data.label = false; data.type = "alert"; data.time = Date.now(); messageList.push(data); } for (var UUID in session.pcs) { if (UUID in session.rpcs) { continue; } if (session.pcs[UUID].label) { listMsg = UUID + "; " + session.pcs[UUID].label; } else if (session.directorList.indexOf(UUID) >= 0) { listMsg = UUID + "; Director"; } else { listMsg = UUID + "; Unknown User"; } var data = {}; data.msg = listMsg; data.label = false; data.type = "alert"; data.time = Date.now(); messageList.push(data); } if (listMsg === null) { data.msg = "No other users are connected to you"; data.label = false; data.type = "alert"; data.time = Date.now(); messageList.push(data); } } else if (msg.startsWith("/msg ")) { var msg = msg.split("/msg ")[1]; msg = msg.split(" "); uid = msg.shift().toLowerCase(); msg = msg.join(" "); if (msg == "") { return false; } var sent = false; for (var UUID in session.rpcs) { if (UUID.startsWith(uid)) { sendChat(msg, UUID); // send message to peers var data = {}; data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); sent = true; } else if (session.rpcs[UUID].label && session.rpcs[UUID].label.toLowerCase().startsWith(uid)) { sendChat(msg, UUID); // send message to peers var data = {}; data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); sent = true; } else if (session.directorList.indexOf(UUID) >= 0 && "director".startsWith(uid)) { sendChat(msg, UUID); // send message to peers var data = {}; data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); sent = true; } } for (var UUID in session.pcs) { if (UUID in session.rpcs) { continue; } if (UUID.startsWith(uid)) { sendChat(msg, UUID); // send message to peers var data = {}; data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); sent = true; } else if (session.pcs[UUID].label && session.pcs[UUID].label.toLowerCase().startsWith(uid)) { sendChat(msg, UUID); // send message to peers var data = {}; data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); sent = true; } else if (session.directorList.indexOf(UUID) >= 0 && "director".startsWith(uid)) { sendChat(msg, UUID); // send message to peers var data = {}; data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); sent = true; } } if (sent == false) { var data = {}; data.msg = "No user found. Message not sent."; data.label = false; data.type = "alert"; data.time = Date.now(); messageList.push(data); updateMessages(); return false; } } else if (msg.startsWith("/")) { data.msg = "Unknown command. Try '/list' or '/msg username message'."; data.label = false; data.type = "alert"; data.time = Date.now(); messageList.push(data); updateMessages(); return false; } else if (session.directorChat === true) { if (session.directorList.length) { for (var i = 0; i < session.directorList.length; i++) { sendChat(msg, session.directorList[i]); // send message to peers } var data = {}; data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); } } else { sendChat(msg); // send message to peers data.time = Date.now(); data.msg = sanitizeChat(msg); // this is what the other person should see data.label = label; data.type = "sent"; messageList.push(data); } document.getElementById("chatInput").value = ""; messageList = messageList.slice(-100); if (!bc && session.broadcastChannel !== false) { log(session.broadcastChannel); session.broadcastChannel.postMessage(data); } updateMessages(); if (isIFrame) { parent.postMessage( { chat: data }, session.iframetarget ); } var apiBlob = {}; apiBlob.time = data.time; apiBlob.msg = msg; apiBlob.label = session.label; apiBlob.type = data.type; pokeAPI("chat", apiBlob); return true; } function disableQualityDirector(UUID) { // lets revert back to the director's quality settings after viewing the scene try { var elements = document.querySelectorAll('[data-action-type="change-quality2"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.add("disable"); elements[0].ariaPressed = "false"; elements[0].classList.remove("pressed"); elements[0].disabled = "true"; elements[0].title = getTranslation("preview-meshcast-disabled"); } var elements = document.querySelectorAll('[data-action-type="change-quality1"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.add("disable"); elements[0].ariaPressed = "false"; elements[0].classList.remove("pressed"); elements[0].disabled = "true"; elements[0].title = getTranslation("preview-meshcast-disabled"); } var elements = document.querySelectorAll('[data-action-type="change-quality3"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.add("disable"); elements[0].ariaPressed = "false"; elements[0].classList.remove("pressed"); elements[0].disabled = "true"; elements[0].title = getTranslation("preview-meshcast-disabled"); } } catch (e) { errorlog(e); } } function applyQualityDirector(uuid = false) { // lets revert back to the director's quality settings after viewing the scene if (uuid) { var eles = document.querySelectorAll('#guestFeeds button.pressed[data-action-type="change-quality1"][data--u-u-i-d="' + uuid + '"],#guestFeeds button.pressed[data-action-type="change-quality2"][data--u-u-i-d="' + uuid + '"],#guestFeeds button.pressed[data-action-type="change-quality3"][data--u-u-i-d="' + uuid + '"]'); eles.forEach(ele => { ele.click(); }); } else { var eles = document.querySelectorAll('#guestFeeds button.pressed[data-action-type="change-quality1"],#guestFeeds button.pressed[data-action-type="change-quality2"],#guestFeeds button.pressed[data-action-type="change-quality3"]'); eles.forEach(ele => { ele.click(); }); } } function toggleQualityDirector(bitrate, UUID, ele) { // ele is specific to the button in the director's room var eles = ele.parentNode.childNodes; for (var i = 0; i < eles.length; i++) { eles[i].className = ""; } ele.classList.add("pressed"); ele.ariaPressed = "true"; session.requestRateLimit(bitrate, UUID); } var clockOverlayTimer = null; function zpadTime(number) { var output = "" + number; while (output.length < 2) { output = "0" + output; } return output; } function showClock() { getById("overlayClockContainer").classList.remove("hidden"); } function hideClock() { getById("overlayClockContainer").classList.add("hidden"); } function setClock(initial = false, color = "#000") { if (initial !== false) { initial = parseInt(initial); getById("overlayClockContainer").dataset.initial = initial; } else { initial = parseInt(getById("overlayClockContainer").dataset.initial); } if (initial < 0) { initial = 0; } updateClock(initial, color); } function stopClock() { var clock = document.getElementById("overlayClock"); //clock.ctx = null; //clock.canvas = null; //if (document.pictureInPictureElement && clock.video) { // if (document.pictureInPictureElement == clock.video){ // document.exitPictureInPicture(); // pokeIframeAPI('picture-in-picture', false); // } //clock.video.remove; //} clock.innerHTML = ""; clearInterval(clockOverlayTimer); //setClock(); updateClock("0", "#444"); } function pauseClock() { clearInterval(clockOverlayTimer); var current = Date.now() - parseInt(getById("overlayClockContainer").dataset.timestamp); if (parseInt(getById("overlayClockContainer").dataset.initial) == 0) { current = parseInt(Math.round(current / 1000)); } else { current = parseInt(getById("overlayClockContainer").dataset.initial) - parseInt(Math.round(current / 1000)); } getById("overlayClockContainer").dataset.current = current; updateClock(current, "#00F"); } function resumeClock() { if ("current" in getById("overlayClockContainer").dataset) { startClock(parseInt(getById("overlayClockContainer").dataset.current)); } } function startClock(restart = true) { clearInterval(clockOverlayTimer); if (restart === true) { getById("overlayClockContainer").dataset.timestamp = Date.now(); } else if (parseInt(getById("overlayClockContainer").dataset.initial) == 0) { getById("overlayClockContainer").dataset.timestamp = Date.now() - parseInt(getById("overlayClockContainer").dataset.current * 1000); } else { getById("overlayClockContainer").dataset.timestamp = Date.now() - (parseInt(getById("overlayClockContainer").dataset.initial) * 1000 - parseInt(getById("overlayClockContainer").dataset.current * 1000)); } stepClock(); var clock = document.getElementById("overlayClock"); if (clock && clock.video) { clock.innerHTML = ""; clock.appendChild(clock.video); clock.video.play(); } clockOverlayTimer = setInterval(function () { stepClock(); }, 999); } function stepClock() { var current = Date.now() - parseInt(getById("overlayClockContainer").dataset.timestamp); if (parseInt(getById("overlayClockContainer").dataset.initial) == 0) { current = parseInt(Math.round(current / 1000)); } else { current = parseInt(getById("overlayClockContainer").dataset.initial) - parseInt(Math.round(current / 1000)); } if (session.directorList.length) { var msg = {}; msg.timer = current; for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } if (current < 0 && current % 2) { updateClock(0, "#F00"); } else if (current < 0) { updateClock(0, "#000"); } else { updateClock(current, "#000"); } } function updateClock(timeleft, color = "#000") { var minutes = Math.floor(timeleft / 60); var seconds = timeleft % 60; var clock = document.getElementById("overlayClock"); if (clock.ctx) { clock.ctx.beginPath(); clock.ctx.rect(0, 0, 230, 40); clock.ctx.fillStyle = color; clock.ctx.fill(); clock.ctx.fillStyle = "#FFF"; clock.ctx.textAlign = "center"; clock.ctx.font = "50px monospace"; clock.ctx.fillText(zpadTime(minutes) + ":" + zpadTime(seconds), 115, 37); } else { clock.innerHTML = zpadTime(minutes) + ":" + zpadTime(seconds); clock.style.backgroundColor = color + "9"; } } function popOutClock(clock) { if (!clock.ctx) { var canvas = document.createElement("canvas"); canvas.width = "230"; canvas.height = "40"; var ctx = canvas.getContext("2d"); clock.canvas = canvas; clock.ctx = ctx; ctx.beginPath(); ctx.rect(0, 0, 230, 40); ctx.fillStyle = "#000"; ctx.fill(); ctx.fillStyle = "#FFF"; ctx.font = "50px monospace"; ctx.textAlign = "center"; ctx.fillText(clock.innerHTML, 115, 37); clock.video = document.createElement("video"); clock.innerHTML = ""; clock.appendChild(clock.video); clock.video.onloadedmetadata = function () { togglePictureInPicture(clock.video); }; clock.video.srcObject = canvas.captureStream(); clock.video.play(); //clock.video.dataset.menu = "context-menu-clock"; } else { clock.innerHTML = ""; clock.appendChild(clock.video); clock.video.play(); togglePictureInPicture(clock.video); } } session.popupChat = null async function createPopoutChat() { if (session.popupChat && !session.popupChat.closed) { session.popupChat.focus(); return; } if (session.broadcastChannelID === false) { session.broadcastChannelID = session.generateStreamID(8); log(session.broadcastChannelID); session.broadcastChannel = new BroadcastChannel(session.broadcastChannelID); session.broadcastChannel.onmessage = function (e) { if ("loaded" in e.data) { session.broadcastChannel.postMessage({ messageList: messageList }); } else if ("msg" in e.data) { sendChatMessage(e.data.msg, true); } }; session.broadcastChannel.onmessageerror = function (e) { errorlog(e); }; } let params = { broadcastChannelID: session.broadcastChannelID, room: session.roomid || false, view: session.view_set ? [...session.view_set, session.streamID].join(",") : (session.roomid ? false : session.streamID), label: session.label || false, password: session.password }; function encrypt(text, key) { const textEncoder = new TextEncoder(); const encodedText = textEncoder.encode(text); const encodedKey = textEncoder.encode(key); const encrypted = encodedText.map((byte, i) => byte ^ encodedKey[i % encodedKey.length] ); return btoa(String.fromCharCode.apply(null, encrypted)); } async function generateSecureUrl(params) { const ENCRYPTION_KEY = 'your32characterlongencryptionkey!!'; const filteredParams = Object.fromEntries( Object.entries(params).filter(([_, v]) => v != null && v !== undefined) ); const paramsString = JSON.stringify(filteredParams); const encrypted = encrypt(paramsString, ENCRYPTION_KEY); return `./popout.html?id=${session.broadcastChannelID}&data=${encodeURIComponent(encrypted)}`; } let srcString = await generateSecureUrl(params); log(srcString); session.popupChat = window.open(srcString, "popup", "width=600,height=480,toolbar=no,menubar=no,resizable=yes"); session.popupChat.document.body.style.margin = "0"; session.popupChat.document.body.style.backgroundColor = "#000"; session.popupChat.document.body.style.padding = "0"; session.popupChat.document.body.style.overflow = "hidden"; session.popupChat.document.title = "Chat pop-out"; const style = session.popupChat.document.createElement('style'); style.textContent = ` @keyframes pulse { 0% { background-color: #000; } 50% { background-color: #333; } 100% { background-color: #000; } } body { animation: pulse 2s ease-in-out infinite; } `; session.popupChat.document.head.appendChild(style); return false; } function replaceURLs(message) { if (!message) return; var urlRegex = /(((https?:\/\/)|(www\.))[^\s]+)/g; return message.replace(urlRegex, function (url) { url = url.replace(//g, ">").replace(/["']/g, ""); // try to sanitize things, just in case. var punc = ""; while (url[url.length - 1] === ".") { url = url.slice(0, -1); punc += "."; } while (url[url.length - 1] === ";") { url = url.slice(0, -1); punc += ";"; } while (url[url.length - 1] === ",") { url = url.slice(0, -1); punc += ","; } while (url[url.length - 1] === "!") { url = url.slice(0, -1); punc += "!"; } while (url[url.length - 1] === ":") { url = url.slice(0, -1); punc += ":"; } while (url[url.length - 1] === "*") { url = url.slice(0, -1); punc += "*"; } while (url[url.length - 1] === ")") { url = url.slice(0, -1); punc += ")"; } while (url[url.length - 1] === "?") { url = url.slice(0, -1); punc += "?"; } var hyperlink = url; if (!hyperlink.match("^https?://")) { hyperlink = "http://" + hyperlink; } if (url.length > 35) { url = url.substring(0, 35) + "..."; } return '' + url + "" + punc; }); } function getChatMessage(msg, label = false, director = false, overlay = false, UUID = false) { msg = sanitizeChat(msg); // keep it clean. if (msg == "") { return; } data = {}; data.time = Date.now(); var apiBlob = {}; apiBlob.time = data.time; apiBlob.msg = msg; apiBlob.label = label; apiBlob.type = "recv"; if (UUID !== false) { apiBlob.UUID = UUID; if (UUID in session.rpcs) { apiBlob.streamID = session.rpcs[UUID].streamID || false; } } const streamFallbackLabel = (!label && session.director && UUID && session.rpcs[UUID] && session.rpcs[UUID].streamID) ? getPeerDisplayName(UUID, false, false) : false; if (label) { label = sanitizeLabel(label); } data.msg = msg; if (label) { data.label = label; if (director) { data.label = "" + data.label + ": "; } else { data.label = "" + data.label + ": "; } label = "" + label + ":"; // label+":"; } else if (director) { data.label = "Director: "; label = "Director:"; } else { if (session.director) { const fallback = streamFallbackLabel || "Someone"; data.label = "" + fallback + ": "; if (streamFallbackLabel) { label = "" + fallback + ":"; } else { label = ""; } } else { data.label = ""; label = ""; } } data.type = "recv"; if (overlay) { if (!(session.cleanOutput && session.cleanish == false)) { var textOverlay = getById("overlayMsgs"); if (textOverlay) { if (overlay == 2) { // Clear previous persistent messages while (textOverlay.querySelector('.persistent-overlay')) { textOverlay.removeChild(textOverlay.querySelector('.persistent-overlay')); } var spanOverlay = document.createElement("span"); spanOverlay.className = 'persistent-overlay'; spanOverlay.innerHTML = "" + label + " " + msg + "
    "; textOverlay.appendChild(spanOverlay); textOverlay.style.display = "block"; } else { var spanOverlay = document.createElement("span"); spanOverlay.innerHTML = "" + label + " " + msg + "
    "; textOverlay.appendChild(spanOverlay); textOverlay.style.display = "block"; var showtime = msg.length * 200 + 3000; if (showtime > 8000) { showtime = 8000; } setTimeout( function (ele) { try { ele.parentNode.removeChild(ele); } catch (e) { } }, showtime, spanOverlay ); } } } } if (isIFrame) { parent.postMessage( { gotChat: data, // deprecated chat: data }, session.iframetarget ); } pokeAPI("chat", apiBlob); if (session.chatbutton === false) { return; } // messages can still appear as overlays ^ messageList.push(data); messageList = messageList.slice(-100); if (session.beepToNotify) { playtone(); showNotification("new message", msg); } updateMessages(); if (session.chat == false) { getById("chattoggle").className = "las la-comments toggleSize pulsate"; getById("chatbutton").className = "float"; if (getById("chatNotification").value) { getById("chatNotification").value = getById("chatNotification").value + 1; } else { getById("chatNotification").value = 1; } getById("chatNotification").classList.add("notification", "red"); } if (session.broadcastChannel !== false) { session.broadcastChannel.postMessage(data); /* send */ } } function rainbow(step, colours) { var r, g, b; var h = 1 - step / colours; var i = ~~(h * 6); var f = h * 6 - i; var q = 1 - f; switch (i % 6) { case 0: (r = 1), (g = f), (b = 0); break; case 1: (r = q), (g = 1), (b = 0); break; case 2: (r = 0), (g = 1), (b = f); break; case 3: (r = 0), (g = q), (b = 1); break; case 4: (r = f), (g = 0), (b = 1); break; case 5: (r = 1), (g = 0), (b = q); break; } var c = "#" + ("00" + (~~(r * 200 + 35)).toString(16)).slice(-2) + ("00" + (~~(g * 200 + 35)).toString(16)).slice(-2) + ("00" + (~~(b * 200 + 35)).toString(16)).slice(-2); return c; } function getColorFromName(str, colorseed = false, totalcolors = false) { var out = 0, len = str.length; if (len > 6) { len = 6; } var seed = 26; if (colorseed) { seed = colorseed || 1; } for (var pos = 0; pos < len; pos++) { out += (str.charCodeAt(pos) - 64) * Math.pow(seed, len - pos - 1); } var colours = 167772; if (totalcolors) { colours = totalcolors; if (colours > 167772) { colours = 167772; } else if (colours < 1) { colours = 1; } } out = parseInt(out % colours); // get modulus if (colours === 1) { return "#F00"; } else if (colours === 2) { switch (out) { case 0: return "#F00"; case 1: return "#00ABFA"; } } else if (colours === 3) { switch (out) { case 0: return "#F00"; case 1: return "#00A800"; case 2: return "#00ABFA"; } } else if (colours === 4) { switch (out) { case 0: return "#F00"; case 1: return "#FFA500"; case 2: return "#00A800"; case 3: return "#00ABFA"; } } else if (colours === 5) { switch (out) { case 0: return "#F00"; case 1: return "#FFA500"; case 2: return "#00A800"; case 3: return "#00ABFA"; case 4: return "#FF39C5"; } } else { out = rainbow(out, colours); } return out; } function updateClosedCaptions(msg, label, UUID) { if (!session.rpcs[UUID].color && session.ccColored) { session.rpcs[UUID].color = getColorFromName(UUID); } msg.counter = parseInt(msg.counter); var temp = document.createElement("div"); temp.innerText = msg.transcript; temp.innerText = temp.innerHTML; var transcript = temp.textContent || temp.innerText || ""; if (transcript == "") { return; } transcript = transcript.charAt(0).toUpperCase() + transcript.slice(1); //transcript = transcript.substr(-1, 5000); // keep it from being too long if (session.nocaptionlabels) { label = ""; } else if (label && !(session.view && !session.view_set)) { label = sanitizeLabel(label); label = "" + label + ": "; } else { label = ""; } var textOverlay = getById("overlayMsgs"); if (textOverlay) { if (document.getElementById(UUID + "_" + msg.counter)) { var spanOverlay = document.getElementById(UUID + "_" + msg.counter); } else { var spanOverlay = document.createElement("span"); spanOverlay.id = UUID + "_" + msg.counter; textOverlay.appendChild(spanOverlay); textOverlay.style.height = "unset"; textOverlay.style.textAlign = "left"; textOverlay.style.display = "block"; textOverlay.style.position = "fixed"; textOverlay.style.bottom = "0"; } spanOverlay.innerHTML = label + transcript + "
    "; spanOverlay.style.fontSize = (parseInt(session.labelsize || 100) / 100.0) * 4.5 + "vh"; spanOverlay.style.lineHeight = (parseInt(session.labelsize || 100) / 100) * 6 + "vh"; spanOverlay.style.margin = (parseInt(session.labelsize || 100) / 100.0) * 0.75 + "vh"; if (session.rpcs[UUID].color && session.ccColored) { spanOverlay.style.color = session.rpcs[UUID].color; } if (msg.isFinal) { var showtime = 3000; clearTimeout(spanOverlay.timeout); spanOverlay.timeout = setTimeout( function (ele) { ele.parentNode.removeChild(ele); }, showtime, spanOverlay ); } else { clearTimeout(spanOverlay.timeout); spanOverlay.timeout = setTimeout( function (ele) { ele.parentNode.removeChild(ele); }, 30000, spanOverlay ); } } } var chatUpdateTimeout = null; function updateMessages() { if (session.chatbutton === false) { return; } getById("chatNotification").classList.remove("notification", "red"); if (session.chat) { getById("chattoggle").classList.remove("pulsate"); } const chatBody = document.getElementById("chatBody"); chatBody.innerHTML = ""; for (var i in messageList) { var time = timeSince(messageList[i].time) || ""; time = " - " + time + ""; var msg = document.createElement("div"); var message = replaceURLs(messageList[i].msg); if (messageList[i].type == "sent") { msg.innerHTML = message + "" + time + ""; msg.classList.add("outMessage"); } else if (messageList[i].type == "recv" || messageList[i].type == "action") { var label = ""; if (messageList[i].label) { label = messageList[i].label; } msg.innerHTML = label + message + "" + time + ""; msg.classList.add("inMessage"); } else if (messageList[i].type == "alert") { msg.innerHTML = message + "" + time + ""; msg.classList.add("inMessage"); } else if (messageList[i].type == "tip") { msg.innerHTML = message + "" + time + ""; msg.classList.add("tipMessage"); } else { msg.innerHTML = message; msg.classList.add("outMessage"); } chatBody.appendChild(msg); } showDownloadLinks(); for (var i in msgTransferList) { var time = timeSince(msgTransferList[i].time) || ""; time = " - " + time + ""; var msg = document.createElement("div"); if ("idx" in msgTransferList[i]) { msg.id = "transfer_" + msgTransferList[i].idx; msg.classList.add("transfer"); } if (msgTransferList[i].type == "sent") { msg.innerHTML = msgTransferList[i].msg + "" + time + ""; msg.classList.add("outMessage"); } else if (msgTransferList[i].type == "recv" || msgTransferList[i].type == "action") { var label = ""; if (msgTransferList[i].label) { label = msgTransferList[i].label; } msg.innerHTML = label + msgTransferList[i].msg + "" + time + ""; msg.classList.add("inMessage"); } else if (msgTransferList[i].type == "alert") { msg.innerHTML = msgTransferList[i].msg + "" + time + ""; msg.classList.add("inMessage"); } else { msg.innerHTML = msgTransferList[i].msg; msg.classList.add("outMessage"); } if (msg.id && document.getElementById(msg.id)) { document.getElementById(msg.id).innerHTML = msg.innerHTML; } else { chatBody.appendChild(msg); } } if (chatUpdateTimeout) { clearInterval(chatUpdateTimeout); } chatBody.scrollTop = chatBody.scrollHeight; if (chatUpdateTimeout) { clearTimeout(chatUpdateTimeout); } chatUpdateTimeout = setTimeout(updateMessages, 60000); } function EnterButtonChat(event) { // Number 13 is the "Enter" key on the keyboard var key = event.which || event.keyCode; if (key === 13) { // Cancel the default action, if needed event.preventDefault(); // Trigger the button element with a click sendChatMessage(); } } function showCustomizer(arg, ele) { //getById("directorLinksButton").innerHTML=' LINKS (GUEST INVITES & SCENES)' getById("showCustomizerButton1").style.backgroundColor = ""; getById("showCustomizerButton2").style.backgroundColor = ""; getById("showCustomizerButton3").style.backgroundColor = ""; getById("showCustomizerButton4").style.backgroundColor = ""; getById("showCustomizerButton1").style.boxShadow = ""; getById("showCustomizerButton2").style.boxShadow = ""; getById("showCustomizerButton3").style.boxShadow = ""; getById("showCustomizerButton4").style.boxShadow = ""; if (getById("customizeLinks" + arg).style.display != "none") { getById("customizeLinks").style.display = "none"; getById("customizeLinks" + arg).style.display = "none"; } else { //directorLinks").style.display="none"; getById("showCustomizerButton" + arg).style.backgroundColor = "#1e0000"; getById("showCustomizerButton" + arg).style.boxShadow = "inset 0px 0px 1px #b90000"; getById("customizeLinks1").style.display = "none"; getById("customizeLinks3").style.display = "none"; getById("customizeLinks").style.display = "block"; getById("customizeLinks" + arg).style.display = "block"; } } function setPTTvalue() { var key = ""; if (PPTHotkey.ctrl) { key += "Control"; } if (PPTHotkey.meta) { if (key) { key += " + "; } key += "Meta"; } if (PPTHotkey.alt) { if (key) { key += " + "; } key += "Alt"; } if (PPTHotkey.key == "Control") { // } else if (PPTHotkey.key == "Alt") { // } else if (PPTHotkey.key == "Meta") { // } else if (PPTHotkey.key !== false) { if (key) { key += " + "; } if (PPTHotkey.key === " ") { key += "Space"; } else { key += PPTHotkey.key; } } else if (key && navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { getById("pptHotKey").title = "Note: Global hot-keys can't simply be Control, Alt, or Meta keys."; } getById("pptHotKey").value = key; try { if (window.electronApi && window.electronApi.updatePPT) { window.electronApi.updatePPT(PPTHotkey); } else if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (!ipcRenderer) { ipcRenderer = require("electron").ipcRenderer; } if (ipcRenderer) { ipcRenderer.send("PPTHotkey", PPTHotkey); } } } catch (e) { errorlog(e); } } var PPTHotkey = getStorage("PPTHotkey") || false; if (PPTHotkey) { setPTTvalue(); } function setHotKeyAuto(hotkeyInput) { PPTHotkey = { ctrl: false, alt: false, meta: false, key: false }; var key = ""; if (hotkeyInput) { const modifiers = hotkeyInput.replaceAll(" ", "+").split("+"); modifiers.forEach(modifier => { const trimmedModifier = modifier.trim().toLowerCase(); if (trimmedModifier === "control") { PPTHotkey.ctrl = true; key += "Control"; } else if (trimmedModifier === "ctrl") { PPTHotkey.ctrl = true; key += "Control"; } else if (trimmedModifier === "alt") { PPTHotkey.alt = true; key += "Alt"; } else if (trimmedModifier === "meta") { PPTHotkey.meta = true; key += "Meta"; } }); var lastKey = modifiers.pop().trim(); PPTHotkey.key = lastKey; if (lastKey || lastKey === " " || lastKey === 0) { if (key) { key += " + "; } if (lastKey === " ") { key += "Space"; } else { key += lastKey; } } } else { PPTHotkey.ctrl = true; PPTHotkey.key = "m"; PPTHotkey.meta = true; key = "Control + Alt + m"; } setStorage("PPTHotkey", PPTHotkey, 99999); getById("pptHotKey").value = key; try { if (window.electronApi && window.electronApi.updatePPT) { window.electronApi.updatePPT(PPTHotkey); } else if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (!ipcRenderer) { ipcRenderer = require("electron").ipcRenderer; } if (ipcRenderer) { ipcRenderer.send("PPTHotkey", PPTHotkey); } } } catch (e) { errorlog(e); } } function setHotKey(keyinput = true) { if (!keyinput) { // clears if false getById("pptHotKey").value = ""; getById("pptHotKey0").value = ""; PPTHotkey = false; removeStorage("PPTHotkey"); try { if (window.electronApi && window.electronApi.updatePPT) { window.electronApi.updatePPT(PPTHotkey); } else if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (!ipcRenderer) { ipcRenderer = require("electron").ipcRenderer; } if (ipcRenderer) { ipcRenderer.send("PPTHotkey", PPTHotkey); } } } catch (e) { errorlog(e); } return; } PPTHotkey = { ctrl: false, alt: false, meta: false, key: false }; log(event); var key = ""; if (event.ctrlKey) { key += "Control"; PPTHotkey.ctrl = true; } if (event.metaKey) { if (key) { key += " + "; } key += "Meta"; PPTHotkey.meta = true; } if (event.altKey) { if (key) { key += " + "; } key += "Alt"; PPTHotkey.alt = true; } if (event.key == "Control") { // } else if (event.key == "Alt") { // } else if (event.key == "Meta") { // } else if (event.key || event.key === " " || event.key === 0) { if (key) { key += " + "; } if (event.key === " ") { key += "Space"; } else { key += event.key; } PPTHotkey.key = event.key; } setStorage("PPTHotkey", PPTHotkey, 99999); event.target.value = key; try { if (window.electronApi && window.electronApi.updatePPT) { window.electronApi.updatePPT(PPTHotkey); } else if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (!ipcRenderer) { ipcRenderer = require("electron").ipcRenderer; } if (ipcRenderer) { ipcRenderer.send("PPTHotkey", PPTHotkey); } } } catch (e) { errorlog(e); } getById("pptHotKey").value = event.target.value; getById("pptHotKey0").value = event.target.value; event.preventDefault(); event.stopPropagation(); return false; } function setupGoogleDriveUploader(filename = false, sessionUri = false) { if (!session.gdrive) { session.gdrive = {}; session.gdrive.accessToken = false; } var gdrive = {}; var uploading = false; var tokenClient; var isInitialized = false; var initializationPromise; const SCOPES = "https://www.googleapis.com/auth/drive.file"; var totalChunksRecorded = 0; var totalChunksUploaded = 0; var currentByte = 0; var chunks = new Blob([]); var finalized = false; gdrive.promise = false; gdrive.sessionUri = sessionUri; // Create an initialization promise to track when everything is ready initializationPromise = new Promise((resolve, reject) => { // We'll resolve this when the token client is fully initialized if (!gdrive.sessionUri) { loadScript("https://accounts.google.com/gsi/client", function () { log("Google Identity Services loaded"); initTokenClient(); resolve(); }); } else { resolve(); } }); // Setup the authentication promise if (!filename && !sessionUri) { var res, rej; gdrive.promise = new Promise((resolve, reject) => { res = resolve; rej = reject; }); gdrive.promise.resolve = res; gdrive.promise.reject = rej; } gdrive.startResumableUpload = async function (fname, retry = true) { console.log("startResumableUpload", retry); const fileMetadata = { name: fname }; if (session.GDRIVE_FOLDERNAME) { let folderId = null; const query = `name = '${session.GDRIVE_FOLDERNAME}' and mimeType = 'application/vnd.google-apps.folder' and 'root' in parents and trashed = false`; const url = `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}`; const response = await fetch(url, { method: "GET", headers: { Authorization: "Bearer " + session.gdrive.accessToken } }); const result = await response.json(); if (result.files && result.files.length > 0) { folderId = result.files[0].id; } if (!folderId) { log("creating new folder as folder not found."); try { const folderMetadata = { name: session.GDRIVE_FOLDERNAME, mimeType: "application/vnd.google-apps.folder" }; const createResponse = await fetch("https://www.googleapis.com/drive/v3/files", { method: "POST", headers: { Authorization: "Bearer " + session.gdrive.accessToken, "Content-Type": "application/json" }, body: JSON.stringify(folderMetadata) }); const createResult = await createResponse.json(); folderId = createResult.id; } catch (e) { errorlog(e); } } if (folderId) { fileMetadata.parents = [folderId]; } } const metadata = new Blob([JSON.stringify(fileMetadata)], { type: "application/json" }); try { var response = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable", { method: "POST", headers: { Authorization: "Bearer " + session.gdrive.accessToken, "Content-Type": "application/json; charset=UTF-8" }, body: metadata }); if (!response.ok) { if (!session.cleanOutput) { warnUser("⚠️ Error: Failed to configure the Google Drive upload."); } throw new Error("Start resumable upload failed: " + response.statusText); } return response.headers.get("Location"); // This is the session URI for the resumable upload } catch (err) { errorlog(err); try { if (retry) { session.gdrive.accessToken = false; var res, rej; gdrive.promise = new Promise((resolve, reject) => { res = resolve; rej = reject; }); gdrive.promise.resolve = res; gdrive.promise.reject = rej; filename = false; // Make sure we're initialized before requesting token await gdrive.ensureInitialized(); tokenClient.requestAccessToken(); await gdrive.promise; if (session.gdrive.accessToken) { return await gdrive.startResumableUpload(fname, false); } else { return false; } } } catch (err2) { errorlog(err2); return false; } } }; function initTokenClient() { console.log("Initializing GIS token client"); if ( typeof google === "undefined" || !google.accounts || !google.accounts.oauth2 ) { const gisError = new Error("Google Identity Services failed to load."); errorlog(gisError); if (!session.cleanOutput) { warnUser("Google sign-in was blocked. Allow accounts.google.com and try again.", 8000); } if (gdrive.promise && gdrive.promise.reject) { gdrive.promise.reject(gisError); } return; } tokenClient = google.accounts.oauth2.initTokenClient({ client_id: session.GDRIVE_CLIENT_ID, scope: SCOPES, callback: onTokenResponse, error_callback: onTokenError }); isInitialized = true; // If we have no promise yet but the user requested access, set one up if (!gdrive.promise && !sessionUri && !filename) { var res, rej; gdrive.promise = new Promise((resolve, reject) => { res = resolve; rej = reject; }); gdrive.promise.resolve = res; gdrive.promise.reject = rej; } // If we have a filename, start upload immediately when possible if (filename) { setTimeout(async () => { try { if (session.gdrive && session.gdrive.accessToken) { console.log("Using cached Google Drive token for upload"); gdrive.sessionUri = await gdrive.startResumableUpload(filename); if (gdrive.sessionUri) { uploadLoop(); return; } console.warn("Failed to reuse cached Drive token, requesting a new one..."); } } catch (err) { errorlog(err); } console.log("Requesting access token for immediate upload"); tokenClient.requestAccessToken(); }, 250); // Small delay to ensure tokenClient is fully initialized } } function onTokenError(response) { console.warn("Token error:", response); if (gdrive.promise && gdrive.promise.reject) { gdrive.promise.reject(response); } } async function onTokenResponse(tokenResponse) { console.log("Token response received", tokenResponse); if (tokenResponse.error === "popup_closed_by_user" || tokenResponse.error === "access_denied") { errorlog("User cancelled the sign-in process."); if (gdrive.promise && gdrive.promise.reject) { gdrive.promise.reject(new Error("User cancelled authentication")); } } else if (tokenResponse.error !== undefined) { errorlog("Token error: " + tokenResponse.error); if (gdrive.promise && gdrive.promise.reject) { gdrive.promise.reject(new Error(tokenResponse.error)); } } else { // Successfully got access token console.log("Access token obtained successfully"); session.gdrive.accessToken = tokenResponse.access_token; if (filename) { try { gdrive.sessionUri = await gdrive.startResumableUpload(filename); console.log("Session URI:", gdrive.sessionUri); uploadLoop(); } catch (e) { console.error("Error starting upload:", e); if (gdrive.promise && gdrive.promise.reject) { gdrive.promise.reject(e); } return; } } // Always resolve the promise if we got a token successfully if (gdrive.promise && gdrive.promise.resolve) { console.log("Resolving promise with access token"); gdrive.promise.resolve(tokenResponse.access_token); } } } // Check if initialized and wait if not gdrive.ensureInitialized = async function () { if (!isInitialized) { console.log("Waiting for initialization to complete..."); await initializationPromise; console.log("Initialization complete"); } }; // Function to manually request access token gdrive.requestAccessToken = async function () { await gdrive.ensureInitialized(); if (tokenClient) { console.log("Manually requesting access token"); tokenClient.requestAccessToken(); } else { console.error("Token client not initialized"); if (gdrive.promise && gdrive.promise.reject) { gdrive.promise.reject(new Error("Token client not initialized")); } } }; gdrive.revokeToken = function () { if (session.gdrive.accessToken) { google.accounts.oauth2.revoke(session.gdrive.accessToken, () => { console.log('Access token revoked'); session.gdrive.accessToken = false; }); } }; /// the following doesn't need to be signed in; just access to the gdrive.sessionUri URL gdrive.addChunk = function (chunk) { if (chunk && chunks) { totalChunksRecorded += chunk.size; chunks = new Blob([chunks, chunk], { type: chunk.type }); if (!session.cleanOutput) { getById("progressContainer").classList.remove("hidden"); } updateProgressBar(); } else if (chunk === false) { finalized = true; } uploadLoop(); }; async function uploadLoop() { if (uploading || !gdrive.sessionUri) { return; } uploading = true; while (chunks && (finalized || chunks.size > 256 * 1024)) { if (finalized) { var chunk = chunks.slice(0, chunks.size); let res = await finalizeUpload(chunk); log(res); return; } else { var chunkSize = Math.floor(chunks.size / (256 * 1024)) * (256 * 1024); var chunk = chunks.slice(0, chunkSize); chunks = chunks.slice(chunkSize); } currentByte = await uploadChunk(chunk); } uploading = false; } async function uploadChunk(chunk) { const endByte = currentByte + chunk.size - 1; totalChunksUploaded += chunk.size; const headers = new Headers({ "Content-Range": `bytes ${currentByte}-${endByte}/*` }); const response = await fetch(gdrive.sessionUri, { method: "PUT", headers: headers, body: chunk }); if (!response.ok && response.status !== 308) { throw new Error(`Failed to upload chunk: ${response.statusText}`); } updateProgressBar(); return endByte + 1; } async function finalizeUpload(chunk) { const endByte = currentByte + chunk.size - 1; const headers = new Headers({ "Content-Range": `bytes ${currentByte}-${endByte}/${endByte + 1}` }); const response = await fetch(gdrive.sessionUri, { method: "PUT", headers: headers, body: chunk }); if (chunk) { totalChunksUploaded += chunk.size; } updateProgressBar(2); return response.json(); } function updateProgressBar(state = 0) { // Implementation unchanged if (state == 2) { setTimeout(function () { if (getById("progressBar").style.width == "100%") { getById("progressContainer").classList.add("hidden"); } }, 1000); getById("progressBar").style.width = "100%"; var msg = {}; msg.gdrive = { up: parseInt(totalChunksUploaded / 1024), rec: parseInt(totalChunksUploaded / 1024), state: state }; for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } else if (totalChunksRecorded > 0) { var progressPercentage = (totalChunksUploaded / (totalChunksRecorded || 1)) * 100; var bytesLeft = parseInt((totalChunksRecorded - totalChunksUploaded) / 1024); getById("progressBar").style.width = progressPercentage + "%"; getById("progressBar").innerHTML = "Upload progress to Google Drive: " + progressPercentage.toFixed(2) + "%, with " + convertKilobytes(bytesLeft) + " left"; var msg = {}; msg.gdrive = { up: parseInt(totalChunksUploaded / 1024), rec: parseInt(totalChunksRecorded / 1024), state: state }; for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } } return gdrive; } function convertKilobytes(kilobytes) { const KB_IN_MB = 1024; const KB_IN_GB = 1024 * 1024; if (kilobytes >= KB_IN_GB) { return Math.ceil(kilobytes / KB_IN_GB).toFixed(0) + " GB"; } else if (kilobytes >= KB_IN_MB) { return Math.ceil(kilobytes / KB_IN_MB).toFixed(0) + " MB"; } else { return kilobytes + "KB"; } } const DROPBOX_APP_KEY_FALLBACK = "uwxixfldkii1xpt"; const DROPBOX_AUTH_URL = "https://www.dropbox.com/oauth2/authorize"; const DROPBOX_TOKEN_URL = "https://api.dropboxapi.com/oauth2/token"; const DROPBOX_SCOPES = "files.content.write files.metadata.write"; const DROPBOX_SDK_URL = "https://cdnjs.cloudflare.com/ajax/libs/dropbox.js/10.34.0/Dropbox-sdk.min.js"; const DROPBOX_OAUTH_STORAGE_KEY = "dropboxOAuthTokens"; const DROPBOX_OAUTH_SESSION_KEY = "dropboxOAuthSession"; const DROPBOX_AUTH_MESSAGE_SOURCE = "vdoninja-dropbox-auth"; const DROPBOX_ALLOWED_REDIRECT_ORIGINS = [ "https://vdo.ninja", "https://dev.versus.cam", "https://versus.cam", "https://backup.vdo.ninja", "https://obs.ninja", "http://localhost:8080" ]; const DROPBOX_REFRESH_SKEW_MS = 120000; var dropboxScriptPromise = null; var dropboxInitPromise = null; var dropboxAuthFlowPromise = null; var dropboxAuthFlowResolver = null; var dropboxAuthFlowRejecter = null; var dropboxAuthWindow = null; var dropboxAuthWindowMonitor = null; function getDropboxAppKey() { if (typeof session !== "undefined" && session.DROPBOX_APP_KEY) { return session.DROPBOX_APP_KEY; } return DROPBOX_APP_KEY_FALLBACK; } function getDropboxRedirectUri() { if (typeof window === "undefined" || !window.location || !window.location.origin) { return DROPBOX_ALLOWED_REDIRECT_ORIGINS[0] + "/dropbox-auth.html"; } var origin = window.location.origin.replace(/\/+$/, ""); if (DROPBOX_ALLOWED_REDIRECT_ORIGINS.indexOf(origin) === -1) { return DROPBOX_ALLOWED_REDIRECT_ORIGINS[0] + "/dropbox-auth.html"; } return origin + "/dropbox-auth.html"; } function persistDropboxAuthSession(sessionData) { try { localStorage.setItem(DROPBOX_OAUTH_SESSION_KEY, JSON.stringify(sessionData)); } catch (e) { } } function clearDropboxAuthSession() { try { localStorage.removeItem(DROPBOX_OAUTH_SESSION_KEY); } catch (e) { } } function getStoredDropboxOAuthTokens() { if (typeof session !== "undefined" && session.dropboxOAuth) { return session.dropboxOAuth; } try { var raw = localStorage.getItem(DROPBOX_OAUTH_STORAGE_KEY); if (!raw) { return null; } var parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { if (typeof session !== "undefined") { session.dropboxOAuth = parsed; if (parsed.accessToken) { session.dropboxAccessToken = parsed.accessToken; } } return parsed; } } catch (e) { } return null; } function persistDropboxOAuthTokens(record) { if (!record || !record.accessToken) { return; } var existing = getStoredDropboxOAuthTokens(); var normalized = { accessToken: record.accessToken, refreshToken: record.refreshToken || (existing && existing.refreshToken) || null, expiresAt: record.expiresAt || (existing && existing.expiresAt) || 0, scope: record.scope || (existing && existing.scope) || DROPBOX_SCOPES, tokenType: record.tokenType || (existing && existing.tokenType) || "bearer" }; try { localStorage.setItem(DROPBOX_OAUTH_STORAGE_KEY, JSON.stringify(normalized)); } catch (e) { } if (typeof session !== "undefined") { session.dropboxOAuth = normalized; session.dropboxAccessToken = normalized.accessToken; } } function clearDropboxOAuthTokens() { if (typeof session !== "undefined") { session.dropboxOAuth = null; } try { localStorage.removeItem(DROPBOX_OAUTH_STORAGE_KEY); } catch (e) { } } function normalizeDropboxTokenResponse(response, fallbackRefreshToken = null) { if (!response || typeof response !== "object" || !response.access_token) { return null; } var expiresIn = 0; if (response.expires_in) { var parsed = parseInt(response.expires_in, 10); if (!isNaN(parsed) && parsed > 0) { expiresIn = parsed * 1000; } } var expiresAt = expiresIn ? Date.now() + Math.max(0, expiresIn - DROPBOX_REFRESH_SKEW_MS) : 0; return { accessToken: response.access_token, refreshToken: response.refresh_token || fallbackRefreshToken || null, expiresAt: expiresAt, scope: response.scope || DROPBOX_SCOPES, tokenType: response.token_type || "bearer" }; } function dropboxTokenExpired(tokens) { if (!tokens || !tokens.accessToken) { return true; } if (!tokens.expiresAt) { return false; } return Date.now() >= tokens.expiresAt; } async function refreshDropboxAccessToken(existingTokens) { if (!existingTokens || !existingTokens.refreshToken) { throw new Error("Dropbox refresh token missing."); } var body = new URLSearchParams(); body.set("grant_type", "refresh_token"); body.set("refresh_token", existingTokens.refreshToken); body.set("client_id", getDropboxAppKey()); var response = await fetch(DROPBOX_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() }); if (!response.ok) { throw new Error("Failed to refresh Dropbox token."); } var data = await response.json(); var normalized = normalizeDropboxTokenResponse(data, existingTokens.refreshToken); if (!normalized) { throw new Error("Dropbox refresh response invalid."); } persistDropboxOAuthTokens(normalized); return normalized; } async function ensureDropboxOAuthAccessToken({ interactive = false } = {}) { var tokens = getStoredDropboxOAuthTokens(); if (tokens && !dropboxTokenExpired(tokens)) { return tokens; } if (tokens && tokens.refreshToken) { try { return await refreshDropboxAccessToken(tokens); } catch (e) { errorlog(e); clearDropboxOAuthTokens(); clearDropboxAuthSession(); tokens = null; } } if (!interactive) { return null; } return beginDropboxOAuthFlow(); } function generateRandomString(length = 64) { var charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; var result = ""; if (typeof window !== "undefined" && window.crypto && window.crypto.getRandomValues) { var values = new Uint32Array(length); window.crypto.getRandomValues(values); for (var i = 0; i < length; i++) { result += charset[values[i] % charset.length]; } } else { for (var j = 0; j < length; j++) { result += charset[Math.floor(Math.random() * charset.length)]; } } return result; } function base64UrlEncode(arrayBuffer) { var bytes = new Uint8Array(arrayBuffer); var binary = ""; for (var i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } async function createDropboxPkcePair() { var verifier = generateRandomString(64); if (typeof window !== "undefined" && window.crypto && window.crypto.subtle && typeof TextEncoder !== "undefined") { try { var data = new TextEncoder().encode(verifier); var digest = await window.crypto.subtle.digest("SHA-256", data); return { verifier: verifier, challenge: base64UrlEncode(digest), method: "S256" }; } catch (e) { } } return { verifier: verifier, challenge: verifier, method: "plain" }; } function generateDropboxState() { return generateRandomString(32); } async function beginDropboxOAuthFlow() { if (dropboxAuthFlowPromise) { return dropboxAuthFlowPromise; } dropboxAuthFlowPromise = new Promise(async (resolve, reject) => { dropboxAuthFlowResolver = resolve; dropboxAuthFlowRejecter = reject; try { var pkce = await createDropboxPkcePair(); var state = generateDropboxState(); var redirectUri = getDropboxRedirectUri(); var clientId = getDropboxAppKey(); clearDropboxAuthSession(); persistDropboxAuthSession({ verifier: pkce.verifier, state: state, redirectUri: redirectUri, clientId: clientId, scope: DROPBOX_SCOPES, origin: typeof window !== "undefined" && window.location ? window.location.origin : "", ts: Date.now() }); var params = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, code_challenge: pkce.challenge, code_challenge_method: pkce.method, token_access_type: "offline", state: state }); if (DROPBOX_SCOPES) { params.set("scope", DROPBOX_SCOPES); } var authUrl = DROPBOX_AUTH_URL + "?" + params.toString(); dropboxAuthWindow = window.open(authUrl, "vdoninja-dropbox-auth", "width=600,height=720"); if (!dropboxAuthWindow) { throw new Error("Dropbox authorization popup was blocked. Allow popups and try again."); } dropboxAuthWindowMonitor = setInterval(() => { if (!dropboxAuthWindow || dropboxAuthWindow.closed) { cleanupDropboxAuthFlow(new Error("Dropbox authorization window was closed."), true); } }, 750); } catch (err) { cleanupDropboxAuthFlow(err, true); } }); return dropboxAuthFlowPromise; } function cleanupDropboxAuthFlow(result, isError) { if (dropboxAuthWindowMonitor) { clearInterval(dropboxAuthWindowMonitor); dropboxAuthWindowMonitor = null; } if (dropboxAuthWindow && !dropboxAuthWindow.closed) { try { dropboxAuthWindow.close(); } catch (e) { } } dropboxAuthWindow = null; var resolver = dropboxAuthFlowResolver; var rejecter = dropboxAuthFlowRejecter; dropboxAuthFlowResolver = null; dropboxAuthFlowRejecter = null; dropboxAuthFlowPromise = null; if (isError) { if (typeof rejecter === "function") { rejecter(result instanceof Error ? result : new Error(result || "Dropbox authorization failed")); } } else if (typeof resolver === "function") { resolver(result); } } function isAllowedDropboxAuthOrigin(origin) { if (!origin) { return false; } if (origin === window.location.origin) { return true; } return DROPBOX_ALLOWED_REDIRECT_ORIGINS.indexOf(origin) !== -1; } function dropboxAuthMessageHandler(event) { if (!event || !event.data || !isAllowedDropboxAuthOrigin(event.origin)) { return; } var data = event.data; if (!data || data.source !== DROPBOX_AUTH_MESSAGE_SOURCE) { return; } if (data.type === "request-session" && event.source && typeof event.source.postMessage === "function") { var sessionPayload = null; try { var rawSession = localStorage.getItem(DROPBOX_OAUTH_SESSION_KEY); if (rawSession) { sessionPayload = JSON.parse(rawSession); } } catch (e) { sessionPayload = null; } if (sessionPayload && data.state && sessionPayload.state && sessionPayload.state !== data.state) { sessionPayload = null; } try { event.source.postMessage({ source: DROPBOX_AUTH_MESSAGE_SOURCE, type: "session", session: sessionPayload }, event.origin); } catch (e) { } return; } if (data.type === "tokens" && data.tokens) { persistDropboxOAuthTokens(data.tokens); clearDropboxAuthSession(); cleanupDropboxAuthFlow(data.tokens, false); } else if (data.type === "error") { if (data.clearTokens) { clearDropboxOAuthTokens(); } clearDropboxAuthSession(); cleanupDropboxAuthFlow(new Error(data.message || "Dropbox authorization failed"), true); } } function streamSaverMessageHandler(event) { if (!event || !event.data || !event.data.streamSaverError) { return; } if (session && session.streamSaverFailed) { return; } try { session.streamSaverFailed = true; } catch (e) {} var reason = event.data.reason ? "\n\nDetails: " + event.data.reason : ""; promptAlt("Recording download setup failed. Local recordings may not save correctly." + reason + "\n\nEnable Service Workers or allow downloads, or use Google Drive recording instead.", false, false, false, 5000); errorlog("StreamSaver failure: " + (event.data.reason || "unknown")); warnlog("StreamSaver failure: " + (event.data.reason || "unknown")); try { if (session && session.directorUUID && session.directorList && session.directorList.length) { var msg = {}; msg.recorder = -3; for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } } catch (e) {} } if (typeof window !== "undefined") { window.addEventListener("message", dropboxAuthMessageHandler, false); window.addEventListener("message", streamSaverMessageHandler, false); } function getStoredDropboxToken() { try { return localStorage.getItem("dropboxAccessToken") || null; } catch (e) { return null; } } function persistDropboxToken(token) { if (!token) { return; } try { localStorage.setItem("dropboxAccessToken", token); } catch (e) { } } function clearDropboxAuthState({ clearToken = false, clearOAuth = false } = {}) { if (typeof session !== "undefined") { session.dbx = false; if (clearToken) { session.dropboxAccessToken = null; try { localStorage.removeItem("dropboxAccessToken"); } catch (e) { } } } if (clearOAuth) { if (typeof session !== "undefined") { session.dropboxAccessToken = null; } clearDropboxOAuthTokens(); clearDropboxAuthSession(); } try { localStorage.removeItem("dropboxSession"); } catch (e) { } } function dropboxErrorSuggestsReauth(error) { if (!error) { return false; } var summary = ""; if (error.error && error.error.error_summary) { summary = error.error.error_summary; } else if (error.error_summary) { summary = error.error_summary; } if (summary && (summary.indexOf("expired_access_token") !== -1 || summary.indexOf("invalid_access_token") !== -1)) { return true; } var status = error.status || (error.error && error.error.status) || false; if (status && parseInt(status) === 401) { return true; } return false; } function ensureDropboxSDKLoaded() { if (window.Dropbox && window.Dropbox.Dropbox) { return Promise.resolve(); } if (dropboxScriptPromise) { return dropboxScriptPromise; } dropboxScriptPromise = new Promise((resolve, reject) => { var existing = document.querySelector("script[src='" + DROPBOX_SDK_URL + "']"); if (existing) { existing.addEventListener("load", () => resolve(), { once: true }); existing.addEventListener("error", () => reject(new Error("Failed to load Dropbox SDK")), { once: true }); } else { var script = document.createElement("script"); script.type = "text/javascript"; script.src = DROPBOX_SDK_URL; script.onload = () => resolve(); script.onerror = () => reject(new Error("Failed to load Dropbox SDK")); document.head.appendChild(script); } }).catch(error => { dropboxScriptPromise = null; throw error; }); return dropboxScriptPromise; } async function setupDropbox(accessToken = null, options = {}) { if (typeof session === "undefined") { return null; } var opts = typeof options === "object" && options !== null ? options : {}; var interactive = opts.interactive === true; var forceReauth = opts.forceReauth === true; var token = null; var manualToken = null; if (typeof accessToken === "string" && accessToken.trim().length) { manualToken = accessToken.trim(); token = manualToken; } if (forceReauth && manualToken) { forceReauth = false; } if (forceReauth) { clearDropboxOAuthTokens(); if (typeof session !== "undefined") { session.dropboxOAuth = null; } } var preferOAuth = !manualToken && (forceReauth || (interactive && Boolean(session.dropboxOAuth || getStoredDropboxOAuthTokens()))); var oauthTokens = null; if (!forceReauth) { oauthTokens = session.dropboxOAuth || getStoredDropboxOAuthTokens(); } if (oauthTokens && dropboxTokenExpired(oauthTokens)) { try { oauthTokens = await refreshDropboxAccessToken(oauthTokens); } catch (e) { errorlog(e); clearDropboxOAuthTokens(); oauthTokens = null; } } if (oauthTokens && oauthTokens.accessToken) { session.dropboxOAuth = oauthTokens; } if (!token && oauthTokens && oauthTokens.accessToken) { token = oauthTokens.accessToken; } if (!token) { var legacySessionToken = session.dropboxAccessToken || null; if (legacySessionToken && (!preferOAuth || (oauthTokens && oauthTokens.accessToken === legacySessionToken))) { token = legacySessionToken; } } if (!token && !preferOAuth) { var paramToken = typeof urlParams !== "undefined" && urlParams.get("dropbox"); token = paramToken || getStoredDropboxToken(); } if (forceReauth) { token = null; } if (!token) { try { var oauthResponse = await ensureDropboxOAuthAccessToken({ interactive: interactive || preferOAuth }); if (oauthResponse && oauthResponse.accessToken) { token = oauthResponse.accessToken; } } catch (e) { throw e; } } if (!token) { return null; } if (!forceReauth && session.dbx && session.dropboxAccessToken === token) { return session.dbx; } session.dropboxAccessToken = token; if (manualToken && opts.persist !== false) { persistDropboxToken(token); } if (dropboxInitPromise) { return dropboxInitPromise; } dropboxInitPromise = (async currentToken => { await ensureDropboxSDKLoaded(); if (!window.Dropbox || !window.Dropbox.Dropbox) { throw new Error("Dropbox SDK unavailable"); } var client = new Dropbox.Dropbox({ accessToken: currentToken }); session.dbx = client; session.dropboxAccessToken = currentToken; resumeDropbox(); return client; })(token) .catch(error => { var needsReauth = dropboxErrorSuggestsReauth(error); clearDropboxAuthState({ clearToken: needsReauth, clearOAuth: needsReauth }); throw error; }) .finally(() => { dropboxInitPromise = null; }); return dropboxInitPromise; } if (typeof window !== "undefined") { window.setupDropbox = setupDropbox; } function resumeDropbox() { var sessionData = localStorage.getItem("dropboxSession"); if (sessionData) { sessionData = JSON.parse(sessionData); sessionData.forEach(main => { session.dbx .filesUploadSessionFinish({ cursor: { session_id: main.result.session_id, offset: main.vdo.offset }, commit: { path: "/" + main.vdo.filename } }) .then(function (response) { console.log(response); console.log("File uploaded to Dropbox:", response.result.path_display); DBXqueue = []; //localStorage.removeItem('dropboxSession'); }) .catch(function (error) { localStorage.removeItem("dropboxSession"); console.error("Error uploading file:", error); if (!session.cleanOutput) { confirmAlt("There was an error finalizing a previous file upload. \n" + (error.error_summary || "") + "\n\nWould you like to keep trying?").then(res => { if (!res) { localStorage.removeItem("dropboxSession"); } }); } }); }); } } async function streamVideoToDropbox(filename) { if (!session.dbx && typeof setupDropbox === "function") { try { await setupDropbox(); } catch (e) { errorlog(e); } } if (!session.dbx) { if (session.directorUUID) { var failMsg = {}; failMsg.dropbox = -2; for (var di = 0; di < session.directorList.length; di++) { failMsg.UUID = session.directorList[di]; session.sendPeers(failMsg, failMsg.UUID); } } return; } var main; try { main = await session.dbx.filesUploadSessionStart({ close: false }); } catch (e) { errorlog(e); if (!session.cleanOutput) { warnUser("Dropbox failed to initialize.\n\nAre your credentials valid? Tokens may expire after a few hours.", 8000); } if (dropboxErrorSuggestsReauth(e)) { clearDropboxAuthState({ clearToken: true, clearOAuth: true }); } return; } var sessionId = main.result.session_id; var offset = 0; var chunkCounter = 0; var DBXqueue = []; var resolverQueue = []; var uploadActive = false; log(main); main.vdo = { filename: filename, offset: offset }; var persisted = localStorage.getItem("dropboxSession"); if (persisted) { try { persisted = JSON.parse(persisted); } catch (e) { errorlog(e); persisted = []; } persisted.push(main); } else { persisted = [main]; } localStorage.setItem("dropboxSession", JSON.stringify(persisted)); if (session.directorUUID) { var initMsg = {}; initMsg.dropbox = -1; for (var ii = 0; ii < session.directorList.length; ii++) { initMsg.UUID = session.directorList[ii]; session.sendPeers(initMsg, initMsg.UUID); } } var totalChunksRecorded = 0; var totalChunksUploaded = 0; function updateTotalChunksRecorded() { if (!session.cleanOutput) { getById("progressContainer").classList.remove("hidden"); } totalChunksRecorded++; updateProgressBar(); } function updateTotalChunksUploaded() { totalChunksUploaded++; updateProgressBar(); } function finishedChunksUploaded() { try { getById("progressBar").style.width = "100%"; setTimeout(function () { if (getById("progressBar").style.width == "100%") { getById("progressContainer").classList.add("hidden"); } }, 1000); } catch (e) { errorlog(e); } } function updateProgressBar() { if (totalChunksRecorded > 0) { var progressPercentage = (totalChunksUploaded / (totalChunksRecorded || 1)) * 100; getById("progressBar").style.width = progressPercentage + "%"; getById("progressBar").innerHTML = "Upload progress to Dropbox: " + progressPercentage.toFixed(2) + "%"; } } function notifyDropboxQueueSize() { if (!session.directorUUID) { return; } var msg = {}; msg.dropbox = DBXqueue.length; for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendPeers(msg, msg.UUID); } } function resolveNext(value) { var resolver = resolverQueue.shift(); if (resolver && resolver.resolve) { resolver.resolve(value); } } function rejectNext(error) { var resolver = resolverQueue.shift(); if (resolver && resolver.reject) { resolver.reject(error); } } function rejectPending(error) { while (resolverQueue.length) { var pending = resolverQueue.shift(); if (pending && pending.reject) { pending.reject(error); } } } function handleDropboxUploadFailure(error) { errorlog(error); if (dropboxErrorSuggestsReauth(error)) { clearDropboxAuthState({ clearToken: true, clearOAuth: true }); } if (!session.cleanOutput) { warnUser("Dropbox upload failed. Please verify your token and try again.", 8000); } } async function processQueue() { if (uploadActive) { return; } uploadActive = true; while (DBXqueue.length) { var current = DBXqueue[0]; try { if (current === false) { await session.dbx.filesUploadSessionFinish({ cursor: { session_id: sessionId, offset: offset }, commit: { path: "/" + filename } }); DBXqueue.shift(); resolveNext(true); var sessionData = localStorage.getItem("dropboxSession"); if (sessionData) { try { sessionData = JSON.parse(sessionData); sessionData = sessionData.filter(entry => entry.vdo.filename !== filename && entry.vdo && entry.vdo.filename); } catch (e) { errorlog(sessionData); errorlog(e); sessionData = []; } } else { sessionData = []; } if (sessionData.length) { localStorage.setItem("dropboxSession", JSON.stringify(sessionData)); } else { localStorage.removeItem("dropboxSession"); } sessionId = false; notifyDropboxQueueSize(); finishedChunksUploaded(); } else { await session.dbx.filesUploadSessionAppendV2({ cursor: { session_id: sessionId, offset: offset }, close: false, contents: current }); offset += current.size; main.vdo.offset = offset; var sessionData = localStorage.getItem("dropboxSession"); if (sessionData) { try { sessionData = JSON.parse(sessionData); sessionData = sessionData.filter(entry => entry.vdo.filename !== filename && entry.vdo && entry.vdo.filename); sessionData.push(main); } catch (e) { errorlog(sessionData); errorlog(e); sessionData = [main]; } } else { sessionData = [main]; } localStorage.setItem("dropboxSession", JSON.stringify(sessionData)); DBXqueue.shift(); resolveNext(true); updateTotalChunksUploaded(); chunkCounter += 1; notifyDropboxQueueSize(); } } catch (error) { rejectNext(error); rejectPending(error); DBXqueue = []; uploadActive = false; handleDropboxUploadFailure(error); return; } } uploadActive = false; } function enqueueChunk(chunk) { return new Promise((resolve, reject) => { if (!sessionId) { reject(new Error("Dropbox session inactive")); return; } if (DBXqueue.length && DBXqueue[DBXqueue.length - 1] === false) { resolve(); return; } DBXqueue.push(chunk); resolverQueue.push({ resolve: resolve, reject: reject }); updateTotalChunksRecorded(); notifyDropboxQueueSize(); processQueue(); }); } function uploadChunk(chunk) { var promise = enqueueChunk(chunk); promise.catch(() => { }); return promise; } return uploadChunk; } var recordingBitratePromise = false; var defaultRecordingBitrate = false; var lastConfiguredRecordingSetup = false; async function recordVideo(target, event = null, videoKbps = false) { // event.currentTarget,this.parentNode.parentNode.dataset.UUID if (session.record === false) { warnlog("recordings are disabled by decree of thy host magistrate"); } if (!target) { return; } var UUID = target.dataset.UUID; if (!UUID) { return; } var video = session.rpcs[UUID].videoElement; if (!video) { return; } if (video.stopWriter) { video.stopWriter(); updateLocalRecordButton(UUID, -1); return; } else if (video.startWriter) { await video.startWriter(); updateLocalRecordButton(UUID, 0); return; } if (event === null) { if (defaultRecordingBitrate === null) { updateLocalRecordButton(UUID, -1); return; } } else if (event.ctrlKey || event.metaKey) { updateLocalRecordButton(UUID, -3); Callbacks.push([recordVideo, target, null, false]); log("Record Video queued"); defaultRecordingBitrate = false; recordingBitratePromise = false; return; } else { defaultRecordingBitrate = false; recordingBitratePromise = false; } log("Record Video Clicked"); if ("recording" in video) { log("ALREADY RECORDING!"); updateLocalRecordButton(UUID, -2); video.recorder.stop(); session.requestRateLimit(35, UUID); // 100kbps if (session.audiobitrate === false) { session.requestAudioRateLimit(-1, UUID); } var elements = document.querySelectorAll('[data-action-type="change-quality2"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; } var elements = document.querySelectorAll('[data-action-type="change-quality1"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; } var elements = document.querySelectorAll('[data-action-type="change-quality3"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; } return; } else { updateLocalRecordButton(UUID, 0); //target.style.backgroundColor = "#FCC"; //target.innerHTML = " Download"; video.recording = true; } video.recorder = {}; var configureRecording = { bitrate: videoKbps, usePCM: (videoKbps === 0 || session.pcm) ? true : false, audioOnly: (videoKbps !== false && videoKbps <= 0) ? true : false }; if (configureRecording.bitrate === false) { if (defaultRecordingBitrate == false) { configureRecording.bitrate = session.recordDefault; if (session.recordLocal !== false) { configureRecording.bitrate = session.recordLocal; } else if (lastConfiguredRecordingSetup !== false) { configureRecording = lastConfiguredRecordingSetup; } if (session.pcm) { configureRecording.usePCM = true; } if (!recordingBitratePromise) { window.focus(); recordingBitratePromise = promptRecordingOptions(getTranslation("press-ok-to-record"), false, configureRecording); } configureRecording = await recordingBitratePromise; if (configureRecording === null) { //target.style.backgroundColor = null; //target.innerHTML = ' record local'; updateLocalRecordButton(UUID, -1); target.style.backgroundColor = ""; try { clearInterval(video.recorder.writer.interval); } catch (e) { } delete video.recorder; delete video.recording; defaultRecordingBitrate = null; return; } defaultRecordingBitrate = configureRecording; lastConfiguredRecordingSetup = configureRecording; } else { configureRecording = defaultRecordingBitrate; } } if (configureRecording.audioOnly) { if (session.audiobitrate === false) { if (configureRecording.usePCM) { session.requestAudioRateLimit(session.audiobitratePRO || 128, UUID); // PCM } else { session.requestAudioRateLimit(configureRecording.bitrate || 32, UUID); // exact? sure. why not. } } } else { if (configureRecording.bitrate < 50) { configureRecording.bitrate = 50; } session.requestRateLimit(configureRecording.bitrate, UUID); // 3200kbps transfer bitrate. Less than the recording bitrate, to avoid waste. if (configureRecording.bitrate > 4000) { if (session.audiobitrate === false) { if (session.pcm) { session.requestAudioRateLimit(session.audiobitratePRO, UUID); } else { session.requestAudioRateLimit(128, UUID); } } } else if (configureRecording.bitrate > 2500) { if (session.audiobitrate === false) { if (session.pcm) { session.requestAudioRateLimit(session.audiobitratePRO, UUID); } else { session.requestAudioRateLimit(80, UUID); } } } } // var cancell = false; if (typeof video.srcObject === "undefined" || !video.srcObject) { return; } video.recorder.stop = function (restart = false, notify = false) { if (session.dbx && video.dropbox && video.dropbox[filename]) { video.dropbox[filename](false); } if (video.gdrive && video.gdrive[filename]) { video.gdrive[filename].addChunk(false); } if (!video.recording) { errorlog("ALREADY STOPPED"); updateLocalRecordButton(UUID, -1); return; } if (notify) { if (!session.cleanOutput) { warnUser("A local recording has stopped unexpectedly."); } if (session.beepToNotify) { playtone(); } target.classList.remove("shake"); setTimeout( function (target) { target.classList.add("shake"); }, 10, target ); } video.recording = false; updateLocalRecordButton(UUID, -2); try { if (video.recorder && video.recorder.mediaRecorder && video.recorder.mediaRecorder.stop) { if (video.recorder.mediaRecorder.state !== "inactive" || video.recorder.mediaRecorder.state === "recording") { video.recorder.mediaRecorder.stop(); } } } catch (e) { errorlog(e); try { video.recorder.mediaRecorder.stop(); } catch (e1) { errorlog(e1); } } session.requestRateLimit(35, UUID); // 100kbps if (session.audiobitrate === false) { session.requestAudioRateLimit(-1, UUID); } var elements = document.querySelectorAll('[data-action-type="change-quality2"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; } var elements = document.querySelectorAll('[data-action-type="change-quality1"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; } var elements = document.querySelectorAll('[data-action-type="change-quality3"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; } cancell = true; // log('Recorded Blobs: ', recordedBlobs); // download(); setTimeout( (writer1, UUID1, video1) => { try { writer1.close(); } catch (e) { } updateLocalRecordButton(UUID1, -1); delete video1.recorder; delete video1.recording; }, 1200, video.recorder.writer, UUID, video ); }; const { readable, writable } = new TransformStream({ transform: (chunk, ctrl) => chunk.arrayBuffer().then(b => ctrl.enqueue(new Uint8Array(b))) }); var filext = ".webm"; let options = {}; if (!configureRecording.audioOnly) { var tryCodec = session.recordingVideoCodec || ""; // Simplified condition to assign tryCodec if (tryCodec && MediaRecorder.isTypeSupported("video/webm;codecs=" + tryCodec)) { if (!session.cleanOutput) { console.log("👍 The browser 'says' it supports " + tryCodec); } options.mimeType = "video/webm;codecs=" + tryCodec; if (session.pcm) { // Fixed the format of the MIME type string var mimeTypeWithPCM = "video/webm;codecs=" + tryCodec + ",pcm"; if (MediaRecorder.isTypeSupported(mimeTypeWithPCM)) { options.mimeType = mimeTypeWithPCM; } else { options.mimeType = "video/webm;codecs=pcm"; } } } else { // Simplified conditions for PCM support if (tryCodec) { warnlog("video/webm;codecs=" + tryCodec + " - is not supported"); } options.mimeType = session.pcm && MediaRecorder.isTypeSupported("video/webm;codecs=pcm") ? "video/webm;codecs=pcm" : "video/webm"; } // Simplified bitrate settings options.videoBitsPerSecond = parseInt(configureRecording.bitrate * 1024); if (configureRecording.bitrate < 1000) { options.audioBitsPerSecond = parseInt(100 * 1024); } else if (configureRecording.bitrate < 6000) { options.audioBitsPerSecond = parseInt(130 * 1024); } else if (configureRecording.bitrate < 20000) { options.audioBitsPerSecond = parseInt(256 * 1024); } else { // If configureRecording.bitrate is >= 20000, use bitsPerSecond for total bitrate options.bitsPerSecond = parseInt(configureRecording.bitrate * 1024); } if (iOS && options.mimeType) { if (!MediaRecorder.isTypeSupported(options.mimeType)) { options.mimeType = "video/mp4"; filext = ".mp4"; } } video.recorder.mediaRecorder = new MediaRecorder(video.srcObject, options); //if (session.dbx){ // video.recorder.dropbox = await streamVideoToDropbox(); // i don't want to upload to dropbox remote streams; just local //} } else { options.mimeType = "audio/webm"; if (configureRecording.usePCM) { if (MediaRecorder.isTypeSupported("audio/webm;codecs=pcm")) { options.mimeType = "audio/webm;codecs=pcm"; } } else { options.bitsPerSecond = parseInt(configureRecording.bitrate * 1024); } var stream = createMediaStream(); video.srcObject.getAudioTracks().forEach(track => { stream.addTrack(track, video.srcObject); }); if (iOS && options.mimeType) { if (!MediaRecorder.isTypeSupported(options.mimeType)) { options.mimeType = "video/mp4"; filext = ".mp4"; } } video.recorder.mediaRecorder = new MediaRecorder(stream, options); //if (session.dbx){ // video.recorder.dropbox = await streamVideoToDropbox(); //} } var timestamp = Date.now(); var filename = ""; if (session.rpcs[UUID].label && session.rpcs[UUID].streamID) { filename = session.rpcs[UUID].label || session.rpcs[UUID].streamID; } else { filename = session.rpcs[UUID].label + "_" + session.rpcs[UUID].streamID; } filename = filename.replace(/[\W]+/g, "_"); filename = filename.substring(0, 200); filename += "_" + timestamp.toString(); var writer = writable.getWriter(); video.recorder.writer = writer; readable.pipeTo(streamSaver.createWriteStream(filename.toString() + filext, video.recorder.stop)); pokeIframeAPI("recording-started"); log(options); function download() { const blob = new Blob(recordedBlobs, { type: "video/webm" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = url; a.download = filename + filext; document.body.appendChild(a); a.click(); setTimeout( function (uu, aa) { document.body.removeChild(aa); window.URL.revokeObjectURL(uu); }, 100, url, a ); } function handleDataAvailable(event) { if (event.data && event.data.size > 0) { //recordedBlobs.push(event.data); try { writer.write(event.data); //////////// if (video.recording) { updateLocalRecordButton(UUID, parseInt((Date.now() - timestamp) / 1000) || 0); } } catch (e) { warnlog("Stream recording error or ended"); } try { if (session.dbx && video.dropbox && video.dropbox[filename]) { video.dropbox[filename](event.data); } if (video.gdrive && video.gdrive[filename]) { video.gdrive[filename].addChunk(event.data); } } catch (e) { errorlog(e); } } } video.recorder.mediaRecorder.ondataavailable = handleDataAvailable; video.recorder.mediaRecorder.onerror = function (event) { errorlog(event); console.log("It's possible using &recordcodec=vp8 might resolve recording errors if caused by an incompatible hardware encoder or codec"); video.recorder.stop(); session.requestRateLimit(35, UUID); if (!session.cleanOutput) { setTimeout(function () { warnUser("an error occured with the media recorder; stopping recording"); }, 1); } }; video.recorder.mediaRecorder.onstop = function (event) { log("mediaRecorder stopped"); }; video.srcObject.onended = function (event) { video.recorder.stop(); //session.requestRateLimit(35, UUID); // changed DEc 05 2023 - not sure this makes sense. if (!session.cleanOutput) { setTimeout(function () { warnUser("stream ended! stopping recording"); }, 1); } }; setTimeout( function (v) { if (v && v.recorder) { v.recorder.mediaRecorder.start(1000); } }, 500, video ); // 100ms chunks return; } function updateGdriveButton(UUID, gdrive, screen = false) { if (screen) { var elements = document.querySelectorAll('[data-action-type="recorder-google-drive-remote"][data--u-u-i-d="' + UUID + '_screen"]'); } else { var elements = document.querySelectorAll('[data-action-type="recorder-google-drive-remote"][data--u-u-i-d="' + UUID + '"]'); } if (elements[0]) { var progressPercentage = parseInt((1000 * gdrive.up) / gdrive.rec) / 10; elements[0].innerText = progressPercentage + "%"; if (gdrive.state && gdrive.state == 2) { elements[0].innerText = "GDrive " + elements[0].innerText + " (done)"; } else { elements[0].innerText += ", " + convertKilobytes(gdrive.rec) + " left"; } } if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") { try { window.dispatchEvent( new CustomEvent("vdoninja:gdrive-progress", { detail: { UUID: UUID, gdrive: gdrive, screen: screen } }) ); } catch (e) { } } } function updateRemoteRecordButton(UUID, recorder, screen = false) { if (screen) { var elements = document.querySelectorAll('[data-action-type="recorder-remote"][data--u-u-i-d="' + UUID + '_screen"]'); } else { var elements = document.querySelectorAll('[data-action-type="recorder-remote"][data--u-u-i-d="' + UUID + '"]'); } if (elements[0]) { var time = parseInt(recorder) || 0; if (time == -4) { if (!session.cleanOutput) { warnUser("A remote recording has stopped unexpectedly.\n\nDid a user cancel the file downlaod?"); } if (session.beepToNotify) { playtone(); } elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].classList.remove("shake"); elements[0].innerHTML = ' stopping...'; setTimeout( function (ele) { ele.classList.add("shake"); }, 10, elements[0] ); } else if (time == -3) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; elements[0].disabled = true; elements[0].innerHTML = ' Not Supported'; if (!session.cleanOutput) { setTimeout(function () { warnUser("The remote browser does not support recording.\n\nPerhaps try local recording instead."); }, 0); } } else if (time == -5) { if (!session.cleanOutput) { setTimeout(function () { warnUser("The remote browser has only experimental support for media recording.\n\nAlso, when this download stops, the remote user may be asked to download the file for it to save."); }, 0); } } else if (time == -2) { elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].innerHTML = ' stopping...'; } else if (time == -1) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; elements[0].innerHTML = ' Record Remote'; } else { var minutes = Math.floor(time / 60); var seconds = time - minutes * 60; elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].innerHTML = ' ' + minutes + "m : " + zpadTime(seconds) + "s"; } } } function updateLocalRecordButton(UUID, recorder) { var elements = document.querySelectorAll('[data-action-type="recorder-local"][data--u-u-i-d="' + UUID + '"]'); if (elements[0]) { var time = parseInt(recorder) || 0; //target.innerHTML = ' ARMED'; // if (time == -3) { elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].innerHTML = ' ARMED'; elements[0].style.backgroundColor = "#BF3F3F"; } else if (time == -2) { elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].innerHTML = ' stopping...'; elements[0].style.backgroundColor = ""; } else if (time == -1) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; elements[0].innerHTML = ' Record Local'; elements[0].style.backgroundColor = ""; } else { var minutes = Math.floor(time / 60); var seconds = time - minutes * 60; elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].innerHTML = ' ' + minutes + "m : " + zpadTime(seconds) + "s"; elements[0].style.backgroundColor = ""; } } } async function recordLocalVideoToggle(startonly = false) { if (!session.videoElement) { return; } log("recordLocalVideoToggle()"); var ele = getById("recordLocalbutton"); if (ele.dataset.state == "0") { if (session.videoElement.recorder && session.videoElement.recorder.closing) { warnlog("already closing"); getById("recordLocalbutton").classList.remove("shake"); getById("recordLocalbutton").classList.add("shake"); setTimeout(function () { getById("recordLocalbutton").classList.remove("shake"); }, 1000); return false; } ele.dataset.state = "1"; ele.style.backgroundColor = "red"; ele.innerHTML = ''; if ("recording" in session.videoElement) { errorlog("its already recording ??"); } else { var res = await recordLocalVideo("start"); log(res); } if (session.director) { var elements = document.querySelectorAll('[data-action-type="recorder-local"][data-sid="' + session.streamID + '"]'); if (elements[0]) { elements[0].classList.add("pressed"); elements[0].ariaPressed = "true"; elements[0].innerHTML = ' Record'; } } return true; } else if (!startonly) { if ("recording" in session.videoElement) { var res = await recordLocalVideo("stop"); log(res); } ele.dataset.state = "0"; ele.style.backgroundColor = ""; ele.innerHTML = ''; if (session.director) { var elements = document.querySelectorAll('[data-action-type="recorder-local"][data-sid="' + session.streamID + '"]'); if (elements[0]) { elements[0].classList.remove("pressed"); elements[0].ariaPressed = "false"; elements[0].innerHTML = ' Record'; } } return false; } } function cleanupSensorData(data) { for (const key in data) { let nonNullFound = false; for (const subKey in data[key]) { if (data[key][subKey] === null) { delete data[key][subKey]; } else if (subKey !== "t") { nonNullFound = true; } } if (!nonNullFound) { delete data[key]; } } } function setupSensorData(pollrate = 30) { session.sensors = {}; session.sensors.data = {}; // session.sensorDataFilter = ["pos","lin","ori","mag","gyro","acc"]; const startSensor = (SensorType, sensorKey) => { if (window[SensorType] && session.sensorDataFilter.includes(sensorKey)) { try { session.sensors.data[sensorKey] = {}; let sensor = new window[SensorType]({ frequency: pollrate }); sensor.addEventListener("reading", () => { try { session.sensors.data[sensorKey].x = sensor.x !== null ? parseFloat(sensor.x.toFixed(5)) : null; session.sensors.data[sensorKey].y = sensor.y !== null ? parseFloat(sensor.y.toFixed(5)) : null; session.sensors.data[sensorKey].z = sensor.z !== null ? parseFloat(sensor.z.toFixed(5)) : null; } catch (e) { } try { session.sensors.data[sensorKey].t = parseInt(Math.round(sensor.timeStamp || 0)) || Date.now(); } catch (e) { errorlog(e); } }); sensor.start(); session.sensors[sensorKey] = sensor; } catch (e) { errorlog(e); } } }; startSensor("Accelerometer", "acc"); startSensor("Gyroscope", "gyro"); startSensor("Magnetometer", "mag"); startSensor("LinearAccelerationSensor", "lin"); if (session.sensorDataFilter.includes("ori")) { try { window.addEventListener("deviceorientation", e => { if (e.alpha || e.beta || e.gamma || e.absolute) { session.sensors.data.ori = { a: e.alpha !== null ? parseFloat(e.alpha.toFixed(5)) : null, b: e.beta !== null ? parseFloat(e.beta.toFixed(5)) : null, g: e.gamma !== null ? parseFloat(e.gamma.toFixed(5)) : null, d: e.absolute || null, t: parseInt(Math.round(e.timeStamp || 0)) || Date.now() }; } }); } catch (e) { errorlog("Device Orientation Error:", e); } } let isFirstUpdate = true; if (navigator.geolocation && session.sensorDataFilter.includes("pos")) { try { navigator.geolocation.watchPosition( pos => { session.sensors.data.pos = { speed: pos.coords.speed !== null ? parseFloat(pos.coords.speed.toFixed(3)) : null, alt: pos.coords.altitude !== null ? parseFloat(pos.coords.altitude.toFixed(3)) : null, acc: pos.coords.accuracy !== null ? parseFloat(pos.coords.accuracy.toFixed(3)) : null, lat: pos.coords.latitude !== null ? parseFloat(pos.coords.latitude.toFixed(3)) : null, lon: pos.coords.longitude !== null ? parseFloat(pos.coords.longitude.toFixed(3)) : null, t: parseInt(Math.round(pos.timeStamp || 0)) || Date.now() }; if (isFirstUpdate && (pos.coords.latitude || pos.coords.longitude)) { isFirstUpdate = false; console.log(session.sensors.data); warnUser("🌎🌍🌏 Geo-location sharing is enabled.\n\nIf being tracked is unwanted, please disable the 'Location' permissions in your browser's site settings.", 10000); } }, error => { errorlog("Geolocation Error:", error); if (error.code === error.PERMISSION_DENIED) { warnUser("Geolocation permission was denied."); } }, { enableHighAccuracy: true, timeout: 5000, maximumAge: 0 } ); } catch (e) { errorlog("Device Orientation Error:", e); } } setInterval(function () { if (session.sensors && session.sensors.data) { cleanupSensorData(session.sensors.data); session.sendMessage({ sensors: session.sensors.data }); } }, parseInt(1000 / pollrate)); } function setupExternalSensorBridge() { if (session.externalSensorBridgeAttached) { return; } session.externalSensorBridgeAttached = true; log("External sensor bridge attached"); window.addEventListener("message", event => { const payload = event.data; if (!payload || !payload.sensors) { return; } if (session.externalSensorOrigin && event.origin && event.origin !== "null" && event.origin !== session.externalSensorOrigin) { log("Sensor message rejected due to origin mismatch: " + event.origin); return; } session.sensors = session.sensors || {}; session.sensors.data = session.sensors.data || {}; try { Object.assign(session.sensors.data, payload.sensors); } catch (e) {} try { session.sendMessage({ sensors: payload.sensors }); } catch (e) { errorlog(e); } }); } //// PCM 16 SAVING LOGIC function PCM16(stream) { if (!stream || !stream.getAudioTracks().length) { errorlog("no audio track found"); return null; } var PCM = stream.getAudioTracks()[0].getSettings(); function audioBufferToWav(buffer, options = {}) { const numChannels = buffer.numberOfChannels; const sampleRate = buffer.sampleRate; const format = options.float32 ? 3 : 1; const bitDepth = format === 3 ? 32 : 16; let samples; if (numChannels === 2) { samples = interleave(buffer.getChannelData(0), buffer.getChannelData(1)); } else { samples = buffer.getChannelData(0); } return encodeWAV(samples, format, sampleRate, numChannels, bitDepth); } function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) { const bytesPerSample = bitDepth / 8; const blockAlign = numChannels * bytesPerSample; const bufferLength = 44 + samples.length * bytesPerSample; const buffer = new ArrayBuffer(bufferLength); const dataView = new DataView(buffer); // referenced from: https://github.com/steveseguin/audiobuffer-to-wav (by Jam3 - MIT lic) writeString(dataView, 0, "RIFF"); dataView.setUint32(4, 36 + samples.length * bytesPerSample, true); writeString(dataView, 8, "WAVE"); writeString(dataView, 12, "fmt "); dataView.setUint32(16, 16, true); dataView.setUint16(20, format, true); dataView.setUint16(22, numChannels, true); dataView.setUint32(24, sampleRate, true); dataView.setUint32(28, sampleRate * blockAlign, true); dataView.setUint16(32, blockAlign, true); dataView.setUint16(34, bitDepth, true); writeString(dataView, 36, "data"); dataView.setUint32(40, samples.length * bytesPerSample, true); if (format === 1) { floatTo16BitPCM(dataView, 44, samples); } else { writeFloat32(dataView, 44, samples); } return buffer; } function interleave(inputL, inputR) { const length = inputL.length + inputR.length; const result = new Float32Array(length); for (let index = 0, inputIndex = 0; index < length; index += 2, inputIndex++) { result[index] = inputL[inputIndex]; result[index + 1] = inputR[inputIndex]; } return result; } function floatTo16BitPCM(output, offset, input) { for (let i = 0; i < input.length; i++, offset += 2) { const s = Math.max(-1, Math.min(1, input[i])); output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); } } function writeFloat32(output, offset, input) { for (let i = 0; i < input.length; i++, offset += 4) { output.setFloat32(offset, input[i], true); } } function writeString(dataView, offset, string) { for (let i = 0; i < string.length; i++) { dataView.setUint8(offset + i, string.charCodeAt(i)); } } // end reference PCM.audioContext = new AudioContext({ sampleRate: PCM.sampleRate }); PCM.source = PCM.audioContext.createMediaStreamSource(stream); PCM.numberOfChannels = PCM.source.channelCount; PCM.scriptNode = PCM.audioContext.createScriptProcessor(4096, PCM.numberOfChannels, PCM.numberOfChannels); // buffer size, input channels, output channels PCM.recording = false; PCM.audioData = []; for (let i = 0; i < PCM.numberOfChannels; i++) { PCM.audioData.push([]); } PCM.scriptNode.onaudioprocess = audioProcessingEvent => { if (!PCM.recording) return; for (let channel = 0; channel < PCM.numberOfChannels; channel++) { const inputData = audioProcessingEvent.inputBuffer.getChannelData(channel); PCM.audioData[channel].push(new Float32Array(inputData)); } }; PCM.source.connect(PCM.scriptNode); PCM.scriptNode.connect(PCM.audioContext.destination); PCM.startRecording = function () { PCM.audioData = []; for (let i = 0; i < PCM.numberOfChannels; i++) { PCM.audioData.push([]); } PCM.recording = true; }; PCM.stopRecording = function (filename = "filename") { PCM.recording = false; const bufferLength = PCM.audioData[0].length * 4096; const audioBuffer = PCM.audioContext.createBuffer(PCM.numberOfChannels, bufferLength, PCM.audioContext.sampleRate); for (let channel = 0; channel < PCM.numberOfChannels; channel++) { const channelData = audioBuffer.getChannelData(channel); PCM.audioData[channel].forEach((chunk, index) => { channelData.set(chunk, index * 4096); }); } const wavArrayBuffer = audioBufferToWav(audioBuffer); const blob = new Blob([wavArrayBuffer], { type: "audio/wav" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename + ".wav"; anchor.click(); URL.revokeObjectURL(url); }; return PCM; } //// END OF PCM 16 SAVING CODE async function recordLocalVideo(action = null, configureRecording = false, remote = false, altUUID = false) { // event.currentTarget,this.parentNode.parentNode.dataset.UUID if (session.record === false) { warnlog("recordings are disabled by decree of thy host magistrate"); } log("original", configureRecording); if (typeof configureRecording !== "object") { let bitrate = configureRecording !== false ? configureRecording : (session.recordLocal !== false ? session.recordLocal : session.recordDefault); configureRecording = { bitrate: bitrate, usePCM: (bitrate === 0 || session.pcm) ? true : false, audioOnly: (bitrate !== false && bitrate <= 0) ? true : false }; } if (remote) { var video = remote; if (remote.id === "videosource" || remote.id === "screensharesource") { remote = false; } } else if (altUUID) { var video = session.screenShareElement; } else { var video = session.videoElement; } if (!video) { warnlog("video not found"); return; } log(video.id); if ("recording" in video) { if (action == "estop") { video.recorder.eStop(); warnlog("EMERGENCY Stopping RECORDING!"); video.recorder.stop(); return; } else if (action == "stop") { log("Stopping RECORDING!"); video.recorder.stop(); return; } else if (action == "start") { errorlog("ALREADY RECORDING!"); if (remote) { getById("recordLocalbutton").dataset.state = "1"; getById("recordLocalbutton").style.backgroundColor = "red"; getById("recordLocalbutton").innerHTML = ''; } return; } else { errorlog("STOPPING RECORDING by default toggle!"); video.recorder.stop(); return; } return; // this should never happen } else if (action == "start") { if (video && video.recorder && video.recorder.closing) { errorlog("Ingore request. Haven't finished closing the previous recording."); return; } if (video.srcObject && video.srcObject.getTracks && !video.srcObject.getTracks().length) { warnlog("No video or audio tracks to record"); return; } if (!MediaRecorder) { var msg = {}; msg.recorder = -3; if (altUUID) { msg.alt = true; } for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } errorlog("no MediaRecorder"); return; } else if (SafariVersion || iPad || iOS) { var msg = {}; msg.recorder = -5; if (altUUID) { msg.alt = true; } for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } log("SAFARI/IOS MODE ENABLED"); } video.recording = true; if (remote) { getById("recordLocalbutton").dataset.state = "1"; getById("recordLocalbutton").style.backgroundColor = "red"; getById("recordLocalbutton").innerHTML = ''; } } else if (action == "stop") { warnlog("stop not sensible"); return; } else { log("action is :" + action); if (video && video.recorder && video.recorder.closing) { errorlog("Ingore request. Haven't finished closing the previous recording."); return; } if (!remote) { getById("recordLocalbutton").dataset.state = "1"; getById("recordLocalbutton").style.backgroundColor = "red"; getById("recordLocalbutton").innerHTML = ''; } video.recording = true; } video.recorder = {}; if (!configureRecording.audioOnly && configureRecording.bitrate < 50) { configureRecording.bitrate = 50; } if (typeof video.srcObject === "undefined" || !video.srcObject) { errorlog("video.srcObject undefined"); return; } log(configureRecording); var timestamp = Date.now(); var filename = ""; if (session.label || session.streamID) { filename = session.label || session.streamID; filename = filename.replace(/[\W]+/g, "_"); filename = filename.substring(0, 200); } filename += "_" + timestamp.toString(); log("filename: " + filename); video.recorder.eStop = function () { try { video.recorder.writer.close(); clearInterval(video.recorder.writer.interval); } catch (e) { } }; video.recorder.stop = function (restart = false, notify = false) { if (session.dbx && video.dropbox && video.dropbox[filename]) { video.dropbox[filename](false); } if (video.gdrive && video.gdrive[filename]) { video.gdrive[filename].addChunk(false); } try { if (!remote) { if (restart) { if (getById("recordLocalbutton").dataset.state == 2) { getById("recordLocalbutton").dataset.state = "0"; getById("recordLocalbutton").style.backgroundColor = ""; getById("recordLocalbutton").innerHTML = ''; if (restart !== true) { warnUser("Media Recording Stopped due to an error: " + restart); } else { warnUser("Media Recording Stopped due to an error."); } restart = false; } else { getById("recordLocalbutton").innerHTML = ''; getById("recordLocalbutton").dataset.state = "2"; } } else { getById("recordLocalbutton").dataset.state = "0"; getById("recordLocalbutton").style.backgroundColor = ""; getById("recordLocalbutton").innerHTML = ''; if (notify) { if (!session.cleanOutput) { warnUser("A recording has stopped unexpectedly."); } if (session.beepToNotify) { playtone(); } getById("recordLocalbutton").classList.remove("shake"); setTimeout(function () { getById("recordLocalbutton").classList.add("shake"); }, 10); } } } } catch (e) { errorlog(e); } if (!video.recording) { errorlog("ALREADY STOPPED"); return; } if (!video.recorder || video.recorder.closing) { errorlog("it's still closing; can't start until its done"); return; } video.recorder.closing = true; // start the closing process try { if (video.recorder && video.recorder.mediaRecorder && video.recorder.mediaRecorder.stop) { if (video.recorder.mediaRecorder.state !== "inactive") { video.recorder.mediaRecorder.stop(); } } } catch (e) { errorlog(e); try { video.recorder.mediaRecorder.stop(); } catch (e1) { errorlog(e1); } } // video.recording = false; setTimeout( (configureRecording, altUUID, video) => { try { video.recorder.writer.close(); } catch (e) { errorlog(e); } try { clearInterval(video.recorder.writer.interval); } catch (e) { errorlog(e); } pokeIframeAPI("recording-stopped"); if (!remote) { try { if (session.directorUUID) { var msg = {}; msg.recorder = -1; if (altUUID) { msg.alt = true; } for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } } catch (e) { errorlog(e); } } try { if (video.recorder && video.recorder.mediaRecorder && video.recorder.mediaRecorder.stop) { if (video.recorder.mediaRecorder.state !== "inactive") { video.recorder.mediaRecorder.stop(); } } } catch (e) { errorlog(e); try { video.recorder.mediaRecorder.stop(); } catch (e1) { errorlog(e1); } } try { delete video.recorder; delete video.recording; } catch (e) { } if (!remote) { if (restart) { setTimeout( function (configureRecording, altUUID) { recordLocalVideo("start", configureRecording, false, altUUID); }, 0, configureRecording, altUUID ); } } }, 500, configureRecording, altUUID, video ); if (!remote) { try { if (session.directorUUID) { var msg = {}; if (notify) { msg.recorder = -4; // user aborted } else { msg.recorder = -2; } if (altUUID) { msg.alt = true; } for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } } catch (e) { errorlog(e); } } }; let options = {}; let filext = ".webm"; log("setting up options"); if (!configureRecording.audioOnly) { log("videoKbps: " + configureRecording.bitrate); var tryCodec = session.recordingVideoCodec || ""; // Simplified condition to assign tryCodec if (tryCodec && MediaRecorder.isTypeSupported("video/webm;codecs=" + tryCodec)) { if (!session.cleanOutput) { console.log("👍 The browser 'says' it supports " + tryCodec); } options.mimeType = "video/webm;codecs=" + tryCodec; if (configureRecording.usePCM) { // Fixed the format of the MIME type string var mimeTypeWithPCM = "video/x-matroska;codecs=" + tryCodec + ",pcm"; if (MediaRecorder.isTypeSupported(mimeTypeWithPCM)) { options.mimeType = mimeTypeWithPCM; } else { options.mimeType = "video/webm;codecs=pcm"; } } } else { // Simplified conditions for PCM support if (tryCodec) { warnlog("video/webm;codecs=" + tryCodec + " - is not supported"); } options.mimeType = configureRecording.usePCM && MediaRecorder.isTypeSupported("video/webm;codecs=pcm") ? "video/webm;codecs=pcm" : "video/webm"; } // Simplified bitrate settings options.videoBitsPerSecond = parseInt(configureRecording.bitrate * 1024); if (configureRecording.bitrate < 1000) { options.audioBitsPerSecond = parseInt(100 * 1024); } else if (configureRecording.bitrate < 6000) { options.audioBitsPerSecond = parseInt(130 * 1024); } else if (configureRecording.bitrate < 20000) { options.audioBitsPerSecond = parseInt(256 * 1024); } else { // If configureRecording.bitrate is >= 20000, use bitsPerSecond for total bitrate options.bitsPerSecond = parseInt(configureRecording.bitrate * 1024); } if (iOS && options.mimeType) { if (!MediaRecorder.isTypeSupported(options.mimeType)) { options.mimeType = "video/mp4"; filext = ".mp4"; } } video.recorder.mediaRecorder = new MediaRecorder(video.srcObject, options); try { log(options); video.recorder.mediaRecorder = new MediaRecorder(video.srcObject, options); } catch (e) { warnlog(e); try { errorlog("options failed"); video.recorder.mediaRecorder = new MediaRecorder(video.srcObject); } catch (e) { errorlog(e); errorlog("Failing the recording"); var msg = {}; msg.recorder = -3; if (altUUID) { msg.alt = true; } for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } getById("recordLocalbutton").dataset.state = "0"; getById("recordLocalbutton").style.backgroundColor = ""; getById("recordLocalbutton").innerHTML = ''; return; } } if (session.dbx) { if (!video.dropbox) { video.dropbox = {}; } video.dropbox[filename] = await streamVideoToDropbox(filename.toString() + filext); // i don't want to upload to dropbox remote streams; just local if (!video.dropbox[filename]) { delete video.dropbox[filename]; } } if (session.gdrive) { if (!video.gdrive) { video.gdrive = {}; } if (session.gdrive === true) { video.gdrive[filename] = setupGoogleDriveUploader(filename.toString() + filext); } else if (session.gdrive.sessionUri) { video.gdrive[filename] = setupGoogleDriveUploader(filename.toString() + filext, session.gdrive.sessionUri); // filename isn't actually being used here session.gdrive = false; } else { errorlog("gdrive partially setup?"); video.gdrive[filename] = setupGoogleDriveUploader(filename.toString() + filext); } if (!video.gdrive[filename]) { delete video.gdrive[filename]; } } log(video.recorder.mediaRecorder); } else { log("Audio only?"); options.mimeType = "audio/webm"; if (configureRecording.usePCM) { if (MediaRecorder.isTypeSupported("audio/webm;codecs=pcm")) { options.mimeType = "audio/webm;codecs=pcm"; } } else { options.bitsPerSecond = parseInt(configureRecording.bitrate * 1024); } var stream = createMediaStream(); var audioTrack = false; video.srcObject.getAudioTracks().forEach(track => { audioTrack = true; stream.addTrack(track, video.srcObject); }); if (!audioTrack) { errorlog("Failing the recording; no audio track"); try { video.recorder.writer.close(); } catch (e) { } try { clearInterval(video.recorder.writer.interval); } catch (e) { } try { delete video.recorder; delete video.recording; } catch (e) { } var msg = {}; msg.recorder = -3; if (altUUID) { msg.alt = true; } for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } getById("recordLocalbutton").dataset.state = "0"; getById("recordLocalbutton").style.backgroundColor = ""; getById("recordLocalbutton").innerHTML = ''; return; } else { if (iOS && options.mimeType) { if (!MediaRecorder.isTypeSupported(options.mimeType)) { options.mimeType = "video/mp4"; } } try { video.recorder.mediaRecorder = new MediaRecorder(stream, options); } catch (e) { warnlog(e); try { errorlog("options failed. failing safe.."); video.recorder.mediaRecorder = new MediaRecorder(stream); } catch (e) { errorlog(e); errorlog("Fail safe failed; closing the recording"); try { video.recorder.writer.close(); } catch (e) { } try { clearInterval(video.recorder.writer.interval); } catch (e) { } try { delete video.recorder; delete video.recording; } catch (e) { } var msg = {}; msg.recorder = -3; if (altUUID) { msg.alt = true; } for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } getById("recordLocalbutton").dataset.state = "0"; getById("recordLocalbutton").style.backgroundColor = ""; getById("recordLocalbutton").innerHTML = ''; return; } } if (session.dbx) { if (!video.dropbox) { video.dropbox = {}; } video.dropbox[filename] = await streamVideoToDropbox(filename.toString() + filext); // i don't want to upload to dropbox remote streams; just local if (!video.dropbox[filename]) { delete video.dropbox[filename]; } } if (session.gdrive) { if (!video.gdrive) { video.gdrive = {}; } if (session.gdrive === true) { video.gdrive[filename] = setupGoogleDriveUploader(filename.toString() + filext); } else if (session.gdrive.sessionUri) { video.gdrive[filename] = setupGoogleDriveUploader(filename.toString() + filext, session.gdrive.sessionUri); // filename isn't actually being used here session.gdrive = false; } else { video.gdrive[filename] = setupGoogleDriveUploader(filename.toString() + filext); errorlog("Gdrive only partially setup"); } if (!video.gdrive[filename]) { delete video.gdrive[filename]; } } } } log(options); function createLock() { let isLocked = false; let queue = Promise.resolve(); return { acquire: async () => { const release = () => { isLocked = false; }; while (isLocked) { await new Promise(resolve => setTimeout(resolve, 10)); } isLocked = true; return release; } }; } var chunkQueue = []; async function handleDataAvailable(event, process = true) { if (!video.recorder.writerLock) { video.recorder.writerLock = createLock(); } // Handle existing queue first while (chunkQueue.length && process) { const ret = await handleDataAvailable(chunkQueue.shift(), false); if (ret === false) { return; } } if (event.data && event.data.size > 0) { try { const release = await video.recorder.writerLock.acquire(); try { if (video && video.recorder && video.recorder.writer && video.recorder.writer._ownerWritableStream && video.recorder.writer._ownerWritableStream._state === "writable") { await video.recorder.writer.write(event.data); } else { throw new Error("Writer not open"); } } catch (e) { if (process === true) { chunkQueue.push(event); } else { chunkQueue.unshift(event); release(); return false; } } finally { release(); } // Rest of the existing code for messaging and cloud uploads if (session.directorList.length) { if (video.recording) { var msg = {}; if (altUUID) { msg.alt = true; } msg.recorder = parseInt((Date.now() - timestamp) / 1000) || 0; for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } } if (session.dbx && video.dropbox && video.dropbox[filename]) { video.dropbox[filename](event.data); } if (video.gdrive && video.gdrive[filename]) { video.gdrive[filename].addChunk(event.data); } } catch (e) { errorlog(e); } } } video.recorder.mediaRecorder.ondataavailable = (event) => { handleDataAvailable(event).catch(e => errorlog(e)); }; video.recorder.mediaRecorder.onerror = function (event) { errorlog(event); console.log("It's possible using &recordcodec=vp8 might resolve recording errors if caused by an incompatible hardware encoder or codec"); if (event && event.error && event.error.name) { video.recorder.stop(event.error.name); } else { video.recorder.stop(true); } }; video.srcObject.onended = function (event) { video.recorder.stop(); }; try { video.recorder.iteration = 0; video.recorder.setupWriter = async function setupWriter(video) { if (video.recorder.writer) { try { video.recorder.writer.close(); await sleep(1000); // we don't cancel the interval obviously } catch (e) { errorlog(e); } } var { readable, writable } = new TransformStream({ transform: (chunk, ctrl) => chunk.arrayBuffer().then(b => ctrl.enqueue(new Uint8Array(b))) }); var writer = await writable.getWriter(); var addon = ""; if (video.recorder.iteration != 0) { addon = "_" + video.recorder.iteration; } video.recorder.iteration += 1; readable.pipeTo(streamSaver.createWriteStream(filename.toString() + filext + addon, video.recorder.stop)); video.recorder.writer = writer; }; await video.recorder.setupWriter(video); if (session.recordingInterval) { // minutes function intervalClosure(video) { var intervalId = setInterval( function (video) { try { video.recorder.setupWriter(video); } catch (e) { clearInterval(intervalId); } }, 1000 * 60 * session.recordingInterval, video ); video.recorder.writer.interval = intervalId; } intervalClosure(video); } video.recorder.mediaRecorder.start(1000); // 100ms chunks log("started recording"); pokeIframeAPI("recording-started"); getById("recordLocalbutton").dataset.state = "1"; getById("recordLocalbutton").style.backgroundColor = "red"; getById("recordLocalbutton").innerHTML = ''; if (session.directorList.length) { var msg = {}; if (altUUID) { msg.alt = true; } msg.recorder = 0; for (var i = 0; i < session.directorList.length; i++) { msg.UUID = session.directorList[i]; session.sendMessage(msg, msg.UUID); } } } catch (e) { errorlog(e); } return; } async function recordWindowCapture(bitrate = 6000) { // Streamlined window/tab recording for scenes // Captures the current browser tab and records to disk // // Customizable via URL parameters: // &recordwindow=BITRATE - recording bitrate in kbps (default: 6000) // &pcm - use PCM audio (lossless, larger files) // &screensharefps=FPS - capture framerate (default: 60) // &screensharequality=X - resolution: 4k, 2k, 1080p, 720p, etc. // &width=W&height=H - custom resolution if (session.recordWindowElement && session.recordWindowElement.recording) { log("Window recording already in progress"); return; } try { // Determine resolution from session parameters var targetWidth = 1920; var targetHeight = 1080; if (session.screensharequality) { var q = parseInt(session.screensharequality); if (q === -2) { // 4k targetWidth = 3840; targetHeight = 2160; } else if (q === -3) { // 2k/1440p targetWidth = 2560; targetHeight = 1440; } else if (q === 1) { // 720p targetWidth = 1280; targetHeight = 720; } else if (q === 2) { // 360p targetWidth = 640; targetHeight = 360; } } if (session.width) { targetWidth = parseInt(session.width) || targetWidth; } if (session.height) { targetHeight = parseInt(session.height) || targetHeight; } // Determine framerate var targetFps = 60; if (session.screensharefps) { targetFps = parseInt(session.screensharefps) || 60; } var constraints = { video: { frameRate: { ideal: targetFps }, width: { ideal: targetWidth }, height: { ideal: targetHeight }, cursor: "never" }, audio: true, preferCurrentTab: true, selfBrowserSurface: "include", surfaceSwitching: "exclude" }; if (session.displaySurface) { constraints.video.displaySurface = session.displaySurface; } if (session.suppressLocalAudioPlayback) { constraints.audio = { suppressLocalAudioPlayback: true }; } log("Starting window capture: " + targetWidth + "x" + targetHeight + "@" + targetFps + "fps"); var stream = await navigator.mediaDevices.getDisplayMedia(constraints); // Create a temp video element to hold the capture var video = document.createElement("video"); video.id = "recordWindowSource"; video.srcObject = stream; video.muted = true; video.autoplay = true; video.playsInline = true; video.style.display = "none"; document.body.appendChild(video); session.recordWindowElement = video; // Handle stream ending (user stops sharing) stream.getVideoTracks()[0].onended = function() { log("Window capture ended"); if (video.recording) { recordLocalVideo("stop", false, video); } video.remove(); session.recordWindowElement = null; // Reset button state if exists var btn = document.getElementById("recordWindowButton"); if (btn) { btn.innerHTML = "● Start Recording"; btn.title = "Record this scene to a local video file"; btn.style.background = "#d00"; btn.style.opacity = "1"; btn.dataset.recording = "0"; } // Stop Go Live if active if (session.goLivePC) { try { session.goLivePC.close(); } catch(e) {} session.goLivePC = null; } var liveBtn = document.getElementById("goLiveButton"); if (liveBtn) { liveBtn.innerHTML = "📡 Go Live"; liveBtn.title = "Stream to Twitch via WHIP (requires stream key)"; liveBtn.style.background = "#6441a5"; liveBtn.dataset.live = "0"; } }; await video.play(); // Start recording using existing infrastructure var usePCM = session.pcm || false; var configureRecording = { bitrate: bitrate, usePCM: usePCM, audioOnly: false }; log("Starting window recording at " + bitrate + " kbps" + (usePCM ? " with PCM audio" : "")); recordLocalVideo("start", configureRecording, video); } catch (e) { errorlog("Window capture failed: " + e); if (session.recordWindowElement) { session.recordWindowElement.remove(); session.recordWindowElement = null; } // Reset button state on error/cancel var btn = document.getElementById("recordWindowButton"); if (btn) { btn.innerHTML = "● Start Recording"; btn.style.background = "#d00"; btn.style.opacity = "1"; btn.dataset.recording = "0"; } } } function localGlobalRecordStart() { document.querySelectorAll("[data-action-type='recorder-local']").forEach(target => { var UUID = target.dataset.UUID; if (!UUID) { return; } var video = session.rpcs[UUID].videoElement; if (!video) { return; } if (!video.stopWriter) { recordVideo(target); // if not started, start } }); recordLocalVideo("start"); // self } function localGlobalRecordStop() { document.querySelectorAll("[data-action-type='recorder-local']").forEach(target => { var UUID = target.dataset.UUID; if (!UUID) { return; } var video = session.rpcs[UUID].videoElement; if (!video) { return; } if (video.stopWriter) { recordVideo(target); // if started, stop } }); recordLocalVideo("stop"); // self } async function remoteGlobalRecordStart() { window.focus(); var bitrate = await promptAlt(miscTranslations["what-bitrate"], false, false, 6000); document.querySelectorAll("[data-action-type='recorder-remote']").forEach(target => { requestVideoRecord(target, true, bitrate); }); } function remoteGlobalRecordStop() { document.querySelectorAll("[data-action-type='recorder-remote']").forEach(target => { if (target.classList.contains("pressed")) { requestVideoRecord(target, false); } }); } session.onTrack = function (event, UUID) { if (session.badStreamList.includes(session.rpcs[UUID].streamID)) { errorlog("new connection is contained in badStreamList 2! This shouldn't happen"); // we will have none of this. return; } var newTracks = []; var newStream = false; if (event.streams && event.streams[0]) { newStream = event.streams[0]; newTracks = newStream.getTracks(); } else if (event.track) { newTracks.push(event.track); } else { errorlog("Something went wrong with incoming track.."); return; } if (session.rpcs[UUID].streamSrc) { var tracks = session.rpcs[UUID].streamSrc.getTracks(); for (var i = 0; i < newTracks.length; i++) { for (var j = 0; j < tracks.length; j++) { if (newTracks[i].id == tracks[j].id && newTracks[i].kind == tracks[j].kind) { // FIX: Only replace if old track is dead (ended) // This prevents audio clicks during normal operation if (tracks[j].readyState === "ended") { try { session.rpcs[UUID].streamSrc.removeTrack(tracks[j]); log("Replaced dead " + tracks[j].kind + " track"); } catch(e) { warnlog(e); } } else { // Old track is still live - skip duplicate as before newTracks.splice(i, 1); i--; } break; } } } } var screenshare = false; var screenshareParentOverride = null; if (session.rpcs[UUID].screenIndexes && session.rpcs[UUID].getReceivers && session.rpcs[UUID].screenIndexes.length) { log("session.rpcs[UUID].screenIndexes: " + session.rpcs[UUID].screenIndexes); var receievers = session.rpcs[UUID].getReceivers(); // excluded for (var i = 0; i < receievers.length; i++) { for (var j = 0; j < newTracks.length; j++) { if (receievers[i].track && receievers[i].track.id == newTracks[j].id && receievers[i].track.kind == newTracks[j].kind) { for (var k = 0; k < session.rpcs[UUID].screenIndexes.length; k++) { if (session.rpcs[UUID].screenIndexes[k] == i) { screenshare = true; break; } } } if (screenshare) { break; } } if (screenshare) { break; } } } if (typeof UUID === "string" && UUID.endsWith("_screen")) { if (!screenshare) { screenshare = true; } if (session.rpcs[UUID] && session.rpcs[UUID].realUUID) { screenshareParentOverride = session.rpcs[UUID].realUUID; } else { screenshareParentOverride = UUID.slice(0, -7); } if (session.rpcs[UUID]) { session.rpcs[UUID].screenShareState = true; if (typeof session.rpcs[UUID].smallScreen === "undefined" || session.rpcs[UUID].smallScreen === null) { session.rpcs[UUID].smallScreen = false; } } } if (screenshare) { const parentUUID = screenshareParentOverride || (typeof UUID === "string" && UUID.endsWith("_screen") ? UUID.slice(0, -7) : UUID); if (parentUUID && session.rpcs[parentUUID]) { session.rpcs[parentUUID].screenShareState = true; } if (parentUUID && session.rpcs[parentUUID + "_screen"]) { session.rpcs[parentUUID + "_screen"].screenShareState = true; } } log("screenshare: " + screenshare); log(session.rpcs[UUID].streamID); try { var index = newTracks.length; while (index--) { if (newTracks[index].kind == "video") { if (session.novideo !== false && !session.novideo.includes(session.rpcs[UUID].streamID)) { if (!(screenshare && session.novideo.includes(session.rpcs[UUID].streamID + ":s"))) { newTracks.splice(index, 1); } continue; } else if (session.rpcs[UUID].settings && session.rpcs[UUID].settings.allowscreenvideo && screenshare) { //newTracks.splice(index,1); continue; } else if (session.rpcs[UUID].settings && !session.rpcs[UUID].settings.video) { newTracks.splice(index, 1); continue; } } else if (newTracks[index].kind == "audio") { if (session.noaudio !== false && !session.noaudio.includes(session.rpcs[UUID].streamID)) { if (!(screenshare && session.noaudio.includes(session.rpcs[UUID].streamID + ":s"))) { newTracks.splice(index, 1); } continue; } else if (session.excludeaudio && session.excludeaudio.includes(session.rpcs[UUID].streamID)) { newTracks.splice(index, 1); continue; } else if (session.rpcs[UUID].settings && session.rpcs[UUID].settings.allowscreenaudio && screenshare) { //newTracks.splice(index,1); continue; } else if (session.rpcs[UUID].settings && !session.rpcs[UUID].settings.audio) { newTracks.splice(index, 1); continue; } } } } catch (e) { errorlog(e); } if (!newTracks.length) { warnlog("NO NEW TRACKS?"); return; } if (session.encodedInsertableStreams && session.rpcs[UUID] && session.rpcs[UUID].getReceivers) { var receievers = session.rpcs[UUID].getReceivers(); // excluded for (var i = 0; i < receievers.length; i++) { for (var j = 0; j < newTracks.length; j++) { if (receievers[i].track && receievers[i].track.id == newTracks[j].id && receievers[i].track.kind == newTracks[j].kind) { try { setupReceiverTransform(receievers[i]); } catch (e) { errorlog(e); } } } } } if (screenshare) { var targetUUID = screenshareParentOverride || UUID; if (session.rpcs[targetUUID]) { session.setupScreenShareAddon(newTracks, targetUUID); } else { session.setupScreenShareAddon(newTracks, UUID); } return; } //if (session.buffer!==false){ playoutdelay(UUID); //} session.directorSpeakerMute(); // apply any mute states to new tracks. session.directorDisplayMute(); if (newStream) { newStream.onremovetrack = function (e1) { try { warnlog("Track was removed"); session.rpcs[UUID].streamSrc.getTracks().forEach(trk => { if (trk.id == e1.track.id && trk.kind == e1.track.kind) { session.rpcs[UUID].streamSrc.removeTrack(trk); } }); if (e1.track.kind == "video") { updateIncomingVideoElement(UUID, true, false); } else { updateIncomingVideoElement(UUID, false, true); } // updateIncomingVideoElement(UUID); // session.rpcs[UUID].videoElement.srcObject = session.rpcs[UUID].streamSrc; setTimeout(function () { updateMixer(); }, 1); } catch (e) { } }; newStream.onerror = function (e1) { errorlog(e1); try { warnlog("Track threw an error; going to reconnect it"); session.rpcs[UUID].streamSrc.getTracks().forEach(trk => { try { if (trk.id == e1.track.id && trk.kind == e1.track.kind) { session.rpcs[UUID].streamSrc.removeTrack(trk); } } catch (e) { } }); if (e1.track.kind == "video") { updateIncomingVideoElement(UUID, true, false); } else { updateIncomingVideoElement(UUID, false, true); } setTimeout(function () { updateMixer(); }, 1); } catch (e) { errorlog(e); } }; } createRichVideoElement(UUID); if (!session.rpcs[UUID].streamSrc) { session.rpcs[UUID].streamSrc = createMediaStream(); mediaSourceUpdated(UUID, session.rpcs[UUID].streamID); } var videoAdded = false; var audioAdded = false; newTracks.forEach(trk => { if (trk.kind == "video") { videoAdded = true; } else if (trk.kind == "audio") { audioAdded = true; } log("adding track"); session.rpcs[UUID].streamSrc.addTrack(trk); }); if (newTracks.length > session.rpcs[UUID].streamSrc.getTracks().length) { errorlog("Not all the tracks were added to the local stream; are the tracks' IDs not unique?"); console.log("streamSrc total tracks: " + session.rpcs[UUID].streamSrc.getTracks().length); } if (isIFrame && session.sendframes) { sendFrameHandler(newTracks, UUID); } if (audioAdded && videoAdded) { updateIncomingVideoElement(UUID); } else if (videoAdded) { updateIncomingVideoElement(UUID, true, false); } else if (audioAdded) { updateIncomingVideoElement(UUID, false, true); if (!session.roomid && session.view && !session.permaid) { setTimeout(function () { updateMixer(); }, 10); // video already has an auto-start, with aspect ratio size change. audio doesn't. } } if (session.twilio && audioAdded) { session.twilio.updateMixer(UUID); } return session; }; function sendFrameHandler(tracks, UUID = null) { tracks.forEach(async trk => { if (trk.kind !== "video") return; log("STARTING NEW SEND STREAM VIDEO TRACK"); const startImageStream = async () => { log("startImageStream"); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d", { willReadFrequently: true }); const processor = new MediaStreamTrackProcessor(trk); const reader = processor.readable.getReader(); while (true) { try { const { done, value: frame } = await reader.read(); if (done) break; try { canvas.width = frame.displayWidth; canvas.height = frame.displayHeight; ctx.drawImage(frame, 0, 0); const format = typeof session.sendframes === "string" ? session.sendframes : "webp"; const imageData = canvas.toDataURL(`image/${format}`, 0.8); parent.postMessage({ type: 'frame', frame: imageData, UUID, streamID: (session.rpcs[UUID] ? session.rpcs[UUID].streamID : null), trackID: trk.id, kind: trk.kind, format: format }, session.sendframes); } finally { frame.close(); } } catch (e) { console.error("Error processing image frame:", e); break; } } }; const startFrameStream = async () => { log("startFrameStream"); const processor = new MediaStreamTrackProcessor(trk); const reader = processor.readable.getReader(); while (true) { try { const { done, value } = await reader.read(); if (done) { if (value) value.close(); break; } try { parent.postMessage({ frame: value, UUID, streamID: (session.rpcs[UUID] ? session.rpcs[UUID].streamID : null), trackID: trk.id, kind: trk.kind, type: "frame" }, session.sendframes, [value]); } finally { value.close(); } } catch (e) { console.error("Error processing video frame:", e); if (e.name === "DataCloneError") { // Fall back to image stream if frame transfer fails return startImageStream(); } break; } } }; try { if (typeof MediaStreamTrackProcessor === 'function') { try { new SharedArrayBuffer(1); await startFrameStream(); } catch (e) { console.warn(e); await startImageStream(); } } else { await startImageStream(); } } catch (e) { console.error("Stream processing failed:", e); } }); } function updateIncomingVideoElement(UUID, video = true, audio = true) { if (!session.rpcs[UUID].videoElement) { return; } if (!session.rpcs[UUID].streamSrc) { return; } if (!session.rpcs[UUID].videoElement.srcObject) { session.rpcs[UUID].videoElement.srcObject = createMediaStream(); } if (video) { var tracks = session.rpcs[UUID].videoElement.srcObject.getVideoTracks(); // add video track session.rpcs[UUID].streamSrc.getVideoTracks().forEach(trk => { var added = false; tracks.forEach(trk2 => { if (trk.id == trk2.id && trk.kind == trk2.kind) { added = true; } }); if (!added) { session.rpcs[UUID].videoElement.srcObject.getVideoTracks().forEach(trk2 => { // make sure only one video track is added at a time. log("removetrack"); session.rpcs[UUID].videoElement.srcObject.removeTrack(trk2); }); if (trk.muted && trk.kind == "video" && session.director) { trk.onunmute = function (e) { if (!session.rpcs[UUID]) { return; } this.onunmute = null; warnlog("ON UN-MUTE"); updateIncomingVideoElement(UUID, true, false); }; } else { if (session.rpcs[UUID].videoElement.controls) { session.rpcs[UUID].videoElement.controls = session.showControls || false; if (session.showControls === null) { setTimeout( function (ele) { if (ele) { ele.controls = true; } }, 500, session.rpcs[UUID].videoElement ); } } session.rpcs[UUID].videoElement.srcObject.addTrack(trk); mediaVideoTrackUpdated(UUID, session.rpcs[UUID].streamID); } } }); if ((session.motionSwitch || session.motionRecord) && !session.rpcs[UUID].motionDetectionInterval) { session.rpcs[UUID].motionDetectionInterval = setTimeout(function () { setInterval(function () { motionDetection(session.rpcs[UUID].videoElement, session.motionSwitch || session.motionRecord); }, 400); }, 2000); } } if (audio) { updateIncomingAudioElement(UUID); // do the same for audio now. if (!video) { if (session.rpcs[UUID] && session.rpcs[UUID].videoElement) { // this bit of code fixes an issue where the volume button doesn't show, after adding an audio track for the first time var pastMuteState = session.rpcs[UUID].videoElement.muted; session.rpcs[UUID].videoElement.muted = !pastMuteState; session.rpcs[UUID].videoElement.muted = pastMuteState; } } } if ((session.optimize === 0) && session.activatedStreams.size && session.rpcs[UUID] && session.rpcs[UUID].streamID) { if (session.activatedStreams.has(session.rpcs[UUID].streamID)) { if (session.activatedStreamsQueue[session.rpcs[UUID].streamID]) { let msgs = session.activatedStreamsQueue[session.rpcs[UUID].streamID]; delete session.activatedStreamsQueue[session.rpcs[UUID].streamID]; msgs.forEach(msgWithTime => { log(msgWithTime); if (msgWithTime.time && (msgWithTime.time > Date.now() - 10000)) { session.directorActions(msgWithTime.msg); } }); } } } } function updateIncomingAudioElement(UUID) { // this can be called when turning on/off inbound audio processing. if (!session.rpcs[UUID] || !session.rpcs[UUID].videoElement || !session.rpcs[UUID].streamSrc) { return; } if (!session.rpcs[UUID].videoElement.srcObject) { session.rpcs[UUID].videoElement.srcObject = createMediaStream(); } log("updateIncomingAudioElement: " + UUID); if (session.audioEffects === true || session.pushLoudness || (session.rpcs[UUID].isolatedChannel !== undefined)) { var tracks = session.rpcs[UUID].streamSrc.getAudioTracks(); if (tracks.length) { var track = tracks[0]; track = addAudioPipeline(UUID, track); log(track); var added = false; var tracks2 = session.rpcs[UUID].videoElement.srcObject.getAudioTracks(); log(tracks2); tracks2.forEach(trk2 => { if (trk2.label && trk2.label == "MediaStreamAudioDestinationNode") { // an old morphed node; delete it. session.rpcs[UUID].videoElement.srcObject.removeTrack(trk2); } else if (track.id == trk2.id && track.kind == trk2.kind) { // maybe it didn't morph; already added either way added = true; } else if (tracks[0].id == trk2.id && tracks[0].kind == trk2.kind && track.id != tracks[0].id) { // remove original audio track that is now morphed session.rpcs[UUID].videoElement.srcObject.removeTrack(trk2); } }); if (!added) { session.rpcs[UUID].videoElement.srcObject.addTrack(track); mediaAudioTrackUpdated(UUID, session.rpcs[UUID].streamID); } } else { session.rpcs[UUID].videoElement.srcObject.getAudioTracks().forEach(trk => { // make sure to remove all tracks. session.rpcs[UUID].videoElement.srcObject.remove(trk); }); } } else { var expected = []; tracks = session.rpcs[UUID].videoElement.srcObject.getAudioTracks(); // add audio tracks session.rpcs[UUID].streamSrc.getAudioTracks().forEach(trk => { var added = false; tracks.forEach(trk2 => { if (trk.id == trk2.id && trk.kind == trk2.kind) { added = true; expected.push(trk2); // } }); if (!added) { session.rpcs[UUID].videoElement.srcObject.addTrack(trk); mediaAudioTrackUpdated(UUID, session.rpcs[UUID].streamID); } }); tracks.forEach(trk => { var added = false; expected.forEach(trk2 => { if (trk.id == trk2.id && trk.kind == trk2.kind) { added = true; } }); if (!added) { // not expected. so lets delete. warnlog("this shouldn't happen that often, audio track orphaned. removing it"); session.rpcs[UUID].videoElement.srcObject.removeTrack(trk); } }); } if (session.mixMinus) { stream = mixMinusAudio(UUID); // only works with p2p; no chunked mode. } } function cycleStyleOptions() { session.style += 1; if (session.style > 6) { session.style = 1; } else if (session.style == 4) { session.style = 5; } for (var UUID in session.rpcs) { if (session.rpcs[UUID].canvas) { try { if (session.rpcs[UUID].canvas) { session.rpcs[UUID].canvas.remove(); } } catch (e) { } session.rpcs[UUID].canvas = null; } updateIncomingAudioElement(UUID); } updateMixer(); } function addAudioPipeline(UUID, track) { // INBOUND AUDIO EFFECTS ; audio tracks only try { if (session.disableViewerWebAudioPipeline) { log("ignoring addAudioPipeline - disableViewerWebAudioPipeline is enabled (noap)"); return track; } log("Triggered webaudio effects path"); for (var tid in session.rpcs[UUID].inboundAudioPipeline) { delete session.rpcs[UUID].inboundAudioPipeline[tid]; // get rid of old nodes. } var trackid = track.id; // this is an audio track, or should be. session.rpcs[UUID].inboundAudioPipeline[trackid] = {}; session.rpcs[UUID].inboundAudioPipeline[trackid].mediaStream = createMediaStream(); session.rpcs[UUID].inboundAudioPipeline[trackid].mediaStream.addTrack(track); if (ChromiumVersion && session.audioEffects) { // I'm going to deprecate this. session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio = createAudioElement(); // TODO: I don't know if this mutedAudio thing matters any more, in recent versions of Chrome, since it won't play even if muted. session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio.muted = true; session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio.playsinline = true; // ## Added Oct 9th 2022. Not sure it's does anything, but might help with iPhones? session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio.srcObject = session.rpcs[UUID].inboundAudioPipeline[trackid].mediaStream; // needs to be added as an streamed element to be usable, even if its hidden session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio.muted = true; //session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio.volume = 0.01; session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio .play() .then(_ => { //session.rpcs[UUID].inboundAudioPipeline[trackid].mutedAudio.muted = false; log("playing 1"); }) .catch(warnlog); } // https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/createMediaStreamTrackSource var source = session.audioCtx.createMediaStreamSource(session.rpcs[UUID].inboundAudioPipeline[trackid].mediaStream); ////////////////// var screwedUp = false; session.rpcs[UUID].inboundAudioPipeline[trackid].destination = false; if (session.rpcs[UUID].isolatedChannel !== undefined) { log("Isolating channel: " + session.rpcs[UUID].isolatedChannel); session.rpcs[UUID].inboundAudioPipeline[trackid].destination = session.audioCtx.createMediaStreamDestination(); source = isolateChannel(source, session.rpcs[UUID].isolatedChannel); screwedUp = true; } if (session.sync !== false) { log("adding a delay node to audio"); source = addDelayNode(source, UUID, trackid); screwedUp = true; } if (session.style === 2) { log("adding a fftwave node to audio"); try { if (session.rpcs[UUID].inboundAudioPipeline[trackid]) { // clear audioMeterGuest, if active. clearTimeout(session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.interval); } } catch (e) { } source = fftWaveform(source, UUID, trackid); } else if (session.style === 3 || session.meterStyle) { log("adding a loudness meter node to audio"); source = audioMeterGuest(source, UUID, trackid); } else if (session.audioMeterGuest) { log("adding a loudness meter node to audio"); source = audioMeterGuest(source, UUID, trackid); } else if (session.activeSpeaker) { log("adding a loudness meter node to audio"); source = audioMeterGuest(source, UUID, trackid); } else if (session.quietOthers) { log("adding a loudness meter node to audio"); source = audioMeterGuest(source, UUID, trackid); } else if (session.pushLoudness) { source = audioMeterGuest(source, UUID, trackid); } else { try { if (session.rpcs[UUID].inboundAudioPipeline[trackid]) { // nothign active, so clear clearTimeout(session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.interval); } } catch (e) { } } if (session.playChannel) { session.rpcs[UUID].inboundAudioPipeline[trackid].destination = session.audioCtx.createMediaStreamDestination(); source = selectChannel(session.rpcs[UUID].inboundAudioPipeline[trackid].destination, source, session.playChannel); screwedUp = true; } else if (session.rpcs[UUID].channelOffset !== false) { log("custom offset set"); session.rpcs[UUID].inboundAudioPipeline[trackid].destination = session.audioCtx.createMediaStreamDestination(); source = offsetChannel(session.rpcs[UUID].inboundAudioPipeline[trackid].destination, source, session.rpcs[UUID].channelOffset, session.rpcs[UUID].channelWidth); screwedUp = true; } else if (session.offsetChannel !== false) { // proably better to do this last. log("adding offset channels"); session.rpcs[UUID].inboundAudioPipeline[trackid].destination = session.audioCtx.createMediaStreamDestination(); source = offsetChannel(session.rpcs[UUID].inboundAudioPipeline[trackid].destination, source, session.offsetChannel, session.channelWidth); screwedUp = true; } else if (session.panning !== false) { // proably better to do this last. log("adding offset channels"); session.rpcs[UUID].inboundAudioPipeline[trackid].destination = session.audioCtx.createMediaStreamDestination(); source = stereoPanning(source, UUID, trackid, session.panning); screwedUp = true; } else if (session.rpcs[UUID].videoElement && session.rpcs[UUID].videoElement.manualSink) { screwedUp = true; // added June-3-22 to allow for custom outputs to different audio output destinations. } if (screwedUp) { warnlog("screwedUp mode activated. dun dun"); if (session.rpcs[UUID].inboundAudioPipeline[trackid].destination === false) { session.rpcs[UUID].inboundAudioPipeline[trackid].destination = session.audioCtx.createMediaStreamDestination(); } source.connect(session.rpcs[UUID].inboundAudioPipeline[trackid].destination); try { if (session.firstPlayTriggered && session.audioCtx.state == "suspended") { log("trying to resume.."); session.audioCtx.resume(); } } catch (e) { warnlog("session.audioCtx.resume(); failed"); } return session.rpcs[UUID].inboundAudioPipeline[trackid].destination.stream.getAudioTracks()[0]; } try { if (session.firstPlayTriggered && session.audioCtx.state == "suspended") { session.audioCtx.resume(); } } catch (e) { warnlog("session.audioCtx.resume(); failed 2"); } return track; } catch (e) { errorlog(e); } return track; } function processMiniInfoUpdate(miniInfo, UUID) { if ("qlr" in miniInfo) { session.rpcs[UUID].stats.info.quality_limitation_reason = miniInfo.qlr; } if ("con" in miniInfo) { session.rpcs[UUID].stats.info.conn_type = miniInfo.con; } if ("cpu" in miniInfo) { session.rpcs[UUID].stats.info.cpuLimited = miniInfo.cpu; if (session.rpcs[UUID].signalMeter) { if (miniInfo.cpu) { session.rpcs[UUID].signalMeter.dataset.cpu = "1"; } else if ("cpu" in miniInfo) { session.rpcs[UUID].signalMeter.dataset.cpu = "0"; } } } if ("hw_enc" in miniInfo) { session.rpcs[UUID].stats.info.hardware_video_encoder = miniInfo.hw_enc; } if ("bat" in miniInfo) { if (typeof miniInfo.bat == "number") { session.rpcs[UUID].stats.info.power_level = miniInfo.bat * 100; } else { session.rpcs[UUID].stats.info.power_level = null; } } if ("chrg" in miniInfo) { session.rpcs[UUID].stats.info.plugged_in = miniInfo.chrg; } if ("out" in miniInfo && "c" in miniInfo.out) { session.rpcs[UUID].stats.info.total_outbound_p2p_connections = miniInfo.out.c; if (session.showConnections && session.rpcs[UUID].connectionDetails) { session.rpcs[UUID].connectionDetails.innerText = "🔗" + session.rpcs[UUID].stats.info.total_outbound_p2p_connections; session.rpcs[UUID].connectionDetails.dataset.value = session.rpcs[UUID].stats.info.total_outbound_p2p_connections; } } if (session.rpcs[UUID].batteryMeter) { batteryMeterInfoUpdate(UUID); } } function batteryMeterInfoUpdate(UUID) { if (session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.power_level !== null) { var level = session.rpcs[UUID].batteryMeter.querySelector(".battery-level"); if (level) { var value = session.rpcs[UUID].stats.info.power_level; if (value > 100) { value = 100; } if (value < 0) { value = 0; } level.style.height = parseInt(value) + "%"; if (value < 15) { session.rpcs[UUID].batteryMeter.classList.remove("warn"); session.rpcs[UUID].batteryMeter.classList.add("alert"); } else if (value < 25) { session.rpcs[UUID].batteryMeter.classList.remove("alert"); session.rpcs[UUID].batteryMeter.classList.add("warn"); } else { session.rpcs[UUID].batteryMeter.classList.remove("alert"); session.rpcs[UUID].batteryMeter.classList.remove("warn"); } if (value < 100) { session.rpcs[UUID].batteryMeter.classList.remove("hidden"); } //session.rpcs[UUID].batteryMeter.title = value+"% battery remaining"; session.rpcs[UUID].batteryMeter.title = parseInt(value) + "% battery remaining"; } } if (session.rpcs[UUID].stats.info && "plugged_in" in session.rpcs[UUID].stats.info && session.rpcs[UUID].stats.info.plugged_in === false) { session.rpcs[UUID].batteryMeter.dataset.plugged = "0"; session.rpcs[UUID].batteryMeter.classList.remove("hidden"); } else { session.rpcs[UUID].batteryMeter.dataset.plugged = "1"; // add on session.rpcs[UUID].batteryMeter.title = parseInt(value) + "% charging"; session.rpcs[UUID].batteryMeter.classList.add("hidden"); } } function setupGuestLabelControl(UUID) { var labelID = getById("label_" + UUID); if (labelID) { labelID.classList.add("contolboxLabel"); labelID.dataset.UUID = UUID; if (session.rpcs[UUID].label) { labelID.innerText = session.rpcs[UUID].label; // Replace underscores with a Space when publishing to HTML. No Double spaces. labelID.classList.remove("addALabel"); } else if (session.directorUUID === UUID) { miniTranslate(labelID, "main-director"); labelID.classList.remove("addALabel"); } else if (session.directorList.indexOf(UUID) >= 0) { miniTranslate(labelID, "co-director"); labelID.classList.remove("addALabel"); } else { miniTranslate(labelID, "add-a-label"); labelID.classList.add("addALabel"); } labelID.onclick = async function (ee) { var oldlabel = ee.target.innerText; if (session.rpcs[ee.target.dataset.UUID].label === false) { oldlabel = ""; } window.focus(); var newlabel = await promptAlt(getTranslation("new-display-name"), false, false, oldlabel); if (newlabel !== null) { newlabel = newlabel.trim(); if (newlabel == "") { newlabel = false; if (session.directorUUID === UUID) { miniTranslate(ee.target, "main-director"); //ee.target.innerHTML = getTranslation("main-director"); ee.target.classList.remove("addALabel"); } else if (session.directorList.indexOf(UUID) >= 0) { miniTranslate(ee.target, "co-director"); //ee.target.innerHTML = getTranslation("co-director"); ee.target.classList.remove("addALabel"); } else { miniTranslate(ee.target, "add-a-label"); //ee.target.innerHTML = getTranslation("add-a-label"); ee.target.classList.add("addALabel"); } } else { ee.target.innerText = newlabel; ee.target.classList.remove("addALabel"); } var data = {}; data.UUID = ee.target.dataset.UUID; data.changeLabel = true; data.value = newlabel; session.sendRequest(data, data.UUID); // Update local label immediately and sync to co-directors try { session.rpcs[UUID].label = newlabel; session.rpcs[UUID].labelSetByDirector = true; // Prevent info message from overwriting // Push label update to co-directors via directorState sync if (session.director && session.rpcs[UUID].streamID) { var labelPayload = { directorState: {} }; labelPayload.directorState[session.rpcs[UUID].streamID] = getDetailedState(session.rpcs[UUID].streamID); session.pushDirectorStateUpdate(labelPayload, "label-change"); } } catch (e) { errorlog(e); } pokeAPI("details", getDetailedState(session.rpcs[UUID].streamID)); } }; } updateAriaLabel(UUID); } function updateAriaLabel(UUID = false) { if (!(UUID in session.rpcs)) { return; } var element = document.getElementById("container_" + UUID); if (!element) { return; } var sid = ""; if (session.rpcs[UUID].streamID) { sid = ": " + session.rpcs[UUID].streamID; } if (session.rpcs[UUID].label) { element.setAttribute("aria-label", session.rpcs[UUID].label); } else if (session.directorUUID === UUID) { element.setAttribute("aria-label", (getTranslation("main-director") || "Main Director") + sid); } else if (session.directorList.indexOf(UUID) >= 0) { element.setAttribute("aria-label", (getTranslation("co-director") || "Co Director") + sid); } else if (sid) { element.setAttribute("aria-label", "ID" + sid); } else { element.setAttribute("aria-label", getTranslation("undefined") || "Undefined"); } element.setAttribute("role", "region"); } function updateLabelDirectors(UUID) { var elements = getById("label_" + UUID); if (elements) { if (session.rpcs[UUID].label) { elements.innerText = session.rpcs[UUID].label; elements.classList.remove("addALabel"); } else if (session.directorUUID === UUID) { miniTranslate(elements, "main-director"); elements.classList.remove("addALabel"); } else if (session.directorList.indexOf(UUID) >= 0) { miniTranslate(elements, "co-director"); elements.classList.remove("addALabel"); } else { miniTranslate(elements, "add-a-label"); elements.classList.add("addALabel"); } } updateAriaLabel(UUID); } function updateLabelDirectors2(UUID) { var elements = getById("label_" + UUID); if (elements) { if (session.directorUUID === UUID) { miniTranslate(elements, "main-director"); elements.classList.remove("addALabel"); } else if (session.directorList.indexOf(UUID) >= 0) { miniTranslate(elements, "co-director"); elements.classList.remove("addALabel"); } else { miniTranslate(elements, "add-a-label"); elements.classList.add("addALabel"); } } updateAriaLabel(UUID); } function directorCoDirectorColoring(UUID) { if (UUID === session.directorUUID) { try { session.rpcs[UUID].stats.info.director = true; getById("container_" + UUID).classList.add("directorBox"); } catch (e) { } } else if (session.directorList.indexOf(UUID) >= 0) { try { session.rpcs[UUID].stats.info.coDirector = true; addDirectorBlue(UUID); } catch (e) { } } } function addDirectorBlue(UUID) { try { getById("container_" + UUID).classList.add("directorBlue"); log("[ui] addDirectorBlue for UUID=" + UUID); } catch (e) { errorlog(e); } } function soloLinkGeneratorInit(UUID) { document.querySelectorAll("container_" + UUID).forEach(ele => { ele.querySelectorAll("[data-sololink]").forEach(ele2 => { // value='" + soloLink + "' href='" + soloLink + "'/>" + soloLink + " var soloLink = soloLinkGenerator(session.rpcs[UUID].streamID, false); ele2.value = soloLink; ele2.href = soloLink; ele2.innerText = soloLink; }); }); } function initRecordingImpossible(UUID) { var ele = document.querySelectorAll('[data-action-type="mute-guest"][data--u-u-i-d="' + UUID + '"]'); if (ele) { ele.disabled = true; ele.title = getTranslation("Audio processing is disabled with this guest. Can't mute or change volume"); } var ele = document.querySelectorAll('[data-action-type="volume"][data--u-u-i-d="' + UUID + '"]'); if (ele) { ele.disabled = true; ele.title = title = getTranslation("Audio processing is disabled with this guest. Can't mute or change volume"); ele.style.opacity = 0.2; } } function initGroupButtons(UUID) { var elements = document.querySelectorAll('[data-action-type="toggle-group"][data--u-u-i-d="' + UUID + '"]'); for (var i = 0; i < elements.length; i++) { elements[i].classList.remove("pressed"); elements[i].ariaPressed = "false"; for (var g = 0; g < session.rpcs[UUID].group.length; g++) { if (elements[i].dataset.group === session.rpcs[UUID].group[g]) { elements[i].classList.add("pressed"); elements[i].ariaPressed = "true"; } } } } function changeGroupDirector(ele, state = null) { var group = ele.dataset.group; var index = session.group.indexOf(group); var change = false; if (state === true) { ele.classList.add("pressed"); ele.ariaPressed = "true"; if (index === -1) { session.group.push(group); change = true; } } else if (state === false) { ele.classList.remove("pressed"); ele.ariaPressed = "false"; if (index > -1) { session.group.splice(index, 1); change = true; } } else if (ele.classList.contains("pressed")) { ele.classList.remove("pressed"); ele.ariaPressed = "false"; if (index > -1) { session.group.splice(index, 1); change = true; } } else { ele.classList.add("pressed"); ele.ariaPressed = "true"; if (index === -1) { session.group.push(group); change = true; } } if (session.group.length || session.allowNoGroup) { session.sendMessage({ group: session.group.join(",") }); } else { session.sendMessage({ group: false }); } if (change) { updateMixer(); pokeIframeAPI("group-set-updated", session.group); } if (session.group.indexOf(group) === -1) { return false; } else { return true; } } function changeGroupDirectorAPI(group, state = null, update = true) { log("changeGroupDirectorAPI()"); group = sanitizeLabel(group); if (document.getElementById("container_director")) { var ele = getById("container_director").querySelector('[data-action-type="toggle-group"][data-group="' + group + '"]'); if (ele) { if (update) { ele.click(); } else if (state === true) { ele.classList.add("pressed"); ele.ariaPressed = "true"; } if (session.group.indexOf(group) === -1) { return false; } else { return true; } } } var index = session.group.indexOf(group); var eleGroup = getById("groups"); eleGroup.classList.remove("hidden"); var ele = eleGroup.querySelector('[data-action-type="toggle-group"][data-group="' + group + '"'); if (eleGroup.showDirector) { if (!ele) { ele = htmlToElement('"); var added = false; eleGroup.querySelectorAll("[data-group]").forEach(ele2 => { log(ele2); if (!added && ele2.dataset.group > group + "") { ele2.parentNode.insertBefore(ele, ele2); added = true; } }); if (!added) { eleGroup.appendChild(ele); } } } else if (!ele) { ele = document.createElement("div"); ele.dataset.actionType = "toggle-group"; ele.dataset.group = group; ele.classList.add("float"); ele.style.display = "inline-block"; ele.role = "button"; ele.innerHTML = '
    ' + group; eleGroup.appendChild(ele); ele.onclick = function () { changeGroupDirectorAPI(this.dataset.group); }; } var changed = false; if (state === true) { if (eleGroup.showDirector) { ele.classList.add("pressed"); ele.ariaPressed = "true"; } else { ele.classList.add("green"); ele.ariaPressed = "true"; } if (index === -1) { session.group.push(group); changed = true; } } else if (state === false) { if (eleGroup.showDirector) { ele.classList.remove("pressed"); ele.ariaPressed = "false"; } else { ele.classList.remove("green"); ele.ariaPressed = "false"; } if (index > -1) { session.group.splice(index, 1); changed = true; } } else if (ele.classList.contains("green")) { if (eleGroup.showDirector) { ele.classList.remove("pressed"); ele.ariaPressed = "false"; } else { ele.classList.remove("green"); ele.ariaPressed = "false"; } if (index > -1) { session.group.splice(index, 1); changed = true; } } else { if (eleGroup.showDirector) { ele.classList.add("pressed"); ele.ariaPressed = "true"; } else { ele.classList.add("green"); ele.ariaPressed = "true"; } if (index === -1) { session.group.push(group); changed = true; } } if (update) { if (session.group.length || session.allowNoGroup) { session.sendMessage({ group: session.group.join(",") }); } else { session.sendMessage({ group: false }); } } if (changed) { updateMixer(); pokeIframeAPI("group-set-updated", session.group); } if (state !== null) { return true; } else if (session.group.indexOf(group) === -1) { return false; } else { return true; } } function changeGroupViewDirectorAPI(group, state = null) { log("changeGroupViewDirectorAPI()"); group = sanitizeLabel(group); var index = session.groupView.indexOf(group); var changed = false; if (state === true) { if (index === -1) { session.groupView.push(group); changed = true; } } else if (state === false) { if (index > -1) { session.groupView.splice(index, 1); changed = true; } } else { if (index > -1) { session.groupView.splice(index, 1); } else { session.groupView.push(group); } changed = true; } if (changed) { updateMixer(); pokeIframeAPI("group-view-set-updated", session.groupView); } if (state !== null) { return true; } else if (session.groupView.indexOf(group) === -1) { return false; } else { return true; } } function changeGroup(ele, state = null) { var group = ele.dataset.group; var index = session.rpcs[ele.dataset.UUID].group.indexOf(group); var changed = false; if (state === true) { ele.classList.add("pressed"); ele.ariaPressed = "true"; if (index === -1) { session.rpcs[ele.dataset.UUID].group.push(group); changed = true; } } else if (state === false) { ele.classList.remove("pressed"); ele.ariaPressed = "false"; if (index > -1) { session.rpcs[ele.dataset.UUID].group.splice(index, 1); changed = true; } } else if (ele.classList.contains("pressed")) { ele.classList.remove("pressed"); ele.ariaPressed = "false"; if (index > -1) { session.rpcs[ele.dataset.UUID].group.splice(index, 1); changed = true; } } else { ele.classList.add("pressed"); ele.ariaPressed = "true"; if (index === -1) { session.rpcs[ele.dataset.UUID].group.push(group); changed = true; } } if (session.rpcs[ele.dataset.UUID].group.length) { session.sendRequest({ group: session.rpcs[ele.dataset.UUID].group.join(",") }, ele.dataset.UUID); } else { session.sendRequest({ group: false }, ele.dataset.UUID); } syncDirectorState(ele); if (changed) { updateMixer(); } if (session.rpcs[ele.dataset.UUID].group.indexOf(group) === -1) { return false; } else { return true; } } function changeChannelOffset(UUID, channel) { var ele = document.querySelectorAll('[data-action-type="add-channel"][data--u-u-i-d="' + UUID + '"]'); for (var i = 0; i < ele.length; i++) { if (channel === i) { if (ele[i].classList.contains("pressed")) { ele[i].classList.remove("pressed"); ele[i].ariaPressed = "false"; channel = false; } else { ele[i].classList.add("pressed"); ele[i].ariaPressed = "true"; } } else { ele[i].classList.remove("pressed"); ele[i].ariaPressed = "false"; } } session.rpcs[UUID].channelOffset = channel; updateIncomingVideoElement(UUID, false, true); if (channel === false) { return false; } else { return true; } } function toggleMonoStereoMic(ele) { if (ele.checked) { session.audioInputChannels = 1; getById("micStereoMonoInput").checked = true; getById("micStereoMonoInput3").checked = true; } else if (urlParams.has("channelcount") || urlParams.has("ac") || urlParams.has("inputchannels")) { // not ideal having this here session.audioInputChannels = urlParams.get("channelcount") || urlParams.get("ac") || urlParams.get("inputchannels") || 0; session.audioInputChannels = parseInt(session.audioInputChannels); if (!session.audioInputChannels) { session.audioInputChannels = false; } getById("micStereoMonoInput").checked = false; getById("micStereoMonoInput3").checked = false; } else { session.audioInputChannels = false; getById("micStereoMonoInput").checked = false; getById("micStereoMonoInput3").checked = false; } try { activatedPreview = false; if (ele.id == "micStereoMonoInput3") { grabAudio("#audioSource3"); } else { grabAudio("#audioSource"); } } catch (e) { errorlog(e); } } function selectChannel(destination, source, channel) { session.audioCtx.destination.channelCountMode = "explicit"; session.audioCtx.destination.channelInterpretation = "discrete"; destination.channelCountMode = "explicit"; destination.channelInterpretation = "discrete"; try { destination.channelCount = 1; } catch (e) { errorlog("Max channels: " + destination.channelCount); } var splitter = session.audioCtx.createChannelSplitter(6); var merger = session.audioCtx.createChannelMerger(1); // mono source.connect(splitter); splitter.connect(merger, channel - 1, 0); return merger; } function offsetChannel(destination, source, offset, width = false) { session.audioCtx.destination.channelCountMode = "explicit"; session.audioCtx.destination.channelInterpretation = "discrete"; destination.channelCountMode = "explicit"; destination.channelInterpretation = "discrete"; try { destination.channelCount = session.audioChannels; } catch (e) { errorlog("Max channels: " + destination.channelCount); } if (width) { var splitter = session.audioCtx.createChannelSplitter(width); var merger = session.audioCtx.createChannelMerger(width + offset); } else { var splitter = session.audioCtx.createChannelSplitter(2); var merger = session.audioCtx.createChannelMerger(2 + offset); } source.connect(splitter); splitter.connect(merger, 0, offset); if (session.stereo && session.stereo != 3) { splitter.connect(merger, 1, 1 + offset); } return merger; } function addReverb(source, UUID, trackid, value) { // not yet actually working. requires a buffer; bleh! if (value === true) { value = Math.random() * (Math.random() * 2 - 1); errorlog(value); } else if (value === false) { return source; } else { value = parseFloat(value / 90) - 1 || 0; if (value < -1) { value = -1; } if (value > 1) { value = 1; } } //// some reverb logic goes here... ///var reverbNode = session.audioCtx.createStereoPanner(); ///session.rpcs[UUID].inboundAudioPipeline[trackid].reverbNode = reverbNode; //// source.connect(reverbNode); return reverbNode; } function stereoPanning(source, UUID, trackid, value) { // Normalize value to [-1, 1] where 0=center if (parseInt(value) === -1) { value = Math.random() * (Math.random() * 2 - 1); warnlog(value); } else if (value === false) { return source; } else if (value === true) { value = 90; } else { // input 0..180 => -1..1 value = parseFloat(value / 90) - 1 || 0; } if (value < -1) value = -1; if (value > 1) value = 1; // Pre-pan gain trim to avoid clipping var gainNode = session.audioCtx.createGain(); session.rpcs[UUID].inboundAudioPipeline[trackid].gainPanNode = gainNode; gainNode.gain.value = 1 - Math.abs(value) / 2; source.connect(gainNode); // Create panner with Safari fallback var panNode; try { if (session.audioCtx.createStereoPanner) { panNode = session.audioCtx.createStereoPanner(); session.rpcs[UUID].inboundAudioPipeline[trackid].panType = "stereo"; panNode.pan.value = value; } else { panNode = session.audioCtx.createPanner(); panNode.panningModel = "equalpower"; panNode.distanceModel = "inverse"; var x = value; var z = 1 - Math.abs(value); try { if (typeof panNode.positionX !== "undefined") { panNode.positionX.value = x; panNode.positionY.value = 0; panNode.positionZ.value = z; } else if (panNode.setPosition) { panNode.setPosition(x, 0, z); } } catch (e) { } session.rpcs[UUID].inboundAudioPipeline[trackid].panType = "panner"; } } catch (e) { warnlog("Stereo panning node creation failed; bypassing"); return gainNode; } session.rpcs[UUID].inboundAudioPipeline[trackid].panNode = panNode; gainNode.connect(panNode); return panNode; } function adjustPan(UUID, value) { if (value === true) { value = Math.random() * (Math.random() * 2 - 1); } else if (value === false) { value = 0; } else { value = parseFloat(value / 90) - 1 || 0; if (value < -1) { value = -1; } if (value > 1) { value = 1; } } for (var trackid in session.rpcs[UUID].inboundAudioPipeline) { if ("panNode" in session.rpcs[UUID].inboundAudioPipeline[trackid]) { try { if (session.rpcs[UUID].inboundAudioPipeline[trackid].panType === "stereo" && session.rpcs[UUID].inboundAudioPipeline[trackid].panNode.pan) { session.rpcs[UUID].inboundAudioPipeline[trackid].panNode.pan.setValueAtTime(value, session.audioCtx.currentTime); } else { // Fallback panner var x = value; var z = 1 - Math.abs(value); var pn = session.rpcs[UUID].inboundAudioPipeline[trackid].panNode; if (typeof pn.positionX !== "undefined") { pn.positionX.setValueAtTime(x, session.audioCtx.currentTime); pn.positionY.setValueAtTime(0, session.audioCtx.currentTime); pn.positionZ.setValueAtTime(z, session.audioCtx.currentTime); } else if (pn.setPosition) { pn.setPosition(x, 0, z); } } } catch (e) { warnlog(e); } } if ("gainPanNode" in session.rpcs[UUID].inboundAudioPipeline[trackid] && session.rpcs[UUID].inboundAudioPipeline[trackid].gainPanNode.gain) { try { session.rpcs[UUID].inboundAudioPipeline[trackid].gainPanNode.gain.setValueAtTime(1 - Math.abs(value) / 2, session.audioCtx.currentTime); } catch (e) { } } } } function addDelayNode(source, UUID, trackid) { // append the delay Node to the track??? WOULD THIS WORK? var delay = parseFloat(session.sync) || 0; if (delay < 0) { delay = 0; } if ((session.audioBuffer !== false) && session.audioBuffer >= 0) { delay += parseFloat(session.audioBuffer); } else if (session.buffer && session.buffer > 0) { delay += parseFloat(session.buffer); } delay = delay / 1000; session.rpcs[UUID].inboundAudioPipeline[trackid].delayNode = session.audioCtx.createDelay(delay + 5); // 5 seconds additionally added for the purpose of flexibility session.rpcs[UUID].inboundAudioPipeline[trackid].delayNode.delayTime.value = delay; // delayTime takes it in seconds. source.connect(session.rpcs[UUID].inboundAudioPipeline[trackid].delayNode); log("added new delay node"); return session.rpcs[UUID].inboundAudioPipeline[trackid].delayNode; } function createStyleCanvas(UUID) { // append the delay Node to the track??? WOULD THIS WORK? if (!session.rpcs[UUID].canvas) { // just make sure that if using &effects or something, to null the canvas after use, else this won't trigger. session.rpcs[UUID].canvas = document.createElement("canvas"); session.rpcs[UUID].canvas.dataset.UUID = UUID; if (session.rpcs[UUID].streamID) { session.rpcs[UUID].canvas.dataset.sid = session.rpcs[UUID].streamID; } session.rpcs[UUID].canvas.style.pointerEvents = "auto"; session.rpcs[UUID].canvasCtx = session.rpcs[UUID].canvas.getContext("2d", { alpha: session.alpha }); // session.rpcs[UUID].canvas.addEventListener("click", function (e) { // show stats of video if double clicked log("clicked"); try { var uid = e.currentTarget.dataset.UUID; if (e.ctrlKey || e.metaKey) { e.preventDefault(); if (session.statsMenu !== false) { if ("stats" in session.rpcs[uid]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, uid); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, uid); } } e.stopPropagation(); return false; } else if ("prePausedBandwidth" in session.rpcs[uid]) { unPauseVideo(e.currentTarget); } } catch (e) { errorlog(e); } }); if (session.statsMenu) { if ("stats" in session.rpcs[UUID]) { var [menu, innerMenu] = statsMenuCreator(); printViewStats(innerMenu, UUID); menu.interval = setInterval(printViewStats, session.statsInterval, innerMenu, UUID); } } if (session.aspectRatio) { if (session.aspectRatio == 1) { session.rpcs[UUID].canvas.width = "720"; session.rpcs[UUID].canvas.height = "1280"; } else if (session.aspectRatio == 2) { session.rpcs[UUID].canvas.width = "960"; session.rpcs[UUID].canvas.height = "960"; } else if (session.aspectRatio == 3) { session.rpcs[UUID].canvas.width = "1280"; session.rpcs[UUID].canvas.height = "960"; } } else { session.rpcs[UUID].canvas.width = "1280"; session.rpcs[UUID].canvas.height = "720"; } updateMixer(); return true; } else { return false; } } function applyStyleEffect(UUID) { if (!session.rpcs[UUID].canvas || !session.rpcs[UUID].canvasCtx) { return; } /* session.rpcs[UUID].canvasContainer = document.createElement("div"); session.rpcs[UUID].canvasContainer.appendChild(session.rpcs[UUID].canvas); session.rpcs[UUID].canvas.style = "width:100%;height:100%;display:block;"; session.rpcs[UUID].canvasContainer.appendChild(session.rpcs[UUID].videoElement); */ if (session.style == 3) { // black session.rpcs[UUID].canvasCtx.fillStyle = "rgb(0, 0, 0)"; session.rpcs[UUID].canvasCtx.fillRect(0, 0, session.rpcs[UUID].canvas.width, session.rpcs[UUID].canvas.height); } else if (session.style == 4) { session.rpcs[UUID].canvasCtx.fillStyle = "rgb(0, 0, 0)"; session.rpcs[UUID].canvasCtx.fillRect(0, 0, session.rpcs[UUID].canvas.width, session.rpcs[UUID].canvas.height); } else if (session.style == 5) { var r = Math.random() * 255; var g = Math.random() * 255; var b = Math.random() * 255; session.rpcs[UUID].canvasCtx.fillStyle = "rgb(" + r + ", " + g + ", " + b + ")"; session.rpcs[UUID].canvasCtx.fillRect(0, 0, session.rpcs[UUID].canvas.width, session.rpcs[UUID].canvas.height); } else if (session.style == 6) { session.rpcs[UUID].canvasCtx.fillStyle = "rgb(0,0,0)"; session.rpcs[UUID].canvasCtx.fillRect(0, 0, session.rpcs[UUID].canvas.width, session.rpcs[UUID].canvas.height); var r = Math.random() * 150 + 50; var g = Math.random() * 150 + 50; var b = Math.random() * 150 + 50; session.rpcs[UUID].canvasCtx.fillStyle = "rgb(" + r + ", " + g + ", " + b + ")"; session.rpcs[UUID].canvasCtx.beginPath(); session.rpcs[UUID].canvasCtx.arc(parseInt(session.rpcs[UUID].canvas.width / 2), parseInt(session.rpcs[UUID].canvas.height / 2), parseInt(session.rpcs[UUID].canvas.height / 4), 0, 2 * Math.PI, false); session.rpcs[UUID].canvasCtx.fill(); if (session.rpcs[UUID].label) { session.rpcs[UUID].canvasCtx.fillStyle = "rgb(0,0,0)"; session.rpcs[UUID].canvasCtx.textAlign = "center"; session.rpcs[UUID].canvasCtx.font = parseInt(session.rpcs[UUID].canvas.height / 2.11) + "px Arial"; session.rpcs[UUID].canvasCtx.fillText(session.rpcs[UUID].label[0].toUpperCase(), parseInt(session.rpcs[UUID].canvas.width / 2), parseInt((session.rpcs[UUID].canvas.height * 2) / 3)); } else { var tmp = getComputedStyle(document.querySelector(":root")).getPropertyValue("--video-background-image").split('"'); if (tmp.length === 3) { var img = new Image(); img.onload = function () { session.rpcs[UUID].canvasCtx.fillStyle = "rgb(25,0,0)"; session.rpcs[UUID].canvasCtx.drawImage(img, parseInt(session.rpcs[UUID].canvas.width / 2 - 110), parseInt(session.rpcs[UUID].canvas.height / 2 - 110), 220, 220); }; img.src = tmp[1]; } } } } function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } function fftWaveform(source, UUID, trackid) { // append the delay Node to the track??? WOULD THIS WORK? // https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode session.rpcs[UUID].inboundAudioPipeline[trackid].analyser = session.audioCtx.createAnalyser(); session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.fftSize = 512; var bufferLength = session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.frequencyBinCount; var dataArray = new Uint8Array(bufferLength); session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.getByteTimeDomainData(dataArray); // analyser.getByteTimeDomainData(dataArray); source.connect(session.rpcs[UUID].inboundAudioPipeline[trackid].analyser); createStyleCanvas(UUID); clearInterval(session.rpcs[UUID].canvasIntervalAction); var canvasIntervalAction = setInterval( function (uuid) { if (session.style !== 2) { clearInterval(canvasIntervalAction); // this is FFT only, so okay to kill. return; } try { session.rpcs[uuid].inboundAudioPipeline[trackid].analyser.getByteTimeDomainData(dataArray); session.rpcs[uuid].canvasCtx.fillStyle = "rgba(0, 0, 0, 0.1)"; session.rpcs[uuid].canvasCtx.fillRect(0, 0, session.rpcs[uuid].canvas.width, session.rpcs[uuid].canvas.height); session.rpcs[uuid].canvasCtx.lineWidth = 10; session.rpcs[uuid].canvasCtx.strokeStyle = "rgb(111, 255, 111)"; var sliceWidth = (session.rpcs[uuid].canvas.width * 1.0) / bufferLength; var loudness = dataArray; var Squares = loudness.map(val => (val - 128.0) * (val - 128.0)); var Sum = Squares.reduce((acum, val) => acum + val); var Mean = Sum / loudness.length; loudness = Math.sqrt(Mean) * 10; session.rpcs[uuid].stats.Audio_Loudness = parseInt(loudness); if (session.pushLoudness == true) { var loudnessObj = {}; loudnessObj[session.rpcs[uuid].streamID] = session.rpcs[uuid].stats.Audio_Loudness; if (isIFrame) { parent.postMessage({ loudness: loudnessObj, action: "loudness", value: loudness, UUID: uuid }, session.iframetarget); } } if (loudness < 2) { return; } //log(bufferLength); session.rpcs[uuid].canvasCtx.beginPath(); var m = session.rpcs[uuid].canvas.height / 256.0; session.rpcs[uuid].canvasCtx.moveTo(0, dataArray[0] * m); var x = 0; for (var i = 1; i < bufferLength; i++) { var y = dataArray[i] * m; session.rpcs[uuid].canvasCtx.lineTo(x, y); x += sliceWidth; } session.rpcs[uuid].canvasCtx.lineTo(session.rpcs[uuid].canvas.width, session.rpcs[uuid].canvas.height / 2); session.rpcs[uuid].canvasCtx.stroke(); } catch (e) { warnlog(e); warnlog("Did the remote source disconnect?"); clearInterval(canvasIntervalAction); warnlog(session.rpcs[uuid]); } }, 50, UUID ); session.rpcs[UUID].canvasIntervalAction = canvasIntervalAction; return session.rpcs[UUID].inboundAudioPipeline[trackid].analyser; } function audioMeterGuest(mediaStreamSource, UUID, trackid) { log("audioMeterGuest started"); session.rpcs[UUID].inboundAudioPipeline[trackid].analyser = session.audioCtx.createAnalyser(); mediaStreamSource.connect(session.rpcs[UUID].inboundAudioPipeline[trackid].analyser); session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.fftSize = 256; session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.smoothingTimeConstant = 0.05; var bufferLength = session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.frequencyBinCount; var dataArray = new Uint8Array(bufferLength); function updateLevels() { try { if (!session.rpcs[UUID]) { return; } session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.getByteFrequencyData(dataArray); var total = 0; for (var i = 0; i < dataArray.length; i++) { total += dataArray[i]; } total = parseInt(total / 150); session.rpcs[UUID].stats.Audio_Loudness = total; if (session.pushLoudness == true) { var loudnessObj = {}; loudnessObj[session.rpcs[UUID].streamID] = session.rpcs[UUID].stats.Audio_Loudness; if (isIFrame) { parent.postMessage({ loudness: loudnessObj, action: "loudness", value: session.rpcs[UUID].stats.Audio_Loudness, UUID: UUID }, session.iframetarget); } } try { clearTimeout(session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.interval); session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.interval = setTimeout(function () { updateLevels(); }, 100); } catch (e) { log("closing old inaudio pipeline"); } if (session.style == 3 || session.meterStyle) { // overrides style if (session.rpcs[UUID].videoElement) { if (total > 40) { session.rpcs[UUID].videoElement.dataset.speaking = "2"; } else if (total > 10) { session.rpcs[UUID].videoElement.dataset.speaking = "1"; } else { session.rpcs[UUID].videoElement.dataset.speaking = "0"; } if (session.meterStyle == 4) { session.rpcs[UUID].videoElement.dataset.loudness = total; return; // this is cause we are using the data-loudness } } else if (session.meterStyle == 4) { return; } } else if (session.scene !== false) { // if a scene, cancel return; } else if (session.audioMeterGuest === false) { // don't show if we just want the volume levels return; } if (session.rpcs[UUID].voiceMeter) { session.rpcs[UUID].voiceMeter.dataset.level = total; if (session.meterStyle == 1) { var perct = Math.min(total, 100); session.rpcs[UUID].voiceMeter.style.height = perct + "%"; if (total > 80) { var R = parseInt((255 * perct) / 100) .toString(16) .padStart(2, "0"); var G = parseInt(255 - (255 * perct) / 100) .toString(16) .padStart(2, "0"); session.rpcs[UUID].voiceMeter.style.backgroundColor = "#" + R + G + "00"; } else { session.rpcs[UUID].voiceMeter.style.backgroundColor = "#00FF00"; } } else { if (total > 15) { session.rpcs[UUID].voiceMeter.style.opacity = 100; // temporary } else { session.rpcs[UUID].voiceMeter.style.opacity = 0; // temporary } } } else { session.rpcs[UUID].voiceMeter = document.createElement("div"); session.rpcs[UUID].voiceMeter.id = "voiceMeter_" + UUID; session.rpcs[UUID].voiceMeter.dataset.level = total; if (session.meterStyle == 1) { session.rpcs[UUID].voiceMeter.classList.add("video-meter2"); } else { if (total > 15) { session.rpcs[UUID].voiceMeter.style.opacity = 100; // temporary } else { session.rpcs[UUID].voiceMeter.style.opacity = 0; // temporary } if (session.meterStyle == 2) { session.rpcs[UUID].voiceMeter.classList.add("video-meter-2"); } else { session.rpcs[UUID].voiceMeter.classList.add("video-meter"); } } updateMixer(); } } catch (e) { warnlog(e); // fail as an exception; this is a control close. return; } } clearTimeout(session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.interval); session.rpcs[UUID].inboundAudioPipeline[trackid].analyser.interval = setTimeout(function () { updateLevels(); }, 100); return session.rpcs[UUID].inboundAudioPipeline[trackid].analyser; } function effectsDynamicallyUpdate(event, ele) { log("effectsDynamicallyUpdate"); let lastEffectValue = session.effect; session.effect = ele.options[ele.selectedIndex].value; getById("selectImageContent").style.display = "none"; getById("selectImageContent3").style.display = "none"; getById("selectImageOverlay").style.display = "none"; getById("selectImageOverlay3").style.display = "none"; getById("selectEffectAmount").style.display = "none"; getById("selectEffectAmount3").style.display = "none"; if (session.effect === "1") { updateRenderOutpipe(); return; } if (session.effect === "7") { // Show zoom amount sliders getById("selectEffectAmount").style.display = "block"; getById("selectEffectAmount3").style.display = "block"; // Show both sets of position controls getById("zoomPositionControls").style.display = "block"; getById("zoomPositionControls3").style.display = "block"; if (session.effectValue_default) { session.effectValue = session.effectValue_default; } else { session.effectValue = 1; } // Set up zoom amount sliders const zoomInputs = ["selectEffectAmountInput", "selectEffectAmountInput3"]; zoomInputs.forEach(id => { const input = getById(id); if (input) { input.min = 1; input.max = 4; input.step = 0.1; input.value = session.effectValue; } }); // Initialize all position sliders const sliderPairs = [ ["zoomPositionX1", "zoomPositionX"], ["zoomPositionY1", "zoomPositionY"] ]; sliderPairs.forEach(pair => { pair.forEach(id => { const slider = getById(id); if (slider) { if (id.includes('X')) { slider.value = xPosition; } else { slider.value = yPosition; } } }); }); } else { // Hide all position controls getById("zoomPositionControls").style.display = "none"; getById("zoomPositionControls3").style.display = "none"; } if (["8", "overlay"].includes(session.effect)) { // like zoom but none if (session.effect === "overlay") { loadOverlayImages(); } updateRenderOutpipe(); return; } if (session.effect == "3a") { session.effect = "3"; session.effectValue = 5; } if (session.effectValue_default === false && session.effect == "3") { session.effectValue = 2; } else { session.effectValue = session.effectValue_default; } if (session.effect == "0" || !session.effect) { updateRenderOutpipe(); return; } else if (session.effect === "3" || session.effect === "4") { if (!["3", "4", "5"].includes(lastEffectValue)) { attemptTFLiteJsFileLoad(); if (!session.tfliteModule.looping) { updateRenderOutpipe(); } } if (session.effect === "3" && session.effectValue_default == false) { getById("selectEffectAmount").style.display = "block"; getById("selectEffectAmount3").style.display = "block"; getById("selectEffectAmountInput").min = 0; getById("selectEffectAmountInput").max = 20; getById("selectEffectAmountInput").step = 1; getById("selectEffectAmountInput3").min = 0; getById("selectEffectAmountInput3").max = 20; getById("selectEffectAmountInput3").step = 1; getById("selectEffectAmountInput").value = session.effectValue; getById("selectEffectAmountInput3").value = session.effectValue; } } else if (session.effect === "5") { if (!["3", "4", "5"].includes(lastEffectValue)) { attemptTFLiteJsFileLoad(); if (!session.tfliteModule.looping) { updateRenderOutpipe(); } } loadContentEffectsImages(); } else if ((session.effect === "14" || session.effect === "15") && session.effectValue_default == false) { getById("selectEffectAmount").style.display = "block"; getById("selectEffectAmount3").style.display = "block"; getById("selectEffectAmountInput").min = 1; getById("selectEffectAmountInput").max = 50; getById("selectEffectAmountInput").step = 1; getById("selectEffectAmountInput3").min = 1; getById("selectEffectAmountInput3").max = 50; getById("selectEffectAmountInput3").step = 1; getById("selectEffectAmountInput").value = parseInt(session.effectValue) || 25; getById("selectEffectAmountInput3").value = parseInt(session.effectValue) || 25; loadContentEffectsImages(); } else if (session.effect === "6") { if (!gpgpuSupport) { if (!session.cleanOutput) { warnUser("Hardware acceleration isn't detected.

    This effect will not work", 4000, false); return; } } else if (gpgpuSupport == "Google SwiftShader") { if (!session.cleanOutput) { warnUser("Hardware acceleration isn't detected.

    Please enable it for this effect to work correctly.

    Settings -> Advanced -> System -> Use hardware-accleration", false, false); } return; } loadTensorflowJS(); updateRenderOutpipe(); //mainMeshMask(); } else { //loadEffect(session.effect); updateRenderOutpipe(); } if (session.permaid === false && session.roomid === false && session.view === false && session.director === false) { updateURL("effects"); } } function loadContentEffectsImages() { if (!["5", "15"].includes(session.effect)) { return; } // only load for certain effects if (session.defaultBackgroundImages) { try { session.defaultBackgroundImages.reverse(); } catch (e) { errorlog("Could not process image list"); session.defaultBackgroundImages = false; session.selectedImage_contents = getById("selectImage_contents"); return; } session.defaultBackgroundImages.forEach(imgSrc => { try { var img = document.createElement("img"); img.onerror = function () { this.style.display = "none"; }; // hide images that fail to load img.crossOrigin = "Anonymous"; img.src = imgSrc; img.style = "max-width:130px;max-height:73.5px;display:inline-block;margin:10px;cursor:pointer;"; img.onclick = function (event) { changeEffectsImage(event, this); }; getById("selectImage_contents").prepend(img); } catch (e) { } }); session.defaultBackgroundImages = false; session.selectedImage_contents = getById("selectImage_contents"); } else if (!session.selectedImage_contents) { session.selectedImage_contents = getById("selectImage_contents"); } if (document.getElementById("selectImageContent")) { document.getElementById("selectImageContent").style.display = "block"; document.getElementById("selectImageContent").appendChild(session.selectedImage_contents); session.selectedImage_contents.classList.remove("hidden"); } else if (document.getElementById("selectImageContent3")) { document.getElementById("selectImageContent3").style.display = "block"; document.getElementById("selectImageContent3").appendChild(session.selectedImage_contents); session.selectedImage_contents.classList.remove("hidden"); } } async function changeOverlayImage(ev, ele) { if (ele.files && ele.files[0]) { if (session.foregroundImg) { session.foregroundImg.classList.remove("selectedContentEffectsImage"); } session.foregroundImg = document.createElement("img"); session.foregroundImg.style = "max-width:130px;max-height:73.5px;display:inline-block;margin:10px;cursor:pointer;"; session.foregroundImg.onclick = function (event) { changeEffectsImage(event, this); }; ele.parentNode.parentNode.insertBefore(session.foregroundImg, ele.parentNode); session.foregroundImg.onload = () => { URL.revokeObjectURL(session.foregroundImg.src); // no longer needed, free memory }; session.foregroundImg.src = URL.createObjectURL(ele.files[0]); // set src to blob url session.foregroundImg.classList.add("selectedContentEffectsImage"); } else if (ele.tagName.toLowerCase() == "img") { if (session.foregroundImg) { session.foregroundImg.classList.remove("selectedContentEffectsImage"); } session.foregroundImg = ele; session.foregroundImg.classList.add("selectedContentEffectsImage"); } } function loadOverlayImages() { if (session.defaultForegroundImages) { try { session.defaultForegroundImages.reverse(); } catch (e) { errorlog("Could not process image list"); session.defaultForegroundImages = false; session.selectImageOverlay_contents = getById("selectImageOverlay_contents"); return; } session.defaultForegroundImages.forEach(imgSrc => { try { var img = document.createElement("img"); img.onerror = function () { this.style.display = "none"; }; // hide images that fail to load img.crossOrigin = "Anonymous"; img.src = imgSrc; img.style = "max-width:130px;max-height:73.5px;display:inline-block;margin:10px;cursor:pointer;"; img.onclick = function (event) { changeOverlayImage(event, this); }; getById("selectImageOverlay_contents").prepend(img); } catch (e) { } }); session.defaultForegroundImages = false; session.selectImageOverlay_contents = getById("selectImageOverlay_contents"); } else if (!session.selectImageOverlay_contents) { session.selectImageOverlay_contents = getById("selectImageOverlay_contents"); } if (document.getElementById("selectImageOverlay")) { document.getElementById("selectImageOverlay").style.display = "block"; document.getElementById("selectImageOverlay").appendChild(session.selectImageOverlay_contents); session.selectImageOverlay_contents.classList.remove("hidden"); } else if (document.getElementById("selectImageOverlay3")) { document.getElementById("selectImageOverlay3").style.display = "block"; document.getElementById("selectImageOverlay3").appendChild(session.selectImageOverlay_contents); session.selectImageOverlay_contents.classList.remove("hidden"); } } var effectsLoaded = {}; var JEELIZFACEFILTER = null; async function loadEffect(effect) { warnlog("effect:" + effect); var filename = effect.replace(/\W/g, ""); if (effectsLoaded[filename]) { effectsLoaded[filename](); return; } else { effectsLoaded[filename] = function () { }; } warnlog("Loading Effect: " + effect); var script = document.createElement("script"); script.onload = async function () { log("LOADED EFFECT"); effectsLoaded[filename] = await effectsEngine(filename); log("effectsEngine();"); if (gpgpuSupport == "Google SwiftShader") { if (!session.cleanOutput) { warnUser("Hardware acceleration isn't detected.

    Please enable it for better performance.

    Settings -> Advanced -> System -> Use hardware-accleration", false, false); } } effectsLoaded[filename](); }; script.src = "./filters/" + filename + ".js?" + parseInt(1000 * Math.random()); document.head.appendChild(script); warnUser("Loading custom effects model...", 1000); } async function loadScript(url, callback = false) { var res = null; var rej = null; var promise = new Promise((resolve, reject) => { res = resolve; rej = reject; }); var check = document.querySelector("script[src='" + url + "']"); if (check) { if (callback) { callback(); } } else { var script = document.createElement("script"); script.type = "text/javascript"; script.src = url; script.onload = function () { res(); if (callback) { callback(); } }; document.head.appendChild(script); } return await promise; } var tokenClient = false; function YoutubeChatInterface(remote = false) { // this lets us query Youtube for chat messages, but its quota limited :( if (!tokenClient) { tokenClient = true; } else { return; } var gisInited = false; var gapiInited = false; var busy = 0; function handleAuthClick() { tokenClient.callback = async resp => { if (resp.error) { errorlog(resp.error); } closeModal(); var auths = gapi.client.getToken(); if (auths) { setStorage("YoutubeAuth", JSON.stringify(auths), auths.expires_in || 3600); } listBroadcasts(); }; var saved = getStorage("YoutubeAuth"); if (saved) { gapi.client.setToken(JSON.parse(saved)); listBroadcasts(); } else if (gapi.client.getToken() === null) { if (remote) { tokenClient.requestAccessToken({ prompt: "consent" }); } else { warnUser("", false, false); } } else { if (remote) { tokenClient.requestAccessToken({ prompt: "" }); } else { warnUser("", false, false); } } } function maybeEnableButtons() { if (gapiInited && gisInited) { handleAuthClick(); } } async function initializeGapiClient() { await gapi.client.init({ apiKey: session.youtubeKey.split(",")[1], discoveryDocs: ["https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest"] }); gapiInited = true; maybeEnableButtons(); } function handleSignoutClick() { let token = gapi.client.getToken(); if (token !== null) { google.accounts.oauth2.revoke(token.access_token); gapi.client.setToken(""); } } async function listBroadcasts() { try { var response = await gapi.client.youtube.liveBroadcasts.list({ broadcastStatus: "active" }); } catch (err) { errorlog(err); return; } let broadcasts = response.result.items; if (!broadcasts || broadcasts.length == 0) { return; } broadcasts.forEach(broadcast => { setTimeout( function (liveChatId) { listMessages(liveChatId); busy += 1; }, 1000, broadcast.snippet.liveChatId ); }); } async function listMessages(liveChatId, pageToken = false) { try { if (pageToken) { var response = await gapi.client.youtube.liveChatMessages.list({ liveChatId: liveChatId, part: ["id", "snippet", "authorDetails"], pageToken: pageToken }); } else { var response = await gapi.client.youtube.liveChatMessages.list({ liveChatId: liveChatId, part: ["id", "snippet", "authorDetails"] }); } var messages = response.result.items; messages.forEach(msg => { pokeIframeAPI("YoutubeChat", msg); }); var polling = response.result.pollingIntervalMillis; var pageToken = response.result.nextPageToken; if (busy > 1) { // popular eh? Lets quickly check for more. } else if (busy > 0) { // a message ! hurrah if (polling < 2000) { polling = 2000; } // Was it just luck? } else if (polling < 5000) { polling = 5000; // let's not spam the api, cause we know there isn't anything waiting.. } busy = 0; // reset setTimeout( function (liveChatId, pageToken) { listMessages(liveChatId, pageToken); }, polling, liveChatId, pageToken ); } catch (err) { return; } } function gisLoaded() { tokenClient = google.accounts.oauth2.initTokenClient({ client_id: session.youtubeKey.split(",")[0], scope: "https://www.googleapis.com/auth/youtube", callback: "" }); gisInited = true; maybeEnableButtons(); } function gapiLoaded() { gapi.load("client", initializeGapiClient); } loadScript("https://apis.google.com/js/api.js", gapiLoaded); loadScript("https://accounts.google.com/gsi/client", gisLoaded); } function loadTensorflowJS() { if (session.TFJSModel != null) { return; } log("loadTensorflowJS()"); session.TFJSModel = true; var script = document.createElement("script"); var script2 = document.createElement("script"); var script3 = document.createElement("script"); var script4 = document.createElement("script"); script.onload = function () { document.head.appendChild(script2); }; script2.onload = function () { document.head.appendChild(script3); }; script3.onload = function () { document.head.appendChild(script4); }; script4.onload = function () { async function loadModel() { session.TFJSModel = await faceLandmarksDetection.load(faceLandmarksDetection.SupportedPackages.mediapipeFacemesh); closeModal(); warnUser("Almost done loading model...", 3000); } loadModel(); }; script.src = "./thirdparty/tfjs/tf-core.js"; script2.src = "./thirdparty/tfjs/tf-converter.js"; script3.src = "./thirdparty/tfjs/tf-backend-webgl.js"; script4.src = "./thirdparty/tfjs/face-landmarks-detection.js"; warnUser("Downloading a big effects model... may take a minute", 15000); script.type = "text/javascript"; script2.type = "text/javascript"; script3.type = "text/javascript"; script4.type = "text/javascript"; document.head.appendChild(script); } var TFLITELOADING = false; function attemptTFLiteJsFileLoad() { if (session.tfliteModule !== false) { return true; } warnUser("Loading effects model..."); TFLITELOADING = true; session.tfliteModule = {}; if (!document.getElementById("tflitesimdjs")) { var tmpScript = document.createElement("script"); tmpScript.onload = loadTFLiteModel; tmpScript.type = "text/javascript"; tmpScript.src = "./thirdparty/tflite/tflite-simd.js?ver=2"; tmpScript.id = "tflitesimdjs"; document.head.appendChild(tmpScript); } return false; } async function changeEffectsImage(ev, ele) { if (ele.files && ele.files[0]) { if (session.effectsImage) { session.effectsImage.classList.remove("selectedContentEffectsImage"); } session.effectsImage = document.createElement("img"); session.effectsImage.style = "max-width:130px;max-height:73.5px;display:inline-block;margin:10px;cursor:pointer;"; session.effectsImage.onclick = function (event) { changeEffectsImage(event, this); }; ele.parentNode.parentNode.insertBefore(session.effectsImage, ele.parentNode); session.effectsImage.onload = () => { URL.revokeObjectURL(session.effectsImage.src); // no longer needed, free memory }; session.effectsImage.src = URL.createObjectURL(ele.files[0]); // set src to blob url session.effectsImage.classList.add("selectedContentEffectsImage"); } else if (ele.tagName.toLowerCase() == "img") { if (session.effectsImage) { session.effectsImage.classList.remove("selectedContentEffectsImage"); } session.effectsImage = ele; session.effectsImage.classList.add("selectedContentEffectsImage"); } } async function changeEffectAmount(ev, ele) { session.effectValue = ele.value; if (ele.id === "selectEffectAmountInput") { getById("selectEffectAmountInput3").value = ele.value; } log("session.effectValue: " + session.effectValue); } async function loadTFLiteModel() { try { if (session.tfliteModule && session.effectsImage) { var img = session.effectsImage; session.tfliteModule = await createTFLiteSIMDModule(); session.effectsImage = img; } else { session.tfliteModule = {}; session.tfliteModule = await createTFLiteSIMDModule(); } if (!session.tfliteModule.simd) { var elements = document.querySelectorAll("[data-warnSimdNotice]"); for (let i = 0; i < elements.length; i++) { elements[i].style.display = "inline-block"; } } } catch (e) { warnlog("TF-LITE FAILED TO LOAD"); closeModal(); return; } const modelResponse = await fetch("./thirdparty/tflite/segm_full_v679.tflite"); session.tfliteModule.model = await modelResponse.arrayBuffer(); session.tfliteModule.HEAPU8.set(new Uint8Array(session.tfliteModule.model), session.tfliteModule._getModelBufferMemoryOffset()); session.tfliteModule._loadModel(session.tfliteModule.model.byteLength); session.tfliteModule.activelyProcessing = false; TFLITELOADING = false; closeModal(); if (LaunchTFWorkerCallback) { TFLiteWorker(); } } function smdInfo() { warnUser("For improved performance, use Chrome v87 or newer with SIMD support enabled.
    Enable SIMD here: chrome://flags/#enable-webassembly-simd", false, false); } async function startPublishing() { if (query("#publishOutURL input[type='text']").dataset.twitch == "true") { session.whipOutput = "https://g.webrtc.live-video.net:4443/v2/offer"; } else { session.whipOutput = query("#publishOutURL input[type='text']").value || session.whipOutput || null; } if (!session.whipOutput) { warnUser("Please first provided an output destination", 2500); return; } if (!session.whipOutputToken) { session.whipOutputToken = query("#publishOutToken input[type='password']").value || false; } if (!session.whipOutputToken && query("#publishOutURL input[type='text']").dataset.twitch == "true") { warnUser("Please enter a Twitch stream token first", 2000); return; } getById("publishSettings").classList.add("hidden"); if (!getById("whipoutvbrcbr").classList.contains("hidden")) { if (getById("whipoutvbrcbr").value === "cbr") { session.cbr = 1; } else { session.cbr = 0; } } if (!getById("whipoutdenoise").classList.contains("hidden")) { if (getById("whipoutdenoise").value === "1") { session.noiseSuppression = true; } else { session.noiseSuppression = false; } } if (!getById("whipoutisolation").classList.contains("hidden")) { if (getById("whipoutisolation").value === "1") { session.voiceIsolation = true; } else { session.voiceIsolation = false; } } if (!getById("whipoutautogain").classList.contains("hidden")) { if (getById("whipoutautogain").value === "1") { session.autoGainControl = true; } else { session.autoGainControl = false; } } if (!getById("whipoutstereo").classList.contains("hidden")) { if (getById("whipoutstereo").value === "1") { session.stereo = 1; } else { session.stereo = 0; } } if (!getById("whipoutbitrateGroupFlag").classList.contains("hidden")) { session.whipOutVideoBitrate = parseInt(getById("whipoutbitrateGroupFlag").value); } if (!getById("whipoutaudiobitrate").classList.contains("hidden")) { session.whipOutAudioBitrate = parseInt(getById("whipoutaudiobitrate").value); } var ret = await publishScreen(); if (ret) { getById("publishSettings").classList.add("hidden"); resizeWindow(1280, 720); document.title = "PUBLISHING🔴" + document.title; } else { getById("publishSettings").classList.remove("hidden"); } return ret; } async function startRecording() { session.recordLocal = session.recordLocal || 6000; var ret = await publishScreen(); if (ret) { getById("publishSettings").classList.add("hidden"); resizeWindow(1280, 720); document.title = "RECORDING🔴" + document.title; recordLocalVideoToggle(); } else { getById("publishSettings").classList.remove("hidden"); } } function twitchSelect(ele) { if (ele.checked) { //query("#publishOutURL input[type='text']").value = query("#publishOutURL input[type='text']").disabled = true; query("#publishOutURL input[type='text']").classList.add("disable"); query("#publishOutURL input[type='text']").dataset.twitch = "true"; query("#publishOutToken input[type='password']").placeholder = "Twitch stream token here"; } else { query("#publishOutURL input[type='text']").disabled = null; query("#publishOutURL input[type='text']").classList.remove("disable"); delete getById("publishOutURL").disabled; query("#publishOutURL input[type='text']").dataset.twitch = "false"; query("#publishOutToken input[type='password']").placeholder = "WHIP auth token here"; } } function resizeWindow(width, height) { if (window.outerWidth) { window.resizeTo(width + (window.outerWidth - window.innerWidth), height + (window.outerHeight - window.innerHeight)); } else { window.resizeTo(500, 500); window.resizeTo(width + (500 - document.body.offsetWidth), height + (500 - document.body.offsetHeight)); } setInterval(function () { if (window.innerWidth / window.innerHeight > 17 / 9 && window.innerWidth / window.innerHeight < 15 / 9) { return; } if (window.outerWidth) { window.resizeTo(width + (window.outerWidth - window.innerWidth), height + (window.outerHeight - window.innerHeight)); } else { window.resizeTo(500, 500); window.resizeTo(width + (500 - document.body.offsetWidth), height + (500 - document.body.offsetHeight)); } }, 5000); } // compress SDP /* function compressSDP(sdp) { // Extract critical values const iceUfrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)[1]; const icePwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)[1]; // Extract fingerprint (removing colons) const fingerprintMatch = sdp.match(/a=fingerprint:sha-256 ([^\r\n]+)/); const fingerprint = fingerprintMatch[1].replace(/:/g, ''); // Extract ICE candidates if they exist const candidates = []; const candidateRegex = /a=candidate:([^\r\n]+)/g; let candidateMatch; while ((candidateMatch = candidateRegex.exec(sdp)) !== null) { const parts = candidateMatch[1].split(' '); // Skip IPv6 candidates if (parts[4].includes(':')) continue; // Only keep foundation, component, protocol, priority, ip, port, type and related values const foundation = parts[0]; const component = parts[1]; const protocol = parts[2].toLowerCase() === 'udp' ? 'u' : 't'; const priority = parseInt(parts[3]); const ip = parts[4]; const port = parseInt(parts[5]); const type = parts[7]; // Encode type: host=h, srflx=s, relay=r let typeCode; switch(type) { case 'host': typeCode = 'h'; break; case 'srflx': typeCode = 's'; break; case 'relay': typeCode = 'r'; break; default: typeCode = 'x'; } // Compact representation of candidate // Format: foundation,component,protocol,priority(shortened),ip,port,type candidates.push( `${foundation},${component},${protocol},${priorityToCompact(priority)},${ip},${port},${typeCode}` ); } // Convert fingerprint from hex to a more compact representation const compactFingerprint = hexToCompact(fingerprint); // Build the compressed string // Format: C(version)|ufrag|pwd|fingerprint|[candidates] let result = `C1|${iceUfrag}|${icePwd}|${compactFingerprint}`; // Add candidates if they exist if (candidates.length > 0) { result += `|${candidates.join('/')}`; } return result; } function playCompressedSDP(sdp){ sdp = decompressSDP(sdp) let msg = {}; msg.description = {sdp, type:"offer"}; msg.UUID = session.generateStreamID(15); session.processDescription2(msg); } function decompressSDP(compressed) { // Parse compressed string // Format: C(version)|ufrag|pwd|fingerprint|[candidates] const parts = compressed.split('|'); // Version check if (parts[0] !== 'C1') { throw new Error('Unsupported compression version'); } const iceUfrag = parts[1]; const icePwd = parts[2]; const fingerprint = compactToHex(parts[3]); // Format fingerprint with colons const formattedFingerprint = fingerprint.match(/.{2}/g).join(':'); // Build the base SDP let sdp = [ 'v=0', 'o=- 1 1 IN IP4 127.0.0.1', 's=-', 't=0 0', 'a=group:BUNDLE 0', 'a=extmap-allow-mixed', 'a=msid-semantic: WMS', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0', `a=ice-ufrag:${iceUfrag}`, `a=ice-pwd:${icePwd}`, 'a=ice-options:trickle', `a=fingerprint:sha-256 ${formattedFingerprint}`, 'a=setup:actpass', 'a=mid:0', 'a=sctp-port:5000', 'a=max-message-size:262144' ].join('\r\n'); // Add candidates if they exist if (parts.length > 4 && parts[4]) { const candidates = parts[4].split('/'); for (const candidate of candidates) { const candParts = candidate.split(','); // Parse the candidate parts const foundation = candParts[0]; const component = candParts[1]; const protocol = candParts[2] === 'u' ? 'UDP' : 'TCP'; const priority = compactToPriority(candParts[3]); const ip = candParts[4]; const port = candParts[5]; // Decode type let type; switch(candParts[6]) { case 'h': type = 'host'; break; case 's': type = 'srflx'; break; case 'r': type = 'relay'; break; default: type = 'unknown'; } // Build the candidate line sdp += `\r\na=candidate:${foundation} ${component} ${protocol} ${priority} ${ip} ${port} typ ${type}`; } } return sdp; } function priorityToCompact(priority) { // Simplified priority encoding - actual implementation would use a more sophisticated approach // For typical values, this could be a mapping to shorter codes return priority.toString(36); } function compactToPriority(compact) { return parseInt(compact, 36); } function hexToCompact(hex) { // Convert hex pairs to numbers, then to a more compressed alphabet // This uses a custom encoding that maps to URL-safe characters const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; let result = ''; // Process 3 bytes (6 hex chars) at a time to produce 4 output chars for (let i = 0; i < hex.length; i += 6) { const chunk = hex.substr(i, 6); if (chunk.length < 6) { // Handle the last partial chunk const value = parseInt(chunk, 16); result += chars[value % 64]; if (chunk.length > 2) { result += chars[Math.floor(value / 64) % 64]; } } else { // Process full 6-hex-char chunk const value = parseInt(chunk, 16); result += chars[value & 0x3F]; result += chars[(value >> 6) & 0x3F]; result += chars[(value >> 12) & 0x3F]; result += chars[(value >> 18) & 0x3F]; } } return result; } function compactToHex(compact) { const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; let result = ''; // Process 4 input chars at a time to produce 6 hex chars for (let i = 0; i < compact.length; i += 4) { let value = 0; for (let j = 0; j < 4 && i + j < compact.length; j++) { const char = compact[i + j]; const charValue = chars.indexOf(char); if (charValue === -1) { throw new Error(`Invalid character in compact string: ${char}`); } value |= charValue << (j * 6); } // Convert to hex const hex = value.toString(16).padStart(6, '0'); result += hex; } // Ensure result is the right length for a SHA-256 hash (64 hex chars) return result.padEnd(64, '0'); } function compressSDPForQR(sdp) { // Extract critical values const iceUfrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)[1]; const icePwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)[1]; // Extract fingerprint (removing colons) const fingerprintMatch = sdp.match(/a=fingerprint:sha-256 ([^\r\n]+)/); const fingerprint = fingerprintMatch[1].replace(/:/g, ''); // Extract ICE candidates if they exist const candidates = []; const candidateRegex = /a=candidate:([^\r\n]+)/g; let candidateMatch; while ((candidateMatch = candidateRegex.exec(sdp)) !== null) { const parts = candidateMatch[1].split(' '); // Skip IPv6 candidates if (parts[4].includes(':')) continue; // Compact candidate representation const foundation = parts[0]; const component = parts[1]; const protocol = parts[2].toLowerCase() === 'udp' ? '1' : '2'; const priority = parseInt(parts[3]); const ip = compressIP(parts[4]); const port = parseInt(parts[5]); // Encode type: host=1, srflx=2, relay=3 let typeCode; switch(parts[7]) { case 'host': typeCode = '1'; break; case 'srflx': typeCode = '2'; break; case 'relay': typeCode = '3'; break; default: typeCode = '0'; } // Ultra-compact representation, optimized for QR candidates.push(`${foundation}${component}${protocol}${compressNumber(priority)}${ip}${port}${typeCode}`); } // Compact encoding of fingerprint, optimized for QR code alphanumeric mode const qrFingerprint = fingerprintToQR(fingerprint); // Use very compact delimiter (single character) let result = `Q${iceUfrag}~${icePwd}~${qrFingerprint}`; // Add candidates with minimal separator if (candidates.length > 0) { result += `~${candidates.join(',')}`; } return result; } function decompressSDPFromQR(compressed) { // Parse compressed string // Format: Q[iceUfrag]~[icePwd]~[fingerprint]~[candidates] if (!compressed.startsWith('Q')) { throw new Error('Unsupported QR compression format'); } const parts = compressed.substring(1).split('~'); const iceUfrag = parts[0]; const icePwd = parts[1]; const fingerprint = qrToFingerprint(parts[2]); // Format fingerprint with colons const formattedFingerprint = fingerprint.match(/.{2}/g).join(':'); // Build the base SDP let sdp = [ 'v=0', 'o=- 1 1 IN IP4 127.0.0.1', 's=-', 't=0 0', 'a=group:BUNDLE 0', 'a=extmap-allow-mixed', 'a=msid-semantic: WMS', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0', `a=ice-ufrag:${iceUfrag}`, `a=ice-pwd:${icePwd}`, 'a=ice-options:trickle', `a=fingerprint:sha-256 ${formattedFingerprint}`, 'a=setup:actpass', 'a=mid:0', 'a=sctp-port:5000', 'a=max-message-size:262144' ].join('\r\n'); // Add candidates if they exist if (parts.length > 3 && parts[3]) { const candidates = parts[3].split(','); for (const candidate of candidates) { // Extract the packed candidate information let i = 0; // Extract foundation (variable length, numeric) let j = i; while (j < candidate.length && /^\d$/.test(candidate[j])) j++; const foundation = candidate.substring(i, j); i = j; // Extract component (1 digit) const component = candidate.substring(i, i + 1); i += 1; // Extract protocol (1 digit code) const protocolCode = candidate.substring(i, i + 1); const protocol = protocolCode === '1' ? 'UDP' : 'TCP'; i += 1; // Extract priority (variable length, encoded) j = i; while (j < candidate.length && /^[0-9A-Z]$/.test(candidate[j])) j++; const priorityEncoded = candidate.substring(i, j); const priority = decompressNumber(priorityEncoded); i = j; // Extract IP (encoded) j = i; while (j < candidate.length && !/^\d$/.test(candidate[j])) j++; const ipEncoded = candidate.substring(i, j); const ip = decompressIP(ipEncoded); i = j; // Extract port (variable length, numeric) j = i; while (j < candidate.length && /^\d$/.test(candidate[j])) j++; const port = candidate.substring(i, j); i = j; // Extract type (1 digit code) const typeCode = candidate.substring(i, i + 1); let type; switch(typeCode) { case '1': type = 'host'; break; case '2': type = 'srflx'; break; case '3': type = 'relay'; break; default: type = 'unknown'; } // Build the candidate line sdp += `\r\na=candidate:${foundation} ${component} ${protocol} ${priority} ${ip} ${port} typ ${type}`; } } return sdp; } function compressIP(ip) { // For common local IPs, use a single character code if (ip === '127.0.0.1') return 'L'; if (ip === '0.0.0.0') return 'Z'; if (ip.startsWith('192.168.')) return 'P' + ip.split('.').slice(2).join(''); if (ip.startsWith('10.')) return 'T' + ip.split('.').slice(1).join(''); if (ip.startsWith('172.')) return 'S' + ip.split('.').slice(1).join(''); // For other IPs, convert to a base36 representation const parts = ip.split('.'); let num = 0; for (let i = 0; i < 4; i++) { num = num * 256 + parseInt(parts[i]); } return num.toString(36).toUpperCase(); } function decompressIP(compressed) { if (compressed === 'L') return '127.0.0.1'; if (compressed === 'Z') return '0.0.0.0'; if (compressed.startsWith('P')) { const suffix = compressed.substring(1); if (suffix.length === 0) return '192.168.0.0'; if (suffix.length === 1) return `192.168.${suffix}.0`; return `192.168.${suffix.substring(0, 1)}.${suffix.substring(1)}`; } if (compressed.startsWith('T')) { const suffix = compressed.substring(1); if (suffix.length === 0) return '10.0.0.0'; if (suffix.length === 1) return `10.${suffix}.0.0`; if (suffix.length === 2) return `10.${suffix.substring(0, 1)}.${suffix.substring(1)}.0`; return `10.${suffix.substring(0, 1)}.${suffix.substring(1, 2)}.${suffix.substring(2)}`; } if (compressed.startsWith('S')) { const suffix = compressed.substring(1); if (suffix.length === 0) return '172.0.0.0'; if (suffix.length === 1) return `172.${suffix}.0.0`; if (suffix.length === 2) return `172.${suffix.substring(0, 1)}.${suffix.substring(1)}.0`; return `172.${suffix.substring(0, 1)}.${suffix.substring(1, 2)}.${suffix.substring(2)}`; } // Default case: convert from base36 const num = parseInt(compressed, 36); return [ (num >> 24) & 0xFF, (num >> 16) & 0xFF, (num >> 8) & 0xFF, num & 0xFF ].join('.'); } function compressNumber(num) { // Convert to base36 for compactness, prefer uppercase for QR return num.toString(36).toUpperCase(); } function decompressNumber(compressed) { return parseInt(compressed, 36); } function fingerprintToQR(hex) { // Convert 64-char hex to a more compact representation // Using base36 for better QR code efficiency (alphanumeric mode) let result = ''; // Process 4 hex chars (16 bits) at a time to produce 3 base36 chars for (let i = 0; i < hex.length; i += 4) { const chunk = hex.substr(i, 4); if (chunk.length < 4) { // Handle the last partial chunk const value = parseInt(chunk, 16); result += value.toString(36).toUpperCase().padStart(3, '0').substring(0, 3); } else { // Process full chunk const value = parseInt(chunk, 16); result += value.toString(36).toUpperCase().padStart(3, '0').substring(0, 3); } } return result; } function qrToFingerprint(qrFp) { let result = ''; // Process 3 base36 chars at a time to produce 4 hex chars for (let i = 0; i < qrFp.length; i += 3) { const chunk = qrFp.substr(i, 3); const value = parseInt(chunk, 36); result += value.toString(16).padStart(4, '0').substring(0, 4); } // Ensure result has correct length for SHA-256 (64 hex chars) return result.padEnd(64, '0'); } function sdpToQRCode(sdp, element) { const compressed = compressSDPForQR(sdp); // Use the provided qrcodejs library new QRCode(element, { text: compressed, width: 128, height: 128, colorDark: "#000000", colorLight: "#ffffff", correctLevel: QRCode.CorrectLevel.M // Use medium error correction }); return compressed; // Return compressed string for reference } function qrToSDP(qrData) { return decompressSDPFromQR(qrData); } */ /// function configureWhipOutSDP(description) { // THIS IS FOR WHIP-OUTPUT; it has var configs = {}; if (SafariVersion && SafariVersion <= 13 && (iOS || iPad)) { return description; // skip. Not going to try to tinker with older iOS SDPs } else if (session.stereo == 3 || session.stereo == 5 || session.stereo == 6 || session.stereo == 1) { // stereo out configs = { stereo: 1 }; log("stereo enabled"); } else if (iOS || iPad) { // iOS doesn't have multichannel, so why even bother configs = {}; } else if (session.stereo == 4) { configs = { stereo: 2 }; log("stereo enabled"); } else { configs = { stereo: 0 }; } if (session.whipOutAudioCodec === "pcm") { if (session.audioInputChannels && session.audioInputChannels == 1) { description.sdp = CodecsHandler.modifyDescPCM(description.sdp, session.micSampleRate || 48000, false); // mono } else if (session.stereo) { description.sdp = CodecsHandler.modifyDescPCM(description.sdp, session.micSampleRate || 48000, true); // mono } else { description.sdp = CodecsHandler.modifyDescPCM(description.sdp, session.micSampleRate || 48000, false); } } else { if (session.whipOutAudioCodec) { description.sdp = CodecsHandler.preferAudioCodec(description.sdp, session.whipOutAudioCodec, session.predAudio, session.pfecAudio); // "red" codec } if (session.noFEC !== null) { configs.useinbandfec = session.noFEC ? 0 : 1; } if (session.maxptime !== false) { configs.maxptime = session.maxptime; } if (session.minptime !== false) { configs.minptime = session.minptime; } if (session.ptime !== false) { configs.ptime = session.ptime; } if (session.dtx !== false) { configs.dtx = session.dtx; // "usedtx", if no loud audio, stops sending audio for 400ms. default. } if (session.whipOutAudioBitrate) { configs.maxaveragebitrate = session.whipOutAudioBitrate * 1024; configs.cbr = session.cbr; } if (Object.keys(configs).length) { log("Processing sdp of type: " + description.type + " ..."); log(configs); description.sdp = CodecsHandler.setOpusAttributes(description.sdp, configs, true); } } if (iOS || iPad) { // solves issues with iOS rotation not being correct if (session.removeOrientationFlag && description.sdp.includes("a=extmap:3 urn:3gpp:video-orientation\r\n")) { description.sdp = description.sdp.replace("a=extmap:3 urn:3gpp:video-orientation\r\n", ""); } } if (session.screenShareState && typeof session.whipOutScreenShareCodec === "object") { session.whipOutScreenShareCodec.reverse().forEach(codec => { description.sdp = CodecsHandler.preferCodec(description.sdp, codec, session.videoErrorCorrection); if (session.whipOutScreenShareBitrate || session.whipOutVideoBitrate) { description.sdp = CodecsHandler.setVideoBitrates( description.sdp, { min: parseInt((session.whipOutScreenShareBitrate || session.whipOutVideoBitrate) / 10) || 1, max: session.whipOutScreenShareBitrate || session.whipOutVideoBitrate || 1 }, codec ); } }); } else if (session.screenShareState && session.whipOutScreenShareCodec) { description.sdp = CodecsHandler.preferCodec(description.sdp, session.whipOutScreenShareCodec, session.videoErrorCorrection); if (session.whipOutScreenShareBitrate || session.whipOutVideoBitrate) { description.sdp = CodecsHandler.setVideoBitrates( description.sdp, { min: parseInt((session.whipOutScreenShareBitrate || session.whipOutVideoBitrate) / 10) || 1, max: session.whipOutScreenShareBitrate || session.whipOutVideoBitrate || 1 }, session.whipOutScreenShareCodec ); } } else if (typeof session.whipOutCodec === "object") { session.whipOutCodec.reverse().forEach(codec => { description.sdp = CodecsHandler.preferCodec(description.sdp, codec, session.videoErrorCorrection); if (session.whipOutVideoBitrate) { description.sdp = CodecsHandler.setVideoBitrates( description.sdp, { min: parseInt(session.whipOutVideoBitrate / 10) || 1, max: session.whipOutVideoBitrate || 1 }, codec ); } }); } else if (session.whipOutCodec) { description.sdp = CodecsHandler.preferCodec(description.sdp, session.whipOutCodec, session.videoErrorCorrection); if (session.whipOutVideoBitrate) { description.sdp = CodecsHandler.setVideoBitrates( description.sdp, { min: parseInt(session.whipOutVideoBitrate / 10) || 1, max: session.whipOutVideoBitrate || 1 }, session.whipOutCodec ); } } else { log("preferring h264 in sdp"); description.sdp = CodecsHandler.preferCodec(description.sdp, "h264", session.videoErrorCorrection); // h264 default. openh264? well, this was breaking with Pion, so, meh. whatever h264 if (session.whipOutVideoBitrate) { description.sdp = CodecsHandler.setVideoBitrates( description.sdp, { min: parseInt(session.whipOutVideoBitrate / 10) || 1, max: session.whipOutVideoBitrate || 1 }, "h264" ); } } var bitrate = 2500; if (session.whipOutVideoBitrate !== false) { bitrate = session.whipOutVideoBitrate; } if (session.screenShareState && session.whipOutScreenShareBitrate !== false) { bitrate = session.whipOutScreenShareBitrate; } session.whipOut.savedBitrate = bitrate; // actual target session.whipOut.setBitrate = bitrate; // max return description; } const scalabilityModes = ["L1T1", "L1T2", "L1T3", "L2T1", "L2T2", "L2T3", "L3T1", "L3T2", "L3T3", "L2T1h", "L2T2h", "L2T3h", "S2T1", "S2T2", "S2T3", "S2T1h", "S2T2h", "S2T3h", "S3T1", "S3T2", "S3T3", "S3T1h", "S3T2h", "S3T3h", "L2T2_KEY", "L2T3_KEY", "L3T2_KEY", "L3T3_KEY"]; function completeLocationURL(originalURL, locationURL) { if (!originalURL) { return locationURL; } else if (!locationURL) { return originalURL; } const parsedOriginalURL = new URL(originalURL); // Check if the location URL is already absolute if (locationURL.startsWith("http://") || locationURL.startsWith("https://")) { return locationURL; } else { // Check if the original URL's pathname ends with a slash or not let basePath = parsedOriginalURL.origin + parsedOriginalURL.pathname; if (!basePath.endsWith("/")) { // If the pathname does not end with a slash, remove the last segment basePath = basePath.substring(0, basePath.lastIndexOf("/") + 1); } // Resolve "." and ".." in the relative URL against the base path const fullPath = new URL(locationURL, basePath).href; return fullPath; } } function getWhipOutCanvasTrack(baseRTC = session.whipOut) { if (!baseRTC) { errorlog("Meshcast/WHIP not connected; cant' create canvas for it"); } if (!baseRTC.canvas) { baseRTC.canvas = document.createElement("canvas"); baseRTC.canvas.width = 320; baseRTC.canvas.height = 180; } if (!baseRTC.ctx) { baseRTC.ctx = baseRTC.canvas.getContext("2d", { alpha: false }); baseRTC.ctx.fillStyle = "#000"; baseRTC.ctx.fillRect(0, 0, baseRTC.canvas.width, baseRTC.canvas.height); } if (!baseRTC.canvasStream) { (function loop() { baseRTC.ctx.fillRect(0, 0, baseRTC.canvas.width, baseRTC.canvas.height); setTimeout(loop, 250); // drawing at 30fps })(); try { baseRTC.ctx.fillRect(0, 0, baseRTC.canvas.width, baseRTC.canvas.height); baseRTC.canvasStream = baseRTC.canvas.captureStream(4); baseRTC.ctx.fillRect(0, 0, baseRTC.canvas.width, baseRTC.canvas.height); } catch (e) { errorlog("Error creating whip output placeholder track"); } } var tracks = baseRTC.canvasStream.getVideoTracks(); if (tracks.length) { return tracks[0] } errorlog("Meschast canvas not working"); return false; } function whipOut() { log("whipOut"); if (session.whipPublishPrimary === false) { log("whipOut skipped: primary WHIP disabled"); return false; } if (!session.videoElement || !session.videoElement.srcObject) { log("no videoElement yet created; can't do whip out until then"); return false; } for (const UUID in session.pcs) { if (!session.pcs.hasOwnProperty(UUID)) { continue; } if (session.pcs[UUID] && session.pcs[UUID].whipout === true) { session.pcs[UUID].whipout = null; } } var candidates = []; var codec = false; var keyframe = false; async function whipConnect() { try { if (!session.configuration) { await chooseBestTURN(); } if (session.encodedInsertableStreams) { // most servers won't support this session.configuration.encodedInsertableStreams = true; } if (session.bundlePolicy) { session.configuration.bundlePolicy = session.bundlePolicy; } var config = { ...session.configuration }; log(config); // do anything whip specific here session.whipOut = new RTCPeerConnection(config); session.whipOut.stats = {}; session.whipOut.maxBandwidth = null; // based on max available bitrate session.whipOut.scale = false; session.whipOut.offerToReceiveAudio = false; session.whipOut.offerToReceiveVideo = false; session.whipOut.keyframeTimeout = null; } catch (err) { errorlog(err); if (!session.cleanOutput) { warnUser("An RTC error occured"); } } try { /// audio tracks var tracks = false; if (session.videoElement && session.videoElement.srcObject) { tracks = session.videoElement.srcObject.getAudioTracks(); } var streamsource = false; if (!tracks || !tracks.length) { if (!session.audioCtx) { session.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } // Create an oscillator at an audible frequency const oscillator = session.audioCtx.createOscillator(); oscillator.frequency.value = 440; // Standard A note // Create a gain to make it nearly silent const gainNode = session.audioCtx.createGain(); gainNode.gain.value = 0.00001; const destination = session.audioCtx.createMediaStreamDestination(); oscillator.connect(gainNode); gainNode.connect(destination); oscillator.start(); streamsource = destination.stream; destination.stream.getAudioTracks().forEach(trk => { tracks = trk; trk.enabled = true; // Force the track to appear active const constraints = { // keep it quite and not denoised out back to zero channelCount: { ideal: 1 }, autoGainControl: { ideal: false }, echoCancellation: { ideal: false }, noiseSuppression: { ideal: false } }; try { trk.applyConstraints(constraints); } catch (e) { console.warn("Could not apply audio constraints", e); } }); } else { tracks = tracks[0]; streamsource = session.videoElement.srcObject; } if (session.audioContentHint && tracks.kind === "audio") { try { tracks.contentHint = session.audioContentHint; } catch (e) { errorlog(e); } } if (tracks) { try { session.whipOut.addTransceiver(tracks, { streams: [streamsource], direction: "sendonly" }); } catch (e) { errorlog(e); session.whipOut.addTrack(tracks); } } } catch (e) { errorlog(e); } try { //// video tracks /// var tracks = false; if (session.videoElement && session.videoElement.srcObject) { tracks = session.videoElement.srcObject.getVideoTracks(); } if (!tracks || !tracks.length) { var track = getWhipOutCanvasTrack(session.whipOut); } else { var track = tracks[0]; } if (track) { // it's actually just "track" now. if (session.screenShareState && session.screenshareContentHint && track.kind === "video") { try { track.contentHint = session.screenshareContentHint; } catch (e) { errorlog(e); } } else if (session.contentHint && track.kind === "video") { try { track.contentHint = session.contentHint; } catch (e) { errorlog(e); } } try { var transceiverSetup = { streams: [session.videoElement.srcObject], direction: "sendonly" }; if (session.scalabilityMode) { // might be a good time to validate the scalabilityMode at this point; check to see if requested codec is available,etc. try { transceiverSetup.sendEncodings = [{ scalabilityMode: session.scalabilityMode }]; } catch (e) { errorlog("Invalid scalability mode provided"); } if (session.whipOutCodec && session.whipOutCodec.length) { //var svcCodecPref = []; //for (var i = 0;i= 0) { session.whipOut.location = this.getResponseHeader("location") || ""; session.whipOut.location = completeLocationURL(session.whipOutput, session.whipOut.location); } else if (!session.whipOut.location && session.whipOutput) { session.whipOut.location = session.whipOutput; session.whipOut.location = completeLocationURL(session.whipOutput, session.whipOut.location); } } catch (e) { errorlog(e); } try { log(this.getAllResponseHeaders()); if (this.getAllResponseHeaders().toLowerCase().indexOf("whep") >= 0) { WHELPlaybackURL = this.getResponseHeader("whep") || this.getResponseHeader("WHEP") || false; } else { console.log("Note: No WHEP key/value was found in the WHIP header response or it was not exposed.\n\nProviding the WHEP URL for this WHIP output via the WHEP header key will allow p2p access to the WHEP stream for others conneted to this peer."); } if (!WHELPlaybackURL && session.whipOutput) { var targetDomain = session.whipOutput.split("/"); try { if (targetDomain.length > 2 && targetDomain[2].endsWith(".cloudflarestream.com") && targetDomain[3].length == 65) { WHELPlaybackURL = "https://" + targetDomain[2] + "/" + targetDomain[3].slice(33, 65) + "/webRTC/play"; session.whipOut.stats.whipHost = "Cloudflare"; } else if (/^https?:\/\/(?:[\w-]+\.)*meshcast\.io(?:\/|$)/i.test(session.whipOutput)) { // this should be only if meshcast doens't return a whep URL. we guess as a fallback. session.whipOut.stats.whipHost = "Meshcast"; session.whipOut.stats.watch_URL = "https://meshcast.io/view.html?geo=" + session.whipOutput.split("https://")[1].split(".")[0] + "&id=" + session.whipOutput.split("meshcast.io/")[1].split("/whip")[0]; } else if (/^https?:\/\/app\.meshcast\.io(?:\/|$)/i.test(session.whipOutput)) { session.whipOut.stats.whipHost = "Meshcast2"; } } catch (e) { errorlog(e); } } else if (WHELPlaybackURL && !(WHELPlaybackURL.startsWith("http://") || WHELPlaybackURL.startsWith("https://"))) { var targetDomain = session.whipOutput.split("/"); if (targetDomain.length > 2) { if (WHELPlaybackURL.startsWith("/")) { WHELPlaybackURL = targetDomain[0] + "//" + targetDomain[2] + WHELPlaybackURL; } else { WHELPlaybackURL = targetDomain[0] + "//" + targetDomain[2] + "/" + WHELPlaybackURL; } } } else if (WHELPlaybackURL && /^https?:\/\/(?:[\w-]+\.)*meshcast\.io(?:\/|$)/i.test(WHELPlaybackURL)) { // this should be only if meshcast doens't return a whep URL. we guess as a fallback. try { session.whipOut.stats.whipHost = "Meshcast"; session.whipOut.stats.watch_URL = "https://meshcast.io/view.html?geo=" + WHELPlaybackURL.split("https://")[1].split(".")[0] + "&id=" + WHELPlaybackURL.split("meshcast.io/")[1].split("/whip")[0]; } catch (e) { errorlog(e); } } log("WHELPlaybackURL: " + WHELPlaybackURL); session.whipOut.stats.whep_URL = WHELPlaybackURL; } catch (e) { errorlog(e); } if (WHELPlaybackURL) { if (WHELPlaybackURL.includes("|")) { var tmp = WHELPlaybackURL.split("|"); session.whipoutSettings = { type: "whep", url: tmp[0].trim(), token: tmp[1].trim(), started: false }; } else { session.whipoutSettings = { type: "whep", url: WHELPlaybackURL, started: false }; } } if (contentType.startsWith("application/sdp") || isSDP(this.responseText)) { var jsep = {}; jsep.sdp = this.responseText; jsep.type = "answer"; try { jsep = configureWhipOutSDP(jsep); } catch (e) { errorlog(e); } warnlog("Processing answer:"); warnlog(jsep); if (session.whipOut && session.whipOut.location) { sessionStorage.setItem("deleteWhipOnLoad", JSON.stringify({ location: session.whipOut.location, whipOutputToken: session.whipOutputToken })); session.whipOut.deleteme = function () { let xhttp = new XMLHttpRequest(); if (session.whipOutputToken) { xhttp.setRequestHeader("Authorization", "Bearer " + session.whipOutputToken); } xhttp.onload = function () { sessionStorage.removeItem("deleteWhipOnLoad"); }; xhttp.onerror = function () { sessionStorage.removeItem("deleteWhipOnLoad"); }; xhttp.open("DELETE", session.whipOut.location, true); xhttp.send(); delete session.whipOut.deleteme; }; } if (session.localNetworkOnly) { jsep.sdp = filterSDPLAN(jsep.sdp); } if (session.stunOnly) { // or whatever flag you want to use jsep.sdp = filterStunOnly(jsep.sdp); } session.whipOut .setRemoteDescription(jsep) .then(async function () { warnlog("SHOULD BE CONNECTED?"); //var content = ""; //while (candidates.length) { // var candidate = candidates.pop(); // content += candidate.candidate; //} //warnlog("Content: " + content.length); //if (content){ // warnlog("SENDING TRICKLE"); //.. I should, but I'm not, since most sites don't support it still. if (session.whipoutSettings) { session.whipoutSettings.started = Date.now(); } if (session.whipOutKeyframe) { clearTimeout(session.whipOut.keyframeTimeout); session.whipOut.keyframeTimeout = setInterval(function () { GOP(); }, session.whipOutKeyframe); // ensure GOP no longer than 6s } session.whipOutSetScale(); await sleep(1000); // give whip server a moment to setup I guess. if (session.whipoutSettings) { for (var UUID in session.pcs) { if (session.pcs[UUID].whipout === null) { var data = {}; data.whepSettings = session.whipoutSettings; if (session.sendMessage(data, UUID)) { session.pcs[UUID].whipout = true; } } } } //} }) .catch(async function (e) { errorlog(e); errorlog("Recieved an invalid SDP answer response from the WHIP endpoint. While things may still work, it won't work as intended."); if (WHELPlaybackURL) { log("Since we have the WHEP URL, we will assume its desired to forward it on, even if it may not work."); if (session.whipoutSettings) { session.whipoutSettings.started = Date.now(); } try { if (session.whipOutKeyframe) { clearTimeout(session.whipOut.keyframeTimeout); session.whipOut.keyframeTimeout = setInterval(function () { GOP(); }, session.whipOutKeyframe); // ensure GOP no longer than 6s } session.whipOutSetScale(); } catch (e) { errorlog(e); } await sleep(1000); // give whip server a moment to setup I guess. if (session.whipoutSettings) { for (var UUID in session.pcs) { if (session.pcs[UUID].whipout === null) { var data = {}; data.whepSettings = session.whipoutSettings; if (session.sendMessage(data, UUID)) { session.pcs[UUID].whipout = true; } } } } } }); } else if (contentType == "application/error") { if (this.responseText == 432) { warnUser("Whip out error: 432"); } else { warnUser("Unknown Whip Out error"); } } else if (callback) { callback(); } else if (WHELPlaybackURL) { errorlog("WHEP URL provided in header response, but no SDP answer provided. Will still use the WHEP URL, and hope for the best.."); if (session.whipoutSettings) { if (session.whipoutSettings) { session.whipoutSettings.started = Date.now(); } await sleep(1000); for (var UUID in session.pcs) { if (session.pcs[UUID].whipout === null) { var data = {}; data.whepSettings = session.whipoutSettings; if (session.sendMessage(data, UUID)) { session.pcs[UUID].whipout = true; } } } } } } else if (this.readyState == 4) { try { if (session.meshcast2 && typeof session.whipOutput === "string" && session.whipOutput.includes("app.meshcast.io/api/gateway/whip")) { let errorCode = null; let errorPayload = null; if (this.getResponseHeader && this.getResponseHeader("content-type") && this.getResponseHeader("content-type").includes("application/json")) { try { errorPayload = JSON.parse(this.responseText || "{}"); } catch (e) { errorPayload = null; } } else { try { errorPayload = JSON.parse(this.responseText || "{}"); } catch (e) { errorPayload = null; } } if (errorPayload && errorPayload.code) { errorCode = errorPayload.code; } if (errorCode === "ORIGIN_NOT_ALLOWED") { if (!session.cleanOutput) { promptAlt("Meshcast2 access blocked for this origin. Create an account to continue.", false, false, false, 5); } } else if (errorCode === "QUOTA_EXCEEDED") { if (!session.cleanOutput) { promptAlt("Meshcast2 anonymous bandwidth limit reached. Premium account required.", false, false, false, 5); } } else if (["TOKEN_INVALID", "TOKEN_EXPIRED", "STREAM_LIMIT_REACHED"].includes(errorCode)) { if (!session.meshcast2FallbackAttempted) { session.meshcast2FallbackAttempted = true; session.meshcast2FallbackActive = true; session.meshcast2Anonymous = false; session.whipoutSettings = false; session.whipOutput = false; session.whipOutputScreen = false; session.whipoutScreenSettings = false; if (!session.cleanOutput) { promptAlt("Meshcast2 token rejected. Falling back to anonymous relay.", false, false, false, 5); } if (typeof meshcast2 === "function") { meshcast2(); } return; } } else if (!session.cleanOutput) { promptAlt("Meshcast2 publish failed. Check your token and try again.", false, false, false, 5); } } } catch (e) { errorlog(e); } } }; if (type === "trickle-ice-sdpfrag") { xhttp.open("PATCH", session.whipOutput, true); // Not supported by most sites yet } else { xhttp.open("POST", session.whipOutput, true); } if (session.whipOutputToken) { xhttp.setRequestHeader("Authorization", "Bearer " + session.whipOutputToken); } xhttp.setRequestHeader("Content-Type", "application/" + type); xhttp.onerror = function (e) { errorlog(e); if (window.location.protocol == "https:" && session.whipOutput.startsWith("http://") && !session.whipOutput.startsWith("http://localhost")) { console.warn("Mixed HTTP and HTTPS content; this may not work. There are some options, like using localhost, disabling web security in your browser, or using SSL entirely"); if (!session.cleanOutput) { if (window.location.hostname === "vdo.ninja") { warnUser("Error: You cannot publish to an HTTP WHIP endpoint from an HTTPS-enabled website.\n\nThere are some possible exceptions and solutions, such as deploying an SSL certificate, hosting from localhost, trying from http://insecure.vdo.ninja, and/or using the Electron Capture app."); } else { warnUser("Error: You cannot publish to an HTTP WHIP endpoint from an HTTPS-enabled website."); } } } else if (!session.cleanOutput) { warnUser("WHIP out failed.\n\nCheck the developer console for possible details."); } }; xhttp.send(data); } catch (e) { errorlog(e); } } function GOP() { log("Sending keyframe"); try { if (!session.whipOut) { return; } var senders = session.whipOut.getSenders(); var sender = false; senders.forEach(senderVideo => { if (senderVideo.track && senderVideo.track.id && senderVideo.track.kind == "video") { sender = senderVideo; } }); if (!sender) { log("GOP(): can't change bitrate; no video sender found"); return false; } var settings = {}; settings.scaleResolutionDownBy = 10; // 50% of default max setEncodings( sender, settings, function (sendr) { var settings = {}; var chromeVersion = getChromiumVersion(); if (chromeVersion > 80) { // just because settings.scaleResolutionDownBy = null; } else { settings.scaleResolutionDownBy = 1.0; } setEncodings(sendr, settings, function () { //log("scaleResolutionDownBy set 3b!"); }); }, sender ); return true; } catch (e) { errorlog(e); } } // WHIP auto-reconnection with exponential backoff var whipReconnecting = false; var whipReconnectAttempts = 0; function retryWhipConnection() { if (!session.whipOutput) { log("No WHIP output configured, stopping retry"); return; } if (whipReconnecting) { return; } // Check if already connected if (session.whipOut && (session.whipOut.connectionState === 'connected' || session.whipOut.iceConnectionState === 'connected' || session.whipOut.iceConnectionState === 'completed')) { log("WHIP connection is already established. No need to reconnect."); whipReconnecting = false; whipReconnectAttempts = 0; return; } whipReconnecting = true; const maxRetries = 5; const initialDelay = 2000; const maxDelay = 20000; let currentRetry = whipReconnectAttempts; let currentDelay = Math.min(initialDelay * Math.pow(2, currentRetry), maxDelay); function attemptReconnect() { if (!session.whipOutput) { log("WHIP output removed, stopping retry"); whipReconnecting = false; return; } // Check if connection recovered if (session.whipOut && (session.whipOut.connectionState === 'connected' || session.whipOut.iceConnectionState === 'connected' || session.whipOut.iceConnectionState === 'completed')) { log("WHIP connection recovered. Stopping retry."); whipReconnecting = false; whipReconnectAttempts = 0; return; } log("Attempting WHIP reconnection (attempt " + (currentRetry + 1) + "/" + maxRetries + ")"); // Close existing connection if (session.whipOut) { try { session.whipOut.close(); } catch (e) { warnlog(e); } session.whipOut = null; } // Reset publishing state publishing = false; candidates = []; // Attempt reconnection - reuses session.whipOutput and session.whipOutputToken try { whipConnect(); // Give it time to connect before checking var checkAttempts = 0; var maxCheckAttempts = 6; // Up to 30 seconds total (6 x 5s) function checkConnectionState() { checkAttempts++; if (session.whipOut && (session.whipOut.connectionState === 'connected' || session.whipOut.iceConnectionState === 'connected' || session.whipOut.iceConnectionState === 'completed')) { log("WHIP reconnection successful"); whipReconnecting = false; whipReconnectAttempts = 0; } else if (session.whipOut && (session.whipOut.connectionState === 'connecting' || session.whipOut.iceConnectionState === 'checking' || session.whipOut.iceConnectionState === 'new')) { // Still connecting - wait longer before declaring failure if (checkAttempts < maxCheckAttempts) { log("WHIP still connecting, waiting... (" + checkAttempts + "/" + maxCheckAttempts + ")"); setTimeout(checkConnectionState, 5000); } else { log("WHIP connection timeout after " + (checkAttempts * 5) + "s"); scheduleNextRetry(); } } else { scheduleNextRetry(); } } function scheduleNextRetry() { currentRetry++; whipReconnectAttempts = currentRetry; if (currentRetry < maxRetries) { currentDelay = Math.min(currentDelay * 2, maxDelay); log("WHIP reconnection not yet established. Retrying in " + currentDelay + "ms"); setTimeout(attemptReconnect, currentDelay); } else { log("WHIP max retries reached, stopping reconnection attempts"); whipReconnecting = false; } } setTimeout(checkConnectionState, 5000); // First check after 5 seconds } catch (e) { errorlog(e); currentRetry++; whipReconnectAttempts = currentRetry; if (currentRetry < maxRetries) { currentDelay = Math.min(currentDelay * 2, maxDelay); setTimeout(attemptReconnect, currentDelay); } else { whipReconnecting = false; } } } // Start first attempt after initial delay log("WHIP connection lost. Will retry in " + currentDelay + "ms"); setTimeout(attemptReconnect, currentDelay); } // Expose for manual reconnection from UI session.restartWhipConnection = function() { log("Manual WHIP restart requested"); whipReconnecting = false; whipReconnectAttempts = 0; if (session.whipOut) { try { session.whipOut.close(); } catch (e) { warnlog(e); } session.whipOut = null; } publishing = false; candidates = []; whipConnect(); }; // Track reconnect attempts for mesh debug visibility session.getWhipReconnectAttempts = function() { return whipReconnectAttempts; }; whipConnect(); } function cleanupStereoSettings(sdp) { if (typeof sdp !== "string" || sdp === "") { return sdp; } let lines = sdp.split("\n"); lines = lines.map(line => { if (line.startsWith("a=fmtp:") && (line.includes("sprop-stereo=0;") || line.includes("stereo=0;"))) { line = line.replace("sprop-stereo=0;", "").replace("stereo=0;", ""); line = line.replace(";;", ";").replace(/;$/, ""); } return line; }); return lines.join("\n"); } function ensureViewerRpcDefaults(UUID) { try { if (!session || !session.rpcs || !session.rpcs[UUID]) { return; } const rpc = session.rpcs[UUID]; if (!rpc.stats) { rpc.stats = {}; } if (typeof rpc.allowGraphs === "undefined") { rpc.allowGraphs = false; } if (typeof rpc.allowDrawing === "undefined") { rpc.allowDrawing = false; } if (!rpc.inboundAudioPipeline) { rpc.inboundAudioPipeline = {}; } if (typeof rpc.channelOffset === "undefined") { rpc.channelOffset = false; } if (typeof rpc.channelWidth === "undefined") { rpc.channelWidth = false; } if (typeof rpc.settings === "undefined") { rpc.settings = false; } if (typeof rpc.defaultSpeaker === "undefined") { rpc.defaultSpeaker = false; } if (typeof rpc.lockedVideoBitrate === "undefined") { rpc.lockedVideoBitrate = false; } if (typeof rpc.lockedAudioBitrate === "undefined") { rpc.lockedAudioBitrate = false; } if (typeof rpc.manualBandwidth === "undefined") { rpc.manualBandwidth = false; } if (typeof rpc.motionDetectionInterval === "undefined") { rpc.motionDetectionInterval = false; } if (typeof rpc.buffer === "undefined") { rpc.buffer = false; } if (typeof rpc.getStatsTimeout === "undefined") { rpc.getStatsTimeout = null; } } catch (e) { errorlog(e); } } function broadcastWhepSettings(kind = "primary") { if (session.noMeshcast) { return false; } let settings = null; let property = "whipout"; let allowProperty = null; if (kind === "screen") { if (!session.screenShareState) { return false; } settings = session.whipoutScreenSettings; property = "whipScreen"; allowProperty = "screenWhepAllowed"; } else { settings = session.whipoutSettings; } if (!settings || !settings.url) { return false; } const startedMarker = typeof settings.started === "number" && settings.started > 0 ? settings.started : false; if (kind === "screen" && !startedMarker) { return false; } const marker = startedMarker || true; let sent = false; for (const UUID in session.pcs) { if (!session.pcs.hasOwnProperty(UUID)) { continue; } const peer = session.pcs[UUID]; if (!peer) { continue; } if (peer[property] === false) { continue; } if (startedMarker && peer[property] === startedMarker) { continue; } if (!startedMarker && peer[property] === marker) { continue; } if (!startedMarker && peer[property] === true) { continue; } if (allowProperty && peer[allowProperty] === false) { continue; } const data = {}; const payload = Object.assign({}, settings); if (kind === "screen") { data.whepScreenSettings = payload; } else { data.whepSettings = payload; } if (session.sendMessage(data, UUID)) { peer[property] = marker; sent = true; } } return sent; } async function whipOutScreen() { log("whipOutScreen"); if (!session.whipPublishScreen || !session.whipOutputScreen) { log("whipOutScreen skipped: screen WHIP disabled"); return false; } if (!session.screenShareState || !session.screenStream) { log("whipOutScreen waiting: no active screen stream"); return false; } try { if (!session.configuration) { await chooseBestTURN(); } } catch (e) { errorlog(e); } for (const UUID in session.pcs) { if (!session.pcs.hasOwnProperty(UUID)) { continue; } if (session.pcs[UUID]) { if (session.pcs[UUID].whipScreen !== false) { session.pcs[UUID].whipScreen = null; } } } const config = { ...session.configuration }; if (session.encodedInsertableStreams) { config.encodedInsertableStreams = true; } if (session.bundlePolicy) { config.bundlePolicy = session.bundlePolicy; } try { if (session.whipOutScreen && session.whipOutScreen.close) { try { session.whipOutScreen.getSenders().forEach(sender => { try { if (sender.replaceTrack) { const result = sender.replaceTrack(null); if (result && typeof result.catch === "function") { result.catch(() => { }); } } } catch (e) { } }); } catch (e) { } session.whipOutScreen.close(); } } catch (e) { errorlog(e); } let pc; try { pc = new RTCPeerConnection(config); } catch (err) { errorlog(err); return false; } session.whipOutScreen = pc; pc.stats = {}; pc.maxBandwidth = null; pc.scale = false; pc.offerToReceiveAudio = false; pc.offerToReceiveVideo = false; const candidates = []; let iceGatheringResolve; const iceGatheringPromise = new Promise(resolve => { iceGatheringResolve = resolve; }); pc.onicecandidate = event => { if (event.candidate) { candidates.push(event.candidate); } else if (iceGatheringResolve) { iceGatheringResolve(); } }; pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === "complete" && iceGatheringResolve) { iceGatheringResolve(); } }; pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === "failed" || pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "closed") { warnlog("Screen WHIP ICE state: " + pc.iceConnectionState); } }; const stream = session.screenStream; stream.getTracks().forEach(track => { try { pc.addTransceiver(track, { direction: "sendonly", streams: [stream] }); } catch (e) { try { pc.addTrack(track, stream); } catch (err) { errorlog(err); } } }); let offer; try { offer = await pc.createOffer(); } catch (e) { errorlog(e); pc.close(); session.whipOutScreen = null; return false; } try { offer = configureWhipOutSDP(offer); } catch (e) { errorlog(e); } try { await pc.setLocalDescription(offer); } catch (e) { errorlog(e); pc.close(); session.whipOutScreen = null; return false; } try { if (session.whipWait) { let timedOut = false; const timer = sleep(session.whipWait).then(() => { timedOut = true; if (iceGatheringResolve) { iceGatheringResolve(); } }); await Promise.race([iceGatheringPromise, timer]); if (timedOut) { warnlog("Screen WHIP ICE gathering timed out after " + session.whipWait + "ms"); } } else { await iceGatheringPromise; } } catch (e) { errorlog(e); } let localSDP = pc.localDescription ? pc.localDescription.sdp : null; if (!localSDP) { pc.close(); session.whipOutScreen = null; return false; } var filteredDesc = filterDescriptionIpv6(pc.localDescription); localSDP = filteredDesc.sdp; localSDP = cleanupStereoSettings(localSDP); function sendOfferToEndpoint(sdpPayload) { return new Promise((resolve, reject) => { const xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function () { if (this.readyState === 4) { if (this.status === 200 || this.status === 201 || this.status === 204) { const contentType = this.getResponseHeader("content-type") || ""; const linkHeader = this.getResponseHeader("link") || ""; const locationHeader = this.getResponseHeader("location") || null; const whepHeader = this.getResponseHeader("whep") || this.getResponseHeader("WHEP") || null; const responseHeaders = this.getAllResponseHeaders ? this.getAllResponseHeaders() : ""; resolve({ status: this.status, contentType, linkHeader, locationHeader, whepHeader, headers: responseHeaders, body: this.responseText || "" }); } else { reject({ status: this.status, body: this.responseText || "" }); } } }; xhttp.onerror = reject; try { xhttp.open("POST", session.whipOutputScreen, true); } catch (e) { reject(e); return; } xhttp.setRequestHeader("Content-Type", "application/sdp"); if (session.whipOutputToken) { xhttp.setRequestHeader("Authorization", "Bearer " + session.whipOutputToken); } xhttp.send(sdpPayload); }); } let response; try { response = await sendOfferToEndpoint(localSDP); } catch (err) { errorlog(err); pc.close(); session.whipOutScreen = null; return false; } const { status, contentType, linkHeader, locationHeader, whepHeader, headers, body } = response; if (locationHeader) { pc.location = completeLocationURL(session.whipOutputScreen, locationHeader); try { sessionStorage.setItem("deleteWhipScreenOnLoad", JSON.stringify({ location: pc.location, whipOutputToken: session.whipOutputToken })); } catch (e) { } pc.deleteme = function () { try { const xhr = new XMLHttpRequest(); xhr.open("DELETE", pc.location, true); if (session.whipOutputToken) { xhr.setRequestHeader("Authorization", "Bearer " + session.whipOutputToken); } xhr.onload = function () { try { sessionStorage.removeItem("deleteWhipScreenOnLoad"); } catch (e) { } }; xhr.onerror = function () { try { sessionStorage.removeItem("deleteWhipScreenOnLoad"); } catch (e) { } }; xhr.send(); } catch (e) { } }; } if (!pc.stats) { pc.stats = {}; } pc.stats.whipHost = "generic"; pc.stats.whep_URL = false; pc.stats.watch_URL = false; let whepUrl = null; let headerWhep = null; try { const allHeaders = headers || ""; if (allHeaders && allHeaders.toLowerCase().indexOf("whep") >= 0) { headerWhep = whepHeader || null; } } catch (e) { errorlog(e); } if (headerWhep) { whepUrl = headerWhep; } if (!whepUrl && linkHeader) { try { const links = linkHeader.split(",").map(link => link.trim()); for (const link of links) { if (link.toLowerCase().includes("rel=\"urn:ietf:params:whep\"") || link.toLowerCase().includes("rel=\"urn:ietf:params:whip\"")) { const urlMatch = link.match(/<([^>]+)>/); if (urlMatch && urlMatch[1]) { whepUrl = urlMatch[1]; break; } } } } catch (e) { errorlog(e); } } if (whepUrl && !(whepUrl.startsWith("http://") || whepUrl.startsWith("https://"))) { var targetDomain = session.whipOutputScreen.split("/"); if (targetDomain.length > 2) { if (whepUrl.startsWith("/")) { whepUrl = targetDomain[0] + "//" + targetDomain[2] + whepUrl; } else { whepUrl = targetDomain[0] + "//" + targetDomain[2] + "/" + whepUrl; } } } if (!whepUrl && session.whipOutputScreen) { var targetDomain = session.whipOutputScreen.split("/"); try { if (targetDomain.length > 2 && targetDomain[2].endsWith(".cloudflarestream.com") && targetDomain[3].length == 65) { pc.stats.whipHost = "Cloudflare"; pc.stats.watch_URL = "https://" + targetDomain[2] + "/" + targetDomain[3].slice(33, 65) + "/webRTC/play"; } else if (/^https?:\/\/(?:[\w-]+\.)*meshcast\.io(?:\/|$)/i.test(session.whipOutputScreen)) { pc.stats.whipHost = "Meshcast"; pc.stats.watch_URL = "https://meshcast.io/view.html?geo=" + session.whipOutputScreen.split("https://")[1].split(".")[0] + "&id=" + session.whipOutputScreen.split("meshcast.io/")[1].split("/whip")[0]; } else if (/^https?:\/\/app\.meshcast\.io(?:\/|$)/i.test(session.whipOutputScreen)) { pc.stats.whipHost = "Meshcast2"; } } catch (e) { errorlog(e); } } pc.stats.whep_URL = whepUrl || false; if (!whepUrl && session.whipoutScreenSettings && session.whipoutScreenSettings.url) { whepUrl = session.whipoutScreenSettings.url; } if (!whepUrl) { whepUrl = session.whipOutputScreen.replace("/whip", "/whep"); } if (contentType && contentType.indexOf("application/sdp") === 0 && body) { try { const answer = { type: "answer", sdp: body }; await pc.setRemoteDescription(answer); } catch (e) { errorlog(e); } } if (!session.whipoutScreenSettings) { session.whipoutScreenSettings = { type: "whep", url: whepUrl, token: session.streamID + "_s", media: "screen", started: false }; } else { session.whipoutScreenSettings.url = whepUrl; } if (!session.whipoutScreenSettings.token) { session.whipoutScreenSettings.token = session.streamID + "_s"; } session.whipoutScreenSettings.media = "screen"; session.whipoutScreenSettings.started = Date.now(); broadcastWhepSettings("screen"); return true; } function whipClient() { // publish to whip.vdo.ninja with obs, to use. experimental if (!session.whipView) { return; } warnlog("WHIP Client started"); var socket = null; var connecting = false; var failedCount = 0; function connect() { clearTimeout(connecting); if (socket) { if (socket.readyState === socket.OPEN) { return; } try { socket.close(); } catch (e) { } } log("Trying to load whip websocket..."); socket = new WebSocket(session.whipServerURL); socket.onclose = function () { failedCount += 1; clearTimeout(connecting); connecting = setTimeout(function () { connect(); }, 100 * (failedCount - 1)); }; socket.onerror = function (e) { console.error(e); failedCount += 1; clearTimeout(connecting); connecting = setTimeout(function () { connect(); }, 100 * failedCount); }; socket.onopen = function () { failedCount = 0; try { var settings = {}; socket.send(JSON.stringify({ join: session.whipView })); } catch (e) { connecting = setTimeout(function () { connect(); }, 1); } }; socket.addEventListener("message", async function (event) { if (event.data) { var data = JSON.parse(event.data); if ("sdp" in data) { try { var resp = await processWhipIn(data); } catch (e) { var resp = e && (e.message || e.toString()); } if (resp) { var ret = {}; var get = data.get; data = {}; if (get) { data.get = get; data.result = resp; ret.callback = data; socket.send(JSON.stringify(ret)); } } } else if (data.type === "candidate") { if (data.candidate && data.streamID) { await handleIncomingIceCandidate(data); } } else if (data.type == "delete") { warnlog("WHIP publisher is actively disconnecting"); // session.closeRPC(i, true); var ret = {}; var get = data.get; data = {}; if (get) { data.get = get; data.result = "OK"; ret.callback = data; socket.send(JSON.stringify(ret)); } } else { warnlog("Unsupported incoming data"); } } }); } connect(); } async function processWhipIn(data) { // LISTEN FOR REMOTE WHIP (from OBS?) var msg = {}; msg.description = {}; msg.description.type = "offer"; msg.description.sdp = data.sdp; var UUID = session.generateRandomString(25); // fake msg.UUID = UUID; if (session.forceNoAudioWhipIn || session.forceNoVideoWhipIn) { try { log(msg.description.sdp + ""); msg.description.sdp = CodecsHandler.modifySdp(msg.description.sdp, session.forceNoAudioWhipIn, session.forceNoVideoWhipIn); } catch (e) { errorlog(e); } } if (!msg.description.sdp.includes("a=group:BUNDLE")) { // handling of bundle-only media lines; gstreamer's whipsink 1.25 for example try { const sdpLines = msg.description.sdp.split('\r\n'); const bundleLines = sdpLines.filter(line => line.includes('a=mid:')); if (bundleLines.length > 0) { const mids = bundleLines.map(line => line.split(':')[1]); sdpLines.splice(1, 0, `a=group:BUNDLE ${mids.join(' ')}`); msg.description.sdp = sdpLines.join('\r\n'); } } catch (e) { errorlog(e); } } if (data.streamID) { msg.streamID = data.streamID; } else { msg.streamID = session.generateRandomString(15); // fake } log("setupIncoming"); await session.setupIncoming(msg); // could end up setting up the peer the wrong way. session.rpcs[UUID].whip = true; var callback = null; var promise = new Promise((resolve, reject) => { callback = resolve; }); session.rpcs[UUID].whipCallback = callback; var callback2 = null; var promise2 = new Promise((resolve, reject) => { callback2 = resolve; }); session.rpcs[UUID].whipCallback2 = callback2; log("CONNECT PEER"); session.connectPeer(msg); log("CONNECT PEER DONE"); log("ICE BUNDLE PROMISE"); setTimeout( function (UUID) { if (session.rpcs[UUID].whipCallback2) { session.rpcs[UUID].whipCallback2([...session.rpcs[UUID].iceBundle]); clearTimeout(session.rpcs[UUID].iceTimer); session.rpcs[UUID].iceTimer = null; session.rpcs[UUID].iceBundle = []; session.rpcs[UUID].whipCallback2 = null; } }, session.whepWait, UUID ); var iceBundle = await promise2; // waiting for ICE GATHER COMPLETE; default 2 second. change with &whipwait=2000 clearTimeout(session.rpcs[UUID].iceTimer); session.rpcs[UUID].iceTimer = null; session.rpcs[UUID].whipCallback2 = null; log("ICE BUNDLE DONE"); log(iceBundle); await promise; session.rpcs[UUID].whipCallback = null; sdpAnswer = session.rpcs[UUID].localDescription.sdp; if (session.localNetworkOnly) { sdpAnswer = filterSDPLAN(sdpAnswer); } if (session.stunOnly) { // or whatever flag you want to use sdpAnswer = filterStunOnly(sdpAnswer); } //iceBundle.forEach(ice => { // not needed, since the localDescription has it embedded already, since we waited // sdpAnswer += `a=${ice.candidate}\r\n`; //}); /* if (true){ // this code tries to force the TURN server into use, but it's not working that I can see. const sdpLines = sdpAnswer.split('\r\n'); const modifiedLines = []; let mediaSection = 0; let candidateAdded = false; let audioPort = null; for (let line of sdpLines) { if (line.startsWith('m=')) { mediaSection++; if (mediaSection === 1) { // Extract audio port audioPort = line.split(' ')[1]; } else if (mediaSection === 2 && audioPort) { // Set video port to match audio port line = `m=video ${audioPort} UDP/TLS/RTP/SAVPF 96`; } } if (line.startsWith('c=')) { line = `c=IN IP4 51.222.12.223`; } if (line.startsWith('a=candidate:') && !candidateAdded) { line = `a=candidate:1 1 UDP 2 51.222.12.223 3478 typ relay raddr 0.0.0.0 rport 0`; candidateAdded = true; } modifiedLines.push(line); } return modifiedLines.join('\r\n'); } */ return sdpAnswer; // return SDP answer for the remote WHIP request } async function handleIncomingIceCandidate(data) { const UUID = Object.keys(session.rpcs).find(uuid => session.rpcs[uuid].streamID === data.streamID); if (UUID && session.rpcs[UUID]) { try { await session.rpcs[UUID].addIceCandidate(new RTCIceCandidate(data.candidate)); log("Added incoming ICE candidate for stream: " + data.streamID); } catch (e) { errorlog("Error adding incoming ICE candidate: ", e); } } else { warnlog("Received ICE candidate for unknown stream: " + data.streamID); } } function processSDPFromServer(sdp) { // not the description package; just the sdp try { if (session.mono && Firefox) { // chrome defaults to mono already, but we can force Firefox mono if needed sdp = CodecsHandler.setOpusAttributes(sdp, { stereo: 0 }, true); } else if (Firefox) { // Let Firefox be Firefox.. else it might break the server. mediamtx err 400 response if mono } else if (session.stereo && session.stereo == 4) { // pro audio only when viewing streams sdp = CodecsHandler.setOpusAttributes(sdp, { stereo: 2 }, true); } else if (session.stereo && !session.mono && session.stereo != 3) { sdp = CodecsHandler.setOpusAttributes(sdp, { stereo: 1 }, true); } } catch (e) { errorlog(e); } return sdp; } function filterIceLAN(candidate) { if (typeof candidate === 'string') { return filterSDPLAN(candidate); } if (!candidate) { return candidate; } try { let candidateString = candidate.candidate; let privateIPPattern = /(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1]))[0-9\.]*/; let isMDNSHostname = candidateString.includes(".local"); if (!(candidateString.includes("typ host") && (privateIPPattern.test(candidateString) || isMDNSHostname))) { log("🟧 dropped candidate due to not being a LAN candidate"); return false; } log("🟢 candidate allowed since host-type and has a LAN-IP or is a .local hostname"); } catch (e) { errorlog(e); } return true; } function filterSDPLAN(sdp) { try { return sdp .split("\n") .filter(line => { if (line.startsWith("a=candidate:")) { let parts = line.split(" "); let type = parts[7]; // The 'typ' field in the candidate string let address = parts[4]; let privateIPPattern = /(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1]))[0-9\.]*/; let isMDNSHostname = address.endsWith(".local"); log("🟥 dropped candidate from SDP due to not being a LAN candidate"); return type === "host" && (privateIPPattern.test(address) || isMDNSHostname); } return true; }) .join("\n"); } catch (e) { errorlog(e); return sdp; } } function filterStunOnlySDP(sdpString) { try { const lines = sdpString.split('\r\n'); const filteredLines = lines.filter(line => { // If it's a candidate line if (line.startsWith('a=candidate:')) { // Only keep STUN candidates return line.includes(' typ srflx '); } // Keep everything that's not a candidate line return true; }); // Process the filtered SDP let processedLines = []; let currentMedia = null; let stunPort = null; let stunAddress = null; // First pass - find STUN candidate details for (const line of filteredLines) { if (line.includes(' typ srflx ')) { const parts = line.split(' '); for (let i = 0; i < parts.length; i++) { if (parts[i] === 'typ' && parts[i + 1] === 'srflx') { stunPort = parts[5]; stunAddress = parts[4]; break; } } } } // Second pass - build the SDP with STUN info for (const line of filteredLines) { if (line.startsWith('m=')) { currentMedia = line.split(' ')[0].substr(2); if (stunPort && (currentMedia === 'audio' || currentMedia === 'video')) { // Use STUN port for media lines const parts = line.split(' '); parts[1] = stunPort; processedLines.push(parts.join(' ')); } else { processedLines.push(line); } } else if (line.startsWith('c=') && stunAddress) { // Use STUN address for connection lines processedLines.push(`c=IN IP4 ${stunAddress}`); } else { processedLines.push(line); } } return processedLines.join('\r\n'); } catch (e) { errorlog(e); return sdpString; // Return original on error } } // If you need to modify the individual candidate filter as well: function filterStunOnly(candidate) { if (typeof candidate === 'string') { return filterStunOnlySDP(candidate); } if (!candidate) { return candidate; } console.log(candidate); try { let candidateString = candidate.candidate; // Only allow STUN candidates if (!candidateString.includes("typ srflx")) { log("🟧 Dropped non-STUN candidate"); return false; } // Make sure it's not containing private IP ranges let privateIPPattern = /(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1]))[0-9\.]*/; if (privateIPPattern.test(candidateString)) { log("🟧 Dropped STUN candidate with private IP"); return false; } log("🟢 Allowed STUN candidate"); return true; } catch (e) { errorlog(e); return false; } } /** * IPv6 Preference Handling * * These functions help prefer IPv4 over IPv6 for WebRTC ICE candidates. * This is useful for networks where IPv6 connectivity is flaky (e.g., firewalls * that drop UDP on IPv6, or unstable IPv6 routing) while IPv4 works reliably. * * Default behavior: IPv4 candidates are prioritized (sent first in bundles) * With &ipv6=0 or &preferipv4: IPv6 candidates are dropped entirely if IPv4 exists * * IPv6-only networks continue to work: if no IPv4 candidates exist, IPv6 is used. */ /** * Detects if an ICE candidate uses IPv6. * IPv6 addresses contain colons, while IPv4 uses dots. * Also handles mDNS hostnames (.local) which are treated as non-IPv6. * * @param {RTCIceCandidate|string} candidate - The ICE candidate to check * @returns {boolean} true if the candidate is IPv6, false otherwise */ function isIpv6Candidate(candidate) { if (!candidate) return false; try { var candidateString = (typeof candidate === 'string') ? candidate : (candidate.candidate || ''); if (!candidateString) return false; // Parse the candidate string to extract the address // Format: "candidate:...
    ..." var parts = candidateString.split(' '); if (parts.length < 5) return false; var address = parts[4]; // mDNS hostnames (.local) are not IPv6 if (address && address.endsWith('.local')) { return false; } // IPv6 addresses contain colons, IPv4 uses dots only // Examples: // IPv4: "192.168.1.1" // IPv6: "2001:db8::1", "::1", "fe80::1%eth0" if (address && address.includes(':')) { return true; } return false; } catch (e) { errorlog("isIpv6Candidate error:", e); return false; } } /** * Filters IPv6 candidates from an SDP string. * Used when session.disableIpv6 is true and IPv4 candidates exist. * * @param {string} sdp - The SDP string to filter * @param {boolean} hasIpv4 - Whether IPv4 candidates exist in this SDP * @returns {string} The filtered SDP string */ function filterSdpIpv6(sdp, hasIpv4) { if (!sdp || typeof sdp !== 'string') return sdp; // If no IPv4 exists, we must keep IPv6 for connectivity if (!hasIpv4) { log("🟡 IPv6 kept in SDP: no IPv4 candidates available (IPv6-only network)"); return sdp; } try { var lines = sdp.split('\n'); var filteredLines = lines.filter(function(line) { if (line.startsWith('a=candidate:')) { var parts = line.split(' '); if (parts.length >= 5) { var address = parts[4]; // Drop IPv6 candidates (contain colons, not .local) if (address && address.includes(':') && !address.endsWith('.local')) { log("🟧 Dropped IPv6 candidate from SDP: " + address); return false; } } } return true; }); return filteredLines.join('\n'); } catch (e) { errorlog("filterSdpIpv6 error:", e); return sdp; } } /** * Checks if an SDP string contains any IPv4 candidates. * Used to determine if we can safely filter out IPv6. * * @param {string} sdp - The SDP string to check * @returns {boolean} true if IPv4 candidates exist */ function sdpHasIpv4Candidates(sdp) { if (!sdp || typeof sdp !== 'string') return false; try { var lines = sdp.split('\n'); for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (line.startsWith('a=candidate:')) { var parts = line.split(' '); if (parts.length >= 5) { var address = parts[4]; // IPv4: contains dots, no colons, not .local if (address && !address.includes(':') && !address.endsWith('.local')) { return true; } // mDNS (.local) counts as non-IPv6, so treat as potential IPv4 if (address && address.endsWith('.local')) { return true; } } } } } catch (e) { errorlog("sdpHasIpv4Candidates error:", e); } return false; } /** * Reorders ICE candidates to prioritize IPv4 over IPv6. * IPv4 candidates are moved to the front of the array. * This helps ensure IPv4 is tried first when both are available. * * @param {Array} candidates - Array of RTCIceCandidate objects * @returns {Array} Reordered array with IPv4 candidates first */ function reorderCandidatesIpv4First(candidates) { if (!candidates || !Array.isArray(candidates) || candidates.length === 0) { return candidates; } try { var ipv4Candidates = []; var ipv6Candidates = []; for (var i = 0; i < candidates.length; i++) { if (isIpv6Candidate(candidates[i])) { ipv6Candidates.push(candidates[i]); } else { ipv4Candidates.push(candidates[i]); } } // Return IPv4 first, then IPv6 return ipv4Candidates.concat(ipv6Candidates); } catch (e) { errorlog("reorderCandidatesIpv4First error:", e); return candidates; } } /** * Filters out IPv6 candidates from an array if IPv4 candidates exist. * Used when session.disableIpv6 is true. * * @param {Array} candidates - Array of RTCIceCandidate objects * @returns {Object} { filtered: Array, hasIpv4: boolean, droppedIpv6: number } */ function filterIpv6FromCandidates(candidates) { var result = { filtered: [], hasIpv4: false, droppedIpv6: 0 }; if (!candidates || !Array.isArray(candidates)) { return result; } var ipv4Candidates = []; var ipv6Candidates = []; for (var i = 0; i < candidates.length; i++) { if (isIpv6Candidate(candidates[i])) { ipv6Candidates.push(candidates[i]); } else { ipv4Candidates.push(candidates[i]); } } result.hasIpv4 = ipv4Candidates.length > 0; if (result.hasIpv4) { // We have IPv4, so we can drop IPv6 result.filtered = ipv4Candidates; result.droppedIpv6 = ipv6Candidates.length; if (ipv6Candidates.length > 0) { log("🟧 Dropped " + ipv6Candidates.length + " IPv6 candidate(s) since IPv4 is available"); } } else { // No IPv4, must use IPv6 for connectivity result.filtered = ipv6Candidates; if (ipv6Candidates.length > 0) { log("🟡 Using " + ipv6Candidates.length + " IPv6 candidate(s): no IPv4 available (IPv6-only network)"); } } return result; } /** * Filters IPv6 candidates from an RTCSessionDescription if needed. * Used when sending SDP offers/answers to filter embedded candidates. * * @param {RTCSessionDescription} description - The description to filter * @returns {RTCSessionDescription|Object} Filtered description (new object if filtered, original if not) */ function filterDescriptionIpv6(description) { if (!description || !description.sdp) return description; // Only filter if disableIpv6 is set if (!session.disableIpv6) return description; try { var sdp = description.sdp; // Check if there are IPv4 candidates - if not, keep IPv6 for connectivity if (!sdpHasIpv4Candidates(sdp)) { log("🟡 IPv6 kept in SDP description: no IPv4 candidates (IPv6-only network)"); return description; } // Filter IPv6 from the SDP var filteredSdp = filterSdpIpv6(sdp, true); // Return new description object with filtered SDP return { type: description.type, sdp: filteredSdp }; } catch (e) { errorlog("filterDescriptionIpv6 error:", e); return description; } } async function whepIn(whepInput = false, whepInputToken = false, UUID = false) { // PLAY WHEP; will continually bring it in, and retry continuously var candidates = []; var responseLocation = false; var acceptPatch = false; var eTag = false; var icePwd = false; var iceUfrag = false; // var reconnect = null; //var maxRetries = 5; //var delay = 2000; if (!UUID) { UUID = "whep_" + session.generateRandomString(25); // fake } whepInput = whepInput || session.whepInput; if (!whepInput) { errorlog("no whepInput"); return; } whepInputToken = whepInputToken || session.whepInputToken; async function whepConnect() { //return new Promise((resolve, reject) => { try { if (!(UUID in session.rpcs)) { session.rpcs[UUID] = {}; } ensureViewerRpcDefaults(UUID); if (!session.configuration) { await chooseBestTURN(); } if (session.encodedInsertableStreams) { // most servers won't support this session.configuration.encodedInsertableStreams = true; } var config = { ...session.configuration }; // if (whepInput.includes("cloudflare")){ // config.iceTransportPolicy = "relay"; // oof. Doesn't work with Cloudflare without this? REVISIT (Update: I guess they fixed it? Oct 23rd its working without it) // } try { session.rpcs[UUID].whep = new RTCPeerConnection(config); } catch (err) { errorlog(err); if (!session.cleanOutput) { warnUser("An RTC error occured"); } } var video = true; var audio = true; if (session.novideo !== false && !session.novideo.includes(session.rpcs[UUID].streamID)) { video = false; } else if (session.rpcs[UUID].settings && !session.rpcs[UUID].settings.video) { video = false; } if (session.noaudio !== false && !session.noaudio.includes(session.rpcs[UUID].streamID)) { audio = false; } else if (session.excludeaudio && session.excludeaudio.includes(session.rpcs[UUID].streamID)) { audio = false; } else if (session.rpcs[UUID].settings && !session.rpcs[UUID].settings.audio) { audio = false; } if (!audio && !video) { errorlog("We will not request the whep source as no audio or video is requested"); return; } if (video) { disableQualityDirector(UUID); } if (!session.manual || !session.director) { window.onresize = updateMixer; window.onorientationchange = function () { setTimeout(updateMixer, 200); }; } try { if (video) { session.rpcs[UUID].whep.addTransceiver("video", { direction: "recvonly" }); } if (audio) { session.rpcs[UUID].whep.addTransceiver("audio", { direction: "recvonly" }); } } catch (e) { errorlog(e); } session.rpcs[UUID].whep.ontrack = function (event) { warnlog("TRACK INBOUND!"); warnlog(event); session.onTrack(event, UUID); // maxRetries = 5; // reset allowed reconnection limit let track = null; if (event.streams && event.streams[0]) { try { let newStream = event.streams[0]; track = newStream.getVideoTracks()[0]; } catch (e) { } } else if (event.track && event.track.kind && (event.track.kind == "video")) { track = event.track } if (track) { log(track); setTimeout(function (track, UUID) { if (session.rpcs[UUID] && track && track.id) { if (session.rpcs[UUID].stats && session.rpcs[UUID].stats[track.id] && ("keyFramesRequested_pli" in session.rpcs[UUID].stats[track.id])) { // TODO ?? maybe not,b ut console.log("I need to request a keyframe. keyFramesRequested_pli: "+session.rpcs[UUID].stats[track.id].keyFramesRequested_pli); } } }, 6100, track, UUID); // 3 seconds for stats to update + 2 second for keyframe to trigger + 1100 for delay } // IF track is a video.. // wait to see if a keyframe is received within 2 seconds // if not keyframe, request one. // session.whipOutKeyframeOnNewViewer // session.rpcs[UUID].stats[trackID].keyFramesRequested_pli = stat.pliCount || 0; /* if ("pliCount" in stat) { data.stats.total_pli_count = stat.pliCount; } if ("keyFramesEncoded" in stat) { data.stats.total_key_frames_encoded = stat.keyFramesEncoded; } */ }; } catch (err) { errorlog(err); if (!session.cleanOutput) { warnUser("An RTC error occured"); } } session.rpcs[UUID].whep.onnegotiationneeded = requestStream; // bug: https://groups.google.com/forum/#!topic/discuss-webrtc/3-TmyjQ2SeE session.rpcs[UUID].whep.oniceconnectionstatechange = function () { if (session.rpcs[UUID] && session.rpcs[UUID].whep && (session.rpcs[UUID].whep.iceConnectionState === 'disconnected' || session.rpcs[UUID].whep.iceConnectionState === 'failed')) { console.warn("ICE connection failed or disconnected"); retryWhepConnection(UUID); } }; session.rpcs[UUID].whep.onconnectionstatechange = function () { if (session.rpcs[UUID] && session.rpcs[UUID].whep && session.rpcs[UUID].whep.connectionState === 'failed') { console.warn("Whep connection failed"); retryWhepConnection(UUID); } }; session.rpcs[UUID].whep.onicecandidate = function (event) { if (!session.rpcs[UUID] || !session.rpcs[UUID].whep) { return; } if (event.candidate == null) { warnlog("END OF ICE CANDIDATES"); if (session.rpcs[UUID].whep.iceCompletedCallback) { session.rpcs[UUID].whep.iceCompletedCallback(); } return; } else if (eTag && icePwd && iceUfrag && acceptPatch && acceptPatch == "application/trickle-ice-sdpfrag" && event.candidate && responseLocation && !session.rpcs[UUID].whep.iceCompletedCallback) { // "left over" candidates not sent with the SDP offer log("Send patch request with ice candidate"); try { if (session.localNetworkOnly) { if (!filterIceLAN(event.candidate)) { return; } } if (session.stunOnly) { // or whatever flag you want to use if (!filterStunOnly(event.candidate)) { return; } } } catch (e) { errorlog(e); } if (event.candidate.candidate) { let patchCandidate = "a=ice-ufrag:" + iceUfrag + "\r\n" + // <== what a mess.. https://datatracker.ietf.org/doc/html/draft-murillo-whep "a=ice-pwd:" + icePwd + "\r\n" + "m=audio 9 RTP/AVP 0\r\n" + "a=mid:0\r\n" + "a=" + event.candidate.candidate + "\r\n" + "a=end-of-candidates\r\n"; // a=ice-ufrag:EsAw // a=ice-pwd:P2uYro0UCOQ4zxjKXaWCBui1 // m=audio RTP/AVP 0 // a=mid:0 // a=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1 // a=candidate:3471623853 1 udp 2122194687 198.51.100.1 61765 typ host generation 0 ufrag EsAw network-id 2 // a=candidate:473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1 // a=candidate:2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2 // a=end-of-candidates // If-Match: "38sdf4fdsf54:EsAw" ajax(patchCandidate, "trickle-ice-sdpfrag", false, { "if-match": eTag }); } } else { try { if (session.localNetworkOnly) { if (!filterIceLAN(event.candidate)) { return; } } if (session.stunOnly) { // or whatever flag you want to use if (!filterStunOnly(event.candidate)) { return; } } } catch (e) { errorlog(e); } candidates.push(event.candidate); // send later if I can? } //log(event.candidate); }; log("onnegotiationneeded event setup"); } function retryWhepConnection(UUID) { if (!session.rpcs[UUID]) { log("Session closed, stopping retry attempts"); return; } if (session.rpcs[UUID].suppressReconnect) { session.rpcs[UUID].reconnecting = false; return; } const parentUUID = session.rpcs[UUID].realUUID || false; if (parentUUID && session.rpcs[parentUUID] && session.rpcs[parentUUID].screenShareState === false) { session.rpcs[UUID].reconnecting = false; return; } if (session.rpcs[UUID].reconnecting) { return; } session.rpcs[UUID].reconnecting = true; const maxRetries = 5; const initialDelay = 2000; const maxDelay = 20000; let currentRetry = 0; let currentDelay = initialDelay; function attemptReconnect() { if (!session.rpcs[UUID]) { log("Session closed during retry, stopping attempts"); return; } if (session.rpcs[UUID].suppressReconnect) { session.rpcs[UUID].reconnecting = false; return; } const parentUUID = session.rpcs[UUID].realUUID || false; if (parentUUID && session.rpcs[parentUUID] && session.rpcs[parentUUID].screenShareState === false) { session.rpcs[UUID].reconnecting = false; return; } if (session.rpcs[UUID].whep && (session.rpcs[UUID].whep.connectionState === 'connected' || session.rpcs[UUID].whep.iceConnectionState === 'connected' || session.rpcs[UUID].whep.iceConnectionState === 'completed')) { log("WHEP connection is already established. No need to reconnect."); session.rpcs[UUID].reconnecting = false; return; } log(`Attempting WHEP reconnection (attempt ${currentRetry + 1}/${maxRetries})`); if (session.rpcs[UUID].whep && session.rpcs[UUID].whep.close) { session.rpcs[UUID].whep.close(); } try { if (session.rpcs[UUID].videoElement && "recorder" in session.rpcs[UUID].videoElement) { session.rpcs[UUID].videoElement.recorder.stop(); } } catch (e) { warnlog(e); } try { if (session.rpcs[UUID].streamSrc) { session.rpcs[UUID].streamSrc.getTracks().forEach(function (track) { track.stop(); log("Track stopped"); }); session.rpcs[UUID].streamSrc = null; } } catch (e) { } try { if (!session.rpcs[UUID].UUID && document.getElementById("container_" + UUID)) { getById("container_" + UUID).parentNode.removeChild(getById("container_" + UUID)); updateLockedElements(); } } catch (e) { warnlog(e); } try { if (session.rpcs[UUID].videoElement) { session.rpcs[UUID].videoElement.remove(); session.rpcs[UUID].videoElement = null; } if (session.rpcs[UUID].canvas) { session.rpcs[UUID].canvas.remove(); } if (session.rpcs[UUID].imageElement) { session.rpcs[UUID].imageElement.remove(); } if ("eventPlayActive" in session.rpcs[UUID]) { clearInterval(session.rpcs[UUID].eventPlayActive); } if (!session.director || session.switchMode) { setTimeout(function () { updateMixer(); }, 1); } } catch (e) { warnlog(e); } whepConnect(UUID).then(() => { log("WHEP reconnection successful"); session.rpcs[UUID].reconnecting = false; }).catch(error => { warnlog("WHEP reconnection failed:", error); currentRetry++; if (currentRetry < maxRetries) { currentDelay = Math.min(currentDelay * 2, maxDelay); log(`Retrying in ${currentDelay}ms`); setTimeout(attemptReconnect, currentDelay); } else { log("Max retries reached, stopping reconnection attempts"); session.rpcs[UUID].reconnecting = false; // Implement user feedback here } }); } attemptReconnect(); } var requestingStream = false; function requestStream(event) { if (requestingStream) { log(event); errorlog("onnegotiationneeded again?"); return; } requestingStream = true; warnlog("ON NEGO NEEDED"); warnlog(event); try { session.rpcs[UUID].whep .createOffer() .then(async function (offer) { if (session.localNetworkOnly) { offer.sdp = filterSDPLAN(offer.sdp); } if (session.stunOnly) { // or whatever flag you want to use offer.sdp = filterStunOnly(offer.sdp); } offer.sdp = processSDPFromServer(offer.sdp); //offer.sdp = CodecsHandler.setOpusAttributes(offer.sdp, {'stereo': 1}); return session.rpcs[UUID].whep.setLocalDescription(offer); }) .then(async function () { //log(session.rpcs[UUID].whep.localDescription); try { if (session.whepWait) { log("Waiting for ice candidates to collect. At least 300ms recommended; at most 30-seconds."); let startTime = Date.now(); const { promise, resolve } = sleepCancellable(session.whepWait); // 2000ms default; I want to give the ICE / STUN / TURN time to collect. session.rpcs[UUID].whep.iceCompletedCallback = resolve; // Can complete earlier if possible. await promise; // pausing for a moment; until all collected or timed out log("Finished waiting for ice candidates. Waited " + (Date.now() - startTime) / 1000 + "-seconds"); delete session.rpcs[UUID].whep.iceCompletedCallback; } } catch (e) { errorlog(e); } // candidates = []; // clear collected candidates so far, as they are part of the localDescription's footer probably var sdp = session.rpcs[UUID].whep.localDescription.sdp; sdp = processSDPFromServer(sdp); //description.sdp = processSDPFromServer(description.sdp); // cause server to not send if (sdp.includes("sendrecv")) { errorlog("Should not include sendrecv"); sdp = sdp.replace("a=sendrecv", "a=recvonly"); sdp = sdp.replace("v=sendrecv", "v=recvonly"); } ajax(sdp, "sdp"); }) .catch(function (err) { requestingStream = false; }); } catch (e) { requestingStream = false; errorlog(e); } } function ajax(dataPayload, type, callback = false, headers = false) { // https://datatracker.ietf.org/doc/html/draft-murillo-whep //log(dataPayload); try { var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function () { if (this.readyState == 4 && (this.status == 200 || this.status == 201)) { try { // 200 not in spec (meant to be an options response), but I want to be flexible let headers = xhttp.getAllResponseHeaders(); var contentType = false; if (headers.indexOf("content-type") >= 0) { contentType = this.getResponseHeader("content-type"); } if (headers.indexOf("location") >= 0) { responseLocation = this.getResponseHeader("location"); } if (headers.indexOf("accept-patch") >= 0) { acceptPatch = this.getResponseHeader("accept-patch"); } if (headers.indexOf("etag") >= 0) { eTag = this.getResponseHeader("etag"); } if (responseLocation && !(responseLocation.startsWith("http://") || responseLocation.startsWith("https://"))) { let requestURL = new URL(whepInput); // Replace 'yourRequestURL' with the URL you posted to. let protocol = requestURL.protocol; let hostname = requestURL.hostname; let port = requestURL.port || (protocol === "https:" ? "443" : "80"); // Default port based on protocol responseLocation = `${protocol}//${hostname}:${port}${responseLocation}`; } if (contentType && contentType.startsWith("application/sdp")) { var description = {}; description.sdp = this.responseText; description.type = "answer"; warnlog("Processing answer:"); iceUfrag = description.sdp.match(/a=ice-ufrag:(.*)\r\n/); if (iceUfrag) { iceUfrag = iceUfrag[1]; } icePwd = description.sdp.match(/a=ice-pwd:(.*)\r\n/); if (icePwd) { icePwd = icePwd[1]; } if (session.localNetworkOnly) { description.sdp = filterSDPLAN(description.sdp); } if (session.stunOnly) { // or whatever flag you want to use description.sdp = filterStunOnly(description.sdp); } description.sdp = processSDPFromServer(description.sdp); // setup stereo/mono session.rpcs[UUID].whep .setRemoteDescription(description) .then(function () { warnlog("SHOULD BE CONNECTED?"); requestingStream = false; }) .catch(function (e) { log(e); requestingStream = false; }); // the request is done, but lets handle any old ice candidates if (eTag && icePwd && iceUfrag && acceptPatch && acceptPatch == "application/trickle-ice-sdpfrag" && candidates.length && responseLocation && !session.rpcs[UUID].whep.iceCompletedCallback) { // "left over" candidates not sent with the SDP offer log("Send patch request with ice candidates"); let patchCandidates = "a=ice-ufrag:" + iceUfrag + "\r\n" + // <== what a mess.. https://datatracker.ietf.org/doc/html/draft-murillo-whep "a=ice-pwd:" + icePwd + "\r\n" + "m=audio 9 RTP/AVP 0\r\n" + // if I leave out the port (9), then MediaMTX breaks, but this is not in the draft spec as linked above "a=mid:0\r\n"; candidates.forEach(candidate => { patchCandidates += "a=" + candidate.candidate + "\r\n"; }); candidates = []; patchCandidates += "a=end-of-candidates\r\n"; // // If-Match: "38sdf4fdsf54:EsAw" ajax(patchCandidates, "trickle-ice-sdpfrag", false, { "if-match": eTag }); } else { warnlog("Trickling candidates via PATCH requests not supported it seems"); } } else if (contentType == "application/error") { if (!session.cleanOutput) { warnUser("Unknown WHEP playback error"); } } else if (callback) { callback(); } } catch (e) { requestingStream = false; } } else if (this.readyState == 4 && this.status == 204) { requestingStream = false; if (type == "trickle-ice-sdpfrag") { // patch candidate request accepted? } else { // not in spec? } } else if (this.readyState == 4) { requestingStream = false; // console.warn(this.status, this.readyState); if (this.status == 432) { } else if (this.status == 405) { // GET, HEAD or PUT not allowed atm } else if (this.status == 501) { } else if (this.status == 412) { // etag trickle did not match } if (!this.status || this.status >= 400) { if (type === "sdp") { retryWhepConnection(UUID); } } } }; if (type === "trickle-ice-sdpfrag") { if (responseLocation) { xhttp.open("PATCH", responseLocation, true); } else { xhttp.open("PATCH", whepInput, true); // cause who knows. worth trying? } } else { xhttp.open("POST", whepInput, true); } xhttp.setRequestHeader("Content-Type", "application/" + type); if (whepInputToken) { xhttp.setRequestHeader("Authorization", "Bearer " + whepInputToken); // spam it on every request; fail safe } if (headers) { Object.keys(headers).forEach(key => { xhttp.setRequestHeader(key, headers[key]); }); } xhttp.onerror = function (e) { errorlog(e); requestingStream = false; if (!session.cleanOutput) { if (whepInput.startsWith("https://")) { if (location.protocol !== "https:") { warnUser("WHEP playback failed.\n\nThe website needs to be loaded via https (ssl) to access media devices."); } else if ("isSecureContext" in window && window.isSecureContext === false) { warnUser("WHEP playback failed.\n\nThe website may have assets loaded in an insecure context."); } else { retryWhepConnection(UUID); } } else { // vdo.ninja itself is secure if (location.protocol === "https:") { if (location.hostname == "vdo.ninja") { warnUser("WHEP playback failed.\n\nThe WHEP URL needs to be using https if from an SSL-enabled website.\n\nPerhaps try using http://insecure.vdo.ninja instead.", false, false); } else { warnUser("WHEP playback failed.\n\nThe WHEP URL needs to be using https if from an SSL-enabled website."); } } else if ("isSecureContext" in window && window.isSecureContext === false) { warnUser("WHEP playback failed.\n\nThe website may have assets loaded in a secure context."); } else { retryWhepConnection(UUID); } } } }; xhttp.send(dataPayload); } catch (e) { requestingStream = false; errorlog(e); } } whepConnect(); return UUID; } //////// function whepOut() { // publish to whep.vdo.ninja with obs, to use. experimental if (!session.whepHost) { return; } warnlog("WHEP Client started"); var socket = null; var connecting = false; var failedCount = 0; function connect() { clearTimeout(connecting); if (socket) { if (socket.readyState === socket.OPEN) { return; } try { socket.close(); } catch (e) { } } log("Trying to load whep websocket..."); socket = new WebSocket("wss://whep.vdo.ninja"); socket.onclose = function () { failedCount += 1; clearTimeout(connecting); connecting = setTimeout(function () { connect(); }, 100 * (failedCount - 1)); }; socket.onerror = function (e) { console.error(e); failedCount += 1; clearTimeout(connecting); connecting = setTimeout(function () { connect(); }, 100 * failedCount); }; socket.onopen = function () { failedCount = 0; try { var settings = {}; socket.send(JSON.stringify({ join: session.whepHost })); } catch (e) { connecting = setTimeout(function () { connect(); }, 1); } }; socket.addEventListener("message", async function (event) { if (event.data) { log(event.data); var data = JSON.parse(event.data); if ("sdp" in data) { var resp = await processWHEPout(data); if (resp) { var ret = {}; var get = data.get; data = {}; if (get) { data.get = get; data.result = resp; ret.callback = data; log(ret); socket.send(JSON.stringify(ret)); } } } else if (data.type == "delete") { warnlog("WHIP Client is actively disconnecting"); // session.closeRPC(i, true); var ret = {}; var get = data.get; data = {}; if (get) { data.get = get; data.result = "OK"; ret.callback = data; log(ret); socket.send(JSON.stringify(ret)); } } } }); } connect(); } async function processWHEPout(data) { // LISTEN FOR REMOTE WHIP var description = {}; description.type = "offer"; var isGstreamer = false; description.sdp = data.sdp.replace(/a=rtpmap:111 OPUS\/48000\r\n/g, "a=rtpmap:111 opus/48000/2\r\n"); // gstreamer fix if (description.sdp !== data.sdp) { isGstreamer = true; // ugh. i'll need to revisit when gstreamer/whepsrc improves. } var UUID = session.generateRandomString(25) + "_whepout"; // client side made up; just needs to be unique is all; if (UUID in session.pcs) { UUID = session.generateRandomString(25) + "_whepout"; // ha, pretty pointless. } try { if (!session.configuration) { await chooseBestTURN(); } if (session.encodedInsertableStreams) { // most servers won't support this session.configuration.encodedInsertableStreams = true; } var config = { ...session.configuration }; session.pcs[UUID] = new RTCPeerConnection(config); session.pcs[UUID].onicecandidate = event => { if (event.candidate) { // Handle ICE candidate.. or not. } // try { // if (session.localNetworkOnly) { //if (session.localNetworkOnly){ // if (!filterIceLAN(event.candidate)){ // return; // } // } // } // } catch(e) {errorlog(e);} }; if (session.localNetworkOnly) { description.sdp = filterSDPLAN(description.sdp); } if (session.stunOnly) { // or whatever flag you want to use description.sdp = filterStunOnly(description.sdp); } try { await session.pcs[UUID].setRemoteDescription(description); } catch (e) { errorlog(e); errorlog("If you are seeing this error, the browser likely isn't able to match what you are requesting. Compare a normal browser-create SDP with what you are offering."); } const transceivers = session.pcs[UUID].getTransceivers(); transceivers.forEach(transceiver => { const direction = transceiver.currentDirection || transceiver.direction; // Set to sendonly or sendrecv if not already set and if there is a local track to send if (direction !== "sendrecv" && direction !== "sendonly") { transceiver.direction = "sendonly"; // or 'sendrecv' if you also want to receive } else { return; } // Assuming you have a track to send session.videoElement.srcObject.getAudioTracks().forEach(track => { if (transceiver.direction.includes("send")) { if (transceiver.sender.track && transceiver.sender.track.kind != "audio") { return; } else if (transceiver.receiver.track && transceiver.receiver.track.kind != "audio") { return; } transceiver.sender .replaceTrack(track) .then(() => { log(`Added track: ${track.kind}`); }) .catch(errorlog); } }); session.videoElement.srcObject.getVideoTracks().forEach(track => { if (transceiver.direction.includes("send")) { if (transceiver.sender.track && transceiver.sender.track.kind != "video") { return; } else if (transceiver.receiver.track && transceiver.receiver.track.kind != "video") { return; } transceiver.sender .replaceTrack(track) .then(() => { log(`Added track: ${track.kind}`); }) .catch(errorlog); } }); }); //await sleep(300); // give it time to collect ice candidates. too lazy to do a promise callback right now const localDescription = await session.pcs[UUID].createAnswer(); if (session.localNetworkOnly) { localDescription.sdp = filterSDPLAN(localDescription.sdp); } if (session.stunOnly) { // or whatever flag you want to use localDescription.sdp = filterStunOnly(localDescription.sdp); } await session.pcs[UUID].setLocalDescription(localDescription); await sleep(session.whepWait); if (isGstreamer) { return session.pcs[UUID].localDescription.sdp.replace("a=rtpmap:111 opus/48000/2\r\n", "a=rtpmap:111 OPUS/48000\r\n"); // not sure if this makes sense tho. } return session.pcs[UUID].localDescription.sdp; //} } catch (error) { console.error("Error setting up the peer connection:", error); } } ///// function pokePostAPI(action, data, streamID) { var msg = {}; msg.update = {}; msg.update.streamID = streamID || session.streamID || null; msg.update.action = action; msg.update.value = data; try { var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function () { if (this.readyState == 4 && (this.status == 200 || this.status == 201)) { log("good"); } else { warnlog("post api didn't work?"); } }; xhttp.open("POST", session.postApi, true); xhttp.setRequestHeader("Content-type", "application/json"); xhttp.onerror = function (e) { errorlog(e); }; xhttp.send(JSON.stringify(msg)); } catch (e) { errorlog(e); } } var queuedSendingAPIMsgs = []; function pokeAPI(action, data, streamID = null) { if (session.postApi) { pokePostAPI(action, data, streamID); } if (!session.api) { return; } if (session.apiSocket) { try { var msg = {}; msg.update = {}; msg.update.streamID = streamID || session.streamID || null; msg.update.action = action; msg.update.value = data; session.apiSocket.send(JSON.stringify(msg)); } catch (e) { errorlog(e); } } else if (session.apiSocket !== null) { queuedSendingAPIMsgs.push([action, data, streamID]); if (queuedSendingAPIMsgs.length > 20) { queuedSendingAPIMsgs.shift(); } } } function pokeDiscord(action, data = {}) { if (!session || !session.discordHook) { return; } const { streamID, label, ses, hangup, startTime } = data; if (hangup && !session.discordHookSensitive) return; const duration = Math.floor((Date.now() - (startTime || 0)) / 1000); const message = { embeds: [{ title: "Incoming stream disconnected unexpectedly and it didn't reconnect in 60s", color: 0xFF0000, fields: [ { name: "Incoming Stream ID", value: streamID || "Unknown", inline: true }, { name: "Duration of connection", value: `${duration}s`, inline: true } ], timestamp: new Date().toISOString() }] }; if (label) message.embeds[0].fields.push({ name: "Viewer", value: label, inline: true }); if (session.label) message.embeds[0].fields.push({ name: "Publisher", value: session.label, inline: true }); setTimeout(() => { // Check if stream is reconnected by looking through all RPCs const isReconnected = Object.values(session.rpcs || {}).some(rpc => rpc.streamID === streamID); if (!isReconnected) { fetch(session.discordHook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(message) }).catch(err => console.error('Discord webhook failed:', err)); } }, 60000); } function getGuestTarget(type, id) { var element = document.querySelector('[data-sid="' + id + '"][data-action-type="' + type + '"], [data-sid="' + id + '"] [data-action-type="' + type + '"]'); // data-sid="P5MQpia" if (!element) { return getRightOrderedElement('[data--u-u-i-d] [data-action-type="' + type + '"]', id); } return element; } function getGuestTargetScene(scene, id) { var element = document.querySelector('[data-action-type="addToScene"][data-scene="' + scene + '"][data-sid="' + id + '"], [data-sid="' + id + '"] [data-action-type="addToScene"][data-scene="' + scene + '"]'); // data-sid="P5MQpia" if (!element) { return getRightOrderedElement('[data-action-type="addToScene"][data-scene="' + scene + '"][data--u-u-i-d]', id); } return element; } function getGuestTargetGroup(group, id) { var element = document.querySelector('[data-action-type="toggle-group"][data-group="' + group + '"][data-sid="' + id + '"], [data-sid="' + id + '"] [data-action-type="toggle-group"][data-group="' + group + '"]'); // data-sid="P5MQpia" if (!element) { return getRightOrderedElement('[data-action-type="toggle-group"][data-group="' + group + '"][data--u-u-i-d]', id); } return element; } async function targetGuest(target, action, value = null, value2 = null) { if (target) { if ((target == (parseInt(target) + "")) && (target < 100)) { target -= 1; } } else { target = 1; } warnlog("target " + target); warnlog("action " + action); warnlog("value " + value); if ((action == 0) || (action == "forward") || (action == "transfer")) { var element = getGuestTarget("forward", target); if (element) { return await directMigrate(element, true, value); // if value is set, it will auto transfer the guest to that room. } else { return false; } } else if ((action == 1) || (action == "addScene")) { var scene = 1; if (value == "null" || value == null || value == "toggle") { scene = 1; } else if (value !== true && value !== false) { scene = value; } var element = getGuestTargetScene(scene, target); // oscid/action/target/value 1/1/scene if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); // false or true return } } else if (action == 2 || action == "muteScene") { var element = getGuestTarget("mute-scene", target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directMute(element, true); // false/true } } else if (action == 3 || action == "mic" || action == "audio") { var element = getGuestTarget("mute-guest", target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return remoteMute(element, true); // false/true } } else if (action == 4 || action == "hangup") { var element = getGuestTarget("hangup", target); if (element) { return directHangup(element, true); // false or true; false if confirmed no } } else if (action == 5 || action == "soloChat" || action == "soloTalk") { // see soloChatBidirectional action=9 for two-way var element = getGuestTarget("solo-chat", target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return session.toggleSoloChat(element.dataset.UUID); } } else if (action == 6 || action == "speaker") { var element = getGuestTarget("toggle-remote-speaker", target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return remoteSpeakerMute(element); } } else if (action == 7 || action == "display") { var element = getGuestTarget("toggle-remote-display", target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return remoteDisplayMute(element); } } else if (action == 8 || action == "group") { if (value == "null" || value == null) { value = 1; } var element = getGuestTargetGroup(value, target); if (element) { return changeGroup(element, null, value); } } else if (action == 9 || action == "soloChatBidirectional" || action == "soloTalkBidirectional") { var element = getGuestTarget("solo-chat", target); if (element) { var ctrl = {}; ctrl.ctrlKey = true; if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return session.toggleSoloChat(element.dataset.UUID, ctrl); } } else if (action == 10 || action == "video" || action == "camera") { var element = getGuestTarget("mute-video-guest", target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return remoteMuteVideo(element, true); // false/true } } else if (action == 12 || action == "addScene2") { var element = getGuestTargetScene(2, target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); } } else if (action == 13 || action == "addScene3") { var element = getGuestTargetScene(3, target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); } } else if (action == 14 || action == "addScene4") { var element = getGuestTargetScene(4, target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); } } else if (action == 15 || action == "addScene5") { var element = getGuestTargetScene(5, target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); } } else if (action == 16 || action == "addScene6") { var element = getGuestTargetScene(6, target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); } } else if (action == 17 || action == "addScene7") { var element = getGuestTargetScene(7, target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); } } else if (action == 18 || action == "addScene8") { var element = getGuestTargetScene(8, target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return directEnable(element, true); } } else if (action == 19 || action == "forceKeyframe") { var element = getGuestTarget("force-keyframe", target); if (element) { return requestKeyframeScene(element); } } else if (action == 20 || action == "soloVideo") { var element = getGuestTarget("solo-video", target); if (element) { if (value === true) { element.value = 1; } else if (value === false) { element.value = 0; } return requestInfocus(element); } } else if (action == 21 || action == "sendChat") { var element = getGuestTarget("solo-video", target); // just something that probably exists. if (element) { return sendChat(value, element.dataset.UUID); } } else if (action == "pgm" || action == "channel") { var element = getGuestTarget("isolate-channel", target); // just something that probably exists. if (element) { return directIsolateChannel(element.dataset.UUID, (parseInt(value) || null)); } } else if (action == 22 || action == "sendDirectorChat") { var element = getGuestTarget("solo-video", target); // just something that probably exists. if (element) { return sendChat(value, element.dataset.UUID, true); } } else if (action == "sendPinnedDirectorChat") { var element = getGuestTarget("solo-video", target); // just something that probably exists. if (element) { return sendChat(value, element.dataset.UUID, 2); } } else if (action == 27 || action == "volume") { var element = getGuestTarget("volume", target); if (element) { element.value = parseInt(value) || 0; return remoteVolume(element); } } else if ((action == 28) || (action == "setslot")) { var element = getGuestTarget("setslot", target); if (element) { return setSlot(element, value); } else { return false; } } else if (action == 29 || action == "mixorder") { var element = getGuestTarget("order-down", target); if (element) { if (value === true) { changeOrder(+1, element.dataset.UUID); } else if (value === false) { changeOrder(-1, element.dataset.UUID); } else { changeOrder(value, element.dataset.UUID); } return true; } else { return false; } } else if (action == "requestResolution") { // director's preview or scene preview or s/e; not capture resolution var element = getGuestTarget("solo-video", target); // just need to find the guest if (element) { let resolution = value.split("x"); if (resolution.length == 2) { session.requestResolution(element.dataset.UUID, parseInt(resolution[0]), parseInt(resolution[1])); return true; } else { return "Failed. Must be WIDTHxHEIGHT"; } } return false; } else if (action == "setWidth") { // actual capture resolution ; director only var element = getGuestTarget("solo-video", target); // just need to find the guest if (element) { requestVideoHack("width", parseInt(value), element.dataset.UUID); return true; } return false; } else if (action == "setHeight") { var element = getGuestTarget("solo-video", target); // just need to find the guest if (element) { requestVideoHack("height", parseInt(value), element.dataset.UUID); return true; } return false; } else if (action == "setAspectRatio") { var element = getGuestTarget("solo-video", target); // just need to find the guest if (element) { requestVideoHack("aspectRatio", parseFloat(value), element.dataset.UUID); return true; } return false; } else if (action == "requestAspectRatio") { var element = getGuestTarget("solo-video", target); // just need to find the guest if (element) { let maxDimension = parseInt(value2) || 1920; let aspectRatio = 16 / 9; // default // Parse aspect ratio if (value) { if (value.includes(":")) { let parts = value.split(":"); aspectRatio = parseFloat(parts[0]) / parseFloat(parts[1]); } else { aspectRatio = parseFloat(value); } } // Calculate dimensions let width, height; if (aspectRatio >= 1) { width = maxDimension; height = Math.round(maxDimension / aspectRatio); } else { height = maxDimension; width = Math.round(maxDimension * aspectRatio); } session.requestResolution(element.dataset.UUID, width, height); return true; } return false; } else if (action == "startRoomTimer") { var element = getGuestTarget("create-timer", target); if (element) { element.value = 0; return directTimer(element, false, value); } } else if (action == "pauseRoomTimer") { var element = getGuestTarget("create-timer", target); if (element) { if (element.value == 3) { return directTimer(element, { ctrlKey: true }); } else { return directTimer(element, { ctrlKey: true }); } } } else if (action == "stopRoomTimer") { var element = getGuestTarget("create-timer", target); if (element) { element.value = 1; return directTimer(element); } } else if (Commands[action]) { try { return Commands[action](value, target); } catch (e) { errorlog(e); } } return false; } function oscClient() { // api.vdo.ninja api OSC (websocket / https API hotkey support). The iFrame API method provides greater customization. if (!session.api) { return; } warnlog("oscClient started"); var socket = null; var connecting = false; var failedCount = 0; function connect() { clearTimeout(connecting); if (socket) { if (socket.readyState === socket.OPEN) { return; } try { socket.close(); } catch (e) { } } socket = new WebSocket(session.apiserver); socket.onclose = function () { session.apiSocket = false; failedCount += 1; clearTimeout(connecting); connecting = setTimeout(function () { connect(); }, 100 * (failedCount - 1)); }; socket.onerror = function () { failedCount += 1; clearTimeout(connecting); connecting = setTimeout(function () { connect(); }, 100 * failedCount); }; socket.onopen = function () { failedCount = 0; try { socket.send(JSON.stringify({ join: session.api })); session.apiSocket = socket; if (queuedSendingAPIMsgs.length) { queuedSendingAPIMsgs.forEach(msg => { pokeAPI(msg[0], msg[1], msg[2]); }); queuedSendingAPIMsgs = []; } pokeAPI("details", getDetailedState(session.streamID)); } catch (e) { connecting = setTimeout(function () { connect(); }, 1); } }; socket.addEventListener("message", async function (event) { if (event.data) { var data = JSON.parse(event.data); if ("msg" in data) { data = data.msg; } if ("value" in data) { if ("action" in data && data.action == "layout") { data.value = safelyDecodeValue(data.value); } } var resp = await processMessage(data); if (resp !== null) { var ret = {}; data.result = resp; ret.callback = data; log(ret); socket.send(JSON.stringify(ret)); } } }); } connect(); } function safelyDecodeValue(value) { // since the layout can be a number, json, or base64 encoded json if (Number.isInteger(Number(value))) { return parseInt(value, 10); } try { return JSON.parse(value); // Try parsing as JSON } catch (e) { } try { const decodedValue = atob(value); // Try decoding as base64 instead try { return JSON.parse(decodedValue); // Try parsing as JSON now that we did b64 decoding } catch (e) { return decodedValue; } } catch (e) { return value; } } function setupCommands() { var commands = {}; commands.raisehand = function (value = null, value2 = null) { return raisehand(); }; commands.togglehand = function (value = null, value2 = null) { return raisehand(); }; commands.togglescreenshare = function (value = null, value2 = null) { toggleScreenShare(); return session.screenShareState; }; commands.chat = function (value = null, value2 = null) { toggleChat(value); return session.chat; }; commands.speaker = function (value = null, value2 = null) { if (value === true) { // unmute session.speakerMuted = false; // set toggleSpeakerMute(true); // apply } else if (value === false) { // mute session.speakerMuted = true; // set toggleSpeakerMute(true); // apply } else if (value === "toggle") { // toggle toggleSpeakerMute(); } return session.speakerMuted; }; // mute speaker commands.mic = function (value = null, value2 = null) { if (value === true) { // unmute session.muted = false; // set log(session.muted); toggleMute(true); // apply } else if (value === false) { // mute session.muted = true; // set log(session.muted); toggleMute(true); // apply } else if (value === "toggle") { // toggle toggleMute(); } return session.muted; }; commands.camera = function (value = null, value2 = null) { if (value === true) { // unmute session.videoMuted = false; // set log(session.videoMuted); toggleVideoMute(true); // apply } else if (value === false) { // mute session.videoMuted = true; // set log(session.videoMuted); toggleVideoMute(true); // apply } else if (value === "toggle") { // toggle toggleVideoMute(); } return session.videoMuted; }; commands.video = function (value = null, value2 = null) { if (value === true) { // unmute session.videoMuted = false; // set log(session.videoMuted); toggleVideoMute(true); // apply } else if (value === false) { // mute session.videoMuted = true; // set log(session.videoMuted); toggleVideoMute(true); // apply } else if (value === "toggle") { // toggle toggleVideoMute(); } return session.videoMuted; }; commands.hangup = function (value = null, value2 = null) { hangup(); return true; }; commands.bitrate = function (value = null, value2 = null) { if (value === false) { value = 0; } else if (value === true) { value = -1; } else { value = parseInt(value) || 0; } for (var i in session.rpcs) { try { session.requestRateLimit(value, i); } catch (e) { errorlog(e); } } return value; }; commands.requestStats = function (value = null, value2 = null) { var myStats = { ...session.stats }; myStats.streamID = session.streamID; if (session.whipOut && session.whipOut.stats) { myStats.whipStats = session.whipOut.stats; } if (session.whepIn && session.whepIn.stats) { myStats.whepStats = session.whepIn.stats; } myStats.pcs = {}; myStats.rpcs = {}; for (var uuid in session.pcs) { myStats.pcs[uuid] = session.pcs[uuid].stats; } for (var uuid in session.rpcs) { myStats.rpcs[uuid] = session.rpcs[uuid].stats; myStats.rpcs[uuid].streamID = session.rpcs[uuid].streamID; } return myStats; }; commands.getDetails = function (value = null, value2 = null) { return getDetailedState(value); }; commands.getStats = function (value = null, value2 = null) { return getQuickStats(value); }; commands.getGuestList = function (value = null, value2 = null) { return getGuestList(); }; commands.reload = function (value = null, value2 = null) { reloadRequested(); return true; }; commands.volume = function (value = null, value2 = null) { if (value === false) { value = 0; } else if (value === true) { value = 100; } else { value = parseInt(value) || 0; } value = parseFloat(value / 100); for (var i in session.rpcs) { try { session.rpcs[i].videoElement.volume = parseFloat(value); } catch (e) { errorlog(e); } } return value; }; commands.forceKeyframe = function (value = null, value2 = null) { return session.forcePLI(); }; commands.panning = function (value = null, value2 = null) { if (value === false) { value = 90; } else if (value === true) { value = 90; } else { value = parseInt(value); } for (var uuid in session.rpcs) { try { adjustPan(uuid, value); // &panning needs to be added to enable. playback only; not mic out. } catch (e) { errorlog(e); } } return value; }; commands.record = function (value = null, value2 = null) { if (!session.videoElement) { return; } if (value === false) { // mute if ("recording" in session.videoElement) { recordLocalVideo("stop"); } } else if (value === true) { if ("recording" in session.videoElement) { // already recording } else { recordLocalVideo("start"); } } return value; }; commands.group = function (value = null, value2 = null) { if (value && value !== "null") { return changeGroupDirectorAPI(value); } return false; }; commands.joinGroup = function (value = null, value2 = null) { if (value && value !== "null") { return changeGroupDirectorAPI(value, true); } return false; }; commands.leaveGroup = function (value = null, value2 = null) { if (value && value !== "null") { return changeGroupDirectorAPI(value, false); } return false; }; commands.viewGroup = function (value = null, value2 = null) { if (value && value !== "null") { return changeGroupViewDirectorAPI(value); } return false; }; commands.joinViewGroup = function (value = null, value2 = null) { if (value && value !== "null") { return changeGroupViewDirectorAPI(value, true); } return false; }; commands.leaveViewGroup = function (value = null, value2 = null) { if (value && value !== "null") { return changeGroupViewDirectorAPI(value, false); } return false; }; commands.sendChat = function (value = null, value2 = null) { sendChat(value); // sendChatMessage // this would add it to the chat message return true; }; commands.sendChatMessage = function (value = null, value2 = null) { sendChatMessage(value); return true; }; commands.showChatOverlay = function (value = null, value2 = null) { getChatMessage(value, false, false, true); return true; }; commands.startRoomTimer = function (value = null, value2 = null) { getById("globalTimerDirectorToggle").value = 0; // reset directRoomTimer(getById("globalTimerDirectorToggle"), false, value); return true; }; commands.pauseRoomTimer = function (value = null, value2 = null) { if (getById("globalTimerDirectorToggle").value == 3) { directRoomTimer(getById("globalTimerDirectorToggle"), { ctrlKey: true }, value); } else { directRoomTimer(getById("globalTimerDirectorToggle"), { ctrlKey: true }, value); } return true; }; commands.stopRoomTimer = function (value = null, value2 = null) { getById("globalTimerDirectorToggle").value = 1; // pause directRoomTimer(getById("globalTimerDirectorToggle"), false, value); return true; }; commands.tallylight = function (value = null, value2 = null) { if (value == "onair") { session.tallyOverride = 1; } else if (value == "active") { session.tallyOverride = 2; } else if (value == "standby") { session.tallyOverride = 3; } else if (value == "false") { session.tallyOverride = false; } else if (value == "off") { session.tallyOverride = false; } else if (value) { session.tallyOverride = parseInt(value) || 0; } else { session.tallyOverride = 0; } applySceneState(); return true; }; commands.prevSlide = function (value = null, value2 = null) { var data = {}; data.d = [176, 110, 10]; playbackMIDI(data); return true; }; commands.nextSlide = function (value = null, value2 = null) { var data = {}; data.d = [176, 110, 11]; playbackMIDI(data); return true; }; commands.zoom = function (value = null, value2 = null) { if (value !== null) { const zoomValue = parseFloat(value); const isAbsolute = value2 === true || value2 === "true" || value2 === "abs"; session.remoteZoom(zoomValue, isAbsolute); return { zoom: zoomValue, absolute: isAbsolute }; } return false; }; commands.focus = function (value = null, value2 = null) { if (value !== null) { const focusValue = parseFloat(value); const isAbsolute = value2 === true || value2 === "true" || value2 === "abs"; session.remoteFocus(focusValue, isAbsolute); return { focus: focusValue, absolute: isAbsolute }; } return false; }; commands.pan = function (value = null, value2 = null) { if (value !== null) { const panValue = parseFloat(value); const isAbsolute = value2 === true || value2 === "true" || value2 === "abs"; session.remotePan(panValue, isAbsolute); return { pan: panValue, absolute: isAbsolute }; } return false; }; commands.tilt = function (value = null, value2 = null) { if (value !== null) { const tiltValue = parseFloat(value); const isAbsolute = value2 === true || value2 === "true" || value2 === "abs"; session.remoteTilt(tiltValue, isAbsolute); return { tilt: tiltValue, absolute: isAbsolute }; } return false; }; commands.exposure = function (value = null, value2 = null) { if (value !== null) { const exposureValue = parseFloat(value); const isAbsolute = value2 === true || value2 === "true" || value2 === "abs"; session.remoteExposure(exposureValue, isAbsolute); return { exposure: exposureValue, absolute: isAbsolute }; } return false; }; commands.soloVideo = function (value = null, value2 = null) { var element = getById("highlightDirector"); if (value && value == "toggle") { return requestInfocus(element); } else if (value && value !== "null") { return requestInfocus(element, null, true); } else if (value && value === "null") { return requestInfocus(element); } else { return requestInfocus(element, null, false); } return false; }; commands.highlight = function (value = null, value2 = null) { return commands.soloVideo(value, value2); }; commands.activeSpeaker = function (value = null, value2 = null) { var res = {}; res.previous = session.activeSpeaker; if ((value && (value == "toggle" || value == "null")) || (value === null)) { res.action = "toggle"; if (session.activeSpeaker) { session.activeSpeaker = false; } else { session.activeSpeaker = urlParams.get("activespeaker") || urlParams.get("speakerview") || urlParams.get("sas") || 1; } } else if (value && parseInt(value)) { session.activeSpeaker = parseInt(value) || 1; } else { session.activeSpeaker = false; } if (session.activeSpeaker && !session.activeSpeakerInterval) { if (!session.audioEffects) { session.audioEffects = true; for (var UUID in session.rpcs) { updateIncomingAudioElement(UUID); } } activeSpeaker(false); session.activeSpeakerInterval = setInterval(function () { activeSpeaker(false); }, 100); } else { updateMixer(); } res.current = session.activeSpeaker; res.value = value; return res; }; commands.setBufferDelay = function (value = null, value2 = null) { let delay = parseInt(value) || 0; if (!value2) { session.buffer = delay; } else if (value2 === "*") { for (var uuid in session.rpcs) { session.rpcs[uuid].buffer = delay; playoutdelay(uuid); document.querySelectorAll('#bufferSettings[data--u-u-i-d="' + uuid + '"] input[data-buffer-value]').forEach(ele => { ele.value = delay; }); return true; } } else if (value2 in session.rpcs) { session.rpcs[value2].buffer = delay; playoutdelay(value2); document.querySelectorAll('#bufferSettings[data--u-u-i-d="' + value2 + '"] input[data-buffer-value]').forEach(ele => { ele.value = delay; }); return true; } else if (value2) { let UUID = Object.keys(session.rpcs).find(uuid => session.rpcs[uuid].streamID === value2); if (session.rpcs[UUID]) { session.rpcs[UUID].buffer = delay; playoutdelay(UUID); document.querySelectorAll('#bufferSettings[data--u-u-i-d="' + UUID + '"] input[data-buffer-value]').forEach(ele => { ele.value = delay; }); return true; } else { errorlog("The stream ID specified does not exist: " + value2); } } return false; }; commands.layout = function (value = null, value2 = null) { let response = {}; response.input = value; try { if (parseInt(value) == value) { log(value); value = parseInt(value); if (value == 0) { value = false; // Auto mixer } else { value -= 1; // Adjust index to match layouts array (1-based to 0-based) } response.index = value; } else if (checkType(value) === "Array") { log(value); session.layout_array = value; if (session.layout_array) { session.layout = combinedLayout(session.layout_array); } updateMixer(); if (session.director) { issueLayout("0"); response.issued = true; response.scene = "0"; pokeIframeAPI("layout-updated", value); } response.type = 1; updateMixer(); response.combined_layout = session.layout; var temp = previousDebug; previousDebug = response return { response: response, previous: temp } } else if (checkType(value) === "Object") { log(value); session.layout = value; if (session.director) { issueLayout("0"); response.issued = true; response.scene = "0"; pokeIframeAPI("layout-updated", session.layout); } response.type = 2; updateMixer(); response.layout = session.layout; var temp = previousDebug; previousDebug = response return { response: response, previous: temp } } if (value === null) { session.layout = false; pokeIframeAPI("layout-updated", session.layout); pokeIframeAPI("layout-index", 0); if (session.director) { issueLayout("0"); response.issued = true; response.scene = "0"; } response.type = 3; response.layout = session.layout; updateMixer(); var temp = previousDebug; previousDebug = response return { response: response, previous: temp } } else if (value === false) { session.layout = false; pokeIframeAPI("layout-updated", session.layout); pokeIframeAPI("layout-index", 0); if (session.director) { issueLayout("0"); response.issued = true; response.scene = "0"; } response.layout = session.layout; response.type = 3; updateMixer(); var temp = previousDebug; previousDebug = response return { response: response, previous: temp } } else if (session.layouts && session.layouts[value]) { try { log(session.layouts); var temp = session.layouts[value]; response.type = 4; if (session.director) { var combined = {}; for (var i = 0; i < temp.length; i++) { if (!temp[i]) continue; let streamID = null; // First check if there's a slot assigned if ("slot" in temp[i]) { const slotNumber = parseInt(temp[i].slot) + 1; streamID = session.currentSlots[slotNumber]; } // If no stream found via slot, check defaultStreamID if (!streamID && temp[i].defaultStreamID) { // Check if this defaultStreamID is connected and not assigned to another slot let isConnected = false; let isAlreadyAssigned = false; for (let j in session.rpcs) { if (session.rpcs[j].streamID === temp[i].defaultStreamID) { isConnected = true; // Check if this stream is assigned to any slot for (let slot in session.currentSlots) { if (session.currentSlots[slot] === temp[i].defaultStreamID) { isAlreadyAssigned = true; break; } } break; } } if (isConnected && !isAlreadyAssigned) { streamID = temp[i].defaultStreamID; } } // If we found a streamID, use it, otherwise add to empty slot if (streamID) { combined[streamID] = temp[i]; } else { if (!combined[""]) combined[""] = []; combined[""].push(temp[i]); } } session.layout = combined; log("issuing layout:"); log(session.layout); issueLayout("0"); response.issued = true; response.scene = "0"; response.combined_layout = session.layout; pokeIframeAPI("layout-updated", session.layout); pokeIframeAPI("layout-index", value + 1); } } catch (e) { errorlog(e); response.error = e.message } updateMixer(); temp = previousDebug; previousDebug = response return { response: response, previous: temp } } else { errorlog("no layout found"); log(session.layouts); var temp = previousDebug; previousDebug = response return { response: response, previous: temp } } } catch (e) { response.error = e.message errorlog(e); } var temp = previousDebug; previousDebug = response return { response: response, previous: temp } }; commands.width = function (value = null, value2 = null) { // affects LOCAL camera width let width = value ? parseInt(value) : null; if (width) { updateCameraConstraints("width", width, false, false); return true; } return false; }; commands.height = function (value = null, value2 = null) { // affects LOCAL camera height let height = value ? parseInt(value) : null; if (height) { updateCameraConstraints("height", height, false, false); return true; } return false; }; commands.aspectRatio = function (value = null, value2 = null) { // affects LOCAL camera aspect ratio if (!value) return false; let aspectRatio; if (typeof value === 'string' && value.includes(":")) { let parts = value.split(":"); aspectRatio = parseFloat(parts[0]) / parseFloat(parts[1]); } else { aspectRatio = parseFloat(value); } if (aspectRatio && !isNaN(aspectRatio)) { updateCameraConstraints("aspectRatio", aspectRatio, false, false); return true; } return false; }; commands.videoConstraint = function (value = null, value2 = null) { // Generic video constraint setter for LOCAL camera // Usage: action=videoConstraint&value=CONSTRAINT_NAME&value2=CONSTRAINT_VALUE if (!value || value2 === null || value2 === undefined) return false; // Parse value2 based on common types let constraintValue = value2; // Handle boolean strings if (value2 === "true") { constraintValue = true; } else if (value2 === "false") { constraintValue = false; } else if (value2 == parseFloat(value2)) { // Handle numeric values constraintValue = parseFloat(value2); } // Special handling for aspectRatio with colon notation if (value === "aspectRatio" && typeof value2 === 'string' && value2.includes(":")) { let parts = value2.split(":"); constraintValue = parseFloat(parts[0]) / parseFloat(parts[1]); } // Apply the constraint updateCameraConstraints(value, constraintValue, false, false); return true; }; return commands; } var Commands = setupCommands(); var previousDebug = {}; function checkType(value) { if (Array.isArray(value)) { return 'Array'; } else if (typeof value === 'object' && value !== null) { return 'Object'; } else { return 'Neither an Array nor an Object'; } } async function processMessage(data) { // api.vdo.ninja/apikey/action/value try { warnlog(data); if ("target" in data && data.target !== "null" && data.target !== null) { if ("action" in data) { if ("value" in data && data.value !== "null" && data.value !== null) { return await targetGuest(data.target, data.action, data.value, data.value2 || null); } else { return await targetGuest(data.target, data.action, null); } } } else if ("action" in data && data.action !== "null" && data.action !== null) { if (data.action in Commands) { if ("value" in data && data.value !== "null" && data.value !== null) { if (data.value == "true") { data.value = true; } else if (data.value == "false") { data.value = false; } return Commands[data.action](data.value, data.value2 || null); } else { return Commands[data.action](); } } } } catch (e) { errorlog(e); } return null; } function midiHotkeysNote(note, velocity = false) { if (session.midiHotkeys == 1) { if (note == "G3") { // open and close the chat window toggleChat(); return session.chat; } else if (note == "A3") { // mute your audio output toggleMute(); return session.muted; } else if (note == "B3") { // mute your video output toggleVideoMute(); return session.videoMuted; } else if (note == "C4") { // enable / disable screenshare toggleScreenShare(); return session.screenShareState; } else if (note == "D4") { // completely kill your connection/session hangup(); return true; } else if (note == "E4") { // raise your hand; director sees this raisehand(); return raisehand(); } else if (note == "F4") { // start/stop local recording return recordLocalVideoToggle(); } else if (note == "G4") { // Director Enables their Audio output press2talk(true); return true; } else if (note == "A4") { // Director cut's their audio/video output hangup2(); return true; } else if (note == "B4") { // toggle speaker toggleSpeakerMute(); return session.speakerMuted; } } else if (session.midiHotkeys == 2) { if (note == "G1") { // open and close the chat window toggleChat(); } else if (note == "A1") { // mute your audio output toggleMute(); } else if (note == "B1") { // mute your video output toggleVideoMute(); } else if (note == "C2") { // enable / disable screenshare toggleScreenShare(); } else if (note == "D2") { // completely kill your connection/session hangup(); } else if (note == "E2") { // raise your hand; director sees this raisehand(); } else if (note == "F2") { // start/stop local recording recordLocalVideoToggle(); } else if (note == "G2") { // Director Enables their Audio output press2talk(true); } else if (note == "A2") { // Director cut's their audio/video output hangup2(); } else if (note == "B2") { // toggle speaker toggleSpeakerMute(); } } else if (session.midiHotkeys == 3) { if (note == "C1") { if (velocity == "0") { // open and close the chat window toggleChat(); } else if (velocity == "1") { // mute your audio output toggleMute(); } else if (velocity == "2") { // mute your video output toggleVideoMute(); } else if (velocity == "3") { // enable / disable screenshare toggleScreenShare(); } else if (velocity == "4") { // completely kill your connection/session hangup(); } else if (velocity == "5") { // raise your hand; director sees this raisehand(); } else if (velocity == "6") { // start/stop local recording recordLocalVideoToggle(); } else if (velocity == "7") { // Director Enables their Audio output press2talk(true); } else if (velocity == "8") { // Director cut's their audio/video output hangup2(); } else if (velocity == "9") { // toggle speaker toggleSpeakerMute(); } } } /* if (velocity !== false && typeof velocity !== "undefined") { // Get integer value of velocity const velocityValue = parseInt(velocity); // Check if valid MIDI velocity (0-127) if (!isNaN(velocityValue) && velocityValue >= 0 && velocityValue <= 127) { // Camera control MIDI commands using Channel 1, various CC numbers if (note == "C5") { // Zoom - scale 0-127 to percentage or use relative value const normalizedValue = velocityValue / 127; // 0 to 1 range session.remoteZoom(normalizedValue, true); // absolute value return { zoom: normalizedValue, absolute: true }; } else if (note == "D5") { // Focus - scale 0-127 to focus value const normalizedValue = velocityValue / 127; // 0 to 1 range session.remoteFocus(normalizedValue); return { focus: normalizedValue }; } else if (note == "E5") { // Pan - scale 0-127 to pan value const normalizedValue = (velocityValue - 64) / 64; // -1 to 1 range session.remotePan(normalizedValue); return { pan: normalizedValue }; } else if (note == "F5") { // Tilt - scale 0-127 to tilt value const normalizedValue = (velocityValue - 64) / 64; // -1 to 1 range session.remoteTilt(normalizedValue); return { tilt: normalizedValue }; } else if (note == "G5") { // Exposure - scale 0-127 to exposure value const normalizedValue = velocityValue / 127; // 0 to 1 range session.remoteExposure(normalizedValue); return { exposure: normalizedValue }; } } } */ } function getRightOrderedElement(selector, guestslot, UUID = false) { var elements = getById("guestFeeds").children; if (!UUID) { for (var i = 0; i < elements.length; i++) { try { UUID = elements[i].UUID; var lock = parseInt(document.getElementById("position_" + UUID).dataset.locked); if (lock && lock == guestslot + 1) { return elements[i].querySelector(selector) || false; } } catch (e) { } } } if (elements[guestslot]) { return elements[guestslot].querySelector(selector) || false; } else { return false; } } function midiHotkeysCommand_offset(command, value, offset = 1) { for (var i = 0; i < 9; i++) { if (command == offset + i) { var ele = getRightOrderedElement('[data-action-type="mute-guest"][data--u-u-i-d]', command - offset); if (ele) { remoteMute(ele, true); } } } } function midiHotkeysCommand(command, value) { if (command == 110) { // Existing controls 0-8, 10-11 remain unchanged if (value == 0) { toggleChat(); } else if (value == 1) { toggleMute(); } else if (value == 2) { toggleVideoMute(); } else if (value == 3) { toggleScreenShare(); } else if (value == 4) { hangup(); } else if (value == 5) { raisehand(); } else if (value == 6) { recordLocalVideoToggle(); } else if (value == 7) { press2talk(true); } else if (value == 8) { hangup2(); } // 10 & 11 reserved for PPT slides // Camera controls - relative adjustments else if (value == 20) { // Zoom in (relative +10%) Commands.zoom(0.1); } else if (value == 21) { // Zoom out (relative -10%) Commands.zoom(-0.1); } else if (value == 22) { // Pan left (relative -10%) Commands.pan(-0.1); } else if (value == 23) { // Pan right (relative +10%) Commands.pan(0.1); } else if (value == 24) { // Tilt up (relative +10%) Commands.tilt(0.1); } else if (value == 25) { // Tilt down (relative -10%) Commands.tilt(-0.1); } else if (value == 26) { // Exposure increase (relative +10%) Commands.exposure(0.1); } else if (value == 27) { // Exposure decrease (relative -10%) Commands.exposure(-0.1); } else if (value == 28) { // Focus near (relative -10%) Commands.focus(-0.1); } else if (value == 29) { // Focus far (relative +10%) Commands.focus(0.1); } // Camera presets - absolute positions else if (value == 30) { // Camera preset 1: Center position Commands.zoom(1.0, "abs"); Commands.pan(0, "abs"); Commands.tilt(0, "abs"); } else if (value == 31) { // Camera preset 2: Wide shot Commands.zoom(0.5, "abs"); Commands.pan(0, "abs"); Commands.tilt(0, "abs"); } else if (value == 32) { // Camera preset 3: Close-up Commands.zoom(2.0, "abs"); Commands.pan(0, "abs"); Commands.tilt(0, "abs"); } } else if (command > 110) { // Existing guest slot controls remain unchanged var guestslot = command - 111; if (value == 0) { var ele = getRightOrderedElement('[data-action-type="forward"][data--u-u-i-d]', guestslot); if (ele) { directMigrate(ele, true); } } else if (value == 1) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="1"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value == 2) { var ele = getRightOrderedElement('[data-action-type="mute-scene"][data--u-u-i-d]', guestslot); if (ele) { directMute(ele, true); } } else if (value == 3) { var ele = getRightOrderedElement('[data-action-type="mute-guest"][data--u-u-i-d]', guestslot); if (ele) { remoteMute(ele, true); } } else if (value == 4) { var ele = getRightOrderedElement('[data-action-type="hangup"][data--u-u-i-d]', guestslot); if (ele) { directHangup(ele, true); } } else if (value == 5) { var ele = getRightOrderedElement('[data-action-type="solo-chat"][data--u-u-i-d]', guestslot); if (ele) { session.toggleSoloChat(ele.dataset.UUID); } } else if (value == 6) { var ele = getRightOrderedElement('[data-action-type="toggle-remote-speaker"][data--u-u-i-d]', guestslot); if (ele) { remoteSpeakerMute(ele); } } else if (value == 7) { var ele = getRightOrderedElement('[data-action-type="toggle-remote-display"][data--u-u-i-d]', guestslot); if (ele) { remoteDisplayMute(ele); } } else if (value == 8) { var ele = getRightOrderedElement('[data-action-type="force-keyframe"][data--u-u-i-d]', guestslot); if (ele) { requestKeyframeScene(ele); } } else if (value == 12) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="2"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value == 13) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="3"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value == 14) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="4"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value == 15) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="5"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value == 16) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="6"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value == 17) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="7"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value == 18) { var ele = getRightOrderedElement('[data-action-type="addToScene"][data-scene="8"][data--u-u-i-d]', guestslot); if (ele) { directEnable(ele, true); } } else if (value >= 27) { var ele = getRightOrderedElement('[data-action-type="volume"][data--u-u-i-d]', guestslot); if (ele) { var audioGain = parseInt(value - 27) || 0; if (audioGain < 0) { audioGain = 0; } ele.value = 200; for (var i = 1; i <= 200; i++) { if (volumeLUT[i] >= audioGain) { ele.value = i; break; } } remoteVolume(ele); } } } // Additional MIDI CC commands for finer camera control (80-89) else if (command >= 80 && command <= 89) { // Map MIDI CC values (0-127) to camera control values const normalizedValue = value / 127; // 0 to 1 range if (command == 80) { // CC80: Zoom absolute (0-127 maps to 0-2x zoom) Commands.zoom(normalizedValue * 2, "abs"); } else if (command == 81) { // CC81: Pan absolute (0-127 maps to -1 to +1) Commands.pan((normalizedValue * 2) - 1, "abs"); } else if (command == 82) { // CC82: Tilt absolute (0-127 maps to -1 to +1) Commands.tilt((normalizedValue * 2) - 1, "abs"); } else if (command == 83) { // CC83: Exposure absolute (0-127 maps to 0-1) Commands.exposure(normalizedValue, "abs"); } else if (command == 84) { // CC84: Focus absolute (0-127 maps to 0-1) Commands.focus(normalizedValue, "abs"); } } } session.createResourceChannel = function (UUID) { if (!session.pcs[UUID] || !session.pcs[UUID].allowResources) return; const channel = session.pcs[UUID].createDataChannel("resources", { ordered: true, maxRetransmits: 30 }); session.pcs[UUID].resourceChannel = channel; if (session.pcs[UUID] && !session.pcs[UUID].resourceQueue) { // Initialize if not exists session.pcs[UUID].resourceQueue = [...session.resources]; } session.pcs[UUID].processingResource = false; session.pcs[UUID].resourceChannel.onopen = () => { log("Resource channel opened"); if (session.pcs[UUID].resourceQueue && session.pcs[UUID].resourceQueue.length > 0) { session.processResourceQueue(UUID); // Process any queued items } }; session.pcs[UUID].resourceChannel.onclose = () => { warnlog("Resource channel closed"); if (session.pcs[UUID]) { session.pcs[UUID].processingResource = false; // Reset processing state } }; session.pcs[UUID].resourceChannel.onerror = (e) => { warnlog("Resource channel error"); if (session.pcs[UUID]) { session.pcs[UUID].processingResource = false; // Reset processing state on error } }; }; async function handleImageUpload(file, field) { const buffer = await file.arrayBuffer(); const resource = { type: field.templateName, // Changed from field.type to field.templateName id: field.id, data: buffer, metadata: { filename: file.name, size: file.size, type: field.type, label: field.type, filetype: file.type, id: field.id, templateName: field.templateName } }; for (var UUID in session.pcs) { if (session.pcs[UUID].allowResources) { session.sendResource(resource, UUID); } } session.resources.push(resource); } session.sendResource = async function (resource, UUID) { // Resource format: { type: "avatar"|"overlay"|"qr"|"icon", data: ArrayBuffer, metadata: Object } if (!session.pcs[UUID]) return false; if (!session.pcs[UUID].resourceQueue) { session.pcs[UUID].resourceQueue = [...session.resources]; // Initialize queue if not exists } // Queue the resource session.pcs[UUID].resourceQueue.push(resource); // If channel exists and is open, process queue if (session.pcs[UUID].resourceChannel && session.pcs[UUID].resourceChannel.readyState === "open") { if (!session.pcs[UUID].processingResource) { session.processResourceQueue(UUID); } } else if (!session.pcs[UUID].resourceChannel && session.pcs[UUID].allowResources) { // If channel doesn't exist but resources are allowed, create it session.createResourceChannel(UUID); } return true; }; session.processResourceQueue = async function (UUID) { if (!session.pcs[UUID] || !session.pcs[UUID].resourceChannel || session.pcs[UUID].processingResource) return; const channel = session.pcs[UUID].resourceChannel; const queue = session.pcs[UUID].resourceQueue; if (queue.length === 0) return; session.pcs[UUID].processingResource = true; try { const resource = queue[0]; const chunkSize = 16384; // 16KB chunks const totalChunks = Math.ceil(resource.data.byteLength / chunkSize); log(resource); channel.send(JSON.stringify(resource.metadata)); log("sending.."); await new Promise(r => setTimeout(r, 100)); // Small delay to ensure metadata is processed for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, resource.data.byteLength); const chunk = resource.data.slice(start, end); channel.send(chunk); // Add small delay between chunks to prevent flooding await new Promise(r => setTimeout(r, 50)); } log("sent."); queue.shift(); } catch (e) { errorlog(e); } session.pcs[UUID].processingResource = false; if (queue.length > 0) { setTimeout(() => session.processResourceQueue(UUID), 100); } }; session.recieveResourcesChannel = async function (UUID, channel) { if (!session.allowResources) { warnlog("Someone is trying to send resources despite you asking not to.."); return; } log("Created resource channel"); session.rpcs[UUID].resourceChannel = channel; session.rpcs[UUID].resourceChannel.binaryType = "arraybuffer"; let metadata = null; let receivedChunks = []; let receivedSize = 0; channel.onmessage = async (e) => { // Fixed: Using channel parameter instead of nested property try { if (typeof e.data === "string") { const data = JSON.parse(e.data); log(data); if (data.templateName) { metadata = data; receivedChunks = []; receivedSize = 0; } } else { if (!metadata) return; receivedChunks.push(e.data); receivedSize += e.data.byteLength; if (receivedSize >= metadata.size) { const completeBuffer = await new Blob(receivedChunks).arrayBuffer(); session.processReceivedResource(UUID, completeBuffer, metadata); receivedChunks = []; receivedSize = 0; } } } catch (error) { errorlog(error); } }; channel.onopen = e => { log("Opened resource channel"); }; }; session.processReceivedResource = function (UUID, buffer, data) { if (!session.rpcs[UUID]) return; const createObjectURL = (buffer, type) => { const blob = new Blob([buffer], { type: type || "image/png" }); return URL.createObjectURL(blob); }; try { log(data); session.rpcs[UUID].meta[data.templateName] = data; if (session.rpcs[UUID].meta[data.templateName].value) { URL.revokeObjectURL(session.rpcs[UUID].meta[data.templateName].value); } session.rpcs[UUID].meta[data.templateName].value = createObjectURL(buffer, data.type); log(session.rpcs[UUID].meta[data.templateName].value); updateMixer(); } catch (error) { errorlog(error); } }; function sendRawMIDI(input, UUID = false, streamID = false) { var msg = {}; msg.midi = {}; msg.midi.d = Array.from(input.data); // Convert to regular array msg.midi.s = input.timestamp || Date.now(); if (input.message && input.message.channel) { msg.midi.c = input.message.channel; } else if (input && input.channel) { msg.midi.c = input.channel; } if (UUID && session.pcs[UUID] && session.pcs[UUID].allowMIDI) { session.sendMessage(msg, UUID); } else if (UUID && session.rpcs[UUID] && session.rpcs[UUID].allowMIDI) { session.sendRequest(msg, UUID); } else if (streamID) { for (var UID in session.rpcs) { if (session.rpcs[UID].allowMIDI && session.rpcs[UID].streamID === streamID) { // specific to gstreamer code aplication session.sendRequest(msg, UID); return; // only one stream ID should match } } } else { var list = []; for (var UID in session.pcs) { if (session.pcs[UID].allowMIDI) { if (session.sendMessage(msg, UID)) { list.push(UID); } } } for (var UID in session.rpcs) { if (session.rpcs[UID].allowMIDI) { // specific to gstreamer code aplication if (!list.includes(UID)) { session.sendRequest(msg, UID); } } } } } function sendMIDINote(note, on = true, channel = 1, uuid = null) { // MIDI Note On status byte: 144 + (channel - 1) // MIDI Note Off status byte: 128 + (channel - 1) const statusByte = on ? (144 + (channel - 1)) : (128 + (channel - 1)); const velocity = on ? 127 : 0; // 127 for note on, 0 for note off // Convert note names like "C1", "D3" to MIDI note numbers let noteNumber; if (typeof note === "string") { const noteName = note.slice(0, -1); const octave = parseInt(note.slice(-1)); const noteValues = { "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11 }; // C1 is MIDI note 24, each octave is 12 notes noteNumber = 24 + (octave - 1) * 12 + noteValues[noteName]; } else { noteNumber = note; } // Create MIDI message and send it const data = {}; data.data = [statusByte, noteNumber, velocity]; sendRawMIDI(data, uuid); return { note: noteNumber, status: statusByte, velocity: velocity }; } function buttonMIDI(ele, state = null) { const note = ele.dataset.midiNote; const uuid = ele.dataset.uuid || null; const isToggleMode = ele.dataset.midiMode === 'toggle'; // Handle state tracking similar to changeGroup function let newState; let changed = false; if (state === true) { // Explicit true state requested if (!ele.classList.contains("pressed") || CtrlPressed) { changed = true; ele.classList.add("pressed"); ele.ariaPressed = "true"; } newState = true; } else if (state === false) { // Explicit false state requested if (ele.classList.contains("pressed") || CtrlPressed) { changed = true; ele.classList.remove("pressed"); ele.ariaPressed = "false"; } newState = false; } else if (CtrlPressed) { newState = ele.classList.contains("pressed"); changed = true; } else { // Toggle current state newState = !ele.classList.contains("pressed"); changed = true; if (newState) { ele.classList.add("pressed"); ele.ariaPressed = "true"; } else { ele.classList.remove("pressed"); ele.ariaPressed = "false"; } } // Only send MIDI if state actually changed if (changed) { if (isToggleMode) { // Dual channel toggle sendMIDINote(note, true, newState ? 1 : 2, uuid); } else { // Single channel note on/off const channel = parseInt(ele.dataset.midiChannel || "1"); sendMIDINote(note, newState, channel, uuid); // Only auto-unpress non-toggle buttons if (newState) { setTimeout(() => { ele.classList.remove("pressed"); ele.ariaPressed = "false"; }, 120); } } // Sync director state if available if (typeof syncDirectorState === 'function') { syncDirectorState(ele); } } return newState; } let currentOscillatorIdMidi = 0; function setupMidiOscillator(callbackFunction, frameRate, timeOne = null, thisOscillatorId = null) { if (!thisOscillatorId) { thisOscillatorId = ++currentOscillatorIdMidi; } else if (currentOscillatorIdMidi !== thisOscillatorId) { return false; } if (!session.audioCtx) { session.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } let oscillator = session.audioCtx.createOscillator(); let silence = session.audioCtx.createGain(); silence.gain.value = 0; oscillator.connect(silence); silence.connect(session.audioCtx.destination); if (!timeOne) { timeOne = session.audioCtx.currentTime; } oscillator.onended = () => { oscillator.disconnect(); silence.disconnect(); if (currentOscillatorIdMidi === thisOscillatorId) { let timeTwo = session.audioCtx.currentTime; if (typeof callbackFunction === "function") { callbackFunction(); } setupMidiOscillator(callbackFunction, frameRate, timeTwo, thisOscillatorId); } }; oscillator.start(timeOne); oscillator.stop(timeOne + 1 / frameRate); return function (check = false) { if (check && currentOscillatorIdMidi !== thisOscillatorId) { return true; } else if (check) { return false; } if (currentOscillatorIdMidi === thisOscillatorId) { currentOscillatorIdMidi++; } return false; }; } function playOutMidi(msg) { if (session.midiIn === true || session.midiIn == parseInt(session.midiIn)) { if ("d" in msg) { const outputs = session.midiIn === true ? WebMidi.outputs : [WebMidi.outputs[parseInt(session.midiIn) - 1]]; outputs.forEach(output => { try { if ("c" in msg) { output.channels[msg.c].send(msg.d); } else if (msg.d) { output.send(msg.d); } } catch (e) { errorlog(e); } }); } } } function displayOverlayMessage(label, msg) { if (!(session.cleanOutput && session.cleanish == false)) { var textOverlay = getById("overlayMsgs"); if (textOverlay) { textOverlay.innerText = msg textOverlay.style.display = "block"; var showtime = msg.length * 200 + 3000; if (showtime > 8000) { showtime = 8000; } setTimeout( function (ele) { try { ele.parentNode.removeChild(ele); } catch (e) { } }, showtime, spanOverlay ); } } } let lastMTCValues = { hours: 0, minutes: 0, seconds: 0, frames: 0, type: 0 }; let mtcCounter = 0; let lastDisplayedTime = 0; function handleQuarterFrame(data) { const mtcType = (data >> 4) & 0x7; const mtcValue = data & 0xF; //console.log(`Quarter Frame: Type ${mtcType}, Value ${mtcValue}`); switch (mtcType) { case 0: lastMTCValues.frames = mtcValue; break; case 1: lastMTCValues.frames |= (mtcValue << 4); break; case 2: lastMTCValues.seconds = mtcValue; break; case 3: lastMTCValues.seconds |= (mtcValue << 4); break; case 4: lastMTCValues.minutes = mtcValue; break; case 5: lastMTCValues.minutes |= (mtcValue << 4); break; case 6: lastMTCValues.hours = mtcValue; break; case 7: lastMTCValues.hours |= (mtcValue & 0x1) << 4; lastMTCValues.type = (mtcValue >> 1) & 0x3; break; } mtcCounter++; if (mtcCounter === 8) { mtcCounter = 0; displayTimecode(lastMTCValues); } } function handleFullFrame(data) { const hours = data[0]; const minutes = data[1]; const seconds = data[2]; const frames = data[3]; // The frame rate information might be encoded differently or not present // We'll need to determine how to extract this information from your specific implementation const type = 0; // Default to 24 fps for now, adjust as needed lastMTCValues = { hours, minutes, seconds, frames, type }; displayTimecode(lastMTCValues); mtcCounter = 0; // Reset counter after full frame } function displayTimecode(timecode) { const frameRates = [24, 25, 29.97, 30]; const frameRate = frameRates[timecode.type] || 24; // Default to 24 if unknown const dropFrame = (timecode.type === 2); let timecodeString = `${timecode.hours.toString().padStart(2, '0')}:${timecode.minutes.toString().padStart(2, '0')}:${timecode.seconds.toString().padStart(2, '0')} f${timecode.frames.toString().padStart(2, '0')}/${frameRate.toString()}`; //console.log("Displaying Timecode:", timecodeString, `(${frameRate} fps${dropFrame ? ' DF' : ''})`); displayOverlayMessage("Timecode", timecodeString); } function playbackMIDI(msg, unsafe = false, UUID = null) { if (session.midiIframe) { pokeIframeAPI("midi-in", msg, UUID); } if (session.midiTimecode && msg && msg.d) { const data = msg.d; if (data.length === 2 && data[0] === 241) { handleQuarterFrame(data[1]); } else if (data.length >= 10 && data[0] === 240 && data[1] === 127 && data[4] === 1) { if (data.length === 11) { handleFullFrame(data.slice(6, -1)); } else { handleFullFrame(data.slice(6, 8)); } } } if (session.midiIn === false && session.midiRemote === false) { return; } else if (session.midiOut === session.midiIn && session.midiRemote === false) { return; } log("play out"); if (session.midiDelay) { let timestamp = null; if ("s" in msg) { timestamp = msg.s; } else if ("t" in msg) { timestamp = msg.t; } if (timestamp !== null) { const timeDelay = session.midiDelay - (Date.now() - timestamp); if (timeDelay <= 0) { playOutMidi(msg); } else { setupMidiOscillator(() => playOutMidi(msg), 1000 / timeDelay); } } else { playOutMidi(msg); } } else { playOutMidi(msg); } if (unsafe) { return; } // I don't know how midi remote works in reverse, so lets ignore it if (session.midiRemote == 4) { if (msg.d[0] == 176) { midiHotkeysCommand(msg.d[1], msg.d[2]); } } else if (session.midiRemote == 1 || session.midiRemote == 2 || session.midiRemote == 3) { if (msg.d[0] == 156) { if (msg.d[1] == 33) { midiHotkeysNote("A1", msg.d[2]); } else if (msg.d[1] == 55) { midiHotkeysNote("G3", msg.d[2]); } else if (msg.d[1] == 57) { midiHotkeysNote("A3", msg.d[2]); } else if (msg.d[1] == 59) { midiHotkeysNote("B3", msg.d[2]); } else if (msg.d[1] == 60) { midiHotkeysNote("C4", msg.d[2]); } else if (msg.d[1] == 62) { midiHotkeysNote("D4", msg.d[2]); } else if (msg.d[1] == 64) { midiHotkeysNote("E4", msg.d[2]); } else if (msg.d[1] == 65) { midiHotkeysNote("F4", msg.d[2]); } else if (msg.d[1] == 67) { midiHotkeysNote("G4", msg.d[2]); } else if (msg.d[1] == 69) { midiHotkeysNote("A4", msg.d[2]); } else if (msg.d[1] == 43) { midiHotkeysNote("G2", msg.d[2]); } else if (msg.d[1] == 35) { midiHotkeysNote("B1", msg.d[2]); } else if (msg.d[1] == 36) { midiHotkeysNote("C2", msg.d[2]); } else if (msg.d[1] == 38) { midiHotkeysNote("D2", msg.d[2]); } else if (msg.d[1] == 40) { midiHotkeysNote("E2", msg.d[2]); } else if (msg.d[1] == 41) { midiHotkeysNote("F2", msg.d[2]); } else if (msg.d[1] == 24) { midiHotkeysNote("C1", msg.d[2]); } } } //var output = WebMidi.getOutputById("123456789"); //output = WebMidi.getOutputByName("Axiom Pro 25 Ext Out"); //output = WebMidi.outputs[0]; } function addEventToAll(targets, trigger, callback) { // js helper const target = document.querySelectorAll(targets); var triggers = trigger.split(" "); for (let i = 0; i < target.length; i++) { for (let j = 0; j < triggers.length; j++) { setTimeout( function (t1, t2) { t1.addEventListener(t2, function (e) { callback(e, t1); }); }, 0, target[i], triggers[j] ); } } } function insertAfter(newNode, existingNode) { existingNode.parentNode.insertBefore(newNode, existingNode.nextSibling); } addEventToAll(".column", "click", function (e, ele) { if (ele.classList.contains("skip-animation")) { return; } try { var bounding_box = ele.getBoundingClientRect(); } catch (e) { return; } if (!bounding_box) { errorlog("No bounding box for ele found"); } ele.style.top = bounding_box.y + "px"; ele.style.left = bounding_box.x - 20 + "px"; ele.classList.add("in-animation"); ele.classList.remove("pointer"); ele.classList.remove("rounded"); if (document.getElementById("empty-container")) { getById("empty-container").parentNode.removeChild(getById("empty-container")); } var empty = document.createElement("DIV"); empty.id = "empty-container"; empty.className = "column"; ele.parentNode.insertBefore(empty, ele.nextSibling); const styles = "\ @keyframes outlightbox {\ 0% {\ height: 100%;\ width: 100%;\ top: 0px;\ left: 0px;\ }\ 50% {\ height: 200px;\ top: " + bounding_box.y + "px;\ }\ 100% {\ height: 200px;\ width: " + bounding_box.width + "px;\ top: " + bounding_box.y + "px;\ left: " + bounding_box.x + "px;\ }\ }\ "; if (document.getElementById("lightbox-animations")) { getById("lightbox-animations").innerHTML = styles; } document.body.style.overflow = "hidden"; }); addEventToAll(".close", "click", function (e, ele) { cleanupMediaTracks(); document.querySelectorAll(".hidden2").forEach(ele2 => { ele2.classList.remove("hidden2"); }); ele.style.display = "none"; mapToAll(".container-inner", function (target) { target.style.display = "none"; }); document.body.style.overflow = "auto"; // Get the actual position where the element should return to var emptyContainer = getById("empty-container"); if (emptyContainer) { var targetBox = emptyContainer.getBoundingClientRect(); // Update the outlightbox animation with the correct target position const styles = "\ @keyframes outlightbox {\ 0% {\ height: 100%;\ width: 100%;\ top: 0px;\ left: 0px;\ }\ 50% {\ height: 200px;\ top: " + targetBox.top + "px;\ }\ 100% {\ height: " + targetBox.height + "px;\ width: " + targetBox.width + "px;\ top: " + targetBox.top + "px;\ left: " + targetBox.left + "px;\ }\ }\ "; if (document.getElementById("lightbox-animations")) { getById("lightbox-animations").innerHTML = styles; } // Don't set position here - let the animation handle it setTimeout(function () { // just smoothes things out; breathing room to clean up things first. ele.parentNode.classList.add("out-animation"); }, 1); } e.stopPropagation(); }); addEventToAll(".column", "animationend", function (e, ele) { if (e.animationName == "inlightbox") { ele.classList.add("skip-animation"); mapToAll( ".close", function (target) { target.style.display = "block"; }, ele ); document.querySelectorAll("#header, #miniTaskBarm, #credits, .columnfade").forEach(ele2 => { if (ele2 !== ele) { ele2.classList.add("hidden2"); } }); mapToAll( ".container-inner", function (target) { target.style.display = "block"; }, ele ); } else if (e.animationName == "outlightbox") { ele.classList.remove("in-animation"); ele.classList.remove("out-animation"); ele.classList.remove("skip-animation"); ele.classList.remove("columnfade"); ele.classList.add("pointer"); ele.classList.add("rounded"); // Clear all inline styles to fully restore original position ele.style.top = ""; ele.style.left = ""; ele.style.position = ""; ele.style.width = ""; ele.style.height = ""; // Clear stored position data delete ele.dataset.originalTop; delete ele.dataset.originalLeft; delete ele.dataset.originalWidth; delete ele.dataset.originalHeight; if (document.getElementById("empty-container")) { getById("empty-container").parentNode.removeChild(getById("empty-container")); } if (document.getElementById("lightbox-animations") && getById("lightbox-animations").sheet && getById("lightbox-animations").sheet.cssRules.length > 0) { getById("lightbox-animations").sheet.deleteRule(0); } } }); addEventToAll("#audioSource", "mousedown touchend focusin focusout", function (e, ele) { var state = getById("multiselect-trigger").dataset.state || 0; // Does this return TRU instead?? GAH. #TODO: if (state == 0) { getById("multiselect-trigger").dataset.state = 1; getById("multiselect-trigger").classList.add("open"); getById("multiselect-trigger").classList.remove("closed"); mapToAll( ".chevron", function (ele) { ele.classList.remove("bottom"); }, (parentElement = getById("multiselect-trigger")) ); mapToAll( ".multiselect-contents", function (ele) { ele.style.display = "block"; mapToAll( 'input[type="checkbox"]', function (ele2) { ele2.parentNode.style.display = "block"; ele2.style.display = "inline-block"; }, ele ); }, (parentElement = getById("multiselect-trigger").parentNode) ); } e.stopPropagation(); //e.preventDefault(); }); addEventToAll("#audioSource3", "mousedown touchend focusin focusout", function (e, ele) { var state = getById("multiselect-trigger3").dataset.state || 0; // Does this return TRU instead?? GAH. #TODO: if (state == 0) { getById("multiselect-trigger3").dataset.state = 1; getById("multiselect-trigger3").classList.add("open"); getById("multiselect-trigger3").classList.remove("closed"); mapToAll( ".chevron", function (target) { target.classList.remove("bottom"); }, getById("multiselect-trigger3") ); mapToAll( ".multiselect-contents", function (target) { target.style.display = "block"; }, getById("multiselect-trigger3").parentNode ); mapToAll( ".multiselect-contents", function (target) { mapToAll( 'input[type="checkbox"]', function (target2) { target2.style.display = "inline-block"; target2.parentNode.style.display = "block"; }, target ); }, getById("multiselect-trigger3").parentNode ); } e.stopPropagation(); //e.preventDefault(); }); addEventToAll("#multiselect-trigger", "mousedown touchend focusin focusout", function (e, ele) { var state = ele.dataset.state || 0; // Does this return TRU instead?? GAH. #TODO: if (state == 0) { // open the dropdown ele.dataset.state = 1; ele.classList.add("open"); ele.classList.remove("closed"); mapToAll( ".chevron", function (target) { target.classList.remove("bottom"); }, getById("multiselect-trigger") ); mapToAll( ".multiselect-contents", function (target) { target.style.display = "block"; }, ele.parentNode ); mapToAll( ".multiselect-contents", function (target) { mapToAll( 'input[type="checkbox"]', function (target2) { target2.style.display = "inline-block"; target2.parentNode.style.display = "block"; }, target ); }, ele.parentNode ); } else { // close the dropdown ele.dataset.state = 0; ele.classList.add("closed"); ele.classList.remove("open"); mapToAll( ".chevron", function (target) { target.classList.add("bottom"); }, ele ); mapToAll( ".multiselect-contents", function (target) { mapToAll( 'input[type="checkbox"]', function (target2) { target2.style.display = "none"; if (!target2.checked) { target2.parentNode.style.display = "none"; } }, target ); }, ele.parentNode ); } e.preventDefault(); e.stopPropagation(); }); addEventToAll("#multiselect-trigger3", "mousedown touchend focusin focusout", function (e, ele) { var state = ele.dataset.state || 0; // Does this return TRU instead?? GAH. #TODO: if (state == 0) { // open the dropdown ele.dataset.state = 1; ele.classList.add("open"); ele.classList.remove("closed"); mapToAll( ".chevron", function (target) { target.classList.remove("bottom"); }, ele ); mapToAll( ".multiselect-contents", function (target) { target.style.display = "block"; }, ele.parentNode ); mapToAll( ".multiselect-contents", function (target) { mapToAll( 'input[type="checkbox"]', function (target2) { target2.style.display = "inline-block"; target2.parentNode.style.display = "block"; }, target ); }, ele.parentNode ); } else { // close the dropdown ele.dataset.state = 0; ele.classList.add("closed"); ele.classList.remove("open"); mapToAll( ".chevron", function (target) { target.classList.add("bottom"); }, ele ); mapToAll( ".multiselect-contents", function (target) { mapToAll( 'input[type="checkbox"]', function (target2) { target2.style.display = "none"; if (!target2.checked) { target2.parentNode.style.display = "none"; } }, target ); }, ele.parentNode ); } e.preventDefault(); e.stopPropagation(); }); function getSenders2(UUID) { var fixedSenders = []; var isAlt = false; if (!(UUID in session.pcs)) { return fixedSenders; } if ("realUUID" in session.pcs[UUID]) { isAlt = true; UUID = session.pcs[UUID].realUUID; if (!(UUID in session.pcs)) { return fixedSenders; } } var senders = session.pcs[UUID].getSenders(); if (isAlt) { senders.forEach(sender => { if (sender.track && sender.track.id) { if (sender.track.id in screenshareTracks) { // I'm not going to change track.kind, since OBS isn't part of this list fixedSenders.push(sender); } } }); } else { senders.forEach(sender => { if (sender.track && sender.track.id) { if (!(sender.track.id in screenshareTracks)) { fixedSenders.push(sender); } } }); } return fixedSenders; } function getReceivers2(UUID) { var fixedReceivers = []; var isAlt = false; var ssTracks = []; if ("realUUID" in session.rpcs[UUID]) { isAlt = true; UUID = session.rpcs[UUID].realUUID; if (!("screenIndexes" in session.rpcs[UUID])) { errorlog("this is supposed to be a screen share, but no screen share index was found"); return; } ssTracks = session.rpcs[UUID].screenIndexes; } else if ("screenIndexes" in session.rpcs[UUID] && session.rpcs[UUID].screenIndexes) { ssTracks = session.rpcs[UUID].screenIndexes; } if (session.rpcs[UUID] && session.rpcs[UUID].getReceivers) { var receivers = session.rpcs[UUID].getReceivers(); } else { var receivers = []; } try { if (session.rpcs[UUID].whep && session.rpcs[UUID].whep.getReceivers) { // used to be "mc", not "whep" try { receivers = receivers.concat(session.rpcs[UUID].whep.getReceivers()); } catch (e) { errorlog(e); } } } catch (e) { errorlog(e); } if (isAlt) { for (var i = 0; i < receivers.length; i++) { for (var j = 0; j < ssTracks.length; j++) { if (i == ssTracks[j]) { fixedReceivers.push(receivers[i]); break; } } } } else { for (var i = 0; i < receivers.length; i++) { var matched = false; for (var j = 0; j < ssTracks.length; j++) { if (i == ssTracks[j]) { matched = true; } } if (!matched) { fixedReceivers.push(receivers[i]); } } } return fixedReceivers; } function getReceiversMC(UUID) { var fixedReceivers = []; var isAlt = false; var ssTracks = []; if ("realUUID" in session.rpcs[UUID]) { isAlt = true; UUID = session.rpcs[UUID].realUUID; if (!("screenIndexes" in session.rpcs[UUID])) { errorlog("this is supposed to be a screen share, but no screen share index was found"); return; } ssTracks = session.rpcs[UUID].screenIndexes; } else if ("screenIndexes" in session.rpcs[UUID] && session.rpcs[UUID].screenIndexes) { ssTracks = session.rpcs[UUID].screenIndexes; } receivers = []; if (session.rpcs[UUID].whep) { receivers = session.rpcs[UUID].whep.getReceivers(); } if (isAlt) { for (var i = 0; i < receivers.length; i++) { for (var j = 0; j < ssTracks.length; j++) { if (i == ssTracks[j]) { fixedReceivers.push(receivers[i]); break; } } } } else { for (var i = 0; i < receivers.length; i++) { var matched = false; for (var j = 0; j < ssTracks.length; j++) { if (i == ssTracks[j]) { matched = true; } } if (!matched) { fixedReceivers.push(receivers[i]); } } } return fixedReceivers; } async function createSecondStream2(UUID) { if (session.pcs[UUID].allowScreenVideo === false && session.pcs[UUID].allowScreenAudio === false) { return false; } if ("realUUID" in session.pcs[UUID]) { return false; } // we don't want to attach to an existing screen share obviously if (!session.screenStream) { return false; } if (!(UUID + "_screen" in session.pcs)) { warnlog(UUID + "_screen; new screen link"); session.pcs[UUID + "_screen"] = {}; session.pcs[UUID + "_screen"].realUUID = UUID; session.pcs[UUID + "_screen"].stats = {}; session.pcs[UUID + "_screen"].sceneDisplay = null; session.pcs[UUID + "_screen"].sceneMute = null; session.pcs[UUID + "_screen"].solo = null; session.pcs[UUID + "_screen"].allowVideo = session.pcs[UUID].allowScreenVideo; session.pcs[UUID + "_screen"].allowAudio = session.pcs[UUID].allowScreenAudio; session.pcs[UUID + "_screen"].allowDrawing = session.pcs[UUID].allowDrawing; if (session.pcs[UUID + "_screen"].allowDrawing) { if (session.screenShareElement && session.screenShareElement.syncDrawOnVideo) { session.screenShareElement.syncDrawOnVideo(); } } session.pcs[UUID + "_screen"].layout = null; session.pcs[UUID + "_screen"].obsState = {}; session.pcs[UUID + "_screen"].obsState.visibility = null; session.pcs[UUID + "_screen"].obsState.sourceActive = null; session.pcs[UUID + "_screen"].obsState.streaming = null; session.pcs[UUID + "_screen"].obsState.recording = null; session.pcs[UUID + "_screen"].obsState.virtualcam = null; session.pcs[UUID + "_screen"].optimizedBitrate = false; session.pcs[UUID + "_screen"].savedBitrate = false; session.pcs[UUID + "_screen"].bitrateTimeout = null; session.pcs[UUID + "_screen"].bitrateTimeoutFirefox = false; session.pcs[UUID + "_screen"].setAudioBitrate = false; session.pcs[UUID + "_screen"].setBitrate = false; session.pcs[UUID + "_screen"].maxBandwidth = null; // based on max available bitrate session.pcs[UUID + "_screen"].limitAudio = false; session.pcs[UUID + "_screen"].audioMutedOverride = false; session.pcs[UUID + "_screen"].enhanceAudio = false; session.pcs[UUID + "_screen"].meshcast = null; session.pcs[UUID + "_screen"].UUID = UUID + "_screen"; session.pcs[UUID + "_screen"].scale = false; session.pcs[UUID + "_screen"].scaleDueToBitrate = false; session.pcs[UUID + "_screen"].scaleWidth = false; session.pcs[UUID + "_screen"].scaleHeight = false; session.pcs[UUID + "_screen"].scaleSnap = false; session.pcs[UUID + "_screen"].scaleResolution = false; session.pcs[UUID + "_screen"].scene = false; session.pcs[UUID + "_screen"].keyframeRate = false; session.pcs[UUID + "_screen"].keyframeTimeout = null; session.pcs[UUID + "_screen"].label = false; session.pcs[UUID + "_screen"].order = false; session.pcs[UUID + "_screen"].preferVideoCodec = false; session.pcs[UUID + "_screen"].startTime = Date.now(); session.pcs[UUID + "_screen"].needsPublishing = null; // session.pcs[UUID+"_screen"].rotation = false; I don't think this will ever be used? // we will use allowVideo/allowAudio from the main UUID parent session.pcs[UUID + "_screen"].getStats = function () { return new Promise((resolve, reject) => { resolve([]); }); }; } /* if (session.audioContentHint && tracks.length){ tracks.forEach(trk=>{ try { trk.contentHint = session.audioContentHint; } catch(e){ errorlog(e); } }); } */ var senders = getSenders2(UUID + "_screen"); var tracks = session.screenStream.getTracks(); for (var i = 0; i < tracks.length; i++) { var track = tracks[i]; try { if (track.kind === "audio" && session.pcs[UUID + "_screen"].allowAudio === false) { continue; } else if (track.kind === "video" && session.pcs[UUID + "_screen"].allowVideo === false) { continue; } } catch (e) { errorlog(e); } if (session.audioContentHint && track.kind === "audio") { try { track.contentHint = session.audioContentHint; // this gets triggered too often I think } catch (e) { errorlog(e); } } if (session.screenshareContentHint && track.kind === "video") { try { track.contentHint = session.screenshareContentHint; // this gets triggered too often I think } catch (e) { errorlog(e); } } else if (session.contentHint && track.kind === "video") { try { track.contentHint = session.contentHint; // this gets triggered too often I think } catch (e) { errorlog(e); } } var added = false; for (var j = 0; j < senders.length; j++) { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? var sender = senders[j]; if (sender.track && sender.track.kind == track.kind) { sender.replaceTrack(track); // replace may not be supported by all browsers. eek. sender.track.enabled = true; added = true; break; } } if (!added) { session.pcs[UUID].addTrack(track, session.screenStream); } } session.applyIsolatedChat(); updateMixer(); } var screenshareTracks = {}; var firsttime = true; async function createSecondStream() { //////////////////////////// &sstype=3 ? if (session.screenShareState == false) { // adding a screen var video = {}; var quality = session.quality_ss; if (quality === false) { quality = 0; // default to 1080p for screen shares } if (session.quality !== false) { quality = session.quality; } if (session.screensharequality !== false) { quality = session.screensharequality; } if (quality == -1) { // unlocked capture resolution } else if (quality == -2) { video.width = { ideal: 3840 }; video.height = { ideal: 2160 }; } else if (quality == -3) { video.width = { ideal: 2560 }; video.height = { ideal: 1440 }; } else if (quality == 0) { video.width = { ideal: 1920 }; video.height = { ideal: 1080 }; } else if (quality == 1) { video.width = { ideal: 1280 }; video.height = { ideal: 720 }; } else if (quality == 2) { video.width = { ideal: 640 }; video.height = { ideal: 360 }; } else if (quality >= 3) { // lowest video.width = { ideal: 320 }; video.height = { ideal: 180 }; } if (session.width) { video.width = { ideal: session.width }; } if (session.height) { video.height = { ideal: session.height }; } var constraints = { // this part is a bit annoying. Do I use the same settings? I can add custom setting controls here later audio: { echoCancellation: true, // we want to cancel echo, since this is a secondary stream autoGainControl: false, noiseSuppression: false }, video: video //,cursor: {exact: "none"} }; try { let supportedConstraints = navigator.mediaDevices.getSupportedConstraints(); if (supportedConstraints.cursor) { if (session.screensharecursor) { constraints.video.cursor = ["always", "motion"]; } else { constraints.video.cursor = "never"; } } if (session.suppressLocalAudioPlayback && supportedConstraints.suppressLocalAudioPlayback) { constraints.audio.suppressLocalAudioPlayback = true; } // if (session.preferCurrentTab) { constraints.preferCurrentTab = true; } if (session.selfBrowserSurface) { constraints.selfBrowserSurface = session.selfBrowserSurface; // exclude or include } if (session.surfaceSwitching) { constraints.surfaceSwitching = session.surfaceSwitching; // exclude or include } if (session.systemAudio) { constraints.systemAudio = session.systemAudio; // exclude or include } if (session.displaySurface && supportedConstraints.displaySurface) { constraints.video.displaySurface = session.displaySurface; // monitor, window, or browser } } catch (e) { warnlog("navigator.mediaDevices.getSupportedConstraints() not supported"); } if (session.screenshareAEC === false) { constraints.audio.echoCancellation = false; } // we want to keep echo cancellation when doing a secondary screen share, unless explicitly disabled. if (session.screenshareAutogain === false) { constraints.audio.autoGainControl = false; } else if (session.autoGainControl === true) { constraints.audio.autoGainControl = true; } if (session.screenshareDenoise === false) { constraints.audio.noiseSuppression = false; } else if (session.noiseSuppression === true) { constraints.audio.noiseSuppression = true; } if (session.voiceIsolation === true) { constraint.audio.voiceIsolation = true; } //if (audio == false) { // constraints.audio = false; //} var overrideFramerate = false; if (session.screensharefps !== false) { constraints.video.frameRate = { ideal: session.screensharefps, max: session.screensharefps }; } else if (session.frameRate !== false && session.maxframeRate != false) { overrideFramerate = session.frameRate; constraints.video.frameRate = { ideal: session.maxframeRate, max: session.maxframeRate }; } else if (session.frameRate !== false) { constraints.video.frameRate = session.frameRate; } else if (session.maxframeRate != false) { constraints.video.frameRate = { ideal: session.maxframeRate, max: session.maxframeRate }; } else { constraints.video.frameRate = { ideal: 60 }; } if (session.screenshareVideoOnly) { constraints.audio = false; } if (session.forceAspectRatio) { // await updateCameraConstraints("aspectRatio", session.forceAspectRatio); if (constraints.video && constraints.video !== true) { constraints.video.aspectRatio = { ideal: parseFloat(session.forceAspectRatio) }; if (constraints.video.width && !session.width) { delete constraints.video.width; } else if (constraints.video.height && !session.height) { delete constraints.video.height; } } } if (constraints.video !== false && Object.keys(constraints.video).length == 0) { constraints.video = true; } if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) { if (!ElectronDesktopCapture) { if (!session.cleanOutput) { warnUser("Enable Elevated Privileges to allow screen-sharing. (right click this window to see that option)"); } return false; } } log("sstype3 screen share"); log(constraints); navigator.mediaDevices .getDisplayMedia(constraints) .then(async function (stream) { try { var constraint = {}; if (session.forceAspectRatio && session.forceScreenShareAspectRatio === null) { constraint.aspectRatio = parseFloat(session.forceAspectRatio); } else if (session.forceScreenShareAspectRatio) { constraint.aspectRatio = parseFloat(session.forceScreenShareAspectRatio); } if (overrideFramerate) { constraint.frameRate = overrideFramerate; } if (Object.keys(constraint).length) { await stream.getVideoTracks()[0].applyConstraints({ advanced: [constraint] }); log({ advanced: [constraint] }); } } catch (e) { errorlog(e); } session.screenShareState = true; session.screenStream = stream; if (session.whipPublishScreen && session.whipOutputScreen) { whipOutScreen(); } pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); //if (!session.screenVideoElement){ // session.screenVideoElement = createVideoElement() //} try { stream.getVideoTracks()[0].onended = function () { stopSecondScreenshare(); }; } catch (e) { log("No Video selected; screensharing?"); } session.screenStream.getTracks().forEach(function (track) { screenshareTracks[track.id] = true; // obs isn't included, so no point to check track.kind }); for (UUID in session.pcs) { createSecondStream2(UUID); } if (!firsttime) { var msg = {}; msg.screenStopped = false; session.sendMessage(msg); } else if (!session.screenShareElement) { session.screenShareElement = createVideoElement(); session.screenShareElement.muted = true; session.screenShareElement.autoplay = true; session.screenShareElement.controls = session.showControls || false; session.screenShareElement.id = "screensharesource"; session.screenShareElement.dataset.sid = session.streamID + ":s"; if (typeof session.volume == "number") { session.screenShareElement.volume = session.volume; } else { session.screenShareElement.volume = 1.0; // play audio automatically } session.screenShareElement.classList.add("tile"); session.screenShareElement.setAttribute("playsinline", ""); session.screenShareElement.controlTimer = null; session.screenShareElement.dataset.menu = "context-menu-video"; if (!session.cleanOutput) { session.screenShareElement.classList.add("task"); // this adds the right-click menu } createDirectorScreenshareOnlyBox(); if (document.getElementById("videoScreenContainer_director")) { getById("videoScreenContainer_director").appendChild(session.screenShareElement); } session.screenShareElement.onpause = event => { // prevent things from pausing; human or other if (!(event.ctrlKey || event.metaKey)) { log("Video paused; auto playing"); event.currentTarget .play() .then(_ => { log("playing 10"); }) .catch(warnlog); } }; session.screenShareElement.addEventListener("click", function (e) { log("click"); try { if (e.ctrlKey || e.metaKey) { e.preventDefault(); var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu, true); printMyStats(innerMenu, true); e.stopPropagation(); return false; } } catch (e) { errorlog(e); } }); session.screenShareElement.touchTimeOut = null; session.screenShareElement.touchLastTap = 0; session.screenShareElement.touchCount = 0; session.screenShareElement.addEventListener("touchend", function (event) { if (session.disableMouseEvents) { return; } log("touched"); //document.ontouchup = null; //document.onmouseup = null; document.onmousemove = null; document.ontouchmove = null; var currentTime = new Date().getTime(); var tapLength = currentTime - session.screenShareElement.touchLastTap; clearTimeout(session.screenShareElement.touchTimeOut); if (tapLength < 500 && tapLength > 0) { /// log("double touched"); session.screenShareElement.touchCount += 1; event.preventDefault(); if (session.screenShareElement.touchCount < 5) { session.screenShareElement.touchLastTap = currentTime; return false; } session.screenShareElement.touchLastTap = 0; session.screenShareElement.touchCount = 0; var [menu, innerMenu] = statsMenuCreator(); menu.interval = setInterval(printMyStats, session.statsInterval, innerMenu, true); printMyStats(innerMenu, true); event.stopPropagation(); return false; ////// } else { session.screenShareElement.touchCount = 1; session.screenShareElement.touchLastTap = currentTime; session.screenShareElement.touchTimeOut = setTimeout( function (vv) { clearTimeout(vv.touchTimeOut); vv.touchLastTap = 0; vv.touchCount = 0; }, 5000, session.screenShareElement ); } }); } firsttime = false; session.screenShareElement.srcObject = session.screenStream; getById("screensharebutton").classList.add("green"); getById("screensharebutton").ariaPressed = "true"; getById("screensharebutton").title = getTranslation("stop-screen-sharing"); getById("screenshare2button").classList.add("green"); getById("screenshare2button").ariaPressed = "true"; getById("screenshare2button").title = getTranslation("stop-screen-sharing"); getById("screenshare3button").classList.add("green"); getById("screenshare3button").ariaPressed = "true"; getById("screenshare3button").title = getTranslation("stop-screen-sharing"); if (session.autorecord || session.autorecordlocal) { log("AUTO RECORD START SSTYPE3"); setTimeout( function (s) { if (!session.screenStream) { return; } try { var ele = document.getElementById("recordLocalScreenbutton"); if (ele) { ele.classList.add("red"); ele.classList.remove("hidden"); if (!ele.vid) { var v = createVideoElement(); v.muted = true; v.srcObject = s; ele.vid = v; } if (ele.vid.recorder || ele.vid.recording) { ele.vid.recorder.stop(); ele.classList.remove("red"); ele.classList.add("hidden"); ele.vid = null; } else { var videoKbps = session.recordDefault; if (session.recordLocal !== false) { videoKbps = session.recordLocal; } recordLocalVideo(null, videoKbps, ele.vid); } } } catch (e) { errorlog(e); } }, 2000, session.screenStream ); } setTimeout(function () { updateMixer(); }, 100); setTimeout(function () { updateMixer(); }, 1000); }) .catch(function (err) { errorlog(err); }); } else { // removing a screen stopSecondScreenshare(); } } function recordLocalScreenStopRecord() { var ele = document.getElementById("recordLocalScreenbutton"); if (ele) { try { ele.classList.remove("red"); ele.classList.add("hidden"); if (ele.vid) { if (ele.vid.recorder || ele.vid.recording) { ele.vid.recorder.stop(); } ele.vid = null; } } catch (e) { errorlog(e); } } } function stopSecondScreenshare() { var msg = {}; msg.screenStopped = true; session.sendMessage(msg); for (const peerUUID in session.pcs) { if (!session.pcs.hasOwnProperty(peerUUID)) { continue; } const peer = session.pcs[peerUUID]; if (peer && "whipScreen" in peer && peer.whipScreen !== false) { peer.whipScreen = null; } } var ele = document.getElementById("recordLocalScreenbutton"); if (ele) { try { ele.classList.remove("red"); ele.classList.add("hidden"); if (ele.vid) { if (ele.vid.recorder || ele.vid.recording) { ele.vid.recorder.stop(); } ele.vid = null; } } catch (e) { errorlog(e); } } if (session.screenStream) { session.screenStream.getTracks().forEach(function (track) { // previous video track; saving it. Must remove the track at some point. for (UUID in session.pcs) { if (!("realUUID" in session.pcs[UUID])) { continue; } // not a screen share, so skip var senders = getSenders2(UUID); senders.forEach(sender => { // I suppose there could be a race condition between negotiating and updating this. if joining at the same time as changnig streams? if (sender.track && sender.track.kind == "video") { sender.track.enabled = false; } }); } if (track.id in screenshareTracks) { // obs isn't included, so no point to check track.kind log("remove track 2"); session.screenStream.removeTrack(track); track.stop(); screenshareTracks[track.id] = false; } }); } if (document.getElementById("container_screen_director")) { document.getElementById("container_screen_director") } session.screenStream = false; session.screenShareState = false; pokeIframeAPI("screen-share-state", session.screenShareState, null, session.streamID); if (session.whipOutScreen) { try { session.whipOutScreen.getSenders().forEach(sender => { try { sender.track && sender.track.stop && sender.track.stop(); } catch (e) { } }); } catch (e) { } try { session.whipOutScreen.close(); } catch (e) { } session.whipOutScreen = null; } if (session.whipoutScreenSettings) { session.whipoutScreenSettings.started = false; } getById("screensharebutton").classList.remove("green"); getById("screensharebutton").ariaPressed = "false"; getById("screensharebutton").title = getTranslation("share-a-screen"); getById("screenshare2button").classList.remove("green"); getById("screenshare2button").ariaPressed = "false"; getById("screenshare2button").title = getTranslation("share-a-screen"); getById("screenshare3button").classList.remove("green"); getById("screenshare3button").ariaPressed = "false"; getById("screenshare3button").title = getTranslation("share-a-screen"); if (document.getElementById("screensharesource")) { document.getElementById("screensharesource").load() } setTimeout(function () { updateMixer(); }, 100); setTimeout(function () { updateMixer(); }, 1000); } function enableFullscreenZoom() { const content = document.getElementById('gridlayout'); let lastScrollPosition = { x: 0, y: 0 }; content.style.overflow = "visible"; content.style.transformOrigin = "center center"; // Set transform origin to center // Add styles for the container to allow centering content.style.position = "relative"; content.parentElement.style.display = "flex"; content.parentElement.style.justifyContent = "center"; content.parentElement.style.alignItems = "center"; content.parentElement.style.minHeight = "100vh"; content.parentNode.insertAdjacentHTML('afterbegin', '
    \ \
    '); const viewport = document.querySelector('meta[name="viewport"]'); viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=4.0, user-scalable=yes'; if (!viewport) { const meta = document.createElement('meta'); meta.name = 'viewport'; meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=4.0, user-scalable=yes'; document.head.appendChild(meta); } const slider = document.getElementById('zoomSlider2'); slider.addEventListener('input', (e) => { const scale = e.target.value / 100; // Calculate the center point before scaling const rect = content.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; // Apply the transform with translate to maintain center point content.style.transform = `scale(${scale})`; // Calculate scroll position to keep center point const newWidth = rect.width * scale; const newHeight = rect.height * scale; const newLeft = centerX - newWidth / 2; const newTop = centerY - newHeight / 2; window.scrollTo( window.scrollX + (newLeft - rect.left), window.scrollY + (newTop - rect.top) ); }); } // Auth Access Control Functions let currentRoomSettings = null; async function loadRoomAccessSettings() { if (!session.authMode || !session.roomid || !window.vdoAuth) return; try { // Get room settings const response = await fetch(`${AUTH_SERVICE_URL}/api/room/access`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ room: session.roomid }) }); if (response.ok) { const roomInfo = await response.json(); if (roomInfo.isOwner) { // Get detailed room settings const settingsResponse = await fetch(`${AUTH_SERVICE_URL}/api/room/settings/${session.realRoomId || session.roomid}`, { headers: { 'Authorization': `Bearer ${session.authToken}` } }); if (settingsResponse.ok) { currentRoomSettings = await settingsResponse.json(); // Update UI with current settings const accessMode = currentRoomSettings.accessMode || 'public'; document.querySelector(`input[name="roomAccessMode"][value="${accessMode}"]`).checked = true; updateRoomAccessMode(accessMode); // Load allowlist if (currentRoomSettings.allowlist && currentRoomSettings.allowlist.length > 0) { displayAllowlist(currentRoomSettings.allowlist); } // Load pending access requests loadAccessRequests(); } } } } catch (e) { console.error('Failed to load room settings:', e); } } function updateRoomAccessMode(mode) { // Show/hide allowlist section based on mode if (mode === 'allowlist') { getById('allowlistSection').style.display = 'block'; getById('accessRequestsSection').style.display = 'block'; } else { getById('allowlistSection').style.display = 'none'; getById('accessRequestsSection').style.display = 'none'; } // Update room settings on server if (currentRoomSettings && window.vdoAuth) { window.vdoAuth.updateRoomSettings(session.realRoomId || session.roomid, { accessMode: mode }); } } function addToAllowlist() { const input = getById('allowlistInput'); const value = input.value.trim(); if (!value) return; // Validate format if (!value.startsWith('@') && !value.startsWith('email:')) { alert('Please enter a username (starting with @) or email pattern (starting with email:)'); return; } // Add to current allowlist if (!currentRoomSettings) { currentRoomSettings = { allowlist: [] }; } if (!currentRoomSettings.allowlist.includes(value)) { currentRoomSettings.allowlist.push(value); // Update server if (window.vdoAuth) { window.vdoAuth.updateRoomSettings(session.realRoomId || session.roomid, { allowlist: currentRoomSettings.allowlist }); } // Update display displayAllowlist(currentRoomSettings.allowlist); // Clear input input.value = ''; } } function displayAllowlist(allowlist) { const display = getById('allowlistDisplay'); display.innerHTML = ''; allowlist.forEach(entry => { const item = document.createElement('div'); item.style.cssText = 'padding: 5px; margin: 2px 0; background: #f0f0f0; border-radius: 3px; display: flex; justify-content: space-between; align-items: center;'; const label = document.createElement('span'); label.textContent = entry; const removeBtn = document.createElement('button'); removeBtn.textContent = 'Remove'; removeBtn.style.cssText = 'padding: 2px 8px; font-size: 12px;'; removeBtn.onclick = () => removeFromAllowlist(entry); item.appendChild(label); item.appendChild(removeBtn); display.appendChild(item); }); } function removeFromAllowlist(entry) { if (!currentRoomSettings || !currentRoomSettings.allowlist) return; const index = currentRoomSettings.allowlist.indexOf(entry); if (index > -1) { currentRoomSettings.allowlist.splice(index, 1); // Update server if (window.vdoAuth) { window.vdoAuth.updateRoomSettings(session.realRoomId || session.roomid, { allowlist: currentRoomSettings.allowlist }); } // Update display displayAllowlist(currentRoomSettings.allowlist); } } async function loadAccessRequests() { if (!session.authMode || !session.roomid || !window.vdoAuth) return; try { const requests = await window.vdoAuth.getRoomAccessRequests(session.realRoomId || session.roomid); displayAccessRequests(requests); } catch (e) { console.error('Failed to load access requests:', e); } } function displayAccessRequests(requests) { const list = getById('accessRequestsList'); list.innerHTML = ''; if (requests.length === 0) { list.innerHTML = '
    No pending requests
    '; return; } requests.forEach(request => { const item = document.createElement('div'); item.style.cssText = 'padding: 10px; margin: 5px 0; background: #f9f9f9; border: 1px solid #ddd; border-radius: 5px;'; const info = document.createElement('div'); const header = document.createElement('div'); header.style.cssText = 'display: flex; align-items: center; margin-bottom: 5px;'; if (request.avatar) { const avatarImg = document.createElement('img'); avatarImg.src = request.avatar; avatarImg.alt = ''; avatarImg.style.cssText = 'width: 30px; height: 30px; border-radius: 50%; margin-right: 10px;'; header.appendChild(avatarImg); } const textWrap = document.createElement('div'); const nameEl = document.createElement('strong'); nameEl.textContent = request.displayName || ''; textWrap.appendChild(nameEl); if (request.userHandle) { const handleEl = document.createElement('span'); handleEl.style.cssText = 'color: #666; margin-left: 5px;'; handleEl.textContent = request.userHandle; textWrap.appendChild(handleEl); } header.appendChild(textWrap); info.appendChild(header); const meta = document.createElement('div'); meta.style.cssText = 'color: #999; font-size: 12px;'; const metaParts = []; if (request.provider) { metaParts.push(request.provider); } if (request.requestedAt) { const requestedDate = new Date(request.requestedAt); if (!Number.isNaN(requestedDate.getTime())) { metaParts.push(requestedDate.toLocaleString()); } } meta.textContent = metaParts.join(' • '); info.appendChild(meta); const actions = document.createElement('div'); actions.style.cssText = 'margin-top: 8px; display: flex; gap: 10px;'; const approveBtn = document.createElement('button'); approveBtn.textContent = 'Approve'; approveBtn.style.cssText = 'padding: 5px 15px; background: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer;'; approveBtn.onclick = () => handleAccessRequest(request.userId, 'approve'); const denyBtn = document.createElement('button'); denyBtn.textContent = 'Deny'; denyBtn.style.cssText = 'padding: 5px 15px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer;'; denyBtn.onclick = () => handleAccessRequest(request.userId, 'deny'); actions.appendChild(approveBtn); actions.appendChild(denyBtn); item.appendChild(info); item.appendChild(actions); list.appendChild(item); }); } async function handleAccessRequest(userId, action) { if (!window.vdoAuth) return; try { const success = await window.vdoAuth.handleAccessRequest(session.realRoomId || session.roomid, userId, action); if (success) { // Reload access requests loadAccessRequests(); // Reload allowlist if approved if (action === 'approve') { loadRoomAccessSettings(); } } } catch (e) { console.error('Failed to handle access request:', e); } }