Merge pull request #33 from nuttylmao/beta

Beta
This commit is contained in:
nutty
2026-09-01 16:19:00 +10:00
committed by GitHub
35 changed files with 655 additions and 158 deletions
+42
View File
@@ -188,6 +188,21 @@ function LoadJSON(settingsJson) {
await PopulateFontDatalist();
}, { once: true });
break;
case 'timezone':
inputElement = document.createElement('input');
inputElement.type = 'text';
inputElement.placeholder = 'Type to search timezone...';
inputElement.id = setting.id;
inputElement.value = settingsMap.has(setting.id) ? settingsMap.get(setting.id) : setting.defaultValue;
inputElement.setAttribute('list', 'timezones');
inputElement.autocomplete = 'off';
// Populate datalist on focus (runs once)
inputElement.addEventListener('focus', function loadOnce() {
inputElement.removeEventListener('focus', loadOnce);
PopulateTimezoneDatalist();
}, { once: true });
break;
case 'button':
inputElement = document.createElement('button');
inputElement.id = setting.id; //Added setting ID
@@ -431,6 +446,30 @@ async function PopulateFontDatalist() {
}
}
function PopulateTimezoneDatalist() {
try {
// Fetch all official IANA timezones natively from the browser
const timezones = Intl.supportedValuesOf('timeZone');
// Create the datalist element
const datalistElement = document.createElement('datalist');
datalistElement.id = 'timezones';
// Append each timezone as an option
timezones.forEach(tz => {
const option = document.createElement('option');
option.value = tz;
datalistElement.appendChild(option);
});
document.body.appendChild(datalistElement);
console.debug(`Loaded ${timezones.length} timezones into auto-suggest.`);
} catch (err) {
console.error("Error generating timezone suggestions:", err);
}
}
/////////////////////////
@@ -600,3 +639,6 @@ LoadJSON(settingsJson);
// Populate local fonts for auto-suggest
PopulateFontDatalist();
// Populate timezones for auto-suggest
PopulateTimezoneDatalist();
+59 -15
View File
@@ -688,25 +688,69 @@ 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';
// Compare Major
if (iMajor > rMajor) return 'compatible';
if (iMajor < rMajor) return 'incompatible';
// Major matches, compare Minor
if (iMinor > rMinor) return 'compatible';
if (iMinor < rMinor) return 'incompatible'; // or 'incompatible' depending on your policy
// Major and Minor match, compare Patch
if (iPatch >= rPatch) return 'compatible';
return 'soft-warning'; // Installed patch is lower than required
}
// 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';
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 <img> 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 <img> tag
const parent = el.parentNode;
while (el.firstChild) {
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
return;
}
}
// 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';
// 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;
}
// 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';
// 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;
}
+28 -12
View File
@@ -26,7 +26,11 @@ const line3 = document.getElementById('line3');
// OPTIONS //
/////////////
const language = urlParams.get("language") || "en";
const timezone = urlParams.get("timezone") || "";
const font = urlParams.get("font") || "";
const textAlignment = urlParams.get("textAlignment") || "center";
const enableLine1 = GetBooleanParam("enableLine1", true);
const line1Format = urlParams.get("line1Format") || "hh:mm:ss A";
@@ -35,7 +39,6 @@ const line1FontWeight = urlParams.get("line1FontWeight") || "700";
const line1FontColor = urlParams.get("line1FontColor") || "#ffffff";
const line1FontOpacity = urlParams.get("line1FontOpacity") || "1";
const line1TextTransform = urlParams.get("line1TextTransform") || "none";
const line1TextAlignment = urlParams.get("line1TextAlignment") || "center";
const enableLine2 = GetBooleanParam("enableLine2", true);
const line2Format = urlParams.get("line2Format") || "ddd, MMM D";
@@ -44,7 +47,6 @@ const line2FontWeight = urlParams.get("line2FontWeight") || "400";
const line2FontColor = urlParams.get("line2FontColor") || "#ffffff";
const line2FontOpacity = urlParams.get("line2FontOpacity") || "0.7";
const line2TextTransform = urlParams.get("line2TextTransform") || "none";
const line2TextAlignment = urlParams.get("line2TextAlignment") || "center";
const enableLine3 = GetBooleanParam("enableLine3", false);
const line3Format = urlParams.get("line3Format") || "ddd DD MMM YYYY hh:mm:ss A z";
@@ -53,7 +55,6 @@ const line3FontWeight = urlParams.get("line3FontWeight") || "600";
const line3FontColor = urlParams.get("line3FontColor") || "#ffffff";
const line3FontOpacity = urlParams.get("line3FontOpacity") || "1";
const line3TextTransform = urlParams.get("line3TextTransform") || "none";
const line3TextAlignment = urlParams.get("line3TextAlignment") || "center";
@@ -75,10 +76,12 @@ if (!enableLine3)
////////////
////////////////
// CLOCKO //
////////////
////////////////
// Helper function to start the clock loop
function StartClock() {
// Check if any active format string includes milliseconds (e.g., 'S', 'SS', 'SSS')
const usesMilliseconds =
(enableLine1 && line1Format.includes('S')) ||
@@ -88,32 +91,45 @@ const usesMilliseconds =
UpdateTime();
if (usesMilliseconds) {
// High-frequency updates for millisecond precision
function updateFrame() {
UpdateTime();
requestAnimationFrame(updateFrame);
}
requestAnimationFrame(updateFrame);
} else {
// Efficient 1-second interval for standard clocks
setInterval(UpdateTime, 1000);
}
}
function UpdateTime() {
const now = dayjs().tz(dayjs.tz.guess());
const activeTimezone = timezone ? timezone : dayjs.tz.guess();
const now = dayjs().tz(activeTimezone);
if (enableLine1) line1.textContent = now.format(line1Format);
if (enableLine2) line2.textContent = now.format(line2Format);
if (enableLine3) line3.textContent = now.format(line3Format);
}
// Set the language and start the clock only after it's loaded
const script = document.createElement('script');
script.src = `https://cdn.jsdelivr.net/npm/dayjs@1/locale/${language}.js`;
script.onload = () => {
dayjs.locale(language);
StartClock();
};
script.onerror = () => {
console.warn(`Locale '${language}' failed to load. Falling back to English.`);
dayjs.locale('en');
StartClock();
};
document.head.appendChild(script);
/////////////
// STYLING //
/////////////
function ApplyStyling(el, fontSize, fontWeight, fontColor, fontOpacity, textTransform, textAlignment) {
function ApplyStyling(el, fontSize, fontWeight, fontColor, fontOpacity, textTransform) {
el.style.fontSize = fontSize + "px";
el.style.fontWeight = fontWeight;
el.style.color = fontColor;
@@ -122,6 +138,6 @@ function ApplyStyling(el, fontSize, fontWeight, fontColor, fontOpacity, textTran
el.style.textAlign = textAlignment;
}
ApplyStyling(line1, line1FontSize, line1FontWeight, line1FontColor, line1FontOpacity, line1TextTransform, line1TextAlignment);
ApplyStyling(line2, line2FontSize, line2FontWeight, line2FontColor, line2FontOpacity, line2TextTransform, line2TextAlignment);
ApplyStyling(line3, line3FontSize, line3FontWeight, line3FontColor, line3FontOpacity, line3TextTransform, line3TextAlignment);
ApplyStyling(line1, line1FontSize, line1FontWeight, line1FontColor, line1FontOpacity, line1TextTransform);
ApplyStyling(line2, line2FontSize, line2FontWeight, line2FontColor, line2FontOpacity, line2TextTransform);
ApplyStyling(line3, line3FontSize, line3FontWeight, line3FontColor, line3FontOpacity, line3TextTransform);
+62 -42
View File
@@ -1,5 +1,54 @@
{
"settings": [
{
"id": "language",
"label": "Language",
"description": "",
"type": "select",
"options": [
{ "value": "en", "label": "English" },
{ "value": "ar", "label": "العربية (Arabic)" },
{ "value": "bg", "label": "Български (Bulgarian)" },
{ "value": "zh-cn", "label": "中文 (Chinese Simplified)" },
{ "value": "zh-tw", "label": "中文 (Chinese Traditional)" },
{ "value": "cs", "label": "Čeština (Czech)" },
{ "value": "da", "label": "Dansk (Danish)" },
{ "value": "nl", "label": "Nederlands (Dutch)" },
{ "value": "fi", "label": "Suomi (Finnish)" },
{ "value": "fr", "label": "Français (French)" },
{ "value": "de", "label": "Deutsch (German)" },
{ "value": "el", "label": "Ελληνικά (Greek)" },
{ "value": "he", "label": "עברית (Hebrew)" },
{ "value": "hi", "label": "हिन्दी (Hindi)" },
{ "value": "hu", "label": "Magyar (Hungarian)" },
{ "value": "id", "label": "Bahasa Indonesia (Indonesian)" },
{ "value": "it", "label": "Italiano (Italian)" },
{ "value": "ja", "label": "日本語 (Japanese)" },
{ "value": "ko", "label": "한국어 (Korean)" },
{ "value": "nb", "label": "Norsk bokmål (Norwegian Bokmål)" },
{ "value": "pl", "label": "Polski (Polish)" },
{ "value": "pt", "label": "Português (Portuguese)" },
{ "value": "ro", "label": "Română (Romanian)" },
{ "value": "ru", "label": "Русский (Russian)" },
{ "value": "sk", "label": "Slovenčina (Slovak)" },
{ "value": "es", "label": "Español (Spanish)" },
{ "value": "sv", "label": "Svenska (Swedish)" },
{ "value": "th", "label": "ไทย (Thai)" },
{ "value": "tr", "label": "Türkçe (Turkish)" },
{ "value": "uk", "label": "Українська (Ukrainian)" },
{ "value": "vi", "label": "Tiếng Việt (Vietnamese)" }
],
"defaultValue": "en",
"group": "General"
},
{
"id": "timezone",
"label": "Timezone",
"description": "Enter an IANA timezone (e.g., America/New_York, Europe/London). Leave blank to use local time.",
"type": "timezone",
"defaultValue": "",
"group": "General"
},
{
"id": "font",
"label": "Font",
@@ -8,6 +57,19 @@
"defaultValue": "",
"group": "Appearance"
},
{
"id": "textAlignment",
"label": "Text Alignment",
"description": "",
"type": "select",
"options": [
{ "value": "left", "label": "Left" },
{ "value": "center", "label": "Center" },
{ "value": "right", "label": "Right" }
],
"defaultValue": "center",
"group": "Appearance"
},
{
"id": "enableLine1",
"label": "Enable Line 1",
@@ -92,20 +154,6 @@
"showIf": "enableLine1",
"group": "Appearance"
},
{
"id": "line1TextAlignment",
"label": "Text Alignment",
"description": "",
"type": "select",
"options": [
{ "value": "left", "label": "Left" },
{ "value": "center", "label": "Center" },
{ "value": "right", "label": "Right" }
],
"defaultValue": "center",
"showIf": "enableLine1",
"group": "Appearance"
},
{
"id": "enableLine2",
"label": "Enable Line 2",
@@ -190,20 +238,6 @@
"showIf": "enableLine2",
"group": "Appearance"
},
{
"id": "line2TextAlignment",
"label": "Text Alignment",
"description": "",
"type": "select",
"options": [
{ "value": "left", "label": "Left" },
{ "value": "center", "label": "Center" },
{ "value": "right", "label": "Right" }
],
"defaultValue": "center",
"showIf": "enableLine2",
"group": "Appearance"
},
{
"id": "enableLine3",
"label": "Enable Line 3",
@@ -287,20 +321,6 @@
"defaultValue": "none",
"showIf": "enableLine3",
"group": "Appearance"
},
{
"id": "line3TextAlignment",
"label": "Text Alignment",
"description": "",
"type": "select",
"options": [
{ "value": "left", "label": "Left" },
{ "value": "center", "label": "Center" },
{ "value": "right", "label": "Right" }
],
"defaultValue": "center",
"showIf": "enableLine3",
"group": "Appearance"
}
]
}
+9 -4
View File
@@ -75,6 +75,7 @@ const showPatreonMemberships = GetBooleanParam("showPatreonMemberships", true);
const showKofiDonations = GetBooleanParam("showKofiDonations", true);
const showTipeeeStreamDonations = GetBooleanParam("showTipeeeStreamDonations", true);
const showFourthwallAlerts = GetBooleanParam("showFourthwallAlerts", true);
const skipFourthwallFreeOrders = GetBooleanParam("skipFourthwallFreeOrders", true);
const furryMode = GetBooleanParam("furryMode", false);
@@ -782,12 +783,12 @@ async function TwitchRewardRedemption(data) {
if (!showTwitchChannelPointRedemptions)
return;
let username = data.user_name;
if (data.user_name.toLowerCase() != data.user_login.toLowerCase())
username = `${data.user_name} (${data.user_login})`;
let username = data.user_name ?? data.user.name;
if (username.toLowerCase() != (data.user_login ?? data.user.login).toLowerCase())
username = `${username} (${data.user_login ?? data.user.login})`;
const rewardName = data.reward.title;
const cost = data.reward.cost;
const userInput = data.user_input;
const userInput = data.user_input ?? data.userInput;
const channelPointIcon = `<img src="icons/badges/twitch-channel-point.png" class="platform"/>`;
let message = `${username} redeemed ${rewardName} ${channelPointIcon} ${cost}`;
@@ -1198,6 +1199,10 @@ function FourthwallOrderPlaced(data) {
const item = data.variants[0].name;
const itemsOrdered = data.variants.length;
// Skip free orders if the user has chosen to do so
if (skipFourthwallFreeOrders && orderTotal == 0)
return;
let message = "";
// If there user did not provide a username, just say "Someone"
+9
View File
@@ -379,6 +379,15 @@
"defaultValue": true,
"group": "Which donation messages do you want to see?"
},
{
"id": "skipFourthwallFreeOrders",
"label": "Skip Free Orders",
"description": "If enabled, free orders from Fourthwall will be skipped.",
"type": "checkbox",
"defaultValue": true,
"showIf": "showFourthwallAlerts",
"group": "Which donation messages do you want to see?"
},
{
"id": "furryMode",
"label": "Furry Mode",
+11 -6
View File
@@ -82,6 +82,7 @@ const showPatreonMemberships = GetBooleanParam("showPatreonMemberships", true);
const showKofiDonations = GetBooleanParam("showKofiDonations", true);
const showTipeeeStreamDonations = GetBooleanParam("showTipeeeStreamDonations", true);
const showFourthwallAlerts = GetBooleanParam("showFourthwallAlerts", true);
const skipFourthwallFreeOrders = GetBooleanParam("skipFourthwallFreeOrders", true);
const furryMode = GetBooleanParam("furryMode", false);
@@ -90,7 +91,7 @@ const furryMode = GetBooleanParam("furryMode", false);
////////////////////
const animationSpeed = GetIntParam("animationSpeed", 0.1);
const randomYouTubeColors = GetBooleanParam("randomYouTubeColors", false);
const randomYouTubeColors = GetBooleanParam("randomYouTubeColors", true);
const youtubeColor = urlParams.get("youtubeColor") || "#f70000";
const youtubeCustomSubIcon = urlParams.get("youtubeCustomSubIcon") || "";
let twitchUsername = urlParams.get("twitchUsername") || "";
@@ -1154,7 +1155,7 @@ async function TwitchRewardRedemption(data) {
if (showAvatar) {
// Render avatars
const username = data.user_login;
const username = data.user_login ?? data.user.login;
const avatarURL = await GetAvatar(username, 'twitch');
const avatar = new Image();
avatar.src = avatarURL;
@@ -1163,12 +1164,12 @@ async function TwitchRewardRedemption(data) {
}
// Set the text
let username = data.user_name;
if (data.user_name.toLowerCase() != data.user_login.toLowerCase())
username = `${data.user_name} (${data.user_login})`;
let username = data.user_name ?? data.user.name;
if (username.toLowerCase() != (data.user_login ?? data.user.login).toLowerCase())
username = `${username} (${data.user_login ?? data.user.login})`;
const rewardName = data.reward.title;
const cost = data.reward.cost;
const userInput = data.user_input;
const userInput = data.user_input ?? data.userInput;
const channelPointIcon = `<img src="icons/badges/twitch-channel-point.png" class="platform"/>`;
titleDiv.innerHTML = `${username} redeemed ${rewardName} ${channelPointIcon} ${cost}`;
@@ -2045,6 +2046,10 @@ function FourthwallOrderPlaced(data) {
const itemImageUrl = data.variants[0].image;
const fourthwallProductImage = `<img src="${itemImageUrl}" class="productImage"/>`;
// Skip free orders if the user has chosen to do so
if (skipFourthwallFreeOrders && orderTotal == 0)
return;
avatarDiv.innerHTML = fourthwallProductImage;
let contents = "";
+9
View File
@@ -517,6 +517,15 @@
"defaultValue": true,
"group": "Which donation messages do you want to see?"
},
{
"id": "skipFourthwallFreeOrders",
"label": "Skip Free Orders",
"description": "If enabled, free orders from Fourthwall will be skipped.",
"type": "checkbox",
"defaultValue": true,
"showIf": "showFourthwallAlerts",
"group": "Which donation messages do you want to see?"
},
{
"id": "furryMode",
"label": "Furry Mode",
+8 -3
View File
@@ -112,6 +112,7 @@ const showTipeeeStreamDonations = GetBooleanParam("showTipeeeStreamDonations", f
const tipeeestreamDonationAction = urlParams.get("tipeeestreamDonationAction") || "";
const showFourthwallAlerts = GetBooleanParam("showFourthwallAlerts", false);
const fourthwallAlertAction = urlParams.get("fourthwallAlertAction") || "";
const skipFourthwallFreeOrders = GetBooleanParam("skipFourthwallFreeOrders", true);
////////////////////
// HIDDEN OPTIONS //
@@ -687,14 +688,14 @@ async function TwitchRewardRedemption(data) {
if (!showTwitchChannelPointRedemptions)
return;
const username = data.user_name;
const username = data.user_name ?? data.user.name;
const rewardName = data.reward.title;
const cost = data.reward.cost;
const userInput = data.user_input;
const userInput = data.user_input ?? data.userInput;
const channelPointIcon = `<img src="icons/badges/twitch-channel-point.png" class="platform" style="height: 1em"/>`;
// Render avatars
const avatarURL = await GetAvatar(data.user_login, 'twitch');
const avatarURL = await GetAvatar(data.user_login ?? data.user.login, 'twitch');
UpdateAlertBox(
'twitch',
@@ -1124,6 +1125,10 @@ function FourthwallOrderPlaced(data) {
const message = DecodeHTMLString(data.statmessageus);
const itemImageUrl = data.variants[0].image;
// Skip free orders if the user has chosen to do so
if (skipFourthwallFreeOrders && orderTotal == 0)
return;
// If there user did not provide a username, just say "Someone"
if (user == undefined)
user = "Someone";
@@ -628,6 +628,15 @@
"showIf": "showFourthwallAlerts",
"group": "Which donation alerts do you want to see?"
},
{
"id": "skipFourthwallFreeOrders",
"label": "Skip Free Orders",
"description": "If enabled, free orders from Fourthwall will be skipped.",
"type": "checkbox",
"defaultValue": true,
"showIf": "showFourthwallAlerts",
"group": "Which donation alerts do you want to see?"
},
{
"id": "address",
"label": "IP Address",
+8 -7
View File
@@ -9,7 +9,7 @@ const urlParams = new URLSearchParams(queryString);
// CONSTANTS //
////////////////
const REQUIRED_VERSION = '0.0.4';
const REQUIRED_VERSION = '1.0.0';
const SMTC_BRIDGE_DOWNLOAD_URL = 'https://github.com/nuttylmao/smtc-bridge/releases';
let VersionChecked = false;
@@ -36,6 +36,8 @@ const useCustomColors = GetBooleanParam("useCustomColors", false);
const color1 = urlParams.get('color1') || '#ffffff';
const color2 = urlParams.get('color2') || '#1d1d1d';
const autoHide = GetBooleanParam("autoHide", false);
const showWhilePaused = GetBooleanParam("showWhilePaused", false);
const includedApplications = urlParams.get('includedApplications') || '';
const excludedApplications = urlParams.get('excludedApplications') || '';
const showAlbumArt = GetBooleanParam("showAlbumArt", true);
@@ -43,7 +45,6 @@ const showProgressBar = GetBooleanParam("showProgressBar", true);
const swapArtistTrack = GetBooleanParam("swapArtistTrack", false);
const showPrimary = GetBooleanParam("showPrimary", true);
const showSecondary = GetBooleanParam("showSecondary", true);
const autoHide = GetBooleanParam("autoHide", false);
const displayDuration = GetIntParam("displayDuration", 5);
const showAnimation = urlParams.get('showAnimation') || 'slide-in-from-bottom';
const hideAnimation = urlParams.get('hideAnimation') || 'slide-out-bottom';
@@ -363,7 +364,7 @@ async function UpdatePlayerState(data) {
for (const targetApp of includedList) {
targetSession = validSessions.find(s => {
const matchesApp = (s.source_app_id || "").toLowerCase().includes(targetApp);
const isPlaying = s.playback_info && s.playback_info.PlaybackStatus === PlaybackStatus.PLAYING;
const isPlaying = s.playback_info && (s.playback_info.PlaybackStatus === PlaybackStatus.PLAYING || (showWhilePaused && s.playback_info.PlaybackStatus === PlaybackStatus.PAUSED));
return matchesApp && isPlaying;
});
if (targetSession) break;
@@ -387,7 +388,7 @@ async function UpdatePlayerState(data) {
// Priority 2: If the current session isn't available/valid, find any session that is currently playing
if (!targetSession || targetSession.playback_info.PlaybackStatus !== PlaybackStatus.PLAYING) {
const playingSession = validSessions.find(s => s.playback_info && s.playback_info.PlaybackStatus === PlaybackStatus.PLAYING);
const playingSession = validSessions.find(s => s.playback_info && (s.playback_info.PlaybackStatus === PlaybackStatus.PLAYING || (showWhilePaused && s.playback_info.PlaybackStatus === PlaybackStatus.PAUSED)));
if (playingSession) {
targetSession = playingSession;
}
@@ -411,7 +412,7 @@ async function UpdatePlayerState(data) {
// 1. Check if playback status has changed and update visibility accordingly
if (playbackInfo.PlaybackStatus !== CurrentPlaybackStatus) {
if (playbackInfo.PlaybackStatus === PlaybackStatus.PLAYING)
if (playbackInfo.PlaybackStatus === PlaybackStatus.PLAYING || (showWhilePaused && playbackInfo.PlaybackStatus === PlaybackStatus.PAUSED))
SetVisibility(true);
else
SetVisibility(false);
@@ -421,7 +422,7 @@ async function UpdatePlayerState(data) {
// 2. Check if the track name/artist have changed - this is our indicator that the next track has loaded
// Only proceed if the player state is actively playing audio
if (CurrentPlaybackStatus == PlaybackStatus.PLAYING) {
if (CurrentPlaybackStatus == PlaybackStatus.PLAYING || (showWhilePaused && CurrentPlaybackStatus == PlaybackStatus.PAUSED)) {
const newTrackKey = `${mediaProps.Title}-${mediaProps.Artist}-${mediaProps.Thumbnail}`;
if (newTrackKey !== CurrentSongKey) {
ChangeTrack(mediaProps, accentColorPalette); // Now trigger your cross-fade logic here!
@@ -442,7 +443,7 @@ async function UpdatePlayerState(data) {
const isPlaying = (targetSession.playback_info.PlaybackStatus === PlaybackStatus.PLAYING);
const currentPositionMs = isPlaying && timelineProps.EndTime > 0 ? timelineProps.Position + driftMs : timelineProps.Position;
SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette);
SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, targetSession.playback_info.PlaybackStatus);
}
}
else {
+27 -19
View File
@@ -150,6 +150,33 @@
"showIf": "useCustomColors",
"group": "Appearance"
},
{
"id": "showWhilePaused",
"label": "Show While Paused",
"description": "If enabled, the widget will be visible even when the track is paused.",
"type": "checkbox",
"defaultValue": false,
"group": "General"
},
{
"id": "autoHide",
"label": "Auto-Hide on Track Change",
"description": "If enabled, the widget will show for a set duration whenever a new track starts.",
"type": "checkbox",
"defaultValue": false,
"group": "General"
},
{
"id": "displayDuration",
"label": "Display Duration (seconds)",
"description": "How long the widget should remain visible.",
"type": "number",
"min": 1,
"max": 60,
"defaultValue": 5,
"showIf": "autoHide",
"group": "General"
},
{
"id": "includedApplications",
"label": "Included Apps",
@@ -208,25 +235,6 @@
"defaultValue": true,
"group": "General"
},
{
"id": "autoHide",
"label": "Auto-Hide on Track Change",
"description": "If enabled, the widget will show for a set duration whenever a new track starts.",
"type": "checkbox",
"defaultValue": false,
"group": "General"
},
{
"id": "displayDuration",
"label": "Display Duration (seconds)",
"description": "How long the widget should remain visible.",
"type": "number",
"min": 1,
"max": 60,
"defaultValue": 5,
"showIf": "autoHide",
"group": "General"
},
{
"id": "showAnimation",
"label": "Show Animation",
+4 -4
View File
@@ -97,7 +97,7 @@ body {
@keyframes slide-in-from-left {
0% {
transform: translateX(-1em);
transform: translateX(-100%);
opacity: 0;
}
@@ -114,14 +114,14 @@ body {
}
100% {
transform: translateX(-1em);
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in-from-right {
0% {
transform: translateX(1em);
transform: translateX(100%);
opacity: 0;
}
@@ -138,7 +138,7 @@ body {
}
100% {
transform: translateX(1em);
transform: translateX(100%);
opacity: 0;
}
}
+5
View File
@@ -3,6 +3,11 @@
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
<div id="pause-overlay">
<svg viewBox="0 0 24 24" style="fill: var(--accent-color, #ffffff);">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</div>
</div>
<div id="text-info-container">
+8 -3
View File
@@ -96,7 +96,12 @@ async function ChangeTrack(mediaProps, accentColorPalette) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
// Set the pause overlay visibility based on playback status
if (playbackStatus === PlaybackStatus.PAUSED)
albumArtContainer.classList.add('is-paused');
else
albumArtContainer.classList.remove('is-paused');
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
@@ -105,7 +110,7 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
progressPercent = Math.min(100, Math.max(0, progressPercent));
progressBar.style.width = `${progressPercent}%`;
if (!useCustomColors)
progressBar.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
document.body.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
else
progressBar.style.setProperty('--accent-color', color1);
document.body.style.setProperty('--accent-color', color1);
}
+30
View File
@@ -21,6 +21,36 @@
display: var(--show-album-art);
}
#album-art-container.is-paused #album-art-layer,
#album-art-container.is-paused #album-art-transition-layer {
opacity: 0.7;
}
#pause-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 10;
border-radius: inherit;
}
#pause-overlay svg {
height: 30%;
width: auto;
}
#album-art-container.is-paused #pause-overlay {
opacity: 1;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
+5
View File
@@ -9,6 +9,11 @@
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
<div id="pause-overlay">
<svg viewBox="0 0 24 24" style="fill: var(--accent-color, #ffffff);">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</div>
</div>
<div id="track-label" class="text-label">&nbsp;</div>
+7 -1
View File
@@ -84,7 +84,7 @@ async function ChangeTrack(mediaProps, accentColorPalette) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
@@ -92,6 +92,12 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set the pause overlay visibility based on playback status
if (playbackStatus === PlaybackStatus.PAUSED)
albumArtContainer.classList.add('is-paused');
else
albumArtContainer.classList.remove('is-paused');
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
+30
View File
@@ -23,6 +23,36 @@
display: var(--show-album-art);
}
#album-art-container.is-paused #album-art-layer,
#album-art-container.is-paused #album-art-transition-layer {
opacity: 0.2;
}
#pause-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 10;
border-radius: inherit;
}
#pause-overlay svg {
height: 30%;
width: auto;
}
#album-art-container.is-paused #pause-overlay {
opacity: 0.8;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
+5
View File
@@ -3,6 +3,11 @@
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
<div id="pause-overlay">
<svg viewBox="0 0 24 24" style="fill: var(--accent-color, #ffffff);">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</div>
</div>
<div id="song-info-container">
+7 -1
View File
@@ -84,7 +84,7 @@ async function ChangeTrack(mediaProps, accentColorPalette) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
@@ -92,6 +92,12 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set the pause overlay visibility based on playback status
if (playbackStatus === PlaybackStatus.PAUSED)
albumArtContainer.classList.add('is-paused');
else
albumArtContainer.classList.remove('is-paused');
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
+31
View File
@@ -24,6 +24,37 @@
flex-shrink: 0;
}
#album-art-container.is-paused #album-art-layer,
#album-art-container.is-paused #album-art-transition-layer {
opacity: 0.7;
filter: grayscale(50%);
}
#pause-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 10;
border-radius: inherit;
}
#pause-overlay svg {
height: 40%;
width: auto;
}
#album-art-container.is-paused #pause-overlay {
opacity: 1;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
+1 -1
View File
@@ -94,7 +94,7 @@ async function ChangeTrack(mediaProps, accentColorPalette) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
const durationMs = timelineProps.EndTime;
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
+7 -1
View File
@@ -97,7 +97,7 @@ async function ChangeTrack(mediaProps, accentColorPalette) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
@@ -122,6 +122,12 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// const transitionWidth = '1em';
// progressBar.style.maskImage = `linear-gradient(to right, black calc(${clipRight}% - ${transitionWidth}), transparent ${clipRight}%)`
// progressBarTrack.style.maskImage = `linear-gradient(to right, transparent calc(${clipRight}% - ${transitionWidth}), black ${clipRight}%)`;
// Set the pause overlay visibility based on playback status
if (playbackStatus === PlaybackStatus.PAUSED)
songInfoContainer.classList.add('is-paused');
else
songInfoContainer.classList.remove('is-paused');
}
// MARQUEE LOGIC
+4
View File
@@ -24,6 +24,10 @@
grid-template-areas: "stack";
}
#song-info-container.is-paused {
opacity: 0.8;
}
/* The content sits on top */
#progress-bar {
position: relative;
+5
View File
@@ -2,6 +2,11 @@
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
<div id="pause-overlay">
<svg viewBox="0 0 24 24" style="fill: var(--accent-color, #ffffff);">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</div>
</div>
<div id="song-info-container">
+9 -3
View File
@@ -78,7 +78,7 @@ async function ChangeTrack(mediaProps) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
@@ -86,6 +86,12 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set the pause overlay visibility based on playback status
if (playbackStatus === PlaybackStatus.PAUSED)
albumArtContainer.classList.add('is-paused');
else
albumArtContainer.classList.remove('is-paused');
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
@@ -95,12 +101,12 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
if (!useCustomColors)
{
progressBarFill.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
document.body.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
document.body.style.color = accentColorPalette.LightVibrant;
}
else
{
progressBarFill.style.setProperty('--accent-color', color1);
document.body.style.setProperty('--accent-color', color1);
document.body.style.color = color1;
}
}
+31
View File
@@ -23,6 +23,37 @@
flex-shrink: 0;
}
#album-art-container.is-paused #album-art-layer,
#album-art-container.is-paused #album-art-transition-layer {
opacity: 0.7;
filter: grayscale(50%);
}
#pause-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 10;
border-radius: inherit;
}
#pause-overlay svg {
height: 40%;
width: auto;
}
#album-art-container.is-paused #pause-overlay {
opacity: 1;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
+5
View File
@@ -6,6 +6,11 @@
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
<div id="pause-overlay">
<svg viewBox="0 0 24 24" style="fill: var(--accent-color, #ffffff);">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</div>
</div>
<div id="song-info-wrapper">
+12 -4
View File
@@ -147,7 +147,7 @@ async function ChangeTrack(mediaProps, accentColorPalette) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
@@ -155,6 +155,12 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set the pause overlay visibility based on playback status
if (playbackStatus === PlaybackStatus.PAUSED)
albumArtContainer.classList.add('is-paused');
else
albumArtContainer.classList.remove('is-paused');
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
@@ -165,9 +171,11 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
if (!useCustomColors) {
switch (themeVariant) {
case "matte":
progressBarFill.style.setProperty('--accent-color', accentColorPalette.DarkVibrant);
document.body.style.setProperty('--accent-color', accentColorPalette.DarkVibrant);
break;
case "matte-dark":
document.body.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
break;
default:
progressBarFill.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
break;
@@ -177,11 +185,11 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
{
switch (themeVariant) {
case "matte":
progressBarFill.style.setProperty('--accent-color', color2);
document.body.style.setProperty('--accent-color', color2);
break;
// case "matte-dark":
default:
progressBarFill.style.setProperty('--accent-color', color1);
document.body.style.setProperty('--accent-color', color1);
break;
}
}
+30
View File
@@ -25,6 +25,36 @@
flex-shrink: 0;
}
#album-art-container.is-paused #album-art-layer,
#album-art-container.is-paused #album-art-transition-layer {
opacity: 0.2;
}
#pause-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 10;
border-radius: inherit;
}
#pause-overlay svg {
height: 50%;
width: auto;
}
#album-art-container.is-paused #pause-overlay {
opacity: 1;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
+50 -1
View File
@@ -8,6 +8,7 @@ const albumArtContainer = document.getElementById('album-art-container');
const albumArtLayer = document.getElementById('album-art-layer');
const albumArtTransition = document.getElementById('album-art-transition-layer');
const progressCircle = document.getElementById('progress-pie-circle');
const vinylContainer = document.getElementById('vinyl-container');
///////////////
// CONSTANTS //
@@ -60,7 +61,7 @@ async function ChangeTrack(mediaProps) {
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette, playbackStatus) {
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
@@ -75,4 +76,52 @@ function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// 0% progress = 157.1 offset. 100% progress = 0 offset.
const offset = circumference - (progressPercent / 100) * circumference;
progressCircle.style.strokeDashoffset = offset;
// Adjust 'Paused' check to match whatever string/value your backend sends
if (playbackStatus === PlaybackStatus.PAUSED)
setVinylPaused(true);
else
setVinylPaused(false);
}
const vinylAnimation = vinylContainer.animate(
[
{ transform: "rotate(0deg)" },
{ transform: "rotate(360deg)" }
],
{
duration: 10000,
iterations: Infinity,
easing: "linear"
}
);
let animationFrame;
function setVinylPaused(shouldPause) {
cancelAnimationFrame(animationFrame);
const from = vinylAnimation.playbackRate;
const to = shouldPause ? 0 : 1;
const duration = 700;
const start = performance.now();
vinylAnimation.play();
function update(now) {
const progress = Math.min((now - start) / duration, 1);
// Smooth acceleration/deceleration
const eased = progress * progress * (3 - 2 * progress);
vinylAnimation.playbackRate = from + (to - from) * eased;
if (progress < 1) {
animationFrame = requestAnimationFrame(update);
} else if (shouldPause) {
vinylAnimation.pause();
}
}
animationFrame = requestAnimationFrame(update);
}
+61 -4
View File
@@ -16,7 +16,7 @@
justify-content: center;
width: 100%;
filter: drop-shadow(0px 10px 15px rgba(0, 0, 0, 0.5));
animation: spin 10s linear infinite;
transition: transform 1.2s cubic-bezier(0.25, 1, 0.5, 1);
}
#vinyl-background {
@@ -91,7 +91,64 @@
transition: all 1s linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@keyframes slide-in-from-top {
0% {
transform: translateY(-100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-in-from-top {
0% {
transform: translateY(-100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-out-top {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-100%);
opacity: 0;
}
}
@keyframes slide-in-from-bottom {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-out-bottom {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(100%);
opacity: 0;
}
}
+8 -8
View File
@@ -51,7 +51,7 @@ async function CustomEvent(data) {
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = data.message;
messageEl.innerHTML = SanitizeHTML(data.message);
// Render emotes
for (i in data.emotes) {
@@ -116,7 +116,7 @@ async function CustomEvent(data) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.cumulative} months${data.monthStreak > 1 ? ' (' + data.monthStreak + ' in a row!)' : ''}</b>`;
if (data.messageStripped)
messageEl.innerHTML += `<br><br><i>${data.messageStripped}</i>`;
messageEl.innerHTML += `<br><br><i>${SanitizeHTML(data.messageStripped)}</i>`;
contentEl.appendChild(messageEl);
@@ -196,7 +196,7 @@ async function CustomEvent(data) {
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = data.rawInput;
messageEl.innerHTML = SanitizeHTML(data.rawInput);
// // Render emotes
// for (i in data.emotes) {
@@ -284,7 +284,7 @@ async function CustomEvent(data) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>sent a Super Chat!`;
if (data.message)
messageEl.innerHTML += `<br><br><i>${data.message}</i>`;
messageEl.innerHTML += `<br><br><i>${SanitizeHTML(data.message)}</i>`;
contentEl.appendChild(messageEl);
@@ -424,7 +424,7 @@ async function CustomEvent(data) {
if (data.tipMessage) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data.tipMessage}</i>`;
messageEl.innerHTML = `<i>${SanitizeHTML(data.tipMessage)}</i>`;
contentEl.appendChild(messageEl);
}
@@ -448,7 +448,7 @@ async function CustomEvent(data) {
if (data.donationMessage) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data.donationMessage}</i>`;
messageEl.innerHTML = `<i>${SanitizeHTML(data.donationMessage)}</i>`;
contentEl.appendChild(messageEl);
}
@@ -475,7 +475,7 @@ async function CustomEvent(data) {
if (data["fw.message"]) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data["fw.message"]}</i>`;
messageEl.innerHTML = `<i>${SanitizeHTML(data["fw.message"])}</i>`;
contentEl.appendChild(messageEl);
}
@@ -537,7 +537,7 @@ async function CustomEvent(data) {
if (customMessage) {
const txt = document.createElement("textarea");
txt.innerHTML = customMessage;
customMessageEl.innerHTML += `<br><i>${txt.value}</i>`;
customMessageEl.innerHTML += `<br><i>${SanitizeHTML(txt.value)}</i>`;
}
// Add a cute thank you message because you're uwu like that
+1 -1
View File
@@ -146,7 +146,7 @@ client.on('Twitch.UpcomingAd', (response) => {
/////////////////////
function TwitchAdRun(data) {
const duration = data.length_seconds;
const duration = data.lengthSeconds ?? data.length_seconds;
// Unset the upcoming ad countdown warning
upcomingAdWarningStartDelay = null;