//////////////////////
// GLOBAL VARIABLES //
//////////////////////
const avatarMap = new Map();
const pronounMap = new Map();
//////////////////////
// HELPER FUNCTIONS //
//////////////////////
function GetBooleanParam(paramName, defaultValue) {
const urlParams = new URLSearchParams(window.location.search);
const paramValue = urlParams.get(paramName);
if (paramValue === null) {
return defaultValue; // Parameter not found
}
const lowercaseValue = paramValue.toLowerCase(); // Handle case-insensitivity
if (lowercaseValue === 'true') {
return true;
} else if (lowercaseValue === 'false') {
return false;
} else {
return paramValue; // Return original string if not 'true' or 'false'
}
}
function GetIntParam(paramName, defaultValue) {
const urlParams = new URLSearchParams(window.location.search);
const paramValue = urlParams.get(paramName);
if (paramValue === null) {
return defaultValue; // or undefined, or a default value, depending on your needs
}
const intValue = parseInt(paramValue, 10); // Parse as base 10 integer
if (isNaN(intValue)) {
return null; // or handle the error in another way, e.g., throw an error
}
return intValue;
}
async function GetKickIds(username) {
// First attempt with the original username
let url = `https://kick.com/api/v2/channels/${username}`;
try {
let response = await fetch(url);
if (!response.ok) {
// Retry with underscores replaced by dashes
const altUsername = username.replace(/_/g, "-");
url = `https://kick.com/api/v2/channels/${altUsername}`;
response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
}
const data = await response.json();
if (data.chatroom && data.chatroom.id) {
return { chatroomId: data.chatroom.id, channelId: data.chatroom.channel_id };
} else {
throw new Error("Chatroom ID not found in response.");
}
} catch (error) {
console.error("Failed to fetch chatroom ID:", error.message);
return null;
}
}
async function GetKickSubBadges(username) {
let url = `https://kick.com/api/v2/channels/${username}`;
try {
let response = await fetch(url);
if (!response.ok) {
// Retry with underscores replaced by dashes
const altUsername = username.replace(/_/g, "-");
url = `https://kick.com/api/v2/channels/${altUsername}`;
response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
}
const data = await response.json();
return data.subscriber_badges || [];
} catch (error) {
console.error("Failed to fetch subscriber badges:", error.message);
return [];
}
}
async function GetAvatar(username, platform) {
// First, check if the username is hashed already
if (avatarMap.has(`${username}-${platform}`)) {
console.debug(`Avatar found for ${username} (${platform}). Retrieving from hash map.`);
return avatarMap.get(`${username}-${platform}`);
}
// If code reaches this point, the username hasn't been hashed, so retrieve avatar
switch (platform) {
case 'twitch': {
console.debug(`No avatar found for ${username} (${platform}). Retrieving from Decapi.`);
let response = await fetch('https://decapi.me/twitch/avatar/' + username);
let data = await response.text();
avatarMap.set(`${username}-${platform}`, data);
return data;
}
case 'kick': {
console.debug(`No avatar found for ${username} (${platform}). Retrieving from Kick.`);
let url = `https://kick.com/api/v2/channels/${username}`;
try {
let response = await fetch(url);
if (!response.ok) {
const altUsername = username.replace(/_/g, "-");
url = `https://kick.com/api/v2/channels/${altUsername}`;
response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
}
let data = await response.json();
const defaultAvatars = [
'https://kick.com/img/default-profile-pictures/default-avatar-1.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-2.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-3.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-4.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-5.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-6.webp',
];
const avatarURL =
data.user?.profile_pic ||
defaultAvatars[hashString(`${username}-${platform}`) % defaultAvatars.length];
avatarMap.set(`${username}-${platform}`, avatarURL);
return avatarURL;
} catch (error) {
console.error("Failed to fetch avatar:", error.message);
const defaultAvatars = [
'https://kick.com/img/default-profile-pictures/default-avatar-1.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-2.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-3.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-4.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-5.webp',
'https://kick.com/img/default-profile-pictures/default-avatar-6.webp',
];
return defaultAvatars[hashString(`${username}-${platform}`) % defaultAvatars.length];
}
function hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0; // convert to 32-bit int
}
return Math.abs(hash);
}
}
}
}
async function GetPronouns(platform, username) {
if (pronounMap.has(username)) {
console.debug(`Pronouns found for ${username}. Retrieving from hash map.`)
return pronounMap.get(username);
}
else {
console.debug(`No pronouns found for ${username}. Retrieving from alejo.io.`)
const response = await client.getUserPronouns(platform, username);
const userFound = response.pronoun.userFound;
const pronouns = userFound ? `${response.pronoun.pronounSubject}/${response.pronoun.pronounObject}` : '';
pronounMap.set(username, pronouns);
return pronouns;
}
}
function RenderKickEmotes(message) {
const emoteRegex = /\[emote:(\d+):([^\]]+)\]/g;
return message.replace(emoteRegex, (_, id, name) => {
const imgUrl = `https://files.kick.com/emotes/${id}/fullsize`;
return `
`;
});
}
function GetCurrentTimeFormatted() {
const now = new Date();
let hours = now.getHours();
const minutes = String(now.getMinutes()).padStart(2, '0');
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
const formattedTime = `${hours}:${minutes} ${ampm}`;
return formattedTime;
}
function DecodeHTMLString(html) {
var txt = document.createElement("textarea");
txt.innerHTML = html;
return txt.value;
}
// Simple HTML escape function to prevent XSS attacks
function EscapeHTML(str) {
return str.replace(/[&<>"']/g, match => {
const escape = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
return escape[match];
});
}
function TranslateToFurry(sentence) {
// Split on
tags, keeping them in the result
const parts = sentence.split(/(
]*>)/gi);
const furryParts = parts.map(part => {
// If this part is an
, leave it unchanged
if (/^
]*>$/.test(part)) {
return part;
}
// Otherwise, apply furry translation
const words = part.toLowerCase().split(/\b/);
return words.map(word => {
if (/\w+/.test(word)) {
let newWord = word;
// Common substitutions
newWord = newWord.replace(/l/g, 'w');
newWord = newWord.replace(/r/g, 'w');
newWord = newWord.replace(/th/g, 'f');
newWord = newWord.replace(/you/g, 'yous');
newWord = newWord.replace(/my/g, 'mah');
newWord = newWord.replace(/me/g, 'meh');
newWord = newWord.replace(/am/g, 'am');
newWord = newWord.replace(/is/g, 'is');
newWord = newWord.replace(/are/g, 'are');
newWord = newWord.replace(/very/g, 'vewy');
newWord = newWord.replace(/pretty/g, 'pwetty');
newWord = newWord.replace(/little/g, 'wittle');
newWord = newWord.replace(/nice/g, 'nyce');
// Random additions
if (Math.random() < 0.15) {
newWord += ' nya~';
} else if (Math.random() < 0.1) {
newWord += ' >w<';
} else if (Math.random() < 0.05) {
newWord += ' owo';
}
return newWord;
}
return word;
}).join('');
});
return furryParts.join('');
}
// Given a string, return a random hex code. The same input always results in the same output
function RandomHex(str) {
// Simple hash to convert string to a number
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
hash |= 0;
}
// Use the hash to generate a hue (0–360)
const hue = Math.abs(hash) % 360;
// Force super saturation + readable brightness
const saturation = 100; // maximum saturation
const lightness = 55; // tweak 50–60 depending on your background
// Convert HSL → RGB → hex
function hslToHex(h, s, l) {
s /= 100;
l /= 100;
const k = n => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = n =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const r = Math.round(255 * f(0));
const g = Math.round(255 * f(8));
const b = Math.round(255 * f(4));
return (
"#" +
r.toString(16).padStart(2, "0") +
g.toString(16).padStart(2, "0") +
b.toString(16).padStart(2, "0")
);
}
return hslToHex(hue, saturation, lightness);
}
// Used to construct a message from "parts" variable commonly found in Streamer.bot chat messages
function ConstructMessageFromParts(parts) {
return parts.map(part => {
if (part.emoji)
return `
`;
if (!part.type)
return EscapeHTML(part.text);
if (part.source == 'Twemoji')
return EscapeHTML(part.text);
switch (part.type)
{
case "text":
return EscapeHTML(part.text);
case "cheer":
// TODO: It seems like Streamer.bot v1.0.5-alpha3 doesn't include the imageUrl for bits in the message parts, only the bits count. We may need to hardcode the image URL format for Twitch bits, or find another way to retrieve it.
let imageUrl = '';
if (!part.imageUrl) {
const tiers = [100000, 10000, 5000, 1000, 100, 10, 1];
const activeTier = tiers.find(tier => part.bits >= tier) || 1;
part.imageUrl = `https://d3aqoihi2n8ty8.cloudfront.net/actions/cheer/dark/animated/${activeTier}/4.gif`;
}
// Render the cheer emote image
const emoteImg = `
`;
// Render the bits count
const bitLabel = `${EscapeHTML(part.bits.toString())}`;
return emoteImg + bitLabel;
case "mention":
return part.text;
default:
return `
`;
}
}).join('');
}
// Used to construct a message from "emotes" variable commonly found in Streamer.bot chat messages
function RenderMessageWithEmotesHTML(originalMessage, emotes) {
if (!emotes || emotes.length === 0) return originalMessage;
// Sort emotes by startIndex
emotes.sort((a, b) => a.startIndex - b.startIndex);
let html = '';
let cursor = 0;
emotes.forEach(emote => {
// Add text before the emote
if (emote.startIndex > cursor) {
html += EscapeHTML(originalMessage.slice(cursor, emote.startIndex));
}
// Add emote image
html += `
`;
cursor = emote.endIndex + 1;
});
// Add remaining text after last emote
if (cursor < originalMessage.length) {
html += EscapeHTML(originalMessage.slice(cursor));
}
return html;
}
async function LoadHTMLTemplate(name) {
const response = await fetch(name);
if (!response.ok) {
throw new Error(`Failed to load template: ${name}`);
}
const html = await response.text();
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
const template = tempDiv.querySelector("template");
if (!template) {
throw new Error(`No found in ${name}.html`);
}
// Avoid duplicate registration
if (!document.getElementById(template.id)) {
document.body.appendChild(template.cloneNode(true));
}
}
function ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(time) {
if (isNaN(time) || time <= 0) return "0:00";
const totalSeconds = Math.floor(time / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
// Format seconds with a leading zero
const paddedSeconds = ('0' + seconds).slice(-2);
if (hours > 0) {
// Format minutes with a leading zero if hours are present
const paddedMinutes = ('0' + minutes).slice(-2);
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
}
return `${minutes}:${paddedSeconds}`;
}
async function GetAccentPalette(imageUrl) {
// 1. Dynamic Loader: Load Vibrant.js
if (typeof Vibrant === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = "https://cdnjs.cloudflare.com/ajax/libs/node-vibrant/3.1.6/vibrant.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
return new Promise((resolve) => {
// Vibrant can take the URL directly!
Vibrant.from(imageUrl).getPalette((err, palette) => {
if (err) {
console.warn("Vibrant failed, using fallback.");
return resolve({
Vibrant: "#ffffff",
Muted: "#cccccc",
DarkVibrant: "#000000"
});
}
// Extract Hex from each swatch
const hexPalette = {};
for (let role in palette) {
if (palette[role]) {
hexPalette[role] = palette[role].getHex();
}
}
resolve(hexPalette);
});
});
}
// Generic popup
function SplashscreenPopup(iconSrc, title, subtitle, attribute, background, button) {
// 1. Check if a popup is already on screen
const existingOverlay = document.getElementById('global-common-overlay');
// If it exists AND isn't already in the middle of fading out, DO NOTHING
if (existingOverlay && !existingOverlay.classList.contains('common-closing')) {
return null;
}
// If there IS a popup but it IS fading out, kill it instantly to make room for the new one
if (existingOverlay) {
existingOverlay.remove();
}
// 2. Inject global styles if they don't already exist in the head
if (!document.getElementById('global-common-popup-styles')) {
const styleSheet = document.createElement('style');
styleSheet.id = 'global-common-popup-styles';
styleSheet.innerText = `
#global-common-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: transparent;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
animation: commonFadeIn 2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.common-popup-card {
width: 90%;
max-width: 500px;
border-radius: 24px;
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
box-shadow: 0 30px 60px rgba(0,0,0,0.4),
0 0 0 1px rgba(255, 255, 255, 0.08);
animation: commonScaleIn 2.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
gap: 32px;
padding: 32px;
}
.common-popup-icon-container {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.common-popup-img {
height: 48px;
width: auto;
object-fit: contain;
display: block;
}
.common-popup-text-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
text-align: left;
gap: 8px;
}
.common-popup-title {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 22px;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.02em;
line-height: 1.2;
}
.common-popup-title:last-child {
margin-bottom: 0;
}
.common-popup-subtitle {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.65);
margin: 0;
line-height: 1.5;
}
.common-popup-attribute {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
word-break: break-all;
}
/* 💎 GLASSMORPHIC BUTTON STYLES */
.common-popup-button {
margin-top: 6px;
padding: 10px 20px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
font-family: system-ui, sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
text-align: center;
width: fit-content;
}
.common-popup-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
.common-popup-button:active {
transform: scale(0.97);
}
@keyframes commonFadeIn {
from { opacity: 0; } to { opacity: 1; }
}
@keyframes commonScaleIn {
from { transform: scale(0.95) translateY(10px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
#global-common-overlay.common-closing {
animation: commonFadeOut 2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.common-closing .common-popup-card {
animation: commonScaleOut 2.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes commonFadeOut {
from { opacity: 1; } to { opacity: 0; }
}
@keyframes commonScaleOut {
from { transform: scale(1) translateY(0); opacity: 1; }
to { transform: scale(0.95) translateY(10px); opacity: 0; }
}
`;
document.head.appendChild(styleSheet);
}
// 3. Create elements
const overlay = document.createElement('div');
overlay.id = 'global-common-overlay';
const card = document.createElement('div');
card.className = 'common-popup-card';
card.style.background = background || 'linear-gradient(135deg, #1a1a1e 0%, #111113 100%)';
// 4. Construct Inner Content (Strict Conditional Rendering)
const iconMarkup = iconSrc
? ``
: '';
const titleMarkup = title
? ``
: '';
const subtitleMarkup = subtitle
? ``
: '';
const attributeMarkup = attribute
? ``
: '';
// Render button conditional markup
const buttonMarkup = (button && button.text)
? ``
: '';
card.innerHTML = `
${iconMarkup}
`;
// 5. Setup Action Listeners if Button Exists
if (button && button.text && button.action) {
const actionBtn = card.querySelector('.common-popup-button');
if (actionBtn) {
actionBtn.addEventListener('click', () => {
if (typeof button.action === 'function') {
button.action();
} else if (typeof button.action === 'string') {
window.open(button.action, '_blank');
}
});
}
}
// 6. Append to page DOM
overlay.appendChild(card);
document.body.appendChild(overlay);
return {
element: overlay,
close: () => {
overlay.classList.add('common-closing');
overlay.addEventListener('animationend', (event) => {
if (event.target === overlay) {
overlay.remove();
}
});
}
};
}
function VersionCheck(requiredVersion, installedVersion) {
const [rMajor, rMinor, rPatch] = requiredVersion.split('.').map(Number);
const [iMajor, iMinor, iPatch] = installedVersion.split('.').map(Number);
// 1. CRITICAL: Major versions must match exactly.
if (iMajor !== rMajor) {
console.log(`VersionCheck: Major version mismatch. Required: ${rMajor}, Installed: ${iMajor}`);
return 'incompatible';
}
// 2. SOFT WARNING: The installed minor version is lower than what is required.
if (iMinor < rMinor) {
console.log(`VersionCheck: Minor version mismatch. Required: ${rMinor}, Installed: ${iMinor}`);
return 'soft-warning';
}
// 3. SILENT WARNING: Minors match, but the installed patch version is lagging.
if (iMinor === rMinor && iPatch < rPatch) {
console.log(`VersionCheck: Patch version mismatch. Required: ${rPatch}, Installed: ${iPatch}`);
return 'compatible';
}
// 4. PERFECT: Installed version meets or exceeds the required baseline.
console.log(`VersionCheck: Installed version meets or exceeds the required baseline. Required: ${requiredVersion}, Installed: ${installedVersion}`);
return 'compatible';
}
function SanitizeHTML(htmlString) {
// 1. Parse the string into a temporary DOM document
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
// 2. Define allowed tags and attributes
const allowedTags = ['IMG', 'SPAN', 'B', 'I', 'BR', 'EM', 'STRONG'];
const allowedAttrs = ['src', 'class'];
// 3. Walk through all elements in the parsed document
const allElements = doc.body.querySelectorAll('*');
allElements.forEach(el => {
// Check if it's an
tag and enforce class restrictions
if (el.tagName === 'IMG') {
const className = el.getAttribute('class') || '';
// Split classes by whitespace in case there are multiple (e.g., class="emote custom")
const classes = className.split(/\s+/);
// Must contain either 'emote' or 'bits' to be allowed
const hasValidClass = classes.includes('emote') || classes.includes('bits');
if (!hasValidClass) {
// Unwrap or remove the unauthorized
tag
const parent = el.parentNode;
while (el.firstChild) {
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
return;
}
}
// If any other tag isn't in our allowed list, unwrap it
if (!allowedTags.includes(el.tagName)) {
const parent = el.parentNode;
while (el.firstChild) {
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
return;
}
// Strip out any attributes not explicitly allowed (like onerror, onload, etc.)
Array.from(el.attributes).forEach(attr => {
if (!allowedAttrs.includes(attr.name.toLowerCase())) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
}