NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Twitch Chat over Video // @name:es Chat Twitch sobre Video // @namespace http://tampermonkey.net/ // @version 2.0.4 // @description Moves the chat to the video as an overlay. // @description:es Mueve el chat de twitch sobre el video. // @copyright 2022, enzo418 (https://openuserjs.org/users/enzo418) // @downloadURL https://openuserjs.org/install/enzo418/Twitch_Chat_over_Video.user.js // @updateURL https://openuserjs.org/meta/enzo418/Twitch_Chat_over_Video.meta.js // @author enzo418 // @match https://www.twitch.tv/* // @grant none // @license Apache-2.0 // ==/UserScript== // ==OpenUserJS== // @author enzo418 // ==/OpenUserJS== /* jshint esversion: 8 */ (async function () { 'use strict'; /* Updates: 2.0.4: - Clear UserEvents when the window lost focus 2.0.3: - Changed the selector for the vod chat container. 2.0.2: - Fixed chat input wasn't working. - Now it moves the whole chat container, including channel points, configuration and emoji menu 2.0.1: - Fixed toggle icon isn't showing 2.0.0: - Fixed scroll not working - Fixed chat jumps when hovering the mouse over it - Completely reformed the code 1.1.12: - Option to delete a message in the moderator menu 1.1.11: - Fixed scroll issue when banning - Default position of modmenu is right above the username 1.1.10: - Fixed create multiple times the overlay - Replaced text of "Resume auto scroll" with a circle 1.1.9: - Mod menu added, you can ban, timeout and un-ban a user in full-screen mode - To quickly timeout a user for 60 seconds, hold down Control and right-click the name 1.1.8: - When you click on a user in the chat it shows the built-in twitch menu to manage that user, such as timeout and ban. - Now you don't need to reload the page in order to see the chat in full-screen when changing between channels or steam/vod 1.1.7: - Fix: input chat not displayed due to a class error again and auto scroll fixed 1.1.6: - Fix: input chat not displayed due to a class error 1.1.5: - Feature: Show the chat input when the mouse enters the chat 1.1.4: - Fixed white box icon due to new twitch css rules. 1.1.3: - Fixed a issue realted to last BTTV Fix. 1.1.2: - Fixed a issue with BTTV pinned messages. - Now the overlay can be resized moving its corners (top and right). - Now can change the opacity of the overlay pressing the ALT key and rotating the mouse wheel. 1.1.1: - Fixed a bug where the chat input is not displayed 1.1.0: - Added scroll in chat overlay. - Added support to VODs. 1.0.0: - Initial release */ /* Details: https://openuserjs.org/scripts/enzo418/Twitch_Chat_over_Video */ function query(str, source = document) { return source.querySelector(str); } function createElementFromHTML(htmlString) { var div = document.createElement('div'); div.innerHTML = htmlString.trim(); return div.firstChild; } function insertStyle(style) { var newStyle = document.createElement("style"); newStyle.innerHTML = style; document.getElementsByTagName("head")[0].appendChild(newStyle); } /** * Solution to BTTV pin messages issue => Mutator observer on the pin container to * remove all its children when the overlay is toggled (show/hide). * * BTTV module uses a mutator on the chat so we can't really bypass that. **/ function SolutionBTTVPinIssue () { var target = document.querySelector("#bttv-pin-container"); var observer = new MutationObserver(mutations => { mutations.forEach(mutation => { // remove every "#bttv-pinned-highlight" mutation.addedNodes.forEach(node => { node.remove(); }); }); }); if (target) { observer.observe(target, { childList: true }); // Stop the mutator after a while because we not longer need it. setTimeout(() => observer.disconnect(), 1000); } } function selector (selector) { var val = null; let isVOD = window.location.pathname.indexOf("video") >= 0; switch (selector) { case "playerContainer": val = 'div[data-test-selector="video-player__video-container"]'; // Video Player break; case "videoPlayer": val = 'div[data-a-target="video-player"]'; // break; case "playerLayoutContainer": val = '.persistent-player'; // Video Player + Video layout break; case "chatColumnContainer": val = 'div[data-a-target="right-column-chat-bar"]'; // chat scrollable break; case "chatContainer": val = isVOD ? '.video-chat ul' : 'div[data-test-selector="chat-scrollable-area__message-container"]'; // chat + input break; case "chatInputAreaWithoutOverlay": val = ".chat-input"; // chat input (before overlay) - Children of the container with follower/sub restrictions break; case "playerLayoutControls": val = '.video-ref .player-controls__right-control-group'; // break; case "fullScreenButton": val = 'button[data-a-target="player-fullscreen-button"]'; // full screen button break; case "chatUserCard": val = ".chat-room__viewer-card"; // break; case "realChatInput": val = "[data-a-target='chat-input']"; break; default: val = ""; break; } return val; } var isVOD = window.location.pathname.indexOf("video") >= 0; // wait until the chat loaded while ((isVOD || query(selector("chatContainer")) === null) && query(selector("fullScreenButton")) === null) { await new Promise(resolve => setTimeout(resolve, 100)); } var checkExist = setInterval(function () { if (query(selector("chatContainer")) !== null && query(selector("fullScreenButton")) !== null) { clearInterval(checkExist); main(); } }, 100); const getOverlayTemplate = () => ` <div id="toggle-chat-overlay-fs" class="tw-button-icon--overlay preset-0"> <button class="tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-button-icon tw-button-icon--overlay tw-core-button tw-core-button--overlay tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative" aria-label="Toggle Chat Overlay"> <span class="tw-button-icon__icon"> <div style="width: 2rem; height: 2rem;"> <div class="tw-align-items-center tw-full-width tw-icon tw-icon--fill tw-inline-flex"> <div class="tw-aspect tw-aspect--align-top"> <div class="tw-aspect__spacer"></div> <!--- Original SVG author: Becris --> <svg style="overflow: hidden; width: 90%;" class="tw-icon__svg" width="100%" version="1.1" transform="scale(1)" viewBox="0 -80 500 580" style="overflow: hidden;height: auto;width: 100%;height: fit-content;"><path id="svg_3" d="m426.667,0.002l-375.467,0c-28.277,0 -51.2,22.923 -51.2,51.2l0,273.067c0,28.277 22.923,51.2 51.2,51.2l60.587,0l-9.284,83.456c-1.035,9.369 5.721,17.802 15.09,18.837c4.838,0.534 9.674,-1.023 13.292,-4.279l108.919,-98.014l186.863,0c28.277,0 51.2,-22.923 51.2,-51.2l0,-273.067c0,-28.277 -22.923,-51.2 -51.2,-51.2zm17.066,324.267c0,9.426 -7.641,17.067 -17.067,17.067l-193.416,0c-4.217,0.001 -8.284,1.564 -11.418,4.386l-80.452,72.414l6.434,-57.839c1.046,-9.367 -5.699,-17.809 -15.067,-18.856c-0.63,-0.07 -1.263,-0.106 -1.897,-0.105l-79.65,0c-9.426,0 -17.067,-7.641 -17.067,-17.067l0,-273.067c0,-9.426 7.641,-17.067 17.067,-17.067l375.467,0c9.426,0 17.067,7.641 17.067,17.067l0,273.067l-0.001,0z" fill="#fff"/> <rect id="svg_chat_input" height="122" width="369" y="200.4335" x="56.4335" stroke-width="0" stroke="#000" fill="#fff"/> <line id="svg_overlay_hide" stroke="#ffffff" stroke-linecap="undefined" stroke-linejoin="undefined" id="svg_6" y2="348.43349" x2="448.43349" y1="26.43349" x1="30.4335" stroke-width="28.5" fill="none"/> </svg> </div> </div> </div> </span> </button> </div> `; const getAutoScrollButtonTemplate = (style, preset) => ` ${(preset === 0 && `<button style="left: 0px;bottom: 0px;position: absolute; background-color: rgb(0, 0, 0);z-index: 999999;${style}" id="resume-auto-scroll" class="tw-absolute tw-bottom-0 tw-pd-x-1">Resume auto scroll</button>` ) || ""} ${(preset === 1 && `<button style="background-color: rgb(10 1 1 / 34%);z-index: 999999;display: block;right: 0px;padding: 5px;${style}" id="resume-auto-scroll" class="tw-absolute tw-bottom-0 tw-pd-x-1"> <div clas="circle" style="content: '';width: 5px;height: 5px;border-radius: 5px;background-color: #8500ff;"></div> </button>` ) || ""} `; const getModeratorMenuStyle = () => `#container-chat-moderator-menu { z-index: 9999999; position: absolute; top: 18px; background-color: #18181b; padding: 0.2rem; /* border: 1px #fff solid; */ text-align: center; text-transform: uppercase; display: flex; flex-direction: column; color: #fff; border-radius: 3px; } #container-chat-moderator-menu .close-button { position: absolute; top: 0; right: 0; font-size: small; font-weight: bolder; color: #f00; background-color: #0000007d; padding: 0 0.2rem; cursor: pointer; } #container-chat-moderator-menu .mod-timeout { display: flex; flex-direction: column; } #container-chat-moderator-menu .mod-timeout span { margin-right: 0.5rem; } #container-chat-moderator-menu .mod-timeout .container-timeout-options { display: flex; flex-direction: row; } #container-chat-moderator-menu .mod-timeout .container-timeout-options .timout-time { margin-right: 0.5rem !important; border: 0 !important; padding: 0 0.2rem; border-radius: 3px; text-transform: none; } #container-chat-moderator-menu .mod-timeout .container-timeout-options .timout-time:hover { background-color: #a7bdf1e0; color: black; } #container-chat-moderator-menu .mod-option-selectable { cursor: pointer; } #container-chat-moderator-menu .mod-option-selectable:hover { background-color: #1d4a71f7; } #container-chat-moderator-menu .option-ban.mod-option-selectable:hover { background-color: #7b1717; } #chat-moderator-menu > div { border-bottom: 0.1rem solid #070708; padding: 0.2rem 0; border-radius: 3px; }`; const getModeratorMenuTemplate = () => ` <div id="container-chat-moderator-menu"> <div id="chat-moderator-menu"> <span class="close-button">x</span> <!-- <div>un-timeout</div> --> <div class="mod-timeout"> <span>timeout</span> <div class="container-timeout-options"> <div data-timeout="60" class="timout-time mod-option-selectable"> 1m </div> <div data-timeout="300" class="timout-time mod-option-selectable"> 5m </div> <div data-timeout="600" class="timout-time mod-option-selectable"> 10m </div> <div data-timeout="1800" class="timout-time mod-option-selectable"> 30m </div> </div> </div> <div data-unban="true" class="mod-option-selectable">un-ban</div> <div data-deletemessage="true" class="mod-option-selectable">delete message</div> <div data-ban="true" class="mod-option-selectable option-ban">ban</div> </div> </div>`; const getStyles = () => ` #overlay-fs { position: absolute; display:none; z-index: 9999; bottom: 10%; border-radius: 2px;right: 0 !important; } #overlay-fs #chat-container-fs { color:white; height: 100%; overflow-y: scroll; overflow-x:hidden; } /** hide chat scrollbar */ #chat-container-fs::-webkit-scrollbar { display: none; } #change-height-overlay-fs { width: 100%; height: 17px; cursor: row-resize; position: absolute; top: 0; z-index: 100; } #change-width-overlay-fs { width: 17px; height: 100%; cursor: col-resize; position: absolute; right: 0; z-index: 100; } #toggle-chat-overlay-fs.preset-0 #svg_chat_input, .preset-2 #svg_chat_input, .preset-0 #svg_overlay_hide, .preset-1 #svg_overlay_hide { display: none; } #toggle-chat-overlay-fs.preset-1 #svg_chat_input { display: block; } #toggle-chat-overlay-fs.preset-2 #svg_overlay_hide { display: block; } /** this fixed the jumping thing */ div#chat-container-fs { padding-bottom: 1rem; } `; const getNullDivElement = () => `<div style="display:none"></div>`; /** * Abstract **/ class ElementController { constructor (elm) { // if element is null or undefined use an empty div hidden so it doesn't break this._element = elm || createElementFromHTML(getNullDivElement()); this.initialParent = this._element.parentNode; } set element (el) { this._element = el; this.initialParent = el.parentNode; } get element() { return this._element; } hide () { this.element.style.display = "none"; } show () { this.element.style.display = "block"; } /** * @param {double} width **/ set width(width) { this.element.style.width = width + "px"; } /** * @return {double} width **/ get width() { return parseFloat(this.element.style.width.replace("px", "")); } /** * @param {double} height **/ set height(height) { this.element.style.height = height + "px"; } /** * @return {double} height **/ get height() { return parseFloat(this.element.style.height.replace("px", "")); } /** * @param {double} opacity **/ set opacity (opacity) { this.element.style.background = `rgb(0 0 0 / ${opacity}%)`; } /** * @return {double} opacity **/ get opacity () { let a = this.element.style.background; return parseFloat(a.substring(a.indexOf("/ ") + 2, a.indexOf("%"))); } /** * @param {HTMLElement} opacity **/ move (to) { to.appendChild(this.element); } /** * Moves the element to the initial parent **/ moveToInitialParent () { this.initialParent.appendChild(this.element); } /** * Adds a event listener to the element * @param {string} event * @param {function} callback * @param {boolean} useCapture **/ on (event, callback, useCapture = false) { this.element.addEventListener(event, callback.bind(this), useCapture); } /** * Sets the element id * @param {string} id **/ set id(id) { this.element.id = id; this._id = "#"+id; } get id() { return this.element.id; } get idSelector() { return this._id; } setAsDraggable (condition_drag_element) { var startX = 0, startY = 0, lastBottom = window.innerHeight * 5 / 100; const maxY = window.innerHeight; const minY = 0; var maxX = window.innerWidth - this.element.getBoundingClientRect().width; const minX = 0; this.element.onmousedown = (e) => { e = e || window.event; if (condition_drag_element(e.target)) { // recalculate bound maxX = window.innerWidth - this.element.getBoundingClientRect().width; e.preventDefault(); // get the mouse cursor position at startup: startX = e.clientX; startY = e.clientY; document.onmouseup = () => { // stop moving when mouse button is released: document.onmouseup = null; document.onmousemove = null; }; // call a function whenever the cursor moves: document.onmousemove = (e) => { e = e || window.event; e.preventDefault(); // move Y lastBottom += startY - e.clientY; lastBottom = lastBottom < minY ? minY : (lastBottom > maxY ? maxY : lastBottom); this.element.style.bottom = lastBottom + "px"; // move X var left = this.element.offsetLeft - (startX - e.clientX); left = left < minX ? minX : (left >= maxX ? maxX : left); this.element.style.left = left + "px"; startX = e.clientX; startY = e.clientY; }; } }; } } /** * This class represents an Element that is able to change the height of another element. **/ class ElementChangeHeight extends ElementController { constructor(resizableElement, initialHeigth, id = "", heightChangeRate = 10, callback_onchange) { super(document.createElement("div")); this.id = id; this.target = resizableElement; this.initialHeigth = initialHeigth; this.heightChangeRate = heightChangeRate; this.callback_onchange = callback_onchange; this.setEventsChangeHeight(); } /** * @param {Float32Array} heightChangeRate **/ set heightChangeRate (heightChangeRate) { this._heightChangeRate = heightChangeRate; } get heightChangeRate () { return this._heightChangeRate; } /** * @param {HTMLElement} target **/ set target (target) { this._target = target; } get target () { return this._target; } /** * @param {Float32Array} initialHeigth **/ set initialHeigth (initialHeigth) { this._initialHeigth = initialHeigth; } get initialHeigth () { return this._initialHeigth; } setEventsChangeHeight() { var startY = 0, heightChange = this.initialHeigth, height = 0; this.element.onmousedown = (e) => { e.preventDefault(); startY = e.clientY; document.onmouseup = () => { this.callback_onchange(height); // stop moving when mouse button is released: document.onmouseup = null; document.onmousemove = null; }; // call a function whenever the cursor moves: document.onmousemove = (e) => { e = e || window.event; e.preventDefault(); heightChange += startY - e.clientY; height = (this.heightChangeRate + heightChange); this.target.style.height = height + "px"; this.initialHeigth = height; startY = e.clientY; }; }; } } /** * This class represents an Element that is able to change the width of another element. **/ class ElementChangeWidth extends ElementController { constructor (resizableElement, initialWidth, id = "", widthChangeRate = 10, callback_onchange) { super(document.createElement("div")); this.id = id; this.callback_onchange = callback_onchange; this.setEventsChangeWidth(this.element, resizableElement, initialWidth, widthChangeRate); } setEventsChangeWidth (elmnt, target, initialWidth, widthChangeRate) { var startX = 0, widthChange = initialWidth, width = 0; elmnt.onmousedown = (e) => { e.preventDefault(); startX = e.clientX; document.onmouseup = () => { this.callback_onchange(width); // stop moving when mouse button is released: document.onmouseup = null; document.onmousemove = null; }; // call a function whenever the cursor moves: document.onmousemove = (e) => { e = e || window.event; e.preventDefault(); widthChange += e.clientX - startX; width = (widthChangeRate + widthChange); target.style.width = width + "px"; initialWidth = width; startX = e.clientX; }; }; } } class OverlayContainer extends ElementController { constructor (opacity, width, height, callback_onchangeheight, callback_onchangewidth) { super(document.createElement("div")); this.id = "overlay-fs"; this.opacity = opacity; this.width = width; this.height = height; this.changeHeight = new ElementChangeHeight( this.element, height, "change-height-overlay-fs", 10, callback_onchangeheight ); this.changeWidth = new ElementChangeWidth( this.element, width, "change-width-overlay-fs", 10, callback_onchangewidth ); this.changeHeight.move(this.element); this.changeWidth.move(this.element); this.setAsDraggable( (el) => el.localName !== "textarea" // allow to select text from the input && el.localName !== "span" // allow to select text from the chat && el.id != this.changeHeight.id && el.id != this.changeWidth.id ); } moveToPlayer () { // set the overlay to be a children of the main video container var mainPlayer = query(selector("playerContainer")); this.move(mainPlayer); } } class ChatContainer extends ElementController { /** * @param {HTMLElement} element * @param {BigInteger} opacity * @param {UserEvents} userEvents **/ constructor (element, opacity, userEvents) { super(element); this.opacity = opacity; this.events = userEvents; this.lastTimestamp = 0; } /** * @param {HTMLElement} el **/ set element(el) { super.element = el; // since the element of the controller can change set all // the listeners/attributes in the setter of the element this.id = "chat-container-fs"; this.on("scroll", this.onScroll); } get element() { return super.element; } autoScrollTick () { this.element.scrollTop = this.element.scrollHeight; } stopAutoScroll () { clearInterval(this.interval); this.interval = null; this.buttonAutoScroll.show(); } startAutoScroll () { this.stopAutoScroll(); this.interval = setInterval(this.autoScrollTick.bind(this), 100); this.events.mouseWheelEvent = false; this.buttonAutoScroll.hide(); } get scrollPosition () { return this.element.scrollTop; } set scrollPosition (pos) { this.element.scrollTop = pos; } /** * @param {ToggleOverlayButton} buttonAutoScroll **/ set autoScrollButton (buttonAutoScroll) { this.buttonAutoScroll = buttonAutoScroll; } /** * @return {ToggleOverlayButton} buttonAutoScroll **/ get autoScrollButton () { return this.buttonAutoScroll; } onScroll (e) { if (this.interval === null ) { // reached bottom if (this.element.scrollHeight - this.element.scrollTop === this.element.clientHeight) { this.startAutoScroll(); this.lastTimestamp = e.timeStamp; } } else { // remove auto-scroll if (!this.events.isKeyPressed("Alt") && (this.events.isAnyPressed(["PageUp", "PageDown"]) || this.events.mouseWheelEvent) && e.timeStamp - this.lastTimestamp > 1500) { this.stopAutoScroll(); } } } static getChatContainerFromDOM () { return query(selector("chatContainer")); } } class ChatInput extends ElementController { constructor (element, opacity) { super(element); this.opacity = opacity; } /** * @param {HTMLElement} el **/ set element(el) { super.element = el; // since the element of the controller can change set all // the listeners/attributes in the setter of the element this.id = "chat-input-fs"; } get element() { return super.element; } hide () { this.element.setAttribute('style', "display: none !important"); this.getRealInput().removeEventListener("click", this.onClick.bind(this)); } show () { this.element.setAttribute('style', "display: block !important"); this.getRealInput().addEventListener("click", this.onClick.bind(this)); } /** this class holds the input container * and with this function returns the * textarea element **/ getRealInput () { return this.element.querySelector(selector("realChatInput")); } /** * Moves the element to the initial parent **/ moveToInitialParent () { this.initialParent.insertBefore( this.element, this.initialParent.children[this.initialParent.children.length - 1] ); } onClick() { const input = this.getRealInput(); input.focus(); // triggers chat_input:focus-within input.setAttribute("data-focus-visible-added", ""); } static getChatInputFromDOM () { let node = query(selector("chatInputAreaWithoutOverlay")); return node; } } class UserCard extends ElementController { constructor (element) { super(element); } static getUserCardFromDOM () { return query(selector("chatUserCard")); } } class ToggleOverlayButton extends ElementController { /** * @param {ChatContainer} chatContainer * @param {ChatInput} chatInput * @param {Configuration} config * @param {int} preset **/ constructor (chatContainer, chatInput, config, preset) { super(createElementFromHTML(getOverlayTemplate(preset))); this.id = "toggle-chat-overlay-fs"; this.preset = preset; this.chatContainer = chatContainer; this.chatInput = chatInput; this.config = config; this.on("click", this.onClick); } /** * @param {int} preset **/ set preset(preset) { this.element.classList.replace("preset-" + this._preset, "preset-" + preset); this._preset = preset; } get preset () { return this._preset; } /** * Moves the element to the elemnt * @param {HTMLElement} to **/ move (to) { to.insertBefore(this.element, to.children[0]); } /** * @param {Event} e **/ onClick (e) { var isVOD = this.config.get("is_vod"); var matching = e.target.closest(this.idSelector); if (matching) { var preset = this.preset + 1; preset = preset === 3 ? 0 : (preset === 1 && isVOD ? 2 : preset); switch (preset) { case 0: this.chatContainer.show(); if (!isVOD) { this.chatInput.hide(); } break; case 1: this.chatInput.show(); break; case 2: this.chatContainer.hide(); break; } this.preset = preset; } } } class AutoScrollButton extends ElementController { /** * @param {ChatContainer} chatContainerInstance * @param {boolean} stop_on_hover **/ constructor (chatContainerInstance, stop_on_hover) { super( createElementFromHTML( getAutoScrollButtonTemplate( "display: none", stop_on_hover ? 1 : 0 ) ) ); this.chat = chatContainerInstance; this.on("click", this.onClick); } onClick () { this.chat.startAutoScroll(); this.hide(); } } class ModeratorMenu extends ElementController { /** * @param {Moderator} moderator * @param {ChatContainer} chatContainer **/ constructor (chatContainer) { super(createElementFromHTML(getModeratorMenuTemplate())); this.chat = chatContainer; this.on("click", this.onClick); this.hide(); } /** * @param {Moderator} moderator **/ set moderator (moderator) { this._moderator = moderator; } get moderator () { return this._moderator; } onClick (e) { var lastScrollTop = this.chat.scrollPosition; this.moderator.handleMenuClickEvent(e.target); // fix: twitch resetting the chat scroll to the top after ban... this.chat.scrollPosition = lastScrollTop; } centerMenu (chat_line, author_label, scrollPosition) { var top = chat_line.offsetTop - scrollPosition - this.element.clientHeight / 2; this.element.style.top = top + "px"; this.element.style.left = author_label.offsetLeft / 2 + "px"; } } /** * Abstract **/ class ReactInstance { constructor () { } // gets the property name that holds the react component instance static getReactInstance(element) { const key = Object.keys(element).find(k => k.toLowerCase().indexOf("internalinstance") >= 0); return key && element[key]; } // searches a react component that meets the condition static getReactProp(element, condition, max_deep = 30) { var node = this.getReactInstance(element); while (node && !condition(node) && max_deep > 0) { node = node.return; // node.return is the parent instance max_deep--; } return max_deep >= 0 ? node : null; } } class Twitch { /** * @param {ChatInput} chatInputInstance This element has all the props inside that we need, e.g. if the user is mod **/ constructor (chatInputInstance) { this.chat = chatInputInstance.element; this.lastUrl = window.location.href; this.setProps(); } /** * To update the props change the chat * @param {HTMLElement} chat **/ set chat (chat) { this._chat = chat; this.setProps(); } get chat () { return this._chat; } isCurrentUserModerator () { return this.props?.isCurrentUserModerator; } sendMessage (msg) { this.props.onSendMessage(msg); } setProps () { if (!this.props || this.lastUrl != window.location.href) { this.lastUrl = window.location.href; this.props = ReactInstance.getReactProp( this.chat, (node) => node.stateNode && node.stateNode.props && node.stateNode.props.onSendMessage ); if (this.props) { this.props = this.props.stateNode.props; } } } } /** This handles all the commands to ban/timeout and un-ban users * * You can directly ban a user by calling banUser, same with timeout. * But to handle the difference in time between the click on a user and * the time that the user choices an option, first you need to set the user * or the messageid. * Then handleMenuClickEvent uses this user to apply the command. * * https://help.twitch.tv/s/article/chat-commands **/ class Moderator { /** * @param {Twitch} twitch * @param {ModeratorMenu} moderatorMenu **/ constructor (twitch, moderatorMenu) { this.twitch = twitch; this.modMenu = moderatorMenu; } ban (user) { this.sendCommand("/ban " + user); } unban (user) { this.sendCommand("/unban " + user); } timeout (user, time) { this.sendCommand("/timeout " + user + " " + time); } deleteMessage (messageid) { this.sendCommand("/delete " + messageid); } handleMenuClickEvent (element) { if ("ban" in element.dataset) { this.ban(this.user); } else if ("unban" in element.dataset) { this.unban(this.user); } else if ("timeout" in element.dataset) { this.timeout(this.user, parseInt(element.dataset.timeout)); } else if ("deletemessage" in element.dataset) { this.deleteMessage(this.messageid); } this.modMenu.hide(); } /** * @param {string} user **/ set user (user) { this._user = user; } get user () { return this._user; } /** * @param {string} messageid **/ set messageid (messageid) { this._messageid = messageid; } get messageid () { return this._messageid; } sendCommand (command) { this.twitch.sendMessage(command); } } class Configuration { constructor (configuration) { this.config = configuration; } set(key, value) { this.config[key] = value; } get(key) { return this.config[key]; } } /** * This class keeps a register of the keys pressed (only the ones that you pass on observe) * * Get the key name (not key code) from this list https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values * or by pressing it https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code **/ class UserEvents { /** * @param {Array<String>} observe **/ constructor (observe = []) { this.keysPressed = []; this.observe = observe; document.body.addEventListener('keydown', (event) => { if (this.observe.includes(event.key)) { this.keyPressed(event.key); } }); document.body.addEventListener('keyup', (event) => { if (this.observe.includes(event.key)) { this.keyRealeased(event.key); } }); this._mouseWheelEvent = false; } isKeyPressed (key) { return this.keysPressed.indexOf(key) >= 0; } /** * Checks if any of the keys is pressed * @param {Array<String>} keys **/ isAnyPressed (keys) { return this.keysPressed.findIndex(k => keys.indexOf(k) >= 0) >= 0; } /** * @param {string} key **/ keyPressed (key) { if (!this.isKeyPressed(key)) { this.keysPressed.push(key); } } keyRealeased (key) { this.keysPressed = this.keysPressed.filter(k => k != key); } /** * Removes all keys pressed and not released until now **/ releaseAll () { this.keysPressed = []; } /** * @param {string} key **/ observeKey (key) { this.observe.push(key); } /** * @param {boolean} value **/ set mouseWheelEvent(value) { this._mouseWheelEvent = value; } /** * @return {boolean} is mouse wheel event **/ get mouseWheelEvent() { return this._mouseWheelEvent; } } class ChatFS { constructor () { this.enabled = null; } get preset() { return this.toggleOverlayButton.preset; } onClick (e) { const { target } = e; if (!isVOD && this.twitch.isCurrentUserModerator()) { if (target.classList.value.indexOf("author") >= 0) { var usermessage = ReactInstance.getReactProp(target, node => node && node.stateNode && node.stateNode.props && node.stateNode.props.message); if (usermessage) { const { id: messageid, user } = usermessage.stateNode.props.message; this.moderator.messageid = messageid; this.moderator.user = user.userLogin; if (!this.events.isKeyPressed("Control")) { var chat_line = e.path.find(el => el.classList.contains("chat-line__message")); // chat_line should be the the div that contains the message content this.modMenu.centerMenu(chat_line, target, this.chatContainer.scrollPosition); this.modMenu.show(); } else { this.moderator.timeoutUser(user.userLogin, 60); } } e.stopPropagation(); } } } onWheel (e) { if (this.events.isKeyPressed("Alt")) { e.preventDefault(); var bcop = this.config.get("overlay_opacity"); bcop += 5 * e.deltaY / Math.abs(e.deltaY); bcop = bcop > 100 ? 100 : (bcop < 0 ? 0 : bcop); this.config.set("overlay_opacity", bcop); this.overlay.opacity = bcop; this.chatContainer.opacity = bcop; this.chatInput.opacity = bcop; } else { this.events.mouseWheelEvent = true; // force to change the scroll position this.chatContainer.scrollPosition = this.chatContainer.scrollPosition + e.deltaY; } } onMouseEnter (e) { if (!this.config.get("is_vod")) { this.chatInput.show(); this.chatInput.opacity = this.config.get("overlay_opacity"); } if (e.target.classList.contains("chat-line__message") && this.config.get("stop_on_hover")) { this.chatContainer.stopAutoScroll(); } } onMouseLeave () { if (!this.config.get("is_vod") && this.preset != 1) { this.chatInput.hide(); } if (this.config.get("stop_on_hover")) { this.chatContainer.startAutoScroll(); } } hide () { this.overlay.hide(); /// move chat this.chatContainer.moveToInitialParent(); this.chatContainer.stopAutoScroll(); this.chatContainer.opacity = 0; if (!this.config.get("is_vod")) { this.chatInput.moveToInitialParent(); this.chatInput.show(); } // hide scroll button this.autoScrollButton.hide(); this.toggleOverlayButton.hide(); if (!this.config.get("is_vod") && !this.twitch.isCurrentUserModerator() && this.userCard.element) { this.userCard.moveToInitialParent(); } this.enabled = false; } show () { this.overlay.height = this.config.get("overlay_height"); this.overlay.width = this.config.get("overlay_width"); this.overlay.moveToPlayer(); this.overlay.show(); // update chat container element bc it changes when you change the channel this.chatContainer.element = query(selector("chatContainer")); this.chatContainer.move(this.overlay.element); if (!this.config.get("is_vod")) { this.chatInput.element = ChatInput.getChatInputFromDOM(); this.twitch.chat = this.chatInput.element; if (this.preset !== 1) { this.chatInput.hide(); } // Set the background of the chat input to transparent var textarea = this.chatInput.getRealInput(); if (textarea) { textarea.style.background = "transparent"; } this.chatInput.move(this.overlay.element); this.chatInput.opacity = this.config.get("overlay_opacity"); console.log("opacity:", this.config.get("overlay_opacity")); } this.chatContainer.startAutoScroll(); this.toggleOverlayButton.move(query(selector("playerLayoutControls"))); this.toggleOverlayButton.show(); /// set video close to the bottom. Is not relevant to the overlay but it looks nice // query(selector("playerContainer")).style.bottom = "0px" if (!this.config.get("is_vod") && !this.twitch.isCurrentUserModerator() && this.userCard.element) { this.userCard.move(this.overlay.element); } if (this.preset === 2) { this.overlay.hide(); } this.enabled = true; } setEvents () { this.overlay.on("click", this.onClick.bind(this)); this.overlay.on("wheel", this.onWheel.bind(this)); this.overlay.on("mouseenter", this.onMouseEnter.bind(this)); this.overlay.on("mouseleave", this.onMouseLeave.bind(this)); } create () { let styles = getModeratorMenuStyle() + getStyles() /*add all the others styles*/; this.config = new Configuration({ // chat stop_on_hover: false, // overlay overlay_width: window.innerWidth * 0.23, overlay_height: window.innerHeight * 0.5, overlay_opacity: 23, initial_preset: 0, is_vod: window.location.pathname.indexOf("video") >= 0 }); this.events = new UserEvents(["Alt", "PageUp", "PageDown", "Control"]); this.overlay = new OverlayContainer( this.config.get("overlay_opacity"), this.config.get("overlay_width"), this.config.get("overlay_height"), this.onChangeOverlayHeight.bind(this), this.onChangeOverlayWidth.bind(this) ); this.chatContainer = new ChatContainer( ChatContainer.getChatContainerFromDOM(), this.config.get("overlay_opacity"), this.events ); this.chatInput = new ChatInput( ChatInput.getChatInputFromDOM(), this.config.get("overlay_opacity") ); this.userCard = new UserCard( UserCard.getUserCardFromDOM() ); this.toggleOverlayButton = new ToggleOverlayButton( this.overlay, this.chatInput, this.config, this.config.get("initial_preset") ); this.autoScrollButton = new AutoScrollButton( this.chatContainer, this.config.get("stop_on_hover") ); this.modMenu = new ModeratorMenu(this.chatContainer); this.twitch = new Twitch(this.chatInput); this.moderator = new Moderator(this.twitch, this.modMenu); this.modMenu.moderator = this.moderator; this.chatContainer.buttonAutoScroll = this.autoScrollButton; this.modMenu.move(this.overlay.element); this.autoScrollButton.move(this.overlay.element); this.setEvents(); // Clear UserEvents when the window lost focus window.addEventListener('blur', () => { this.events.releaseAll(); }); insertStyle(styles); } toggle () { // if enabled is null then is the first time it's toggled. if (this.enabled === null) { this.create(); } // is vod? this.config.set("is_vod", window.location.pathname.indexOf("video") >= 0); // restart key press events this.events.releaseAll(); SolutionBTTVPinIssue(); // Handle toggle overlay when user enters fullscreen mode if (!this.enabled) { this.show(); } else { this.hide(); } } onChangeOverlayHeight(height) { this.config.set("overlay_height", height); } onChangeOverlayWidth(width) { this.config.set("overlay_width", width); } } function main() { var isMouseEvent = null; var overlay = new ChatFS(); // allow changes from console for testing window.overlay = overlay; window.selector = selector; window.query = query; //// TOGGLE OVERLAY ON FULL SCREEN: // If the button is pressed, the "click" event is triggered first and the isMouseEvent is set to // true so that when the fullscreen event is triggered it doesnt toggle the overlay again. query(selector("fullScreenButton")).addEventListener("click", (e) => { e.preventDefault(); isMouseEvent = true; overlay.toggle(); }); document.onfullscreenchange = () => { if (!isMouseEvent) { overlay.toggle(); } isMouseEvent = false; }; } })();