diff --git a/README.md b/README.md index 68dbb78..202f50b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +![SpoTUI animation](banner-gif.gif) + # SpoTUI SpoTUI is a terminal-style Spotify skin built for Spicetify. @@ -10,7 +12,6 @@ It combines: ## Screenshot ![SpoTUI preview](preview.png) - ## Features - Terminal-inspired interface diff --git a/banner-gif.gif b/banner-gif.gif new file mode 100644 index 0000000..b919aee Binary files /dev/null and b/banner-gif.gif differ diff --git a/preview.png b/preview.png index 35b7b25..b24603c 100644 Binary files a/preview.png and b/preview.png differ diff --git a/theme.js b/theme.js index de6757d..2b92101 100644 --- a/theme.js +++ b/theme.js @@ -28,7 +28,7 @@ const style = ` top: 41%; transform: translate(-50%, -50%); color: #ff8c42; - opacity: 0.28; + opacity: 1; white-space: pre; text-align: center; font-family: "JetBrains Mono", "Fira Code", monospace; @@ -37,6 +37,64 @@ const style = ` pointer-events: none; user-select: none; z-index: 0; + display: flex; + justify-content: center; + align-items: center; +} + +.spotui-ascii-grid { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + line-height: 1; + font-size: clamp(9px, 1.4vw, 22px); + letter-spacing: 0; + font-weight: 400; + font-variant-ligatures: none; + font-kerning: none; + -webkit-font-smoothing: antialiased; + user-select: none; + white-space: pre; + padding: 20px; + contain: layout style paint; +} + +.spotui-ascii-row { + display: flex; + flex-wrap: nowrap; + white-space: nowrap; + contain: layout style paint; +} + +.spotui-ascii-char { + display: inline-block; + font-size: clamp(9px, 1.4vw, 22px); + line-height: 1; + width: 1ch; + text-align: left; + position: relative; + text-shadow: 0 0 6px currentColor; +} + +@media (max-width: 700px) { + .spotui-ascii-grid { + font-size: clamp(5px, 1.1vw, 11px); + } + + .spotui-ascii-char { + font-size: clamp(5px, 1.1vw, 11px); + } +} + +@media (max-width: 450px) { + .spotui-ascii-grid { + font-size: clamp(3.5px, 1.4vw, 7px); + } + + .spotui-ascii-char { + font-size: clamp(3.5px, 1.4vw, 7px); + } } #spotui-output { @@ -178,19 +236,497 @@ body.spotui-tui-hidden #spotui-popup { `; const SPOTUI_ASCII_ART = [ - " ██████ ██▓███ ▒█████ ▄▄▄█████▓ █ ██ ██▓", - "▒██ ▒ ▓██░ ██▒▒██▒ ██▒▓ ██▒ ▓▒ ██ ▓██▒▓██▒", - "░ ▓██▄ ▓██░ ██▓▒▒██░ ██▒▒ ▓██░ ▒░▓██ ▒██░▒██▒", - " ▒ ██▒▒██▄█▓▒ ▒▒██ ██░░ ▓██▓ ░ ▓▓█ ░██░░██░", - "▒██████▒▒▒██▒ ░ ░░ ████▓▒░ ▒██▒ ░ ▒▒█████▓ ░██░", - "▒ ▒▓▒ ▒ ░▒▓▒░ ░ ░░ ▒░▒░▒░ ▒ ░░ ░▒▓▒ ▒ ▒ ░▓ ", - "░ ░▒ ░ ░░▒ ░ ░ ▒ ▒░ ░ ░░▒░ ░ ░ ▒ ░", - "░ ░ ░ ░░ ░ ░ ░ ▒ ░ ░░░ ░ ░ ▒ ░", - " ░ ░ ░ ░ ░ ", - " ", + " ▄████████ ▄███████▄ ▄██████▄ ███ ███ █▄ ▄█ ", + " ███ ███ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ", + " ███ █▀ ███ ███ ███ ███ ▀███▀▀██ ███ ███ ███▌ ", + " ███ ███ ███ ███ ███ ███ ▀ ███ ███ ███▌ ", + "▀███████████ ▀█████████▀ ███ ███ ███ ███ ███ ███▌ ", + " ███ ███ ███ ███ ███ ███ ███ ███ ", + " ▄█ ███ ███ ███ ███ ███ ███ ███ ███ ", + " ▄████████▀ ▄████▀ ▀██████▀ ▄████▀ ████████▀ █▀ ", ]; +const GLITCH_CHARS = "01"; +const ORANGE_PALETTE = [ + "#ff6a00", + "#ff7a0a", + "#ff8c1a", + "#ff9e33", + "#ffb04d", + "#ffc266", + "#ffd480", + "#ffe699", +]; +function getCharColor(row, col, totalRows, totalCols) { + const normRow = row / Math.max(totalRows - 1, 1); + const normCol = col / Math.max(totalCols - 1, 1); + const mix = normRow * 0.55 + normCol * 0.45; + const idx = Math.floor(mix * (ORANGE_PALETTE.length - 1)); + const frac = mix * (ORANGE_PALETTE.length - 1) - idx; + const i = Math.min(idx, ORANGE_PALETTE.length - 2); + const c1 = ORANGE_PALETTE[i]; + const c2 = ORANGE_PALETTE[i + 1] || ORANGE_PALETTE[i]; + const r1 = parseInt(c1.slice(1, 3), 16); + const g1 = parseInt(c1.slice(3, 5), 16); + const b1 = parseInt(c1.slice(5, 7), 16); + const r2 = parseInt(c2.slice(1, 3), 16); + const g2 = parseInt(c2.slice(3, 5), 16); + const b2 = parseInt(c2.slice(5, 7), 16); + const r = Math.round(r1 + (r2 - r1) * frac); + const g = Math.round(g1 + (g2 - g1) * frac); + const b = Math.round(b1 + (b2 - b1) * frac); + return `rgb(${r},${g},${b})`; +} + +let asciiAnimationInitialized = false; +let asciiCharData = []; + +function initAsciiAnimation() { + if (asciiAnimationInitialized) return; + asciiAnimationInitialized = true; + + const logo = document.getElementById("spotui-logo"); + if (!logo) return; + + logo.innerHTML = ""; + + const grid = document.createElement("div"); + grid.className = "spotui-ascii-grid"; + logo.appendChild(grid); + + const rows = SPOTUI_ASCII_ART.length; + const cols = Math.max(...SPOTUI_ASCII_ART.map((row) => row.length)); + const charData = []; + const rowSpansCache = []; + + SPOTUI_ASCII_ART.forEach((line, rowIdx) => { + const rowDiv = document.createElement("div"); + rowDiv.className = "spotui-ascii-row"; + rowDiv.dataset.row = rowIdx; + const padded = line.padEnd(cols, " "); + const chars = [...padded]; + const rowSpans = []; + chars.forEach((ch, colIdx) => { + const span = document.createElement("span"); + span.className = "spotui-ascii-char"; + span.textContent = ch; + span.dataset.row = rowIdx; + span.dataset.col = colIdx; + span.dataset.original = ch; + const color = getCharColor(rowIdx, colIdx, rows, cols); + span.style.color = color; + span.dataset.origColor = color; + charData.push({ + row: rowIdx, + col: colIdx, + el: span, + original: ch, + color, + }); + rowSpans.push(span); + rowDiv.appendChild(span); + }); + rowSpansCache.push(rowSpans); + grid.appendChild(rowDiv); + }); + + asciiCharData = charData; + + function resetGrid() { + charData.forEach(({ el, original, color }) => { + el.textContent = original; + el.style.color = color; + }); + } + + function getRowSpans(rowIdx) { + return rowSpansCache[rowIdx] || []; + } + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + async function decryptRow(rowIdx) { + const spans = getRowSpans(rowIdx); + if (!spans.length) return; + const chars = [...GLITCH_CHARS]; + const origs = spans.map((span) => span.dataset.original || " "); + const colors = spans.map((span) => span.dataset.origColor || "#ff8c1a"); + + spans.forEach((span) => { + span.textContent = chars[Math.floor(Math.random() * chars.length)]; + }); + + const indices = Array.from({ length: spans.length }, (_, i) => i); + for (let i = indices.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + + const batchSize = 4; + for (let start = 0; start < indices.length; start += batchSize) { + const batch = indices.slice(start, start + batchSize); + batch.forEach((idx) => { + spans[idx].textContent = chars[Math.floor(Math.random() * chars.length)]; + }); + await sleep(8); + batch.forEach((idx) => { + spans[idx].textContent = origs[idx]; + spans[idx].style.color = colors[idx]; + }); + await sleep(6); + } + } + + async function glitchRowWave(rowIdx, duration = 500) { + const spans = getRowSpans(rowIdx); + if (!spans.length) return; + const origs = spans.map((span) => span.dataset.original || " "); + const colors = spans.map((span) => span.dataset.origColor || "#ff8c1a"); + const chars = [...GLITCH_CHARS]; + const steps = 8; + for (let step = 0; step < steps; step += 1) { + spans.forEach((span) => { + const randIdx = Math.floor(Math.random() * chars.length); + span.textContent = chars[randIdx]; + const hue = 20 + Math.random() * 35; + span.style.color = `hsl(${hue}, 100%, ${50 + Math.random() * 30}%)`; + }); + await sleep(Math.floor(duration / steps)); + } + spans.forEach((span, i) => { + span.textContent = origs[i] || " "; + span.style.color = colors[i] || "#ff8c1a"; + }); + } + + async function burstGlitch(duration = 800) { + const centerRow = Math.floor(rows / 2); + const centerCol = Math.floor(cols / 2); + const withDist = charData.map((entry) => { + const dr = entry.row - centerRow; + const dc = entry.col - centerCol; + return { ...entry, dist: Math.sqrt(dr * dr + dc * dc) }; + }); + const maxDist = Math.max(...withDist.map((entry) => entry.dist), 1); + const chars = [...GLITCH_CHARS]; + const steps = 8; + for (let step = 0; step < steps; step += 1) { + const progress = step / steps; + withDist.forEach(({ el, original, color, dist }) => { + const norm = dist / maxDist; + const threshold = progress * 1.1; + if (norm < threshold + 0.12 && norm > threshold - 0.12) { + if (Math.random() < 0.75) { + const randIdx = Math.floor(Math.random() * chars.length); + el.textContent = chars[randIdx]; + el.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${50 + Math.random() * 30}%)`; + } + } else if (norm < threshold - 0.12) { + el.textContent = original; + el.style.color = color; + } + }); + await sleep(Math.floor(duration / steps)); + } + resetGrid(); + } + + async function pulseGlitch(duration = 1200) { + const centerRow = Math.floor(rows / 2); + const centerCol = Math.floor(cols / 2); + const withDist = charData.map((entry) => { + const dr = entry.row - centerRow; + const dc = entry.col - centerCol; + return { ...entry, dist: Math.sqrt(dr * dr + dc * dc) }; + }); + const maxDist = Math.max(...withDist.map((entry) => entry.dist), 1); + const chars = [...GLITCH_CHARS]; + const waves = 3; + const stepsPerWave = 10; + + for (let wave = 0; wave < waves; wave += 1) { + for (let step = 0; step < stepsPerWave; step += 1) { + const progress = step / stepsPerWave; + const threshold = progress * 1.0; + withDist.forEach(({ el, original, color, dist }) => { + const norm = dist / maxDist; + if (norm < threshold + 0.1 && norm > threshold - 0.1) { + if (Math.random() < 0.7) { + const randIdx = Math.floor(Math.random() * chars.length); + el.textContent = chars[randIdx]; + el.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${55 + Math.random() * 25}%)`; + } + } else if (norm < threshold - 0.1 && wave === waves - 1) { + el.textContent = original; + el.style.color = color; + } + }); + await sleep(Math.floor(duration / (waves * stepsPerWave))); + } + await sleep(40); + } + resetGrid(); + } + + async function implosionGlitch(duration = 900) { + const centerRow = Math.floor(rows / 2); + const centerCol = Math.floor(cols / 2); + const withDist = charData.map((entry) => { + const dr = entry.row - centerRow; + const dc = entry.col - centerCol; + return { ...entry, dist: Math.sqrt(dr * dr + dc * dc) }; + }); + const maxDist = Math.max(...withDist.map((entry) => entry.dist), 1); + const chars = [...GLITCH_CHARS]; + const steps = 10; + + withDist.forEach(({ el }) => { + const randIdx = Math.floor(Math.random() * chars.length); + el.textContent = chars[randIdx]; + el.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${45 + Math.random() * 35}%)`; + }); + + for (let step = 0; step < steps; step += 1) { + const progress = step / steps; + const threshold = 1.0 - progress * 1.1; + withDist.forEach(({ el, original, color, dist }) => { + const norm = dist / maxDist; + if (norm <= threshold) { + el.textContent = original; + el.style.color = color; + } + }); + await sleep(Math.floor(duration / steps)); + } + resetGrid(); + } + + async function spiralGlitch(duration = 1000) { + const centerRow = Math.floor(rows / 2); + const centerCol = Math.floor(cols / 2); + const withAngle = charData.map((entry) => { + const dr = entry.row - centerRow; + const dc = entry.col - centerCol; + const angle = Math.atan2(dc, dr); + const dist = Math.sqrt(dr * dr + dc * dc); + return { ...entry, angle, dist }; + }); + const chars = [...GLITCH_CHARS]; + const steps = 36; + const wedgeWidth = 0.5; + + for (let step = 0; step < steps; step += 1) { + const sweepAngle = (step / steps) * Math.PI * 2 - Math.PI; + withAngle.forEach(({ el, original, color, angle, dist }) => { + let diff = Math.abs(angle - sweepAngle); + if (diff > Math.PI) diff = Math.PI * 2 - diff; + if (diff < wedgeWidth && dist > 0.1) { + const randIdx = Math.floor(Math.random() * chars.length); + el.textContent = chars[randIdx]; + el.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${55 + Math.random() * 25}%)`; + } else { + el.textContent = original; + el.style.color = color; + } + }); + await sleep(Math.floor(duration / steps)); + } + resetGrid(); + } + + async function fuzzWaveGlitch(duration = 1000) { + const centerRow = Math.floor(rows / 2); + const centerCol = Math.floor(cols / 2); + const withDist = charData.map((entry) => { + const dr = entry.row - centerRow; + const dc = entry.col - centerCol; + return { ...entry, dist: Math.sqrt(dr * dr + dc * dc) }; + }); + const maxDist = Math.max(...withDist.map((entry) => entry.dist), 1); + const chars = [...GLITCH_CHARS]; + const steps = 20; + const bandWidth = 0.25; + + for (let step = 0; step < steps; step += 1) { + const progress = step / steps; + const targetNorm = progress * 1.0; + withDist.forEach(({ el, original, color, dist }) => { + const norm = dist / maxDist; + const distanceFromTarget = Math.abs(norm - targetNorm); + if (distanceFromTarget < bandWidth && Math.random() < 0.65) { + const randIdx = Math.floor(Math.random() * chars.length); + el.textContent = chars[randIdx]; + el.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${50 + Math.random() * 30}%)`; + } else if (distanceFromTarget > bandWidth * 1.5) { + el.textContent = original; + el.style.color = color; + } + }); + await sleep(Math.floor(duration / steps)); + } + resetGrid(); + } + + async function staticGlitch(duration = 600) { + const chars = [...GLITCH_CHARS]; + const steps = 6; + for (let step = 0; step < steps; step += 1) { + charData.forEach(({ el }) => { + if (Math.random() < 0.8) { + el.textContent = chars[Math.floor(Math.random() * chars.length)]; + el.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${50 + Math.random() * 30}%)`; + } + }); + await sleep(Math.floor(duration / steps)); + } + resetGrid(); + } + + async function horizontalBand(direction = 1, duration = 800) { + const chars = [...GLITCH_CHARS]; + const start = direction === 1 ? 0 : rows - 1; + const totalSteps = rows + 2; + for (let step = 0; step <= totalSteps; step += 1) { + resetGrid(); + const bandCenter = start + direction * step; + const bandTop = Math.max(0, bandCenter - 1); + const bandBottom = Math.min(rows - 1, bandCenter + 1); + for (let row = bandTop; row <= bandBottom; row += 1) { + const spans = getRowSpans(row); + spans.forEach((span) => { + span.textContent = chars[Math.floor(Math.random() * chars.length)]; + span.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${50 + Math.random() * 30}%)`; + }); + } + await sleep(Math.floor(duration / totalSteps)); + } + resetGrid(); + } + + async function verticalSlice(direction = 1, duration = 800) { + const chars = [...GLITCH_CHARS]; + const start = direction === 1 ? 0 : cols - 1; + const totalSteps = cols + 2; + for (let step = 0; step <= totalSteps; step += 1) { + resetGrid(); + const bandCenter = start + direction * step; + const bandLeft = Math.max(0, bandCenter - 1); + const bandRight = Math.min(cols - 1, bandCenter + 1); + charData.forEach(({ el, col }) => { + if (col >= bandLeft && col <= bandRight) { + el.textContent = chars[Math.floor(Math.random() * chars.length)]; + el.style.color = `hsl(${20 + Math.random() * 35}, 100%, ${50 + Math.random() * 30}%)`; + } + }); + await sleep(Math.floor(duration / totalSteps)); + } + resetGrid(); + } + + async function stageWaveDown() { + for (let row = 0; row < rows; row += 1) { + await glitchRowWave(row, 300); + await sleep(20); + } + } + + async function stageWaveUp() { + for (let row = rows - 1; row >= 0; row -= 1) { + await glitchRowWave(row, 260); + await sleep(15); + } + } + + async function stageDecrypt() { + const chars = [...GLITCH_CHARS]; + charData.forEach(({ el }) => { + const randIdx = Math.floor(Math.random() * chars.length); + el.textContent = chars[randIdx]; + }); + for (let row = 0; row < rows; row += 1) { + await decryptRow(row); + } + } + + async function stageBurst() { + await burstGlitch(900); + } + + async function stagePulse() { + await pulseGlitch(1200); + } + + async function stageImplosion() { + await implosionGlitch(900); + } + + async function stageSpiral() { + await spiralGlitch(1000); + } + + async function stageFuzzWave() { + await fuzzWaveGlitch(1000); + } + + async function stageStatic() { + await staticGlitch(700); + } + + async function stageHSlashDown() { + await horizontalBand(1, 800); + } + + async function stageHSlashUp() { + await horizontalBand(-1, 800); + } + + async function stageVSlashRight() { + await verticalSlice(1, 800); + } + + async function stageVSlashLeft() { + await verticalSlice(-1, 800); + } + + const stageFunctions = [ + stageWaveDown, + stageWaveUp, + stageDecrypt, + stageBurst, + stagePulse, + stageImplosion, + stageSpiral, + stageFuzzWave, + stageStatic, + stageHSlashDown, + stageHSlashUp, + stageVSlashRight, + stageVSlashLeft, + ]; + + function shuffleArray(array) { + for (let index = array.length - 1; index > 0; index -= 1) { + const j = Math.floor(Math.random() * (index + 1)); + [array[index], array[j]] = [array[j], array[index]]; + } + return array; + } + + async function runLoop() { + while (true) { + const shuffled = shuffleArray([...stageFunctions]); + for (const stageFn of shuffled) { + await stageFn(); + await sleep(700 + Math.random() * 400); + } + resetGrid(); + await sleep(300); + } + } + + runLoop().catch(console.error); +} let tuiMode = "command"; @@ -343,16 +879,7 @@ function createTerminal() { box.id = "spotui-tui"; setTuiMode("command"); box.innerHTML = ` - +
SpoTUI v1.0 Type /help @@ -364,6 +891,7 @@ Type /help
`; document.body.appendChild(box); + initAsciiAnimation(); const input = document.getElementById("spotui-input"); input.addEventListener("keydown", async (e) => {