Merge pull request #30 from nuttylmao/beta

Beta
This commit is contained in:
nutty
2026-08-11 18:01:06 +10:00
committed by GitHub
57 changed files with 4412 additions and 18 deletions
+101
View File
@@ -4,6 +4,7 @@ const urlParams = new URLSearchParams(queryString);
const settingsJson = urlParams.get("settingsJson") || "";
const widgetURL = urlParams.get("widgetURL") || "";
const showUnmuteIndicator = GetBooleanParam("showUnmuteIndicator", false);
const useStreamerBot = GetBooleanParam("usesStreamerBot", false);
// Page elements
const widgetUrlInputWrapper = document.getElementById('widgetUrlInputWrapper');
@@ -101,6 +102,7 @@ function LoadJSON(settingsJson) {
inputElement.id = setting.id; //Added setting ID
inputElement.value = settingsMap.has(setting.id) ? settingsMap.get(setting.id) : setting.defaultValue;
inputElement.autocomplete = 'new-password';
inputElement.placeholder = setting.placeholder ? setting.placeholder : '';
break;
case 'password':
inputElement = document.createElement('input');
@@ -168,6 +170,24 @@ function LoadJSON(settingsJson) {
inputElement.setAttribute('list', 'streamer-bot-actions');
inputElement.autocomplete = 'off';
break;
case 'font':
inputElement = document.createElement('input');
inputElement.type = 'text';
inputElement.placeholder = 'Type to search...';
inputElement.id = setting.id; //Added setting ID
inputElement.value = settingsMap.has(setting.id) ? settingsMap.get(setting.id) : setting.defaultValue;
inputElement.setAttribute('list', 'fonts');
inputElement.autocomplete = 'off';
// Trigger permission prompt and load fonts on first click/focus
inputElement.addEventListener('focus', async function loadOnce() {
// Remove listener so it only triggers once per session
inputElement.removeEventListener('focus', loadOnce);
inputElement.placeholder = 'Type to search...';
await PopulateFontDatalist();
}, { once: true });
break;
case 'button':
inputElement = document.createElement('button');
inputElement.id = setting.id; //Added setting ID
@@ -211,6 +231,7 @@ function LoadJSON(settingsJson) {
SaveSettingsToStorage();
RefreshWidgetPreview();
InterpolatePlaceholders();
});
settingItemContent.appendChild(inputElement);
@@ -257,6 +278,7 @@ function LoadJSON(settingsJson) {
UpdateSettingItemVisibility();
RefreshWidgetPreview();
SaveSettingsToStorage();
InterpolatePlaceholders();
})
.catch(error => console.error('Error loading settings:', error));
}
@@ -315,6 +337,9 @@ function RefreshWidgetPreview() {
}
function UpdateStreamerBotConnection() {
if (!useStreamerBot)
return;
let addressElement = document.getElementById('address');
let portElement = document.getElementById('port');
@@ -370,6 +395,38 @@ async function GetSBActions() {
document.body.appendChild(datalistElement);
}
async function PopulateFontDatalist() {
if (!('queryLocalFonts' in window)) {
console.warn("Local Font Access API is not supported in this browser. Font auto-suggestions will be disabled.");
return;
}
try {
// Request permission and fetch local fonts
const availableFonts = await window.queryLocalFonts();
// Extract unique font family names and sort them alphabetically
const fontFamilies = [...new Set(availableFonts.map(font => font.family))].sort();
// Create the datalist element
const datalistElement = document.createElement('datalist');
datalistElement.id = 'fonts';
// Append each font family as an option
fontFamilies.forEach(family => {
const option = document.createElement('option');
option.value = family;
datalistElement.appendChild(option);
});
document.body.appendChild(datalistElement);
console.debug(`Loaded ${fontFamilies.length} local fonts into auto-suggest.`);
} catch (err) {
console.error("Permission denied or error fetching local fonts:", err);
}
}
/////////////////////////
@@ -487,6 +544,47 @@ function GetSettingDepth(setting, allSettings) {
return depth;
}
function InterpolatePlaceholders() {
// Regex to match anything inside curly braces, e.g., {smtcBridgeAddress}
const tokenRegex = /\{([^}]+)\}/g;
// Iterate over all settings from the original JSON in memory
settingsData.settings.forEach(setting => {
// Only process settings that actually have a placeholder in their description
if (setting.description && setting.description.includes('{')) {
// Start with the original untouched description from the JSON
let dynamicHTML = setting.description;
let match;
// Reset regex state (required when reusing a global regex in a loop)
tokenRegex.lastIndex = 0;
// Find all {tokens} in the string
while ((match = tokenRegex.exec(setting.description)) !== null) {
const targetId = match[1]; // The exact text inside the braces
const targetInput = document.getElementById(targetId);
if (targetInput) {
// Get current value, or fallback to the JSON default if the box is empty
const fallback = settingsData.settings.find(s => s.id === targetId)?.defaultValue || '';
const currentValue = targetInput.value || fallback;
// Replace the token with the actual value in our temporary string
dynamicHTML = dynamicHTML.replace(match[0], currentValue);
}
}
// Find the specific <p> tag for this setting in the DOM and overwrite its HTML
const descriptionParagraph = document.querySelector(`#item-${setting.id} p`);
if (descriptionParagraph) {
descriptionParagraph.innerHTML = dynamicHTML;
}
}
});
}
@@ -495,3 +593,6 @@ LoadSettingsFromStorage();
// Load default settings
LoadJSON(settingsJson);
// Populate local fonts for auto-suggest
PopulateFontDatalist();
+28
View File
@@ -0,0 +1,28 @@
// ==========================================
// WINDOWS SMTC API CONSTANTS (IMMUTABLE)
// ==========================================
// Playback Status (session.playback_info.PlaybackStatus)
const PlaybackStatus = Object.freeze({
CLOSED: 0, // Engine uninitialized or empty
OPENED: 1, // Pipeline loaded but idling
CHANGING: 2, // Buffering, track skipping, or seeking
STOPPED: 3, // Track queued but fully stopped (at 0:00)
PLAYING: 4, // Audio actively streaming (Run dead reckoning)
PAUSED: 5 // Audio frozen (Halt dead reckoning)
});
// Playback Type (session.playback_info.PlaybackType)
const PlaybackType = Object.freeze({
UNKNOWN: 0, // Generic audio wrapper
MUSIC: 1, // Pure audio pipeline (Spotify, iTunes, etc.)
VIDEO: 2, // Visual media feed (Chrome/Firefox YouTube/Twitch tabs)
IMAGE: 3 // Static slideshow presentation hook
});
// Auto Repeat Mode (session.playback_info.AutoRepeatMode)
const AutoRepeatMode = Object.freeze({
NONE: 0, // Plays queue through and terminates
TRACK: 1, // Single active song looping indefinitely
LIST: 2 // Parent playlist/album looping indefinitely
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

+298
View File
@@ -412,3 +412,301 @@ async function LoadHTMLTemplate(name) {
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
? `<div class="common-popup-icon-container">
<img src="${iconSrc}" class="common-popup-img" alt="Alert Icon" />
</div>`
: '';
const titleMarkup = title
? `<label class="common-popup-title">${title}</label>`
: '';
const subtitleMarkup = subtitle
? `<label class="common-popup-subtitle">${subtitle}</label>`
: '';
const attributeMarkup = attribute
? `<label class="common-popup-attribute">${attribute}</label>`
: '';
// Render button conditional markup
const buttonMarkup = (button && button.text)
? `<button class="common-popup-button">${button.text}</button>`
: '';
card.innerHTML = `
${iconMarkup}
<div class="common-popup-text-content">
${titleMarkup}
${subtitleMarkup}
${attributeMarkup}
${buttonMarkup}
</div>
`;
// 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';
}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<link rel="shortcut icon" href="#" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="main-container">
<div style="display: flex; flex-direction: column;">
<label id="line1" class="timeLabel"></label>
<label id="line2" class="timeLabel"></label>
<label id="line3" class="timeLabel"></label>
</div>
</div>
</body>
<script src="/.common/utils/helpers.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/utc.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/timezone.min.js"></script>'
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/advancedFormat.js"></script>'
<script src="./script.js"></script>
+127
View File
@@ -0,0 +1,127 @@
/////////////
// IMPORTS //
/////////////
dayjs.extend(window.dayjs_plugin_utc);
dayjs.extend(window.dayjs_plugin_timezone);
dayjs.extend(window.dayjs_plugin_advancedFormat);
////////////////////
// URL PARAMETERS //
////////////////////
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainContainer = document.getElementById('main-container');
const line1 = document.getElementById('line1');
const line2 = document.getElementById('line2');
const line3 = document.getElementById('line3');
/////////////
// OPTIONS //
/////////////
const font = urlParams.get("font") || "";
const enableLine1 = GetBooleanParam("enableLine1", true);
const line1Format = urlParams.get("line1Format") || "hh:mm:ss A";
const line1FontSize = GetIntParam("line1FontSize", 50);
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";
const line2FontSize = GetIntParam("line2FontSize", 40);
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";
const line3FontSize = GetIntParam("line3FontSize", 30);
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";
////////////////
// PAGE SETUP //
////////////////
// Set the font for the entire page if specified
if (font)
document.body.style.fontFamily = `'${font}'`;
// Hide lines that are not enabled
if (!enableLine1)
line1.style.display = "none";
if (!enableLine2)
line2.style.display = "none";
if (!enableLine3)
line3.style.display = "none";
////////////
// CLOCKO //
////////////
// Check if any active format string includes milliseconds (e.g., 'S', 'SS', 'SSS')
const usesMilliseconds =
(enableLine1 && line1Format.includes('S')) ||
(enableLine2 && line2Format.includes('S')) ||
(enableLine3 && line3Format.includes('S'));
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());
if (enableLine1) line1.textContent = now.format(line1Format);
if (enableLine2) line2.textContent = now.format(line2Format);
if (enableLine3) line3.textContent = now.format(line3Format);
}
/////////////
// STYLING //
/////////////
function ApplyStyling(el, fontSize, fontWeight, fontColor, fontOpacity, textTransform, textAlignment) {
el.style.fontSize = fontSize + "px";
el.style.fontWeight = fontWeight;
el.style.color = fontColor;
el.style.opacity = fontOpacity;
el.style.textTransform = textTransform;
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);
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/.common/resources/logo.png" type="image/png">
<title>nutty</title>
<style>
body {
margin: 0;
display: flex;
background: #181818;
}
#widgetContainer {
width: 100vw;
height: 100vh;
border-width: 0px;
}
</style>
</head>
<body>
<iframe id="widgetContainer"></iframe>
<script src="script.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
const widgetContainer = document.getElementById('widgetContainer');
const settingsPageURL = '/.common/core/settings-core';
const currentURL = window.location.href;
let settingsJSON;
let baseURL = currentURL;
if (baseURL.endsWith("index.html"))
baseURL = baseURL.replace("index.html", "");
settingsJSON = "?settingsJson=" + baseURL + "settings.json";
const lastSlashIndex = baseURL.lastIndexOf("/");
let widgetURL = "&widgetURL=" + baseURL.replace("/settings", "");
const usesStreamerBot = "&usesStreamerBot=false";
console.debug("Window Ref: " + window.location.href);
console.debug("Base URL: " + baseURL);
console.debug("Settings JSON: " + settingsJSON);
console.debug("Widget URL: " + widgetURL);
console.debug("Uses Streamer Bot: " + usesStreamerBot);
widgetContainer.src = settingsPageURL + settingsJSON + widgetURL + usesStreamerBot;
+306
View File
@@ -0,0 +1,306 @@
{
"settings": [
{
"id": "font",
"label": "Font",
"description": "<a href=\"ms-settings:fonts\">Check installed fonts</a>",
"type": "font",
"defaultValue": "",
"group": "Appearance"
},
{
"id": "enableLine1",
"label": "Enable Line 1",
"description": "Show the first line of the clock",
"type": "checkbox",
"defaultValue": true,
"group": "Appearance"
},
{
"id": "line1Format",
"label": "Format",
"description": "<a href='https://day.js.org/docs/en/display/format' target='_blank'>Click here for formatting options</a>",
"type": "text",
"defaultValue": "hh:mm:ss A",
"showIf": "enableLine1",
"group": "Appearance"
},
{
"id": "line1FontSize",
"label": "Font Size",
"description": "",
"type": "number",
"min": 1,
"max": 120,
"defaultValue": 50,
"showIf": "enableLine1",
"group": "Appearance"
},
{
"id": "line1FontWeight",
"label": "Font Weight",
"description": "",
"type": "select",
"options": [
{ "value": "100", "label": "Thin" },
{ "value": "200", "label": "Extra Light" },
{ "value": "300", "label": "Light" },
{ "value": "400", "label": "Regular" },
{ "value": "500", "label": "Medium" },
{ "value": "600", "label": "Semi Bold" },
{ "value": "700", "label": "Bold" },
{ "value": "800", "label": "Extra Bold" },
{ "value": "900", "label": "Black" }
],
"defaultValue": "700",
"showIf": "enableLine1",
"group": "Appearance"
},
{
"id": "line1FontColor",
"label": "Font Color",
"description": "",
"type": "color",
"defaultValue": "#ffffff",
"showIf": "enableLine1",
"group": "Appearance"
},
{
"id": "line1FontOpacity",
"label": "Font Opacity",
"description": "",
"type": "number",
"defaultValue": "1",
"min": 0,
"max": 1,
"step": ".01",
"showIf": "enableLine1",
"group": "Appearance"
},
{
"id": "line1TextTransform",
"label": "Text Transform",
"description": "",
"type": "select",
"options": [
{ "value": "none", "label": "Normal" },
{ "value": "uppercase", "label": "Uppercase" },
{ "value": "lowercase", "label": "Lowercase" },
{ "value": "capitalize", "label": "Capitalize" }
],
"defaultValue": "uppercase",
"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",
"description": "Show the second line of the clock",
"type": "checkbox",
"defaultValue": true,
"group": "Appearance"
},
{
"id": "line2Format",
"label": "Format",
"description": "<a href='https://day.js.org/docs/en/display/format' target='_blank'>Click here for formatting options</a>",
"type": "text",
"defaultValue": "ddd, MMM D",
"showIf": "enableLine2",
"group": "Appearance"
},
{
"id": "line2FontSize",
"label": "Font Size",
"description": "",
"type": "number",
"min": 1,
"max": 120,
"defaultValue": 40,
"showIf": "enableLine2",
"group": "Appearance"
},
{
"id": "line2FontWeight",
"label": "Font Weight",
"description": "",
"type": "select",
"options": [
{ "value": "100", "label": "Thin" },
{ "value": "200", "label": "Extra Light" },
{ "value": "300", "label": "Light" },
{ "value": "400", "label": "Regular" },
{ "value": "500", "label": "Medium" },
{ "value": "600", "label": "Semi Bold" },
{ "value": "700", "label": "Bold" },
{ "value": "800", "label": "Extra Bold" },
{ "value": "900", "label": "Black" }
],
"defaultValue": "400",
"showIf": "enableLine2",
"group": "Appearance"
},
{
"id": "line2FontColor",
"label": "Font Color",
"description": "",
"type": "color",
"defaultValue": "#ffffff",
"showIf": "enableLine2",
"group": "Appearance"
},
{
"id": "line2FontOpacity",
"label": "Font Opacity",
"description": "",
"type": "number",
"defaultValue": "0.7",
"min": 0,
"max": 1,
"step": ".01",
"showIf": "enableLine2",
"group": "Appearance"
},
{
"id": "line2TextTransform",
"label": "Text Transform",
"description": "",
"type": "select",
"options": [
{ "value": "none", "label": "Normal" },
{ "value": "uppercase", "label": "Uppercase" },
{ "value": "lowercase", "label": "Lowercase" },
{ "value": "capitalize", "label": "Capitalize" }
],
"defaultValue": "uppercase",
"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",
"description": "Show the third line of the clock",
"type": "checkbox",
"defaultValue": false,
"group": "Appearance"
},
{
"id": "line3Format",
"label": "Format",
"description": "<a href='https://day.js.org/docs/en/display/format' target='_blank'>Click here for formatting options</a>",
"type": "text",
"defaultValue": "ddd DD MMM YYYY hh:mm:ss A z",
"showIf": "enableLine3",
"group": "Appearance"
},
{
"id": "line3FontSize",
"label": "Font Size",
"description": "",
"type": "number",
"min": 1,
"max": 120,
"defaultValue": 30,
"showIf": "enableLine3",
"group": "Appearance"
},
{
"id": "line3FontWeight",
"label": "Font Weight",
"description": "",
"type": "select",
"options": [
{ "value": "100", "label": "Thin" },
{ "value": "200", "label": "Extra Light" },
{ "value": "300", "label": "Light" },
{ "value": "400", "label": "Regular" },
{ "value": "500", "label": "Medium" },
{ "value": "600", "label": "Semi Bold" },
{ "value": "700", "label": "Bold" },
{ "value": "800", "label": "Extra Bold" },
{ "value": "900", "label": "Black" }
],
"defaultValue": "600",
"showIf": "enableLine3",
"group": "Appearance"
},
{
"id": "line3FontColor",
"label": "Font Color",
"description": "",
"type": "color",
"defaultValue": "#ffffff",
"showIf": "enableLine3",
"group": "Appearance"
},
{
"id": "line3FontOpacity",
"label": "Font Opacity",
"description": "",
"type": "number",
"defaultValue": "1",
"min": 0,
"max": 1,
"step": ".01",
"showIf": "enableLine3",
"group": "Appearance"
},
{
"id": "line3TextTransform",
"label": "Text Transform",
"description": "",
"type": "select",
"options": [
{ "value": "none", "label": "Normal" },
{ "value": "uppercase", "label": "Uppercase" },
{ "value": "lowercase", "label": "Lowercase" },
{ "value": "capitalize", "label": "Capitalize" }
],
"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"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
* {
margin: 0;
padding: 0;
}
html {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
text-transform: uppercase;
}
#main-container {
display: flex;
flex-direction: column;
justify-content: center;
box-sizing: border-box;
height: 95vh;
padding: 1em;
}
.timeLabel {
font-size: 40px;
/* text-shadow: rgb(0, 0, 0) 2px 2px 2px; */
color: white;
}
#line1 {
text-align: center;
}
#line2 {
text-align: right;
}
+2 -1
View File
@@ -97,7 +97,8 @@ let youtubeUsername = urlParams.get("youtubeUsername") || "";
////////////////
// Set fonts for the widget
document.body.style.fontFamily = font;
if (font)
document.body.style.fontFamily = `'${font}'`;
document.body.style.fontSize = `${fontSize}px`;
// Set the background color
+2 -2
View File
@@ -59,8 +59,8 @@
{
"id": "font",
"label": "Font",
"description": "",
"type": "text",
"description": "<a href=\"ms-settings:fonts\">Check installed fonts</a>",
"type": "font",
"defaultValue": "",
"group": "Appearance"
},
+2 -1
View File
@@ -104,7 +104,8 @@ let youtubeUsername = urlParams.get("youtubeUsername") || "";
////////////////
// Set fonts for the widget
document.body.style.fontFamily = font;
if (font)
document.body.style.fontFamily = `'${font}'`;
document.body.style.fontSize = `${fontSize}px`;
// Set line spacing
+2 -2
View File
@@ -59,8 +59,8 @@
{
"id": "font",
"label": "Font",
"description": "",
"type": "text",
"description": "<a href=\"ms-settings:fonts\">Check installed fonts</a>",
"type": "font",
"defaultValue": "",
"group": "Appearance"
},
+7 -1
View File
@@ -129,7 +129,8 @@ if (!showAvatar) {
}
// Set fonts for the widget
document.body.style.fontFamily = font;
if (font)
document.body.style.fontFamily = `'${font}'`;
document.body.style.fontSize = `${fontSize}px`;
document.body.style.color = fontColor;
@@ -255,6 +256,11 @@ client.on('YouTube.GiftMembershipReceived', (response) => {
YouTubeGiftMembershipReceived(response.data);
})
client.on('Kick.Follow', (response) => {
console.debug(response.data);
KickFollow(response.data);
})
client.on('Streamlabs.Donation', (response) => {
console.debug(response.data);
StreamlabsDonation(response.data);
+2 -2
View File
@@ -19,8 +19,8 @@
{
"id": "font",
"label": "Font",
"description": "",
"type": "text",
"description": "<a href=\"ms-settings:fonts\">Check installed fonts</a>",
"type": "font",
"defaultValue": "",
"group": "Appearance"
},
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Now Playing</title>
<meta charset="UTF-8">
<link rel="shortcut icon" href="#" />
<link rel="stylesheet" href="./style.css" />
<link id="theme-style" rel="stylesheet" />
</head>
<body>
<div id="main-container">
</div>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/color-thief/2.3.2/color-thief.min.js"></script>
<script src="/.common/utils/helpers.js"></script>
<script src="/.common/enums/smtc-enums.js"></script>
<script id="theme-script" src="./script.js"></script>
</html>
+526
View File
@@ -0,0 +1,526 @@
////////////////
// PARAMETERS //
////////////////
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
////////////////
// CONSTANTS //
////////////////
const REQUIRED_VERSION = '0.0.4';
const SMTC_BRIDGE_DOWNLOAD_URL = 'https://github.com/nuttylmao/smtc-bridge/releases';
let VersionChecked = false;
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainContainer = document.getElementById('main-container');
/////////////
// OPTIONS //
/////////////
const smtcBridgeAddress = urlParams.get('smtcBridgeAddress') || '127.0.0.1';
const smtcBridgePort = urlParams.get('smtcBridgePort') || '5000';
const theme = urlParams.get('theme') || 'standard';
const font = urlParams.get("font") || "";
const fontSize = GetIntParam("fontSize", 20);
const maxWidth = GetIntParam("maxWidth", 500);
const verticalAlignment = urlParams.get('verticalAlignment') || 'align-to-center';
const textAlignment = urlParams.get('textAlignment') || 'left';
const useCustomColors = GetBooleanParam("useCustomColors", false);
const color1 = urlParams.get('color1') || '#ffffff';
const color2 = urlParams.get('color2') || '#1d1d1d';
const includedApplications = urlParams.get('includedApplications') || '';
const excludedApplications = urlParams.get('excludedApplications') || '';
const showAlbumArt = GetBooleanParam("showAlbumArt", true);
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';
/////////////////
// GLOBAL VARS //
/////////////////
let smtcBridgePopup = null;
let versionCheckPopup = null;
let errorPopup = null;
let skipVersionCheck = false;
let CurrentPlaybackStatus;
let CurrentSongKey;
let hideTimeout = null;
////////////////
// PAGE SETUP //
////////////////
// Set fonts for the widget
if (font)
document.body.style.fontFamily = `'${font}'`;
document.body.style.fontSize = `${fontSize}px`;
// Set album art visibility
if (showAlbumArt)
document.documentElement.style.setProperty('--show-album-art', ``);
else
document.documentElement.style.setProperty('--show-album-art', `none`);
// Set text alignment
document.documentElement.style.setProperty('--text-alignment', `${textAlignment}`);
// Set vertical alignment
switch (verticalAlignment) {
case 'align-to-top':
mainContainer.style.alignItems = 'flex-start';
break;
case 'align-to-center':
mainContainer.style.alignItems = 'center';
break;
case 'align-to-bottom':
mainContainer.style.alignItems = 'flex-end';
break;
}
////////////////
// LOAD THEME //
////////////////
// Load the theme
async function LoadTheme()
{
// Check if the theme inherits from a base theme
let baseTheme = theme;
try {
const response = await fetch(`./themes/${theme}/inherits.json`);
if (response.ok) {
const config = await response.json();
baseTheme = config['base-theme'];
}
} catch (e) {
// No inheritance file found, assume it is a base theme
}
// Fetch the HTML structure from the base theme folder
const response = await fetch(`./themes/${baseTheme}/index.html`);
const html = await response.text();
mainContainer.innerHTML = html;
// Swap the CSS file
const link = document.getElementById('theme-style');
link.href = `./themes/${baseTheme}/style.css`;
// Load the theme-specific JS
// We remove the old script tag and add a new one
const oldScript = document.getElementById('theme-script');
if (oldScript) oldScript.remove();
const script = document.createElement('script');
script.src = `./themes/${baseTheme}/script.js`;
script.id = 'theme-script';
document.body.appendChild(script);
// If the base theme != theme, that means this is a variant, so load the variant's CSS
if (baseTheme != theme)
{
// Load the variant CSS
const baseLink = document.createElement('link');
baseLink.rel = 'stylesheet';
baseLink.className = 'theme-style'; // Use class for easy batch removal
baseLink.href = `./themes/${theme}/style.css`;
document.head.appendChild(baseLink);
window.ThemeVariant = theme;
}
}
LoadTheme();
/////////////////
// NOW PLAYING //
/////////////////
async function FetchMedia() {
try {
const response = await fetch(`http://${smtcBridgeAddress}:${smtcBridgePort}/now-playing`);
const data = await response.json();
// Remove the SMTC Bridge popup if it's on screen
CloseSMTCBridgePopup();
// Check for errors in the response
if (data.error) {
ShowErrorPopup(data.error);
return;
}
else {
CloseErrorPopup();
}
// Check the SMTC Bridge version
if (!VersionChecked) {
CheckSMTCBridgeVersion(data.app_version);
VersionChecked = true;
}
// Update the UI with the received data
// console.log(data);
UpdatePlayerState(data);
} catch (error) {
console.error("Failed to connect to Flask media server:", error);
// Show a popup to instruct the user to install SMTC Bridge
ShowWaitingForSMTCBridgePopup();
}
}
window.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
// Start polling every 1000 milliseconds
setInterval(FetchMedia, 1000);
// Run once immediately on script load
FetchMedia();
}, 100);
});
////////////
// POPUPS //
////////////
function ShowWaitingForSMTCBridgePopup() {
const newPopup = SplashscreenPopup(
'/.common/resources/smtc-bridge-icon.png',
'Waiting for SMTC Bridge',
'Please launch SMTC Bridge',
'', // Attribute text
'linear-gradient(0deg, #111111 0%, #11001f 100%)',
{
text: 'Download',
action: () => {
window.open(SMTC_BRIDGE_DOWNLOAD_URL, "_blank");
}
}
);
if (newPopup) {
smtcBridgePopup = newPopup;
}
}
function ShowSMTCBridgeUpdateRequiredPopup(installedVersion) {
const newPopup = SplashscreenPopup(
'/.common/resources/smtc-bridge-icon.png',
'Update Required',
'Your version of SMTC Bridge is out of date.',
`<b>Installed Version: ${installedVersion}</b><br><b>New Version: ${REQUIRED_VERSION}</b>`,
'linear-gradient(0deg, #1b0005 0%, #4f000d 100%)',
{
text: 'Download',
action: () => {
window.open(SMTC_BRIDGE_DOWNLOAD_URL, "_blank");
}
}
);
if (newPopup) {
versionCheckPopup = newPopup;
}
}
function ShowSMTCBridgeUpdateAvailablePopup(installedVersion) {
const newPopup = SplashscreenPopup(
'/.common/resources/smtc-bridge-icon.png',
'Update Available',
'A new version of SMTC Bridge is available.',
`<b>Installed Version: ${installedVersion}</b><br><b>New Version: ${REQUIRED_VERSION}</b>`,
'linear-gradient(0deg, #2a004f 0%, #4f2675 100%)',
{
text: 'Download',
action: () => {
window.open(SMTC_BRIDGE_DOWNLOAD_URL, "_blank");
}
}
);
if (newPopup) {
versionCheckPopup = newPopup;
}
setTimeout(() => {
CloseVersionCheckPopup();
}, 10000);
}
function ShowErrorPopup(errorMessage) {
const newPopup = SplashscreenPopup(
'/.common/resources/smtc-bridge-icon.png',
'SMTC Bridge Error',
`I'm a shit programmer and I fucked something up.`,
`Error: ${errorMessage}`,
'linear-gradient(0deg, #1b0005 0%, #4f000d 100%)'
);
if (newPopup) {
errorPopup = newPopup;
}
}
function CloseErrorPopup() {
if (errorPopup) {
errorPopup.close();
errorPopup = null;
}
}
function CloseSMTCBridgePopup()
{
if (smtcBridgePopup) {
smtcBridgePopup.close();
smtcBridgePopup = null;
}
}
function CloseVersionCheckPopup()
{
if (versionCheckPopup) {
versionCheckPopup.close();
versionCheckPopup = null;
}
}
//////////////////////
// HELPER FUNCTIONS //
//////////////////////
function CheckSMTCBridgeVersion(installedVersion) {
// Check that the server/client are on the same SMTC Bridge version
// const VersionStatus = VersionCheck(REQUIRED_VERSION, installedVersion);
const versionStatus = VersionCheck(REQUIRED_VERSION, installedVersion);
if (skipVersionCheck)
return;
switch (versionStatus)
{
case 'incompatible':
ShowSMTCBridgeUpdateRequiredPopup(installedVersion);
skipVersionCheck = false;
break;
case 'soft-warning':
ShowSMTCBridgeUpdateAvailablePopup(installedVersion);
skipVersionCheck = true;
break;
default:
CloseVersionCheckPopup();
break;
}
}
// Each theme must implement the following
async function UpdatePlayerState(data) {
// Parse and clean settings arrays
const includedList = includedApplications
? includedApplications.split(',').map(app => app.trim().toLowerCase()).filter(Boolean)
: [];
const excludedList = excludedApplications
? excludedApplications.split(',').map(app => app.trim().toLowerCase()).filter(Boolean)
: [];
// Filter out any sessions belonging to excluded apps
const validSessions = data.sessions.filter(s => {
const appId = (s.source_app_id || "").toLowerCase();
// Check if the source app ID matches any exclusion entry
const isExcluded = excludedList.some(excluded => appId.includes(excluded));
return !isExcluded;
});
let targetSession = null;
// Priority Check: If user specified included apps, hunt for them in exact order
if (includedList.length > 0) {
// Step 1: Look through the included list for an app that is CURRENTLY PLAYING
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;
return matchesApp && isPlaying;
});
if (targetSession) break;
}
// Step 2: If none of the included apps are playing, fall back to ANY session in the included list (even if paused)
if (!targetSession) {
for (const targetApp of includedList) {
targetSession = validSessions.find(s =>
(s.source_app_id || "").toLowerCase().includes(targetApp)
);
if (targetSession) break;
}
}
} else {
// Fallback when no included list is provided:
// Priority 1: Check Windows' current focused session ID first
if (data.current_session_id) {
targetSession = validSessions.find(s => s.source_app_id === data.current_session_id);
}
// 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);
if (playingSession) {
targetSession = playingSession;
}
}
// Priority 3: Ultimate fallback to the first valid session available if nothing else matched
if (!targetSession && validSessions.length > 0) {
targetSession = validSessions[0];
}
}
// If a target session was found, update the state of the widget
if (targetSession) {
// Extract the relevant properties from the session
const playbackInfo = targetSession.playback_info;
const mediaProps = targetSession.media_properties;
const timelineProps = targetSession.timeline_properties;
// Calcualte an accent color
const accentColorPalette = await GetAccentPalette(mediaProps.Thumbnail ?? './images/placeholder.png');
// 1. Check if playback status has changed and update visibility accordingly
if (playbackInfo.PlaybackStatus !== CurrentPlaybackStatus) {
if (playbackInfo.PlaybackStatus === PlaybackStatus.PLAYING)
SetVisibility(true);
else
SetVisibility(false);
CurrentPlaybackStatus = playbackInfo.PlaybackStatus;
}
// 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) {
const newTrackKey = `${mediaProps.Title}-${mediaProps.Artist}-${mediaProps.Thumbnail}`;
if (newTrackKey !== CurrentSongKey) {
ChangeTrack(mediaProps, accentColorPalette); // Now trigger your cross-fade logic here!
CurrentSongKey = newTrackKey; // Update the tracker with the string key
}
}
// 3. Update the progress info
if (timelineProps) {
// Parse the Windows timestamp into a JavaScript time object
const lastUpdateAnchor = Date.parse(timelineProps.LastUpdatedTime.replace(' ', 'T'));
// Calculate the drift (i.e. how many milliseconds have passed since Windows last updated)
const driftMs = Date.now() - lastUpdateAnchor;
// Add that drift to the reported Position
// Only add drift if the status is PLAYING
const isPlaying = (targetSession.playback_info.PlaybackStatus === PlaybackStatus.PLAYING);
const currentPositionMs = isPlaying && timelineProps.EndTime > 0 ? timelineProps.Position + driftMs : timelineProps.Position;
SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette);
}
}
else {
SetVisibility(false);
}
}
function SetVisibility(visible) {
// Always clear any pending hide timers whenever we change visibility
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
if (visible) {
mainWrapper.style.animation = `${showAnimation} 0.5s ease-out forwards`;
// Only set a new timer if autoHide is enabled
if (autoHide) {
hideTimeout = setTimeout(() => {
SetVisibility(false);
}, displayDuration * 1000);
}
} else {
mainWrapper.style.animation = `${hideAnimation} 0.5s ease-out forwards`;
}
}
// MARQUEE LOGIC
// This is a more advanced marquee implementation that calculates the overflow distance and adjusts the scroll speed accordingly
const labelData = new Map();
// Adjust this value to change scroll speed (pixels per second)
const SCROLL_SPEED_PX_PER_SEC = 40;
function SetLabelText(elementId, text) {
const label = document.getElementById(elementId);
if (!label) return;
labelData.set(elementId, text);
ApplyMarquee(label, text);
// Keep it responsive on container resize
if (!label._resizeObserver) {
label._resizeObserver = new ResizeObserver(() => {
const currentText = labelData.get(elementId);
if (currentText) ApplyMarquee(label, currentText);
});
label._resizeObserver.observe(label);
}
}
function ApplyMarquee(label, text) {
// 1. Render single span to measure dimensions
label.classList.remove('is-overflowing');
label.innerHTML = `<span class="scroll-content"></span>`;
label.querySelector('.scroll-content').textContent = text;
const scrollContent = label.querySelector('.scroll-content');
const textWidth = scrollContent.scrollWidth;
const containerWidth = label.clientWidth;
const overflowDistance = textWidth - containerWidth;
// 2. Check if text exceeds container
if (overflowDistance > 0) {
// Calculate travel distance in percentage relative to scrollContent's width
const distancePercent = (overflowDistance / textWidth) * 100;
// Compute duration to keep speed consistent regardless of length
// We multiply travel time by 2 (for both directions) plus 3 seconds for pauses
const travelTime = overflowDistance / SCROLL_SPEED_PX_PER_SEC;
const totalDuration = (travelTime * 2) + 3;
// Pass calculated variables to CSS
scrollContent.style.setProperty('--scroll-distance', `-${distancePercent}%`);
label.style.setProperty('--marquee-duration', `${totalDuration}s`);
label.classList.add('is-overflowing');
}
}
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/.common/resources/logo.png" type="image/png">
<title>nutty</title>
<style>
body {
margin: 0;
display: flex;
background: #181818;
}
#widgetContainer {
width: 100vw;
height: 100vh;
border-width: 0px;
}
</style>
</head>
<body>
<iframe id="widgetContainer"></iframe>
<script src="script.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
const widgetContainer = document.getElementById('widgetContainer');
const settingsPageURL = '/.common/core/settings-core';
const currentURL = window.location.href;
let settingsJSON;
let baseURL = currentURL;
if (baseURL.endsWith("index.html"))
baseURL = baseURL.replace("index.html", "");
settingsJSON = "?settingsJson=" + baseURL + "settings.json";
const lastSlashIndex = baseURL.lastIndexOf("/");
let widgetURL = "&widgetURL=" + baseURL.replace("/settings", "");
const usesStreamerBot = "&usesStreamerBot=false";
console.debug("Window Ref: " + window.location.href);
console.debug("Base URL: " + baseURL);
console.debug("Settings JSON: " + settingsJSON);
console.debug("Widget URL: " + widgetURL);
console.debug("Uses Streamer Bot: " + usesStreamerBot);
widgetContainer.src = settingsPageURL + settingsJSON + widgetURL + usesStreamerBot;
+307
View File
@@ -0,0 +1,307 @@
{
"settings": [
{
"id": "theme",
"label": "Theme",
"description": "",
"type": "select",
"options": [
{
"value": "standard",
"label": "Standard"
},
{
"value": "matte",
"label": "Matte (Light)"
},
{
"value": "matte-dark",
"label": "Matte (Dark)"
},
{
"value": "compact",
"label": "Compact"
},
{
"value": "compact-inverted",
"label": "Compact (Inverted)"
},
{
"value": "simple",
"label": "Simple"
},
{
"value": "classic",
"label": "Classic"
},
{
"value": "card",
"label": "Card"
},
{
"value": "album-art",
"label": "Album Art"
},
{
"value": "vinyl",
"label": "Vinyl"
},
{
"value": "color-palette",
"label": "Color Palette"
}
],
"defaultValue": "standard",
"group": "Appearance"
},
{
"id": "font",
"label": "Font",
"description": "<a href=\"ms-settings:fonts\">Check installed fonts</a>",
"type": "font",
"defaultValue": "",
"group": "Appearance"
},
{
"id": "fontSize",
"label": "Font Size",
"description": "",
"type": "number",
"min": 0,
"defaultValue": 20,
"group": "Appearance"
},
{
"id": "maxWidth",
"label": "Max Width",
"description": "Set to 0 to utilize full browser width",
"type": "number",
"min": 0,
"defaultValue": 500,
"group": "Appearance"
},
{
"id": "verticalAlignment",
"label": "Vertical Alignment",
"description": "",
"type": "select",
"options": [
{
"value": "align-to-top",
"label": "Align To Top"
},
{
"value": "align-to-center",
"label": "Align To Center"
},
{
"value": "align-to-bottom",
"label": "Align To Bottom"
}
],
"defaultValue": "align-to-center",
"group": "Appearance"
},
{
"id": "textAlignment",
"label": "Text Alignment",
"description": "",
"type": "select",
"options": [
{
"value": "left",
"label": "Left"
},
{
"value": "center",
"label": "Center"
},
{
"value": "right",
"label": "Right"
}
],
"defaultValue": "left",
"group": "Appearance"
},
{
"id": "useCustomColors",
"label": "Use Custom Colors",
"description": "",
"type": "checkbox",
"defaultValue": false,
"group": "Appearance"
},
{
"id": "color1",
"label": "Primary Color",
"description": "",
"type": "color",
"defaultValue": "#ffffff",
"showIf": "useCustomColors",
"group": "Appearance"
},
{
"id": "color2",
"label": "Secondary Color",
"description": "",
"type": "color",
"defaultValue": "#1d1d1d",
"showIf": "useCustomColors",
"group": "Appearance"
},
{
"id": "includedApplications",
"label": "Included Apps",
"description": "List of apps to display, in order of priority.<br>Leave empty to track currently focused app.<br><a href='http://{smtcBridgeAddress}:{smtcBridgePort}/sessions' target='_blank' style='color: #4da6ff; text-decoration: underline;'>View active sources</a>",
"type": "text",
"placeholder": "Spotify.exe, vlc.exe",
"defaultValue": "",
"group": "General"
},
{
"id": "excludedApplications",
"label": "Excluded Apps",
"description": "Apps listed here will not be displayed.<br><a href='http://{smtcBridgeAddress}:{smtcBridgePort}/sessions' target='_blank' style='color: #4da6ff; text-decoration: underline;'>View active sources</a>",
"type": "text",
"placeholder": "Chrome, vlc.exe",
"defaultValue": "",
"group": "General"
},
{
"id": "showAlbumArt",
"label": "Show Album Art",
"description": "",
"type": "checkbox",
"defaultValue": true,
"group": "General"
},
{
"id": "showProgressBar",
"label": "Show Progress Bar",
"description": "Some apps do not display progress info correctly.",
"type": "checkbox",
"defaultValue": true,
"group": "General"
},
{
"id": "swapArtistTrack",
"label": "Swap Artist and Track",
"description": "Displays Artist above Track instead of Track above Artist.",
"type": "checkbox",
"defaultValue": false,
"group": "General"
},
{
"id": "showPrimary",
"label": "Show Primary Text",
"description": "",
"type": "checkbox",
"defaultValue": true,
"group": "General"
},
{
"id": "showSecondary",
"label": "Show Secondary Text",
"description": "",
"type": "checkbox",
"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",
"description": "",
"type": "select",
"options": [
{
"value": "fade-in",
"label": "Fade In"
},
{
"value": "slide-in-from-top",
"label": "Slide In From Top"
},
{
"value": "slide-in-from-bottom",
"label": "Slide In From Bottom"
},
{
"value": "slide-in-from-left",
"label": "Slide In From Left"
},
{
"value": "slide-in-from-right",
"label": "Slide In From Right"
}
],
"defaultValue": "slide-in-from-bottom",
"group": "General"
},
{
"id": "hideAnimation",
"label": "Hide Animation",
"description": "",
"type": "select",
"options": [
{
"value": "fade-out",
"label": "Fade Out"
},
{
"value": "slide-out-top",
"label": "Slide Out Top"
},
{
"value": "slide-out-bottom",
"label": "Slide Out Down"
},
{
"value": "slide-out-left",
"label": "Slide Out Left"
},
{
"value": "slide-out-right",
"label": "Slide Out Right"
}
],
"defaultValue": "slide-out-bottom",
"group": "General"
},
{
"id": "smtcBridgeAddress",
"label": "Address",
"description": "",
"type": "text",
"defaultValue": "127.0.0.1",
"group": "SMTC Bridge"
},
{
"id": "smtcBridgePort",
"label": "Port",
"description": "",
"type": "text",
"defaultValue": "5000",
"group": "SMTC Bridge"
}
]
}
+252
View File
@@ -0,0 +1,252 @@
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-size: 20px;
color: white;
margin: 0;
}
#main-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
padding: 1em;
box-sizing: border-box;
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes slide-in-from-top {
0% {
transform: translateY(-1em);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-in-from-top {
0% {
transform: translateY(-1em);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-out-top {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-1em);
opacity: 0;
}
}
@keyframes slide-in-from-bottom {
0% {
transform: translateY(1em);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-out-bottom {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(1em);
opacity: 0;
}
}
@keyframes slide-in-from-left {
0% {
transform: translateX(-1em);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out-left {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(-1em);
opacity: 0;
}
}
@keyframes slide-in-from-right {
0% {
transform: translateX(1em);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out-right {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(1em);
opacity: 0;
}
}
@property --mask-left {
syntax: '<color>';
inherits: false;
initial-value: black;
}
@property --mask-right {
syntax: '<color>';
inherits: false;
initial-value: transparent;
}
.text-label {
--fade-width: 0.5em;
white-space: nowrap;
overflow: hidden;
display: block;
width: 100%;
mask-image: var(--trailing-fade);
-webkit-mask-image: var(--trailing-fade);
transition: opacity 0.25s ease-in-out;
}
.text-label:empty {
display: none !important;
}
.text-label:has(.scroll-content:empty),
.text-label:has(#track-label:empty) {
display: none !important;
}
.text-label .scroll-content {
display: inline-flex;
white-space: nowrap;
will-change: transform;
}
.text-label.is-overflowing .scroll-content {
animation: ping-pong-scroll var(--marquee-duration, 8s) ease-in-out infinite;
}
.text-label.is-overflowing {
animation: ping-pong-mask var(--marquee-duration, 8s) ease-in-out infinite;
/* Static gradient structure referencing dynamic color variables */
mask-image: linear-gradient(
to right,
var(--mask-left) 0px,
black var(--fade-width),
black calc(100% - var(--fade-width)),
var(--mask-right) 100%
);
-webkit-mask-image: linear-gradient(
to right,
var(--mask-left) 0px,
black var(--fade-width),
black calc(100% - var(--fade-width)),
var(--mask-right) 100%
);
}
@keyframes ping-pong-scroll {
0%, 15% {
transform: translateX(0%);
}
50%, 65% {
transform: translateX(var(--scroll-distance, -100%));
}
100% {
transform: translateX(0%);
}
}
@keyframes ping-pong-mask {
/* 1. AT START: Left is solid (black), Right is faded (transparent) */
0%, 15% {
--mask-left: black;
--mask-right: transparent;
}
/* 2. MOVING LEFT: Left smoothly fades in (transparent), Right stays faded */
22%, 43% {
--mask-left: transparent;
--mask-right: transparent;
}
/* 3. AT FAR END: Right smoothly solidifies (black), Left stays faded */
50%, 65% {
--mask-left: transparent;
--mask-right: black;
}
/* 4. MOVING RIGHT: Right smoothly fades back out (transparent) */
72%, 93% {
--mask-left: transparent;
--mask-right: transparent;
}
/* 5. RETURN TO START: Left smoothly solidifies (black) */
100% {
--mask-left: black;
--mask-right: transparent;
}
}
+15
View File
@@ -0,0 +1,15 @@
<div id="main-wrapper">
<div id="song-info-container">
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
</div>
<div id="text-info-container">
<div id="artist-label" class="text-label">&nbsp;</div>
<div id="track-label" class="text-label">&nbsp;</div>
</div>
<div id="progress-bar"></div>
</div>
</div>
+111
View File
@@ -0,0 +1,111 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
const songInfoContainer = document.getElementById('song-info-container');
const albumArtContainer = document.getElementById('album-art-container');
const albumArtLayer = document.getElementById('album-art-layer');
const albumArtTransition = document.getElementById('album-art-transition-layer');
const textInfoContainer = document.getElementById('text-info-container');
const trackLabel = document.getElementById('track-label');
const artistLabel = document.getElementById('artist-label');
const progressBar = document.getElementById('progress-bar');
////////////////
// PAGE SETUP //
////////////////
// Set property visibility
trackLabel.style.display = showPrimary ? '' : 'none';
artistLabel.style.display = showSecondary ? '' : 'none';
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
mainWrapper.style.height = `${mainWrapper.style.clientWidth}px`;
// Set progress bar visibility
if (!showProgressBar)
progressBar.style.display = 'none';
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps, accentColorPalette) {
// Fade in the overlay (shows the new text)
if (trackLabel.innerText != (swapArtistTrack ? mediaProps.Artist : mediaProps.Title))
trackLabel.style.opacity = "0";
if (artistLabel.innerText != (swapArtistTrack ? mediaProps.Title : mediaProps.Artist))
artistLabel.style.opacity = "0";
albumArtLayer.style.opacity = "0";
// Wait for fade (0.5s), then swap the real text and hide overlay
setTimeout(() => {
SetLabelText('track-label', swapArtistTrack ? mediaProps.Artist : mediaProps.Title);
SetLabelText('artist-label', swapArtistTrack ? mediaProps.Title : mediaProps.Artist);
// Extract the image source string (use fallback if Windows has no art)
const newArtUrl = mediaProps.Thumbnail ?? './images/placeholder.png';
// Set the image
albumArtLayer.style.backgroundImage = `url('${newArtUrl}')`;
// Apply a tint
if (!useCustomColors)
{
const baseColor = accentColorPalette.DarkMuted;
// Solid from 0% to 30%, then fading to 0% opacity at 100%
textInfoContainer.style.background = `linear-gradient(to top, ${baseColor}FF 0%, ${baseColor}FF 10%, ${baseColor}00 100%)`;
if (!showAlbumArt)
songInfoContainer.style.background = accentColorPalette.LightVibrant;
// Apply text color
document.body.style.color = accentColorPalette.LightVibrant;
}
else
{
const baseColor = color2;
// Solid from 0% to 30%, then fading to 0% opacity at 100%
textInfoContainer.style.background = `linear-gradient(to top, ${baseColor}FF 0%, ${baseColor}FF 10%, ${baseColor}00 100%)`;
if (!showAlbumArt)
songInfoContainer.style.background = color1;
// Apply text color
document.body.style.color = color1;
}
trackLabel.style.opacity = "";
artistLabel.style.opacity = "";
albumArtLayer.style.opacity = "";
setTimeout(() => {
// Set the image
albumArtTransition.style.backgroundImage = `url('${newArtUrl}')`;
SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
}, 250);
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
progressBar.style.width = `${progressPercent}%`;
if (!useCustomColors)
progressBar.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
else
progressBar.style.setProperty('--accent-color', color1);
}
+92
View File
@@ -0,0 +1,92 @@
:root {
--border-radius: 30px;
}
#main-wrapper {
width: 500px;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.5em;
opacity: 0;
aspect-ratio: 1 / 1;
}
#album-art-container {
aspect-ratio: 1 / 1;
overflow: hidden;
position: relative;
background-size: cover;
background-position: center;
display: var(--show-album-art);
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transition: opacity 0.5s ease;
border-radius: var(--border-radius);
}
#album-art-layer {
z-index: 1;
transition: all 0.5s ease;
}
#album-art-transition-layer {
z-index: 0;
}
#song-info-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
#text-info-container {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 6% 7%;
padding-top: 50%;
box-sizing: border-box;
z-index: 2;
text-align: var(--text-alignment);
}
#track-label {
font-size: 2em;
font-weight: 700;
text-align: var(--text-alignment);
}
#artist-label {
font-size: 1.5em;
font-weight: 500;
opacity: 0.8;
text-align: var(--text-alignment);
}
#progress-bar {
height: 1%;
width: 0%; /* JS will control this */
background: linear-gradient(to top, #ffffff00, var(--accent-color, #ffffff));
/* background-color: var(--accent-color, #ffffff); */
transition: width 1s ease-in-out, background 1s ease-in-out;
/* border-radius: 10000px; */
position: absolute;
bottom: 0;
left: 0;
z-index: 3;
}
+30
View File
@@ -0,0 +1,30 @@
<div id="main-wrapper">
<div id="song-info-container">
<div id="background-layer"></div>
<div id="background-transition-layer"></div>
<div id="song-info-wrapper">
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
</div>
<div id="track-label" class="text-label">&nbsp;</div>
<div id="artist-label" class="text-label">&nbsp;</div>
<div id="progress-container">
<div id="progress-bar-track">
<div id="progress-bar-fill"></div>
<div></div>
</div>
<div id="time-container">
<div id="current-time">0:00</div>
<div id="duration">0:00</div>
</div>
</div>
</div>
</div>
</div>
+110
View File
@@ -0,0 +1,110 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
const albumArtContainer = document.getElementById('album-art-container');
const songInfoContainer = document.getElementById('song-info-container');
const albumArtLayer = document.getElementById('album-art-layer');
const albumArtTransition = document.getElementById('album-art-transition-layer');
const backgroundLayer = document.getElementById('background-layer');
const backgroundTransitionLayer = document.getElementById('background-transition-layer');
const trackLabel = document.getElementById('track-label');
const artistLabel = document.getElementById('artist-label');
const progressContainer = document.getElementById('progress-container');
const progressBarFill = document.getElementById('progress-bar-fill');
const currentTimeLabel = document.getElementById('current-time');
const durationLabel = document.getElementById('duration');
////////////////
// PAGE SETUP //
////////////////
// Set property visibility
trackLabel.style.display = showPrimary ? '' : 'none';
artistLabel.style.display = showSecondary ? '' : 'none';
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
// Set progress bar visibility
if (!showProgressBar)
progressContainer.style.display = 'none';
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps, accentColorPalette) {
// Fade in the overlay (shows the new text)
if (trackLabel.innerText != (swapArtistTrack ? mediaProps.Artist : mediaProps.Title))
trackLabel.style.opacity = "0";
if (artistLabel.innerText != (swapArtistTrack ? mediaProps.Title : mediaProps.Artist))
artistLabel.style.opacity = "0";
backgroundLayer.style.opacity = "0";
albumArtLayer.style.opacity = "0";
// Wait for fade (0.5s), then swap the real text and hide overlay
setTimeout(() => {
SetLabelText('track-label', swapArtistTrack ? mediaProps.Artist : mediaProps.Title);
SetLabelText('artist-label', swapArtistTrack ? mediaProps.Title : mediaProps.Artist);
// Extract the image source string (use fallback if Windows has no art)
const newArtUrl = mediaProps.Thumbnail ?? './images/placeholder.png';
// Set the image
backgroundLayer.style.backgroundImage = `url('${newArtUrl}')`;
albumArtLayer.style.backgroundImage = `url('${newArtUrl}')`;
// Apply a tint
backgroundLayer.style.backgroundColor = accentColorPalette.DarkMuted + "80"; // 80 is 50% opacity in hex
trackLabel.style.opacity = "";
artistLabel.style.opacity = "";
backgroundLayer.style.opacity = "";
albumArtLayer.style.opacity = "";
setTimeout(() => {
// Set the image
backgroundTransitionLayer.style.backgroundImage = `url('${newArtUrl}')`;
albumArtTransition.style.backgroundImage = `url('${newArtUrl}')`;
// Apply a tint:
backgroundTransitionLayer.style.backgroundColor = accentColorPalette.DarkMuted + "80"; // 80 is 50% opacity in hex
SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
}, 250);
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
progressBarFill.style.width = `${progressPercent}%`;
if (!useCustomColors)
{
progressBarFill.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
}
else
{
progressBarFill.style.setProperty('--accent-color', color1);
document.body.style.color = color1;
}
}
+172
View File
@@ -0,0 +1,172 @@
:root {
--border-radius: 20px;
--component-radius: calc(var(--border-radius) * 0.75);
}
#main-wrapper {
width: 500px;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.5em;
opacity: 0;
}
#album-art-container {
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: var(--component-radius);
position: relative;
background-size: cover;
background-position: center;
margin: 0em 0em 1em 0em;
display: var(--show-album-art);
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transition: opacity 0.5s ease;
}
#album-art-layer {
z-index: 1;
transition: all 0.5s ease;
}
#album-art-transition-layer {
z-index: 0;
}
#song-info-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
/* The background now fills the frame */
#background-layer,
#background-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-blend-mode: overlay; /* Or 'soft-light' for a subtler effect */
filter: blur(20px) brightness(0.3);
transform: scale(1.1); /* Slightly zoom in to cover edges when blurred */
}
#background-layer {
transition: all 0.5s ease;
}
#background-transition-layer {
z-index: 0;
}
/* The content sits on top */
#song-info-wrapper {
position: relative; /* Keeps it in the document flow */
z-index: 2; /* Sits above the background-layer */
padding: 10% 7.5%;
display: flex;
flex-direction: column;
gap: 0.3em;
max-height: 100%;
}
#track-label {
font-size: 1em;
font-weight: 600;
text-align: var(--text-alignment);
}
#artist-label {
font-size: 0.8em;
font-weight: 300;
opacity: 0.6;
text-align: var(--text-alignment);
}
.text-label {
white-space: nowrap;
/* overflow: hidden; */
mask-image: var(--trailing-fade);
-webkit-mask-image: var(--trailing-fade);
transition: opacity 0.25s ease-in-out;
}
#progress-container {
display: flex;
flex-direction: column;
gap: 0.2em;
margin-top: 1em;
}
#progress-bar-track {
width: 100%;
height: 0.4em;
border-radius: 10000px;
background-color: #ffffff3a;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
}
#progress-bar-fill {
height: 100%;
width: 0%; /* JS will control this */
border-radius: 10000px;
background-color: var(--accent-color, #ffffff);
transition: width 1s ease-in-out, background-color 1s ease-in-out;
/* This makes the handle track the end of the bar */
position: relative;
}
#progress-bar-fill::after {
content: '';
position: absolute;
right: -6px; /* Adjust to center the circle on the end of the bar */
top: 50%;
transform: translateY(-50%);
width: 0.8em; /* Diameter of your circle */
height: 0.8em;
background-color: var(--accent-color, #ffffff);
border-radius: 50%;
filter: brightness(1.4);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
/* Optional: Hide it if the width is too small */
opacity: 0;
transition: all 1s ease-in-out;
}
/* Show the handle when the progress bar is active */
#progress-bar-track:hover #progress-bar-fill::after,
#progress-bar-fill:not([style*="width: 0%"])::after {
opacity: 1;
}
#time-container {
display: flex;
justify-content: space-between;
font-size: 0.7em;
opacity: 0.5;
}
+30
View File
@@ -0,0 +1,30 @@
<div id="main-wrapper">
<div id="yet-another-wrapper-yup">
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
</div>
<div id="song-info-container">
<div id="background-layer"></div>
<div id="background-transition-layer"></div>
<div id="song-info-wrapper">
<div id="track-label" class="text-label">&nbsp;</div>
<div id="artist-label" class="text-label">&nbsp;</div>
<div id="progress-container">
<div id="progress-bar-track">
<div id="progress-bar-fill"></div>
<div></div>
</div>
<div id="time-container">
<div id="current-time">0:00</div>
<div id="duration">0:00</div>
</div>
</div>
</div>
</div>
</div>
</div>
+110
View File
@@ -0,0 +1,110 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
const albumArtContainer = document.getElementById('album-art-container');
const songInfoContainer = document.getElementById('song-info-container');
const albumArtLayer = document.getElementById('album-art-layer');
const albumArtTransition = document.getElementById('album-art-transition-layer');
const backgroundLayer = document.getElementById('background-layer');
const backgroundTransitionLayer = document.getElementById('background-transition-layer');
const trackLabel = document.getElementById('track-label');
const artistLabel = document.getElementById('artist-label');
const progressContainer = document.getElementById('progress-container');
const progressBarFill = document.getElementById('progress-bar-fill');
const currentTimeLabel = document.getElementById('current-time');
const durationLabel = document.getElementById('duration');
////////////////
// PAGE SETUP //
////////////////
// Set property visibility
trackLabel.style.display = showPrimary ? '' : 'none';
artistLabel.style.display = showSecondary ? '' : 'none';
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
// Set progress bar visibility
if (!showProgressBar)
progressContainer.style.display = 'none';
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps, accentColorPalette) {
// Fade in the overlay (shows the new text)
if (trackLabel.innerText != (swapArtistTrack ? mediaProps.Artist : mediaProps.Title))
trackLabel.style.opacity = "0";
if (artistLabel.innerText != (swapArtistTrack ? mediaProps.Title : mediaProps.Artist))
artistLabel.style.opacity = "0";
backgroundLayer.style.opacity = "0";
albumArtLayer.style.opacity = "0";
// Wait for fade (0.5s), then swap the real text and hide overlay
setTimeout(async () => {
SetLabelText('track-label', swapArtistTrack ? mediaProps.Artist : mediaProps.Title);
SetLabelText('artist-label', swapArtistTrack ? mediaProps.Title : mediaProps.Artist);
// Extract the image source string (use fallback if Windows has no art)
const newArtUrl = mediaProps.Thumbnail ?? './images/placeholder.png';
// Set the image
backgroundLayer.style.backgroundImage = `url('${newArtUrl}')`;
albumArtLayer.style.backgroundImage = `url('${newArtUrl}')`;
// Apply a tint
backgroundLayer.style.backgroundColor = accentColorPalette.DarkMuted + "80"; // 80 is 50% opacity in hex
trackLabel.style.opacity = "";
artistLabel.style.opacity = "";
backgroundLayer.style.opacity = "";
albumArtLayer.style.opacity = "";
setTimeout(() => {
// Set the image
backgroundTransitionLayer.style.backgroundImage = `url('${newArtUrl}')`;
albumArtTransition.style.backgroundImage = `url('${newArtUrl}')`;
// Apply a tint
backgroundTransitionLayer.style.backgroundColor = accentColorPalette.DarkMuted + "80"; // 80 is 50% opacity in hex
SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
}, 250);
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
progressBarFill.style.width = `${progressPercent}%`;
if (!useCustomColors)
{
progressBarFill.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
}
else
{
progressBarFill.style.setProperty('--accent-color', color1);
document.body.style.color = color1;
}
}
+185
View File
@@ -0,0 +1,185 @@
:root {
--border-radius: 20px;
}
#main-wrapper {
width: 500px;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.5em;
opacity: 0;
}
#album-art-container {
/* 1. Tell it to maintain a 1:1 square ratio */
height: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: var(--border-radius);
position: relative;
background-size: cover;
background-position: center;
display: var(--show-album-art);
flex-shrink: 0;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transition: opacity 0.5s ease;
}
#album-art-layer {
z-index: 1;
transition: all 0.5s ease;
}
#album-art-transition-layer {
z-index: 0;
}
#song-info-container {
position: relative;
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
#yet-another-wrapper-yup {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
overflow: hidden;
gap: 1em;
transition: all 0.25s ease-in-out;
}
/* The background now fills the frame */
#background-layer,
#background-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-blend-mode: overlay; /* Or 'soft-light' for a subtler effect */
filter: blur(10px) brightness(0.3) saturate(1.2);
transform: scale(1.1); /* Slightly zoom in to cover edges when blurred */
}
#background-layer {
transition: all 0.5s ease;
}
#background-transition-layer {
z-index: 0;
}
/* The content sits on top */
#song-info-wrapper {
position: relative; /* Keeps it in the document flow */
z-index: 2; /* Sits above the background-layer */
padding: 1em 1.3em;
display: flex;
flex-direction: column;
gap: 0.3em;
}
#track-label {
font-size: 1em;
font-weight: 600;
text-align: var(--text-alignment);
}
#artist-label {
font-size: 0.8em;
font-weight: 300;
opacity: 0.6;
text-align: var(--text-alignment);
}
.text-label {
white-space: nowrap;
overflow: hidden;
mask-image: var(--trailing-fade);
-webkit-mask-image: var(--trailing-fade);
transition: opacity 0.25s ease-in-out;
}
#progress-container {
display: flex;
flex-direction: column;
gap: 0.2em;
margin-top: 0.2em;
}
#progress-bar-track {
width: 100%;
height: 0.4em;
border-radius: 10000px;
background-color: #ffffff3a;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
}
#progress-bar-fill {
height: 100%;
width: 0%; /* JS will control this */
border-radius: 10000px;
background-color: var(--accent-color, #ffffff);
transition: width 1s ease-in-out, background-color 1s ease-in-out;
/* This makes the handle track the end of the bar */
position: relative;
}
#progress-bar-fill::after {
content: '';
position: absolute;
right: -6px; /* Adjust to center the circle on the end of the bar */
top: 50%;
transform: translateY(-50%);
width: 0.8em; /* Diameter of your circle */
height: 0.8em;
background-color: var(--accent-color, #ffffff);
border-radius: 50%;
filter: brightness(1.4);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
/* Optional: Hide it if the width is too small */
opacity: 0;
transition: all 1s ease-in-out;
}
/* Show the handle when the progress bar is active */
#progress-bar-track:hover #progress-bar-fill::after,
#progress-bar-fill:not([style*="width: 0%"])::after {
opacity: 1;
}
#time-container {
display: flex;
justify-content: space-between;
font-size: 0.7em;
opacity: 0.5;
}
@@ -0,0 +1,51 @@
<div id="main-wrapper">
<div id="color-palette">
<!-- Vibrant -->
<div id="box-vibrant" class="color-box">
<div class="color-info">
<span class="secondary-text">Vibrant</span>
<span id="label-vibrant" class="primary-text"></span>
</div>
</div>
<!-- Muted -->
<div id="box-muted" class="color-box">
<div class="color-info">
<span class="secondary-text">Muted</span>
<span id="label-muted" class="primary-text"></span>
</div>
</div>
<!-- Dark Vibrant -->
<div id="box-dark-vibrant" class="color-box">
<div class="color-info">
<span class="secondary-text">Dark Vibrant</span>
<span id="label-dark-vibrant" class="primary-text"></span>
</div>
</div>
<!-- Dark Muted -->
<div id="box-dark-muted" class="color-box">
<div class="color-info">
<span class="secondary-text">Dark Muted</span>
<span id="label-dark-muted" class="primary-text"></span>
</div>
</div>
<!-- Light Vibrant -->
<div id="box-light-vibrant" class="color-box">
<div class="color-info">
<span class="secondary-text">Light Vibrant</span>
<span id="label-light-vibrant" class="primary-text"></span>
</div>
</div>
<!-- Light Muted -->
<div id="box-light-muted" class="color-box">
<div class="color-info">
<span class="secondary-text">Light Muted</span>
<span id="label-light-muted" class="primary-text"></span>
</div>
</div>
</div>
</div>
+125
View File
@@ -0,0 +1,125 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
// Color Boxes
const boxVibrant = document.getElementById('box-vibrant');
const boxMuted = document.getElementById('box-muted');
const boxDarkVibrant = document.getElementById('box-dark-vibrant');
const boxDarkMuted = document.getElementById('box-dark-muted');
const boxLightVibrant = document.getElementById('box-light-vibrant');
const boxLightMuted = document.getElementById('box-light-muted');
// Primary Text Labels (for Hex codes)
const labelVibrant = document.getElementById('label-vibrant');
const labelMuted = document.getElementById('label-muted');
const labelDarkVibrant = document.getElementById('label-dark-vibrant');
const labelDarkMuted = document.getElementById('label-dark-muted');
const labelLightVibrant = document.getElementById('label-light-vibrant');
const labelLightMuted = document.getElementById('label-light-muted');
/////////////////
// GLOBAL VARS //
/////////////////
const colorRoles = ['Vibrant', 'Muted', 'DarkVibrant', 'DarkMuted', 'LightVibrant', 'LightMuted'];
////////////////
// PAGE SETUP //
////////////////
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
// Set primary and secondary text visibility
if (showPrimary)
document.documentElement.style.setProperty('--show-primary', '');
else
document.documentElement.style.setProperty('--show-primary', 'none');
if (showSecondary)
document.documentElement.style.setProperty('--show-secondary', '');
else
document.documentElement.style.setProperty('--show-secondary', 'none');
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps, accentColorPalette) {
// Map elements for easy lookup
const labels = {
Vibrant: labelVibrant,
Muted: labelMuted,
DarkVibrant: labelDarkVibrant,
DarkMuted: labelDarkMuted,
LightVibrant: labelLightVibrant,
LightMuted: labelLightMuted
};
const boxes = {
Vibrant: boxVibrant,
Muted: boxMuted,
DarkVibrant: boxDarkVibrant,
DarkMuted: boxDarkMuted,
LightVibrant: boxLightVibrant,
LightMuted: boxLightMuted
};
// Fade out all labels
colorRoles.forEach(role => {
labels[role].style.opacity = 0;
});
// Wait for fade, then swap text, colors, and restore opacity
setTimeout(() => {
colorRoles.forEach(role => {
const colorValue = accentColorPalette[role];
labels[role].textContent = colorValue;
boxes[role].style.setProperty('--box-bg', colorValue);
boxes[role].style.color = colorValue;
labels[role].style.opacity = '';
});
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
const durationMs = timelineProps.EndTime;
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
if (durationMs <= 0 || !showProgressBar)
progressPercent = 100;
const clipRight = 100 - progressPercent;
const boxes = {
Vibrant: boxVibrant,
Muted: boxMuted,
DarkVibrant: boxDarkVibrant,
DarkMuted: boxDarkMuted,
LightVibrant: boxLightVibrant,
LightMuted: boxLightMuted
};
// Update variables for each box dynamically via loop
colorRoles.forEach(role => {
const box = boxes[role];
if (box && accentColorPalette[role]) {
box.style.setProperty('--box-bg', accentColorPalette[role]);
box.style.setProperty('--clip-right', `${clipRight}%`);
}
});
}
@@ -0,0 +1,64 @@
:root {
--border-radius: 10px;
}
#main-wrapper {
width: 500px;
opacity: 0;
}
#color-palette {
display: flex;
gap: 0.5em;
flex-direction: column;
width: 100%;
}
.color-box {
display: flex;
align-items: flex-end;
padding: 0.5em;
border-radius: var(--border-radius);
text-align: var(--text-alignment);
height: 5em;
position: relative;
overflow: hidden;
}
.color-box::before {
content: '';
position: absolute;
inset: 0;
background: var(--box-bg);
clip-path: inset(0 var(--clip-right, 0%) 0 0);
z-index: 1;
transition: all 1s ease;
}
.color-box .color-info {
position: relative;
z-index: 2;
}
.color-info {
text-transform: uppercase;
opacity: 0.5;
width: 100%;
mix-blend-mode: difference;
display: flex;
flex-direction: column;
}
.primary-text {
font-size: 1em;
font-weight: 700;
transition: opacity 0.5s ease;
display: var(--show-primary);
}
.secondary-text {
opacity: 0.7;
font-size: 0.7em;
font-weight: 700;
display: var(--show-secondary);
}
@@ -0,0 +1,3 @@
{
"base-theme": "compact"
}
+11
View File
@@ -0,0 +1,11 @@
<div id="main-wrapper">
<div id="song-info-container">
<div id="progress-bar-track" class="progress">
<label id="song-label-background" class="text-label"></label>
</div>
<div id="progress-bar" class="progress">
<label id="song-label" class="text-label"></label>
</div>
</div>
</div>
+193
View File
@@ -0,0 +1,193 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
const albumArtContainer = document.getElementById('album-art-container');
const songInfoContainer = document.getElementById('song-info-container');
const progressBar = document.getElementById('progress-bar');
const progressBarTrack = document.getElementById('progress-bar-track');
const songLabel = document.getElementById('song-label');
const songLabelBackground = document.getElementById('song-label-background');
////////////////
// PAGE SETUP //
////////////////
// This theme has variants
const themeVariant = window.ThemeVariant ? window.ThemeVariant : '';
// Set property visibility
if (!showPrimary)
document.documentElement.style.setProperty('--show-primary', `none`);
if (!showSecondary)
document.documentElement.style.setProperty('--show-secondary', `none`);
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps, accentColorPalette) {
// Fade in the overlay (shows the new text)
songLabel.style.opacity = "0";
songLabelBackground.style.opacity = "0";
// Wait for fade (0.5s), then swap the real text and hide overlay
setTimeout(() => {
if (swapArtistTrack) {
SetSongInfo(mediaProps.Artist, mediaProps.Title);
} else {
SetSongInfo(mediaProps.Title, mediaProps.Artist);
}
// Extract the image source string (use fallback if Windows has no art)
// Set the pill color
if (!useCustomColors)
{
switch (themeVariant) {
case "compact-inverted":
progressBarTrack.style.backgroundColor = accentColorPalette.LightVibrant;
progressBar.style.background = `color-mix(in srgb, ${accentColorPalette.DarkMuted}, black 60%)`;
songLabel.style.color = accentColorPalette.LightVibrant;
songLabelBackground.style.color = accentColorPalette.DarkVibrant;
break;
default:
progressBarTrack.style.backgroundColor = `color-mix(in srgb, ${accentColorPalette.DarkMuted}, black 60%)`;
progressBar.style.background = accentColorPalette.LightVibrant;
songLabel.style.color = accentColorPalette.DarkVibrant;
songLabelBackground.style.color = accentColorPalette.LightVibrant;
break;
}
}
else
{
switch (themeVariant) {
case "compact-inverted":
progressBarTrack.style.backgroundColor = `color-mix(in srgb, ${color1}, black 60%)`;
progressBar.style.background = color2;
songLabel.style.color = color1;
songLabelBackground.style.color = color2;
break;
default:
progressBarTrack.style.backgroundColor = color2;
progressBar.style.background = color1;
songLabel.style.color = color2;
songLabelBackground.style.color = color1;
break;
}
}
songLabel.style.opacity = "";
songLabelBackground.style.opacity = "";
setTimeout(() => {
SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
}, 250);
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
// If the duration is 0, that means the session isn't returning any timeline info, so just put the progress at 100%
if (durationMs <= 0 || !showProgressBar)
{
progressBarTrack.style.display = 'none';
return;
}
else
progressBarTrack.style.display = '';
// Calculate the progress percentage
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
// Calculate how much needs to be hidden (the right-side offset)
const clipRight = 100 - progressPercent;
// Update the clip-path
progressBar.style.clipPath = `inset(0 ${clipRight}% 0 0)`;
progressBarTrack.style.clipPath = `inset(0 0 0 ${progressPercent}%)`;
// // Set the progress bar but with a transition for smoothness
// const clipRight = progressPercent;
// 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}%)`;
}
// MARQUEE LOGIC
// This is a more advanced marquee implementation that calculates the overflow distance and adjusts the scroll speed accordingly
// Call this function whenever the track changes
function SetSongInfo(trackName, artistName) {
// Update main text and background layer simultaneously
UpdateSingleSongLabel('song-label', trackName, artistName);
UpdateSingleSongLabel('song-label-background', trackName, artistName);
}
function UpdateSingleSongLabel(containerId, trackName, artistName) {
const container = document.getElementById(containerId);
if (!container) return;
let scrollContent = container.querySelector('.scroll-content');
// Only rebuild the HTML structure if the text content actually changed or doesn't exist yet
const currentTrackEl = container.querySelector('.track-label');
const currentArtistEl = container.querySelector('.artist-label');
if (!scrollContent || !currentTrackEl || currentTrackEl.textContent !== trackName || currentArtistEl.textContent !== artistName) {
container.classList.remove('is-overflowing');
container.innerHTML = `
<span class="scroll-content">
<span class="track-label">${trackName}</span>
<span class="artist-label">${artistName}</span>
</span>
`;
scrollContent = container.querySelector('.scroll-content');
}
// Encapsulate measurement logic so it can be called safely by the observer
const updateMetrics = () => {
scrollContent = container.querySelector('.scroll-content');
const textWidth = scrollContent.scrollWidth;
const containerWidth = container.clientWidth;
const overflowDistance = textWidth - containerWidth;
if (overflowDistance > 0) {
const distancePercent = (overflowDistance / textWidth) * 100;
const travelTime = overflowDistance / SCROLL_SPEED_PX_PER_SEC;
const totalDuration = (travelTime * 2) + 3;
container.style.setProperty('--scroll-distance', `-${distancePercent}%`);
container.style.setProperty('--marquee-duration', `${totalDuration}s`);
container.classList.add('is-overflowing');
} else {
container.classList.remove('is-overflowing');
}
};
updateMetrics();
// Attach ResizeObserver once to update metrics only (No recursive function resets!)
if (!container._resizeObserver) {
container._resizeObserver = new ResizeObserver(() => {
updateMetrics();
});
container._resizeObserver.observe(container);
}
}
+102
View File
@@ -0,0 +1,102 @@
#main-wrapper {
width: 500px;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 1em;
opacity: 0;
align-items: center;
}
#song-info-container {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
align-items: center;
border-radius: 1em;
transition: all 1s ease-in-out;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
display: grid;
/* This creates a single cell that both children will occupy */
grid-template-areas: "stack";
}
/* The content sits on top */
#progress-bar {
position: relative;
z-index: 2; /* Sits above the progress bar */
transition: all 1s ease-in-out;
width: 100%;
}
#progress-bar-track {
transition: all 1s ease-in-out;
}
.progress {
box-sizing: border-box;
padding: 0.25em 0.75em;
grid-area: stack;
display: flex;
min-width: 0;
}
#song-label,
#song-label-background {
width: 100%;
text-align: var(--text-alignment);
}
#artist-label,
#artist-label-background {
font-size: 0.8em;
font-weight: 400;
opacity: 0.7;
}
#track-label {
font-weight: 700;
}
.text-label {
transition: opacity 0.25s ease-in-out;
}
/* Inner wrapper holding track + artist inline */
#song-label .scroll-content,
#song-label-background .scroll-content {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.5em; /* Spacing between Track and Artist */
white-space: nowrap;
will-change: transform;
}
/* Preserve original font weights */
.track-label {
font-weight: 700;
display: var(--show-primary);
}
.artist-label {
font-size: 0.8em;
font-weight: 400;
opacity: 0.7;
display: var(--show-secondary);
}
/* Marquee scroll animation */
#song-label.is-overflowing .scroll-content,
#song-label-background.is-overflowing .scroll-content {
animation: ping-pong-scroll var(--marquee-duration, 8s) ease-in-out infinite;
}
/* Mask animation applied to container */
#song-label.is-overflowing,
#song-label-background.is-overflowing {
animation: ping-pong-mask var(--marquee-duration, 8s) ease-in-out infinite;
}
@@ -0,0 +1,3 @@
{
"base-theme": "standard"
}
+17
View File
@@ -0,0 +1,17 @@
#track-label {
font-weight: 800;
}
#artist-label {
font-weight: 500;
opacity: 0.7;
}
.progress-text {
font-weight: 500;
opacity: 0.7;
}
#progress-bar-track {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); /* Adds depth */
}
+3
View File
@@ -0,0 +1,3 @@
{
"base-theme": "standard"
}
+17
View File
@@ -0,0 +1,17 @@
#track-label {
font-weight: 800;
}
#artist-label {
font-weight: 500;
opacity: 0.7;
}
.progress-text {
font-weight: 500;
opacity: 0.7;
}
#progress-bar-track {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); /* Adds depth */
}
+19
View File
@@ -0,0 +1,19 @@
<div id="main-wrapper">
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
</div>
<div id="song-info-container">
<div id="track-label" class="text-label">&nbsp;</div>
<div id="artist-label" class="text-label">&nbsp;</div>
<div id="progress-container">
<div id="current-time" class="progress-text">0:00</div>
<div id="progress-bar-track">
<div id="progress-bar-fill"></div>
<div></div>
</div>
<div id="duration" class="progress-text">0:00</div>
</div>
</div>
</div>
+127
View File
@@ -0,0 +1,127 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
const albumArtContainer = document.getElementById('album-art-container');
const songInfoContainer = document.getElementById('song-info-container');
const albumArtLayer = document.getElementById('album-art-layer');
const albumArtTransition = document.getElementById('album-art-transition-layer');
const trackLabel = document.getElementById('track-label');
const artistLabel = document.getElementById('artist-label');
const progressContainer = document.getElementById('progress-container');
const progressBarFill = document.getElementById('progress-bar-fill');
const currentTimeLabel = document.getElementById('current-time');
const durationLabel = document.getElementById('duration');
////////////////
// PAGE SETUP //
////////////////
// Set property visibility
trackLabel.style.display = showPrimary ? '' : 'none';
artistLabel.style.display = showSecondary ? '' : 'none';
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
// Set progress bar visibility
if (!showProgressBar)
progressContainer.style.display = 'none';
// If text alignment is 'right', swap the album art to the right hand side too
if (textAlignment == 'right') {
mainWrapper.style.flexDirection = 'row-reverse';
}
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps) {
if (trackLabel.innerText != (swapArtistTrack ? mediaProps.Artist : mediaProps.Title))
trackLabel.style.opacity = "0";
if (artistLabel.innerText != (swapArtistTrack ? mediaProps.Title : mediaProps.Artist))
artistLabel.style.opacity = "0";
albumArtLayer.style.opacity = "0";
// Wait for fade (0.5s), then swap the real text and hide overlay
setTimeout(() => {
// trackLabel.innerText = swapArtistTrack ? mediaProps.Artist : mediaProps.Title;
// artistLabel.innerText = swapArtistTrack ? mediaProps.Title : mediaProps.Artist;
SetLabelText('track-label', swapArtistTrack ? mediaProps.Artist : mediaProps.Title);
SetLabelText('artist-label', swapArtistTrack ? mediaProps.Title : mediaProps.Artist);
// Extract the image source string (use fallback if Windows has no art)
const newArtUrl = mediaProps.Thumbnail ?? './images/placeholder.png';
// Set the image
albumArtLayer.style.backgroundImage = `url('${newArtUrl}')`;
trackLabel.style.opacity = "";
artistLabel.style.opacity = "";
albumArtLayer.style.opacity = "";
setTimeout(() => {
// Set the image
albumArtTransition.style.backgroundImage = `url('${newArtUrl}')`;
SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
}, 250);
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
progressBarFill.style.width = `${progressPercent}%`;
if (!useCustomColors)
{
progressBarFill.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
document.body.style.color = accentColorPalette.LightVibrant;
}
else
{
progressBarFill.style.setProperty('--accent-color', color1);
document.body.style.color = color1;
}
}
// Helper function to calculate height of album art
const observer = new ResizeObserver(entries => {
for (let entry of entries) {
// Get the accurate rendered height of song-info-container
const infoHeight = entry.contentRect.height;
// Calculate 125% of that height
const targetSize = infoHeight * 1.5;
console.log(targetSize);
// Apply to album art (setting both width & height ensures it stays square)
albumArtContainer.style.height = `${targetSize}px`;
albumArtContainer.style.width = `${targetSize}px`;
}
});
// Start watching the song info container for size changes
observer.observe(songInfoContainer);
+115
View File
@@ -0,0 +1,115 @@
:root {
--border-radius: 10px;
}
#main-wrapper {
width: 500px;
display: flex;
flex-direction: row;
align-items: center;
gap: 1em;
opacity: 0;
}
#album-art-container {
/* 1. Tell it to maintain a 1:1 square ratio */
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: var(--border-radius);
position: relative;
background-size: cover;
background-position: center;
display: var(--show-album-art);
flex-shrink: 0;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transition: opacity 0.5s ease;
}
#album-art-layer {
z-index: 1;
transition: all 0.5s ease;
}
#album-art-transition-layer {
z-index: 0;
}
#song-info-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
flex-grow: 1;
min-width: 0;
gap: 0.25em;
transition: color 1s ease;
}
#background-transition-layer {
z-index: 0;
}
#track-label {
font-size: 1em;
font-weight: 600;
text-align: var(--text-alignment);
}
#artist-label {
font-size: 0.8em;
font-weight: 400;
opacity: 0.6;
text-align: var(--text-alignment);
}
#progress-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.2em;
margin-top: 0.2em;
gap: 0.5em;
}
#progress-bar-track {
width: 100%;
height: 0.4em;
border-radius: 1em;
background-color: #ffffff3a;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
}
#progress-bar-fill {
height: 100%;
width: 0%; /* JS will control this */
border-radius: 1em;
background-color: var(--accent-color, #ffffff);
transition: width 1s ease-in-out, background-color 1s ease-in-out;
/* This makes the handle track the end of the bar */
position: relative;
}
.progress-text {
font-size: 0.7em;
opacity: 0.5;
}
.text-label {
white-space: nowrap;
overflow: hidden;
mask-image: var(--trailing-fade);
-webkit-mask-image: var(--trailing-fade);
transition: opacity 0.25s ease-in-out;
}
+26
View File
@@ -0,0 +1,26 @@
<div id="main-wrapper">
<div id="song-info-container">
<div id="background-layer"></div>
<div id="background-transition-layer"></div>
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
</div>
<div id="song-info-wrapper">
<div id="track-label" class="text-label">&nbsp;</div>
<div id="artist-label" class="text-label">&nbsp;</div>
<div id="progress-container">
<div id="current-time" class="progress-text">0:00</div>
<div id="progress-bar-track">
<div id="progress-bar-fill"></div>
<div></div>
</div>
<div id="duration" class="progress-text">0:00</div>
</div>
</div>
</div>
</div>
+188
View File
@@ -0,0 +1,188 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
const albumArtContainer = document.getElementById('album-art-container');
const songInfoContainer = document.getElementById('song-info-container');
const songInfoWrapper = document.getElementById('song-info-wrapper');
const albumArtLayer = document.getElementById('album-art-layer');
const albumArtTransition = document.getElementById('album-art-transition-layer');
const backgroundLayer = document.getElementById('background-layer');
const backgroundTransitionLayer = document.getElementById('background-transition-layer');
const trackLabel = document.getElementById('track-label');
const artistLabel = document.getElementById('artist-label');
const progressContainer = document.getElementById('progress-container');
const progressBarFill = document.getElementById('progress-bar-fill');
const currentTimeLabel = document.getElementById('current-time');
const durationLabel = document.getElementById('duration');
////////////////
// PAGE SETUP //
////////////////
// This theme has variants
const themeVariant = window.ThemeVariant ? window.ThemeVariant : '';
// Set property visibility
trackLabel.style.display = showPrimary ? '' : 'none';
artistLabel.style.display = showSecondary ? '' : 'none';
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
// Set progress bar visibility
if (!showProgressBar)
progressContainer.style.display = 'none';
// Theme specific setup
switch (themeVariant) {
case "matte":
case "matte-dark":
backgroundLayer.style.display = 'none';
backgroundTransitionLayer.style.display = 'none';
break;
}
// If text alignment is 'right', swap the album art to the right hand side too
if (textAlignment == 'right') {
songInfoContainer.style.flexDirection = 'row-reverse';
}
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps, accentColorPalette) {
// Fade in the overlay (shows the new text)
if (trackLabel.innerText != (swapArtistTrack ? mediaProps.Artist : mediaProps.Title))
trackLabel.style.opacity = "0";
if (artistLabel.innerText != (swapArtistTrack ? mediaProps.Title : mediaProps.Artist))
artistLabel.style.opacity = "0";
backgroundLayer.style.opacity = "0";
albumArtLayer.style.opacity = "0";
// Wait for fade (0.5s), then swap the real text and hide overlay
setTimeout(async () => {
SetLabelText('track-label', swapArtistTrack ? mediaProps.Artist : mediaProps.Title);
SetLabelText('artist-label', swapArtistTrack ? mediaProps.Title : mediaProps.Artist);
if (!useCustomColors)
{
switch (themeVariant) {
case "matte":
document.body.style.color = accentColorPalette.DarkVibrant;
break;
case "matte-dark":
document.body.style.color = accentColorPalette.LightVibrant;
break;
}
}
else
{
switch (themeVariant) {
case "matte":
document.body.style.color = color2;
break;
// case "matte-dark":
default:
document.body.style.color = color1;
break;
}
}
// Extract the image source string (use fallback if Windows has no art)
const newArtUrl = mediaProps.Thumbnail ?? './images/placeholder.png';
// Set the image
if (!useCustomColors) {
switch (themeVariant) {
case "matte":
songInfoContainer.style.backgroundColor = accentColorPalette.LightVibrant;
break;
case "matte-dark":
songInfoContainer.style.backgroundColor = `color-mix(in srgb, ${accentColorPalette.DarkMuted}, black 60%)`;
break;
}
}
else
{
switch (themeVariant) {
case "matte":
songInfoContainer.style.backgroundColor = color1;
break;
// case "matte-dark":
default:
songInfoContainer.style.backgroundColor = `color-mix(in srgb, ${color2}, black 60%)`;
break;
}
}
backgroundLayer.style.backgroundImage = `url('${newArtUrl}')`;
albumArtLayer.style.backgroundImage = `url('${newArtUrl}')`;
// Apply a tint
backgroundLayer.style.backgroundColor = accentColorPalette.DarkMuted + "80"; // 80 is 50% opacity in hex
trackLabel.style.opacity = "";
artistLabel.style.opacity = "";
backgroundLayer.style.opacity = "";
albumArtLayer.style.opacity = "";
setTimeout(() => {
// Set the image
backgroundTransitionLayer.style.backgroundImage = `url('${newArtUrl}')`;
albumArtTransition.style.backgroundImage = `url('${newArtUrl}')`;
// Apply a tint
backgroundTransitionLayer.style.backgroundColor = accentColorPalette.DarkMuted + "80"; // 80 is 50% opacity in hex
SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
}, 250);
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// Update the label using your naming convention
currentTimeLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(currentPositionMs);
durationLabel.innerText =
ConvertMillisecondsToHoursMinutesSecondsSoItLooksBetterAndNotCringe(timelineProps.EndTime);
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
let progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
progressBarFill.style.width = `${progressPercent}%`;
progressBarFill.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
if (!useCustomColors) {
switch (themeVariant) {
case "matte":
progressBarFill.style.setProperty('--accent-color', accentColorPalette.DarkVibrant);
break;
case "matte-dark":
default:
progressBarFill.style.setProperty('--accent-color', accentColorPalette.LightVibrant);
break;
}
}
else
{
switch (themeVariant) {
case "matte":
progressBarFill.style.setProperty('--accent-color', color2);
break;
// case "matte-dark":
default:
progressBarFill.style.setProperty('--accent-color', color1);
break;
}
}
}
+149
View File
@@ -0,0 +1,149 @@
:root {
--border-radius: 20px;
--component-radius: calc(var(--border-radius) * 0.75);
}
#main-wrapper {
width: 500px;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.5em;
opacity: 0;
}
#album-art-container {
/* 1. Tell it to maintain a 1:1 square ratio */
height: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: var(--component-radius);
position: relative;
background-size: cover;
background-position: center;
display: var(--show-album-art);
flex-shrink: 0;
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transition: opacity 0.5s ease;
}
#album-art-layer {
z-index: 1;
transition: all 0.5s ease;
}
#album-art-transition-layer {
z-index: 0;
}
#song-info-container {
position: relative;
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
gap: 1.5em;
padding: 0.75em 1em;
transition: all 0.25s ease-in-out;
}
/* The background now fills the frame */
#background-layer,
#background-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-blend-mode: overlay; /* Or 'soft-light' for a subtler effect */
filter: blur(10px) brightness(0.3) saturate(1.2);
transform: scale(1.1); /* Slightly zoom in to cover edges when blurred */
}
#background-layer {
transition: all 0.5s ease;
}
#background-transition-layer {
z-index: 0;
}
/* The content sits on top */
#song-info-wrapper {
position: relative; /* Keeps it in the document flow */
z-index: 2; /* Sits above the background-layer */
/* padding: 1em 1.75em; */
display: flex;
flex-direction: column;
gap: 0.3em;
margin: 0.5em 0em;
flex-grow: 1;
min-width: 0;
}
#track-label {
font-size: 1em;
font-weight: 600;
text-align: var(--text-alignment);
}
#artist-label {
font-size: 0.8em;
font-weight: 300;
opacity: 0.6;
text-align: var(--text-alignment);
}
#progress-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.2em;
margin-top: 0.2em;
gap: 0.5em;
}
#progress-bar-track {
width: 100%;
height: 0.4em;
border-radius: 1em;
background-color: #ffffff3a;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Adds depth */
}
#progress-bar-fill {
height: 100%;
width: 0%; /* JS will control this */
border-radius: 1em;
background-color: var(--accent-color, #ffffff);
transition: width 1s ease-in-out, background-color 1s ease-in-out;
/* This makes the handle track the end of the bar */
position: relative;
}
.progress-text {
font-size: 0.7em;
opacity: 0.5;
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+12
View File
@@ -0,0 +1,12 @@
<div id="main-wrapper">
<svg id="progress-svg" viewBox="0 0 100 100">
<circle id="progress-pie-circle" cx="50" cy="50" r="25"></circle>
</svg>
<div id="vinyl-container">
<img id="vinyl-background" src="/now-playing/themes/vinyl/images/vinyl.png">
<div id="album-art-container">
<div id="album-art-layer"></div>
<div id="album-art-transition-layer"></div>
</div>
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
const mainWrapper = document.getElementById('main-wrapper');
const albumArtContainer = document.getElementById('album-art-container');
// const songInfoContainer = document.getElementById('song-info-container');
const albumArtLayer = document.getElementById('album-art-layer');
const albumArtTransition = document.getElementById('album-art-transition-layer');
const progressCircle = document.getElementById('progress-pie-circle');
///////////////
// CONSTANTS //
///////////////
const circumference = 157.1;
////////////////
// PAGE SETUP //
////////////////
// Set container width
if (maxWidth > 0)
mainWrapper.style.width = `${maxWidth}px`;
else
mainWrapper.style.width = `100%`;
// Set progress bar visibility
if (!showProgressBar)
progressCircle.style.display = 'none';
////////////////////
// CORE FUNCTIONS //
////////////////////
async function ChangeTrack(mediaProps) {
// // Fade in the overlay (shows the new text)
albumArtLayer.style.opacity = "0";
// Wait for fade (0.5s), then swap the real text and hide overlay
setTimeout(() => {
// Extract the image source string (use fallback if Windows has no art)
const newArtUrl = mediaProps.Thumbnail ?? './images/placeholder.png';
// Set the image
albumArtLayer.style.backgroundImage = `url('${newArtUrl}')`;
albumArtLayer.style.opacity = "";
setTimeout(() => {
// Set the image
albumArtTransition.style.backgroundImage = `url('${newArtUrl}')`;
SetVisibility(true); // Show the overlay for a few seconds if autoHide is enabled
}, 250);
}, 250);
}
function SetProgressInfo(timelineProps, currentPositionMs, accentColorPalette) {
// Set progressbar
// Ensure we don't divide by zero or exceed 100%
const durationMs = timelineProps.EndTime;
progressPercent = durationMs > 0 ? (currentPositionMs / durationMs) * 100 : 0;
progressPercent = Math.min(100, Math.max(0, progressPercent));
if (!useCustomColors)
document.documentElement.style.setProperty('--accent-color', `${accentColorPalette.LightVibrant}`);
else
document.documentElement.style.setProperty('--accent-color', color1);
// Calculate the offset.
// 0% progress = 157.1 offset. 100% progress = 0 offset.
const offset = circumference - (progressPercent / 100) * circumference;
progressCircle.style.strokeDashoffset = offset;
}
+97
View File
@@ -0,0 +1,97 @@
#main-wrapper {
width: 500px;
display: flex;
flex-direction: row;
gap: 1em;
opacity: 0;
align-items: center;
justify-content: center;
}
#vinyl-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
filter: drop-shadow(0px 10px 15px rgba(0, 0, 0, 0.5));
animation: spin 10s linear infinite;
}
#vinyl-background {
position: relative;
height: 100%;
width: 100%;
}
#album-art-container {
position: absolute;
width: 35%;
height: 35%;
border-radius: 50%;
overflow: hidden;
-webkit-mask-image: radial-gradient(circle, transparent 3%, black 3.5%);
mask-image: radial-gradient(circle, transparent 3%, black 3.5%);
}
#album-art-layer,
#album-art-transition-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transition: opacity 0.5s ease;
display: var(--show-album-art);
}
#album-art-layer {
z-index: 2;
transition: all 0.5s ease;
}
#album-art-transition-layer {
z-index: 1;
}
#progress-ring-container {
position: absolute;
width: 100%;
height: 100%;
border-radius: 20%;
overflow: hidden; /* This masks the 'pie slice' */
z-index: 0;
}
#progress-svg {
position: absolute;
width: 100%;
height: 100%;
z-index: 0;
/* Rotates the start point to 12 o'clock */
transform: rotate(-90deg);
-webkit-mask-image: radial-gradient(circle, transparent 3%, black 3.5%);
mask-image: radial-gradient(circle, transparent 3%, black 3.5%);
}
#progress-pie-circle {
fill: transparent;
stroke: var(--accent-color); /* Falls back to red if var fails */
stroke-width: 50; /* This thickness makes it a solid pie */
/* 157.1 is the exact circumference of a circle with r=25 (2 * PI * 25) */
stroke-dasharray: 157.1;
stroke-dashoffset: 157.1; /* Starts empty */
/* This creates the smooth, non-stuttering animation */
transition: all 1s linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+1 -1
View File
@@ -192,7 +192,7 @@ async function CustomEvent(data) {
case ('TwitchCustomPowerUpRedemption'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerText = `${data["customPowerUp.bits"]} BITS`;
titleEl.innerText = `${data["customPowerUp.bitsCost"]} BITS`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');