Files
motif/ux-test.html
T
b1rdmania 9937f5a02a fix: make MP3 downloads reliable again
Use bundled lamejs instead of a CDN global, and avoid browser user-gesture download blocking by preparing the MP3 first and downloading on a follow-up click.

Made-with: Cursor
2026-02-27 15:41:07 +00:00

968 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>WARIO SYNTH - UX Test</title>
<style>
/* Local Pixel Font */
@font-face {
font-family: 'Press Start 2P';
src: url('/fonts/PressStart2P.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
/* Game Boy palette - light mode (authentic LCD) */
--gb-lightest: #E0E8C8; /* Screen background */
--gb-light: #A8B89A; /* Surface/cards */
--gb-medium: #6B7B5A; /* Dim text, borders */
--gb-dark: #3A4A2A; /* Text */
--gb-darkest: #1D2A1D; /* Darkest accents */
/* Semantic mapping - LIGHT background, DARK text */
--color-bg: #C8D8B0;
--color-surface: #A8B89A;
--color-border: var(--gb-dark);
--color-text: var(--gb-darkest);
--color-text-dim: var(--gb-medium);
--spacing-unit: 8px;
--pixel-border: 4px;
}
* {
box-sizing: border-box;
image-rendering: pixelated;
}
body {
margin: 0;
padding: calc(var(--spacing-unit) * 3);
background: var(--color-bg);
color: var(--color-text);
font-family: 'Press Start 2P', monospace;
line-height: 2;
font-size: 10px;
min-height: 100vh;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: unset;
}
/* LCD Screen Effect */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.03) 0px,
rgba(0, 0, 0, 0.03) 2px,
transparent 2px,
transparent 4px
);
pointer-events: none;
z-index: 9999;
}
.container {
max-width: 600px;
margin: 0 auto;
}
/* Header */
.hero {
text-align: center;
margin: 0 0 calc(var(--spacing-unit) * 3) 0;
padding: calc(var(--spacing-unit) * 2);
border: var(--pixel-border) solid var(--gb-dark);
background: var(--color-surface);
}
.hero h1 {
color: var(--gb-darkest);
margin: 0;
font-size: 16px;
font-weight: normal;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.hero .tagline {
margin: calc(var(--spacing-unit)) 0 0 0;
color: var(--gb-dark);
font-size: 8px;
}
.hero .test-badge {
display: inline-block;
margin-top: calc(var(--spacing-unit));
padding: 4px 8px;
background: var(--gb-dark);
color: var(--gb-lightest);
font-size: 8px;
}
/* Search */
.search-box {
display: flex;
gap: calc(var(--spacing-unit));
margin-bottom: calc(var(--spacing-unit) * 2);
}
input[type="text"] {
flex: 1;
background: var(--gb-lightest);
color: var(--gb-darkest);
border: var(--pixel-border) solid var(--gb-dark);
padding: 12px 14px;
border-radius: 0;
font-family: 'Press Start 2P', monospace;
font-size: 10px;
box-shadow: inset 3px 3px 0 var(--gb-medium);
}
input[type="text"]:focus {
outline: none;
border-color: var(--gb-darkest);
background: #fff;
}
input[type="text"]::placeholder {
color: var(--gb-medium);
}
button {
background: var(--color-surface);
color: var(--color-text);
border: var(--pixel-border) solid var(--gb-dark);
padding: 12px 16px;
border-radius: 0;
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
text-transform: uppercase;
box-shadow:
inset -3px -3px 0 var(--gb-medium),
inset 3px 3px 0 var(--gb-lightest);
transition: none;
}
button:hover:not(:disabled) {
background: var(--gb-lightest);
color: var(--gb-darkest);
}
button:active:not(:disabled) {
box-shadow: inset 3px 3px 0 var(--gb-medium);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--gb-dark);
color: var(--gb-lightest);
border-color: var(--gb-darkest);
box-shadow:
inset -3px -3px 0 var(--gb-darkest),
inset 3px 3px 0 var(--gb-medium);
}
button.primary:hover:not(:disabled) {
background: var(--gb-darkest);
color: var(--gb-lightest);
}
/* Status */
.status {
padding: calc(var(--spacing-unit) * 2);
background: var(--gb-lightest);
border: var(--pixel-border) solid var(--gb-dark);
color: var(--gb-darkest);
font-size: 8px;
margin-bottom: calc(var(--spacing-unit) * 3);
}
.status.error {
background: var(--gb-dark);
color: var(--gb-lightest);
}
/* Results */
.results {
display: none;
margin-bottom: calc(var(--spacing-unit) * 3);
}
.results.visible {
display: block;
}
.results-label {
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--gb-dark);
margin-bottom: calc(var(--spacing-unit));
}
.result-item {
background: var(--color-surface);
border: var(--pixel-border) solid var(--gb-dark);
padding: 12px 14px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-item:hover {
background: var(--gb-lightest);
}
.result-item.selected {
background: var(--gb-dark);
color: var(--gb-lightest);
}
.result-item.selected::before {
content: '▶ ';
}
.result-title {
font-size: 8px;
}
.result-meta {
font-size: 8px;
color: var(--gb-medium);
}
.result-item.selected .result-meta {
color: var(--gb-light);
}
/* Player Card */
.player-card {
background: var(--gb-dark);
border: var(--pixel-border) solid var(--gb-darkest);
padding: calc(var(--spacing-unit) * 3);
display: none;
}
.player-card.visible {
display: block;
}
.now-playing {
text-align: center;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.now-playing-label {
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--gb-light);
margin-bottom: 4px;
}
.now-playing-title {
font-size: 12px;
color: var(--gb-lightest);
word-break: break-word;
}
/* EQ Visualizer */
.eq-visualizer {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
height: 60px;
background: var(--gb-darkest);
border: 2px solid var(--gb-medium);
padding: 8px;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.eq-bar {
width: 4px;
min-height: 2px;
background: var(--gb-light);
}
.eq-bar.hot {
background: var(--gb-lightest);
}
/* Mode Toggle */
.mode-toggle {
display: flex;
background: var(--gb-darkest);
border: 2px solid var(--gb-medium);
padding: 4px;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.mode-btn {
flex: 1;
padding: 10px 8px;
border: none;
background: transparent;
color: var(--gb-medium);
font-size: 8px;
box-shadow: none;
cursor: pointer;
}
.mode-btn:hover {
color: var(--gb-light);
background: transparent;
}
.mode-btn.active {
background: var(--gb-dark);
color: var(--gb-lightest);
}
/* Player Controls */
.player-controls {
display: flex;
gap: calc(var(--spacing-unit));
justify-content: center;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.player-controls button {
flex: 1;
max-width: 140px;
}
/* Share row */
.share-row {
display: flex;
gap: calc(var(--spacing-unit));
justify-content: center;
margin-bottom: calc(var(--spacing-unit));
}
.share-row button {
flex: 1;
max-width: 140px;
background: var(--gb-darkest);
color: var(--gb-light);
border-color: var(--gb-medium);
}
.share-row button:hover:not(:disabled) {
background: var(--gb-medium);
color: var(--gb-lightest);
}
.share-hint {
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
font-size: 8px;
color: var(--gb-light);
min-height: 16px;
text-align: center;
}
/* Volume */
.volume-row {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 1.5);
padding-top: calc(var(--spacing-unit) * 2);
border-top: 2px solid var(--gb-medium);
}
.volume-label {
font-size: 8px;
color: var(--gb-light);
min-width: 50px;
}
input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 8px;
background: var(--gb-darkest);
border: 2px solid var(--gb-medium);
border-radius: 0;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 0;
background: var(--gb-lightest);
cursor: pointer;
border: none;
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 0;
background: var(--gb-lightest);
cursor: pointer;
border: none;
}
/* Footer */
.footer {
margin-top: calc(var(--spacing-unit) * 4);
text-align: center;
padding-top: calc(var(--spacing-unit) * 2);
border-top: 2px solid var(--gb-medium);
}
.footer p {
font-size: 8px;
color: var(--gb-dark);
margin: 0;
}
.footer a {
color: var(--gb-darkest);
text-decoration: underline;
}
.footer-mascot {
margin-top: calc(var(--spacing-unit) * 3);
text-align: center;
}
.wario-sprite {
width: 120px;
height: auto;
mix-blend-mode: multiply;
opacity: 0.7;
}
/* Mobile adjustments */
@media (max-width: 480px) {
body {
font-size: 11px;
}
.hero h1 {
font-size: 14px;
}
button {
font-size: 9px;
padding: 14px 16px;
}
.mode-btn {
font-size: 7px;
padding: 10px 6px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>WARIO SYNTH</h1>
<p class="tagline">Turn any song into Game Boy music</p>
<span class="test-badge">UX Test Page</span>
</div>
<div class="search-box">
<input type="text" id="songInput" placeholder="Search any song..." value="Hotel California" />
<button id="searchBtn" class="primary">Search</button>
</div>
<div id="status" class="status">Search for a song to get started</div>
<div id="results" class="results">
<div class="results-label">Results</div>
<div id="resultsList"></div>
</div>
<div id="playerCard" class="player-card">
<div class="now-playing">
<div class="now-playing-label">Now Playing</div>
<div class="now-playing-title" id="nowPlayingTitle">--</div>
</div>
<div class="eq-visualizer" id="eqVisualizer"></div>
<div class="mode-toggle">
<button class="mode-btn active" data-mode="preview">Preview</button>
<button class="mode-btn" data-mode="synth">Synth Engine</button>
</div>
<div class="player-controls">
<button id="playBtn" class="primary">Play</button>
<button id="stopBtn" disabled>Stop</button>
</div>
<div class="share-row">
<button id="copyLinkBtn" disabled>Copy link</button>
<button id="shareXBtn" disabled>Share on X</button>
</div>
<div class="share-row">
<button id="downloadMp3Btn" disabled>Download MP3</button>
</div>
<div id="shareHint" class="share-hint"></div>
<div class="volume-row">
<span class="volume-label">Volume</span>
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="0.7" />
</div>
</div>
<div class="footer">
<p>UX Test Page · <a href="/">Back to main</a></p>
</div>
<div class="footer-mascot">
<img src="/wario-sprite.png" alt="Wario" class="wario-sprite" />
</div>
</div>
<script type="module">
import { MIDIService } from './src/services/MIDIService.ts';
import { MIDIParser } from './src/midi/MIDIParser.ts';
import { SoundfontMIDIPlayer } from './src/synthesis/SoundfontMIDIPlayer.ts';
import { MotifEngine } from './src/core/MotifEngine.ts';
import { unlockAudio } from './src/utils/audioUnlock.ts';
// lamejs loaded via CDN script tag (ESM compatibility issues with npm version)
const NUM_BARS = 40;
const midiService = new MIDIService();
let previewPlayer = null;
let motifEngine = null;
let analyser = null;
let dataArray = null;
let animationId = null;
let currentEvents = null;
let currentTitle = '';
let currentMidiUrl = '';
let currentSource = '';
let currentMode = 'preview';
let isPlaying = false;
// DOM
const eqVisualizer = document.getElementById('eqVisualizer');
const nowPlayingTitle = document.getElementById('nowPlayingTitle');
const playBtn = document.getElementById('playBtn');
const stopBtn = document.getElementById('stopBtn');
const copyLinkBtn = document.getElementById('copyLinkBtn');
const shareXBtn = document.getElementById('shareXBtn');
const downloadMp3Btn = document.getElementById('downloadMp3Btn');
const shareHint = document.getElementById('shareHint');
const searchBtn = document.getElementById('searchBtn');
const songInput = document.getElementById('songInput');
const status = document.getElementById('status');
const results = document.getElementById('results');
const resultsList = document.getElementById('resultsList');
const playerCard = document.getElementById('playerCard');
const volumeSlider = document.getElementById('volumeSlider');
const modeBtns = document.querySelectorAll('.mode-btn');
// Create EQ bars
function createBars() {
eqVisualizer.innerHTML = '';
for (let i = 0; i < NUM_BARS; i++) {
const bar = document.createElement('div');
bar.className = 'eq-bar';
bar.style.height = '2px';
eqVisualizer.appendChild(bar);
}
}
// Update visualizer
function updateVisualizer() {
const bars = eqVisualizer.querySelectorAll('.eq-bar');
// If we don't have a real analyser wired up, run a lightweight faux animation
// so the UX test page doesn't depend on player internals.
if (!analyser || !dataArray) {
bars.forEach((bar) => {
const value = isPlaying ? Math.floor(Math.random() * 255) : 0;
const height = Math.max(2, (value / 255) * 40);
bar.style.height = height + 'px';
if (value > 180) bar.classList.add('hot');
else bar.classList.remove('hot');
});
if (isPlaying) animationId = requestAnimationFrame(updateVisualizer);
return;
}
analyser.getByteFrequencyData(dataArray);
const step = Math.floor(dataArray.length / NUM_BARS);
bars.forEach((bar, i) => {
let sum = 0;
for (let j = 0; j < step; j++) {
sum += dataArray[i * step + j] || 0;
}
const value = sum / step;
const height = Math.max(2, (value / 255) * 40);
bar.style.height = height + 'px';
if (value > 180) {
bar.classList.add('hot');
} else {
bar.classList.remove('hot');
}
});
if (isPlaying) animationId = requestAnimationFrame(updateVisualizer);
}
function stopVisualizer() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
const bars = eqVisualizer.querySelectorAll('.eq-bar');
bars.forEach(bar => {
bar.style.height = '2px';
bar.classList.remove('hot');
});
}
// Search
async function handleSearch() {
const query = songInput.value.trim();
if (!query) return;
setStatus('Searching...');
searchBtn.disabled = true;
results.classList.remove('visible');
playerCard.classList.remove('visible');
try {
const searchResults = await midiService.search(query);
if (searchResults.length === 0) {
setStatus('No results found', true);
return;
}
displayResults(searchResults.slice(0, 5));
setStatus(`Found ${searchResults.length} results`);
} catch (e) {
setStatus(`Error: ${e.message}`, true);
} finally {
searchBtn.disabled = false;
}
}
function displayResults(items) {
resultsList.innerHTML = '';
items.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'result-item';
div.innerHTML = `
<span class="result-title">${item.title}</span>
<span class="result-meta">${item.source}</span>
`;
div.addEventListener('click', () => selectResult(item, div));
resultsList.appendChild(div);
});
results.classList.add('visible');
}
async function selectResult(item, element) {
// Update selection UI
document.querySelectorAll('.result-item').forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
setStatus(`Loading ${item.title}...`);
stopPlayback();
try {
const midiBuffer = await midiService.fetchMIDI(item.midiUrl);
if (!midiBuffer) throw new Error('Failed to fetch');
currentEvents = MIDIParser.parseMIDI(midiBuffer);
currentTitle = item.title;
currentMidiUrl = item.midiUrl;
currentSource = item.source;
// Setup audio
const audioContext = await unlockAudio();
// Create preview player
previewPlayer = new SoundfontMIDIPlayer(audioContext);
await previewPlayer.load(currentEvents);
previewPlayer.setVolume(parseFloat(volumeSlider.value));
// Create motif engine
motifEngine = new MotifEngine();
// Setup analyser
// Note: SoundfontMIDIPlayer doesn't expose a stable analyser hook.
// Keep analyser null and use faux EQ animation for the test page.
analyser = null;
dataArray = null;
// Show player
nowPlayingTitle.textContent = currentTitle;
playerCard.classList.add('visible');
playBtn.disabled = false;
copyLinkBtn.disabled = false;
shareXBtn.disabled = false;
downloadMp3Btn.disabled = false;
setStatus('Ready to play');
shareHint.textContent = '';
} catch (e) {
setStatus(`Error: ${e.message}`, true);
}
}
async function handlePlay() {
if (!currentEvents) return;
try {
isPlaying = true;
playBtn.disabled = true;
stopBtn.disabled = false;
if (currentMode === 'preview') {
await previewPlayer.play();
} else {
// Match main page output: passthrough → SynthesisEngine
await motifEngine.generateFromMIDI(currentEvents, 'passthrough');
motifEngine.setVolume(parseFloat(volumeSlider.value));
await motifEngine.play();
}
updateVisualizer();
} catch (e) {
setStatus(`Play error: ${e.message}`, true);
stopPlayback();
}
}
function stopPlayback() {
isPlaying = false;
if (previewPlayer) previewPlayer.stop();
if (motifEngine) motifEngine.stop();
playBtn.disabled = !currentEvents;
stopBtn.disabled = true;
stopVisualizer();
}
function downloadBlob(blob, filename) {
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function floatToInt16(input) {
const out = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]));
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return out;
}
async function downloadMp3() {
if (!currentEvents) return;
try {
downloadMp3Btn.disabled = true;
shareHint.textContent = 'Rendering audio…';
// Stop any current playback first
stopPlayback();
// Ensure Motif engine exists
if (!motifEngine) motifEngine = new MotifEngine();
// Render offline (faster than real-time!)
const sampleRate = 44100;
const audioBuffer = await motifEngine.renderOffline(currentEvents, 'procedural', sampleRate);
shareHint.textContent = 'Encoding MP3…';
// Use lamejs from CDN (global)
const Mp3Encoder = window.lamejs?.Mp3Encoder;
if (!Mp3Encoder) throw new Error('MP3 encoder not available - lamejs not loaded');
const channels = audioBuffer.numberOfChannels;
const kbps = 128;
const encoder = new Mp3Encoder(channels, sampleRate, kbps);
const mp3Chunks = [];
// Get audio data from buffer
const leftData = audioBuffer.getChannelData(0);
const rightData = channels > 1 ? audioBuffer.getChannelData(1) : leftData;
const left = floatToInt16(leftData);
const right = floatToInt16(rightData);
// Encode in 1152-sample frames
const frameSize = 1152;
for (let i = 0; i < left.length; i += frameSize) {
const l = left.subarray(i, i + frameSize);
const r = right.subarray(i, i + frameSize);
const buf = encoder.encodeBuffer(l, r);
if (buf && buf.length) mp3Chunks.push(buf);
}
const tail = encoder.flush();
if (tail && tail.length) mp3Chunks.push(tail);
const blob = new Blob(mp3Chunks, { type: 'audio/mpeg' });
const safeTitle = cleanTitleForShare(currentTitle || 'motif').slice(0, 80) || 'motif';
downloadBlob(blob, `${safeTitle}.mp3`);
shareHint.textContent = 'MP3 downloaded.';
window.setTimeout(() => (shareHint.textContent = ''), 1400);
} catch (e) {
shareHint.textContent = `MP3 failed: ${e && e.message ? e.message : 'Unknown error'}`;
console.error('MP3 download error:', e);
} finally {
downloadMp3Btn.disabled = false;
}
}
async function copyShareLink() {
if (!currentMidiUrl) return;
const url = await buildShortShareUrl();
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
shareHint.textContent = 'Link copied.';
window.setTimeout(() => (shareHint.textContent = ''), 1200);
} catch {
shareHint.textContent = 'Copy failed.';
}
}
function cleanTitleForShare(title) {
return (title || '')
.replace(/\.mid$/i, '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildShareUrl() {
const title = cleanTitleForShare(currentTitle);
// Make BitMidi links short: /play?src=bitmidi&id=12345&title=...
if (currentSource === 'bitmidi') {
const m = String(currentMidiUrl).match(/\/uploads\/(\d+)\.mid/i);
if (m && m[1]) {
return `${window.location.origin}/play?src=bitmidi&id=${encodeURIComponent(m[1])}&title=${encodeURIComponent(title)}`;
}
}
// Fallback: include full MIDI URL (long, but works)
return `${window.location.origin}/play?u=${encodeURIComponent(currentMidiUrl)}&title=${encodeURIComponent(title)}`;
}
async function buildShortShareUrl() {
const title = cleanTitleForShare(currentTitle);
// Prefer creating a backend short link for sharing on X.
try {
let payload = null;
if (currentSource === 'bitmidi') {
const m = String(currentMidiUrl).match(/\/uploads\/(\d+)\.mid/i);
if (m && m[1]) payload = { src: 'bitmidi', id: m[1], title };
}
if (!payload) {
payload = { u: currentMidiUrl, title };
}
const resp = await fetch('/api/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`share:${resp.status}`);
const data = await resp.json();
if (data && data.url) return `${window.location.origin}${data.url}`;
} catch (e) {
// fall back
}
return buildShareUrl();
}
function shareOnX() {
if (!currentMidiUrl) return;
(async () => {
const url = await buildShortShareUrl();
const niceTitle = cleanTitleForShare(currentTitle);
// Keep tweet text clean; URL is passed separately.
const text = niceTitle ? `WARIO SYNTH: ${niceTitle}` : 'WARIO SYNTH chiptune';
const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
window.open(intent, '_blank', 'noopener,noreferrer');
})();
}
function setStatus(msg, isError = false) {
status.textContent = msg;
status.classList.toggle('error', isError);
}
// Mode toggle
modeBtns.forEach(btn => {
btn.addEventListener('click', () => {
modeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMode = btn.dataset.mode;
stopPlayback();
});
});
// Volume
volumeSlider.addEventListener('input', () => {
const vol = parseFloat(volumeSlider.value);
if (previewPlayer) previewPlayer.setVolume(vol);
if (motifEngine) motifEngine.setVolume(vol);
});
// Events
searchBtn.addEventListener('click', handleSearch);
songInput.addEventListener('keypress', e => {
if (e.key === 'Enter') handleSearch();
});
playBtn.addEventListener('click', handlePlay);
stopBtn.addEventListener('click', stopPlayback);
copyLinkBtn.addEventListener('click', () => void copyShareLink());
shareXBtn.addEventListener('click', shareOnX);
downloadMp3Btn.addEventListener('click', () => downloadMp3());
// Init
createBars();
</script>
</body>
</html>