Music-Metadata / index.html
CultriX's picture
Removing AI Features
2646076
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MusiSync - Music Lyrics & Metadata Explorer</title>
<style>
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Dark Theme Colors */
--bg-primary: #0a0a0a;
--bg-secondary: #1a1a1a;
--bg-tertiary: #2a2a2a;
--bg-card: #1e1e1e;
--text-primary: #ffffff;
--text-secondary: #b3b3b3;
--text-muted: #6b7280;
--accent-primary: #1db954;
--accent-secondary: #1ed760;
--accent-tertiary: #ff6b35;
--border-color: #333333;
--shadow: rgba(0, 0, 0, 0.5);
--gradient-primary: linear-gradient(135deg, #1db954, #1ed760);
--gradient-secondary: linear-gradient(135deg, #ff6b35, #ff8e53);
--glass-bg: rgba(30, 30, 30, 0.8);
--glass-border: rgba(255, 255, 255, 0.1);
}
[data-theme="light"] {
/* Light Theme Colors */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #e2e8f0;
--bg-card: #ffffff;
--text-primary: #1a202c;
--text-secondary: #4a5568;
--text-muted: #718096;
--accent-primary: #1db954;
--accent-secondary: #1ed760;
--accent-tertiary: #ff6b35;
--border-color: #e2e8f0;
--shadow: rgba(0, 0, 0, 0.1);
--gradient-primary: linear-gradient(135deg, #1db954, #1ed760);
--gradient-secondary: linear-gradient(135deg, #ff6b35, #ff8e53);
--glass-bg: rgba(248, 250, 252, 0.8);
--glass-border: rgba(0, 0, 0, 0.1);
}
body {
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
transition: all 0.3s ease;
min-height: 100vh;
background-image:
radial-gradient(circle at 20% 50%, rgba(29, 185, 84, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 107, 53, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 80%, rgba(29, 231, 96, 0.05) 0%, transparent 50%);
}
/* Header */
.header {
background: var(--glass-bg);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
padding: 1rem 2rem;
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.8rem;
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.theme-toggle {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 50px;
padding: 0.5rem 1rem;
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.theme-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--shadow);
}
/* Container */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
min-height: calc(100vh - 100px);
}
/* Search Section */
.search-section {
background: var(--bg-card);
border-radius: 20px;
padding: 2rem;
border: 1px solid var(--border-color);
box-shadow: 0 10px 30px var(--shadow);
transition: all 0.3s ease;
}
.search-section:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px var(--shadow);
}
.search-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-group {
position: relative;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-secondary);
}
.input-group input {
width: 100%;
padding: 1rem 1.5rem;
border: 2px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
transition: all 0.3s ease;
}
.input-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.1);
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
z-index: 10;
display: none;
}
.autocomplete-item {
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: background 0.2s ease;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: var(--accent-primary);
color: white;
}
.search-btn {
background: var(--gradient-primary);
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
}
.search-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(29, 185, 84, 0.4);
}
.search-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Results Section */
.results-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.result-card {
background: var(--bg-card);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--border-color);
box-shadow: 0 8px 25px var(--shadow);
transition: all 0.3s ease;
}
.result-card:hover {
transform: translateY(-3px);
box-shadow: 0 15px 35px var(--shadow);
}
.result-card h3 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--accent-primary);
}
/* Album Art Section */
.album-art-card {
text-align: center;
}
.album-art {
width: 100%;
max-width: 300px;
height: auto;
border-radius: 12px;
box-shadow: 0 10px 30px var(--shadow);
transition: transform 0.3s ease;
}
.album-art:hover {
transform: scale(1.05);
}
/* Lyrics Section */
.lyrics-container {
max-height: 400px;
overflow-y: auto;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 8px;
white-space: pre-line;
line-height: 1.8;
}
.lyrics-container::-webkit-scrollbar {
width: 6px;
}
.lyrics-container::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.lyrics-container::-webkit-scrollbar-thumb {
background: var(--accent-primary);
border-radius: 3px;
}
/* Metadata Grid */
.metadata-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.metadata-item {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.metadata-item .label {
font-size: 0.8rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.metadata-item .value {
font-weight: 600;
color: var(--text-primary);
}
/* Loading States */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--text-secondary);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top: 2px solid var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Skeleton Loading */
.skeleton {
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--bg-tertiary) 50%, var(--bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: 4px;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-text {
height: 1rem;
margin: 0.5rem 0;
}
.skeleton-image {
width: 100%;
height: 200px;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.action-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn:hover {
background: var(--accent-primary);
color: white;
transform: translateY(-1px);
}
.action-btn.favorite.active {
background: var(--accent-tertiary);
color: white;
}
/* Error States */
.error {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
padding: 1rem;
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* Favorites Panel */
.favorites-panel {
background: var(--bg-card);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--border-color);
box-shadow: 0 8px 25px var(--shadow);
margin-top: 1rem;
}
.favorites-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 200px;
overflow-y: auto;
}
.favorite-item {
background: var(--bg-secondary);
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.favorite-item:hover {
background: var(--bg-tertiary);
}
.remove-favorite {
color: var(--accent-tertiary);
cursor: pointer;
font-size: 0.8rem;
}
/* Related Songs */
.related-songs {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.related-song {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.related-song:hover {
background: var(--bg-tertiary);
transform: translateY(-1px);
}
.related-song-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.related-song-artist {
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Responsive Design */
@media (max-width: 1024px) {
.container {
grid-template-columns: 1fr;
padding: 1rem;
}
.header {
padding: 1rem;
}
}
@media (max-width: 768px) {
.metadata-grid {
grid-template-columns: repeat(2, 1fr);
}
.action-buttons {
justify-content: center;
}
}
/* Animations */
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--accent-primary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-secondary);
}
/* Focus styles for accessibility */
button:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
/* Hidden state */
.hidden {
display: none !important;
}
/* Notification */
.notification {
position: fixed;
top: 100px;
right: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 8px 25px var(--shadow);
z-index: 1000;
animation: slideInRight 0.3s ease-out;
max-width: 300px;
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.notification.success {
border-left: 4px solid var(--accent-primary);
}
.notification.error {
border-left: 4px solid #ef4444;
}
</style>
</head>
<body>
<header class="header">
<div class="logo">🎵 MusiSync</div>
<button class="theme-toggle" id="themeToggle">
<span id="themeIcon">🌙</span>
<span id="themeText">Dark Mode</span>
</button>
</header>
<div class="container">
<!-- Search Section -->
<div class="search-section">
<h2 class="search-title">Search Music</h2>
<form class="search-form" id="searchForm">
<div class="input-group">
<label for="artistInput">Artist Name</label>
<input type="text" id="artistInput" placeholder="Enter artist name..." autocomplete="off">
<div class="autocomplete-dropdown" id="artistDropdown"></div>
</div>
<div class="input-group">
<label for="songInput">Song Title</label>
<input type="text" id="songInput" placeholder="Enter song title..." autocomplete="off">
<div class="autocomplete-dropdown" id="songDropdown"></div>
</div>
<button type="submit" class="search-btn" id="searchBtn">
<span id="searchBtnText">Search</span>
</button>
</form>
<!-- Favorites Panel -->
<div class="favorites-panel">
<h3>❤️ Favorites</h3>
<div class="favorites-list" id="favoritesList">
<div class="loading" id="favoritesEmpty">No favorites yet. Search for songs and add them to your favorites!</div>
</div>
</div>
</div>
<!-- Results Section -->
<div class="results-section" id="resultsSection">
<!-- Album Art Card -->
<div class="result-card album-art-card hidden" id="albumArtCard">
<h3>🎨 Album Artwork</h3>
<img class="album-art" id="albumArt" alt="Album artwork">
<div class="action-buttons">
<button class="action-btn" id="downloadArt">📥 Download</button>
<button class="action-btn" id="shareArt">🔗 Share</button>
</div>
</div>
<!-- Song Info Card -->
<div class="result-card hidden" id="songInfoCard">
<h3>🎵 Song Information</h3>
<div class="metadata-grid" id="songMetadata"></div>
<div class="action-buttons">
<button class="action-btn favorite" id="favoriteBtn">❤️ Add to Favorites</button>
<button class="action-btn" id="shareBtn">🔗 Share</button>
</div>
</div>
<!-- Artist Info Card -->
<div class="result-card hidden" id="artistInfoCard">
<h3>👤 Artist Information</h3>
<div id="artistInfo"></div>
</div>
<!-- Lyrics Card -->
<div class="result-card hidden" id="lyricsCard">
<h3>📝 Lyrics</h3>
<div class="lyrics-container" id="lyricsContent"></div>
<div class="action-buttons">
<button class="action-btn" id="downloadLyrics">📥 Download Lyrics</button>
<button class="action-btn" id="shareLyrics">🔗 Share</button>
</div>
</div>
<!-- Related Songs Card -->
<div class="result-card hidden" id="relatedSongsCard">
<h3>🎶 Related Songs</h3>
<div class="related-songs" id="relatedSongs"></div>
</div>
</div>
</div>
<script>
// Global App State
const AppState = {
currentSearch: null,
cache: new Map(),
favorites: JSON.parse(localStorage.getItem('musicapp_favorites') || '[]'),
theme: localStorage.getItem('musicapp_theme') || 'dark',
searchHistory: JSON.parse(localStorage.getItem('musicapp_history') || '[]')
};
// API Configuration
const API_CONFIG = {
// Note: Replace with your actual API keys in production
lastfm: {
key: 'demo_key_replace_with_yours',
baseUrl: 'https://ws.audioscrobbler.com/2.0/'
},
musicbrainz: {
baseUrl: 'https://musicbrainz.org/ws/2/',
userAgent: 'MusiSync/1.0 (https://your-app.com)'
},
coverart: {
baseUrl: 'https://coverartarchive.org/'
},
lyrics: {
baseUrl: 'https://api.lyrics.ovh/v1/'
},
itunes: {
baseUrl: 'https://itunes.apple.com/'
}
};
// Utility Functions
class Utils {
static debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
static sanitizeQuery(query) {
return query.trim().replace(/[^\w\s-]/g, '').substring(0, 100);
}
static formatDuration(ms) {
if (!ms) return 'Unknown';
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
static formatDate(dateString) {
if (!dateString) return 'Unknown';
return new Date(dateString).toLocaleDateString();
}
static showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
static downloadFile(content, filename, type = 'text/plain') {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
static getCacheKey(type, params) {
return `${type}_${Object.values(params).join('_')}`;
}
static getCachedData(key, maxAge = 24 * 60 * 60 * 1000) { // 24 hours default
const cached = AppState.cache.get(key);
if (!cached) return null;
if (Date.now() - cached.timestamp > maxAge) {
AppState.cache.delete(key);
return null;
}
return cached.data;
}
static setCachedData(key, data) {
AppState.cache.set(key, {
data,
timestamp: Date.now()
});
}
}
// API Service Classes
class LyricsService {
static async fetchLyrics(artist, title) {
const cacheKey = Utils.getCacheKey('lyrics', { artist, title });
const cached = Utils.getCachedData(cacheKey);
if (cached) return cached;
try {
// Primary: Lyrics.ovh
const response = await fetch(`${API_CONFIG.lyrics.baseUrl}${encodeURIComponent(artist)}/${encodeURIComponent(title)}`);
if (response.ok) {
const data = await response.json();
Utils.setCachedData(cacheKey, data.lyrics);
return data.lyrics;
}
} catch (error) {
console.warn('Lyrics.ovh failed:', error);
}
// Fallback: Mock lyrics for demo
const mockLyrics = `Lyrics for "${title}" by ${artist} are not available.
This is a demo application. In a production environment, you would:
1. Use authenticated APIs like Genius or Musixmatch
2. Implement proper error handling
3. Add retry mechanisms
4. Consider legal aspects of lyrics distribution
For now, enjoy exploring the metadata and album artwork features!`;
Utils.setCachedData(cacheKey, mockLyrics);
return mockLyrics;
}
}
class MetadataService {
static async fetchFromMusicBrainz(artist, title) {
const cacheKey = Utils.getCacheKey('mb_search', { artist, title });
const cached = Utils.getCachedData(cacheKey);
if (cached) return cached;
try {
await this.rateLimitDelay(); // Respect MusicBrainz rate limit
const query = encodeURIComponent(`artist:"${artist}" AND recording:"${title}"`);
const url = `${API_CONFIG.musicbrainz.baseUrl}recording?query=${query}&fmt=json&limit=5`;
const response = await fetch(url, {
headers: {
'User-Agent': API_CONFIG.musicbrainz.userAgent,
'Accept': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
Utils.setCachedData(cacheKey, data);
return data;
}
} catch (error) {
console.warn('MusicBrainz API failed:', error);
}
return null;
}
static async fetchFromLastFm(artist, title) {
const cacheKey = Utils.getCacheKey('lastfm_track', { artist, title });
const cached = Utils.getCachedData(cacheKey);
if (cached) return cached;
try {
// Using JSONP for cross-origin requests
const data = await this.jsonpRequest(
`${API_CONFIG.lastfm.baseUrl}?method=track.getInfo&api_key=${API_CONFIG.lastfm.key}&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&format=json`
);
Utils.setCachedData(cacheKey, data);
return data;
} catch (error) {
console.warn('Last.fm API failed:', error);
}
return null;
}
static async fetchFromItunes(artist, title) {
const cacheKey = Utils.getCacheKey('itunes_search', { artist, title });
const cached = Utils.getCachedData(cacheKey);
if (cached) return cached;
try {
// iTunes Search API supports JSONP
const query = encodeURIComponent(`${artist} ${title}`);
const data = await this.jsonpRequest(
`${API_CONFIG.itunes.baseUrl}search?term=${query}&entity=song&limit=5`
);
Utils.setCachedData(cacheKey, data);
return data;
} catch (error) {
console.warn('iTunes API failed:', error);
}
return null;
}
static jsonpRequest(url) {
return new Promise((resolve, reject) => {
const callbackName = 'jsonp_callback_' + Math.round(100000 * Math.random());
const script = document.createElement('script');
window[callbackName] = function(data) {
delete window[callbackName];
document.body.removeChild(script);
resolve(data);
};
script.src = url + (url.includes('?') ? '&' : '?') + 'callback=' + callbackName;
script.onerror = () => {
delete window[callbackName];
document.body.removeChild(script);
reject(new Error('JSONP request failed'));
};
document.body.appendChild(script);
// Timeout after 10 seconds
setTimeout(() => {
if (window[callbackName]) {
delete window[callbackName];
document.body.removeChild(script);
reject(new Error('JSONP request timeout'));
}
}, 10000);
});
}
static rateLimitDelay() {
// MusicBrainz requires 1 request per second
return new Promise(resolve => setTimeout(resolve, 1000));
}
}
class ArtworkService {
static async fetchAlbumArt(mbid, spotifyUrl, lastfmUrl, itunesUrl) {
// Primary: Cover Art Archive (highest quality)
if (mbid) {
try {
const response = await fetch(`${API_CONFIG.coverart.baseUrl}release/${mbid}`);
if (response.ok) {
const data = await response.json();
if (data.images && data.images.length > 0) {
return data.images[0].thumbnails?.large || data.images[0].image;
}
}
} catch (error) {
console.warn('Cover Art Archive failed:', error);
}
}
// Fallback order: Spotify, iTunes, Last.fm
const fallbacks = [spotifyUrl, itunesUrl, lastfmUrl].filter(Boolean);
for (const url of fallbacks) {
if (await this.isImageAccessible(url)) {
return url;
}
}
return this.getDefaultArtwork();
}
static async isImageAccessible(url) {
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}
static getDefaultArtwork() {
// Generate a beautiful default artwork using CSS gradients
const canvas = document.createElement('canvas');
canvas.width = 300;
canvas.height = 300;
const ctx = canvas.getContext('2d');
// Create gradient
const gradient = ctx.createLinearGradient(0, 0, 300, 300);
gradient.addColorStop(0, '#1db954');
gradient.addColorStop(0.5, '#1ed760');
gradient.addColorStop(1, '#ff6b35');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 300, 300);
// Add music note
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = 'bold 80px Arial';
ctx.textAlign = 'center';
ctx.fillText('🎵', 150, 180);
return canvas.toDataURL();
}
}
// UI Controller Classes
class SearchController {
constructor() {
this.initElements();
this.bindEvents();
this.setupAutocomplete();
}
initElements() {
this.form = document.getElementById('searchForm');
this.artistInput = document.getElementById('artistInput');
this.songInput = document.getElementById('songInput');
this.searchBtn = document.getElementById('searchBtn');
this.searchBtnText = document.getElementById('searchBtnText');
}
bindEvents() {
this.form.addEventListener('submit', this.handleSearch.bind(this));
// Debounced autocomplete
const debouncedAutocomplete = Utils.debounce(this.handleAutocomplete.bind(this), 300);
this.artistInput.addEventListener('input', debouncedAutocomplete);
this.songInput.addEventListener('input', debouncedAutocomplete);
}
setupAutocomplete() {
// Implement autocomplete based on search history
[this.artistInput, this.songInput].forEach(input => {
input.addEventListener('focus', () => this.showAutocomplete(input));
input.addEventListener('blur', () => {
// Delay hiding to allow clicking on suggestions
setTimeout(() => this.hideAutocomplete(input), 200);
});
});
}
async handleSearch(e) {
e.preventDefault();
const artist = Utils.sanitizeQuery(this.artistInput.value);
const song = Utils.sanitizeQuery(this.songInput.value);
if (!artist || !song) {
Utils.showNotification('Please enter both artist and song title', 'error');
return;
}
this.setLoading(true);
try {
AppState.currentSearch = { artist, song };
this.addToHistory(artist, song);
// Start all API calls simultaneously
const [lyrics, metadata, artwork] = await Promise.allSettled([
LyricsService.fetchLyrics(artist, song),
this.fetchAllMetadata(artist, song),
this.fetchArtworkFromMultipleSources(artist, song)
]);
// Update UI with results
if (lyrics.status === 'fulfilled') {
resultsController.displayLyrics(lyrics.value);
}
if (metadata.status === 'fulfilled') {
resultsController.displayMetadata(metadata.value);
}
if (artwork.status === 'fulfilled') {
resultsController.displayArtwork(artwork.value);
}
Utils.showNotification('Search completed successfully!');
} catch (error) {
console.error('Search failed:', error);
Utils.showNotification('Search failed. Please try again.', 'error');
} finally {
this.setLoading(false);
}
}
async fetchAllMetadata(artist, song) {
const results = await Promise.allSettled([
MetadataService.fetchFromMusicBrainz(artist, song),
MetadataService.fetchFromLastFm(artist, song),
MetadataService.fetchFromItunes(artist, song)
]);
// Combine results from multiple sources
const metadata = {
musicbrainz: results[0].status === 'fulfilled' ? results[0].value : null,
lastfm: results[1].status === 'fulfilled' ? results[1].value : null,
itunes: results[2].status === 'fulfilled' ? results[2].value : null
};
return this.normalizeMetadata(metadata, artist, song);
}
normalizeMetadata(sources, artist, song) {
const normalized = {
title: song,
artist: artist,
album: 'Unknown',
duration: null,
releaseDate: 'Unknown',
genre: 'Unknown',
popularity: null,
playCount: null,
mbid: null,
artworkUrls: {}
};
// Extract data from MusicBrainz
if (sources.musicbrainz?.recordings?.length > 0) {
const record = sources.musicbrainz.recordings[0];
normalized.mbid = record.id;
normalized.duration = record.length;
if (record.releases?.length > 0) {
normalized.album = record.releases[0].title;
normalized.releaseDate = record.releases[0].date;
}
if (record.tags?.length > 0) {
normalized.genre = record.tags[0].name;
}
}
// Extract data from Last.fm
if (sources.lastfm?.track) {
const track = sources.lastfm.track;
normalized.playCount = track.playcount;
normalized.album = track.album?.title || normalized.album;
if (track.toptags?.tag?.length > 0) {
normalized.genre = track.toptags.tag[0].name;
}
if (track.album?.image) {
normalized.artworkUrls.lastfm = track.album.image.find(img => img.size === 'large')?.['#text'];
}
}
// Extract data from iTunes
if (sources.itunes?.results?.length > 0) {
const track = sources.itunes.results[0];
normalized.album = track.collectionName || normalized.album;
normalized.duration = track.trackTimeMillis;
normalized.releaseDate = track.releaseDate;
normalized.genre = track.primaryGenreName || normalized.genre;
normalized.artworkUrls.itunes = track.artworkUrl100?.replace('100x100', '600x600');
}
return normalized;
}
async fetchArtworkFromMultipleSources(artist, song) {
// Get artwork URLs from metadata first
const metadata = await this.fetchAllMetadata(artist, song);
return await ArtworkService.fetchAlbumArt(
metadata.mbid,
null, // Spotify URL would go here
metadata.artworkUrls.lastfm,
metadata.artworkUrls.itunes
);
}
handleAutocomplete(e) {
const input = e.target;
const value = input.value.trim();
if (value.length < 2) {
this.hideAutocomplete(input);
return;
}
const suggestions = this.getSuggestions(input.id, value);
this.showSuggestions(input, suggestions);
}
getSuggestions(inputType, value) {
const field = inputType === 'artistInput' ? 'artist' : 'song';
const history = AppState.searchHistory;
return history
.map(entry => entry[field])
.filter(item => item.toLowerCase().includes(value.toLowerCase()))
.filter((item, index, arr) => arr.indexOf(item) === index) // Remove duplicates
.slice(0, 5);
}
showSuggestions(input, suggestions) {
const dropdown = input.nextElementSibling;
dropdown.innerHTML = '';
if (suggestions.length === 0) {
this.hideAutocomplete(input);
return;
}
suggestions.forEach(suggestion => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = suggestion;
item.addEventListener('click', () => {
input.value = suggestion;
this.hideAutocomplete(input);
input.focus();
});
dropdown.appendChild(item);
});
dropdown.style.display = 'block';
}
showAutocomplete(input) {
if (input.value.length >= 2) {
this.handleAutocomplete({ target: input });
}
}
hideAutocomplete(input) {
const dropdown = input.nextElementSibling;
dropdown.style.display = 'none';
}
addToHistory(artist, song) {
const entry = { artist, song, timestamp: Date.now() };
AppState.searchHistory = [entry, ...AppState.searchHistory.filter(
h => !(h.artist === artist && h.song === song)
)].slice(0, 50); // Keep last 50 searches
localStorage.setItem('musicapp_history', JSON.stringify(AppState.searchHistory));
}
setLoading(isLoading) {
this.searchBtn.disabled = isLoading;
this.searchBtnText.textContent = isLoading ? 'Searching...' : 'Search';
if (isLoading) {
this.searchBtn.innerHTML = '<div class="spinner"></div> Searching...';
} else {
this.searchBtn.innerHTML = 'Search';
}
}
}
class ResultsController {
constructor() {
this.initElements();
this.bindEvents();
}
initElements() {
this.albumArtCard = document.getElementById('albumArtCard');
this.albumArt = document.getElementById('albumArt');
this.songInfoCard = document.getElementById('songInfoCard');
this.songMetadata = document.getElementById('songMetadata');
this.artistInfoCard = document.getElementById('artistInfoCard');
this.artistInfo = document.getElementById('artistInfo');
this.lyricsCard = document.getElementById('lyricsCard');
this.lyricsContent = document.getElementById('lyricsContent');
this.relatedSongsCard = document.getElementById('relatedSongsCard');
this.relatedSongs = document.getElementById('relatedSongs');
}
bindEvents() {
// Favorite button
document.getElementById('favoriteBtn').addEventListener('click', this.handleFavorite.bind(this));
// Download buttons
document.getElementById('downloadLyrics').addEventListener('click', this.downloadLyrics.bind(this));
document.getElementById('downloadArt').addEventListener('click', this.downloadArtwork.bind(this));
// Share buttons
document.getElementById('shareBtn').addEventListener('click', this.shareTrack.bind(this));
document.getElementById('shareLyrics').addEventListener('click', this.shareLyrics.bind(this));
document.getElementById('shareArt').addEventListener('click', this.shareArtwork.bind(this));
}
displayLyrics(lyrics) {
this.lyricsContent.textContent = lyrics;
this.showCard(this.lyricsCard);
}
displayMetadata(metadata) {
this.songMetadata.innerHTML = '';
const fields = [
{ label: 'Title', value: metadata.title },
{ label: 'Artist', value: metadata.artist },
{ label: 'Album', value: metadata.album },
{ label: 'Duration', value: Utils.formatDuration(metadata.duration) },
{ label: 'Release Date', value: Utils.formatDate(metadata.releaseDate) },
{ label: 'Genre', value: metadata.genre },
{ label: 'Play Count', value: metadata.playCount ? metadata.playCount.toLocaleString() : 'N/A' }
];
fields.forEach(field => {
const item = document.createElement('div');
item.className = 'metadata-item';
item.innerHTML = `
<div class="label">${field.label}</div>
<div class="value">${field.value}</div>
`;
this.songMetadata.appendChild(item);
});
this.showCard(this.songInfoCard);
// Update favorite button state
this.updateFavoriteButton();
}
displayArtwork(artworkUrl) {
this.albumArt.src = artworkUrl;
this.albumArt.alt = `Album artwork for ${AppState.currentSearch?.song || 'Unknown'}`;
this.showCard(this.albumArtCard);
}
displayArtistInfo(artistData) {
// Mock artist info for demo
this.artistInfo.innerHTML = `
<div class="metadata-grid">
<div class="metadata-item">
<div class="label">Artist</div>
<div class="value">${AppState.currentSearch?.artist || 'Unknown'}</div>
</div>
<div class="metadata-item">
<div class="label">Followers</div>
<div class="value">-</div>
</div>
<div class="metadata-item">
<div class="label">Monthly Listeners</div>
<div class="value">-</div>
</div>
<div class="metadata-item">
<div class="label">Top Genre</div>
<div class="value">-</div>
</div>
</div>
<p style="margin-top: 1rem; color: var(--text-secondary);">
Artist information would be fetched from Spotify or Last.fm APIs in a production environment.
</p>
`;
this.showCard(this.artistInfoCard);
}
displayRelatedSongs() {
// Mock related songs for demo
const mockSongs = [
{ title: 'Related Song 1', artist: 'Similar Artist' },
{ title: 'Related Song 2', artist: 'Another Artist' },
{ title: 'Related Song 3', artist: 'Different Artist' }
];
this.relatedSongs.innerHTML = '';
mockSongs.forEach(song => {
const songElement = document.createElement('div');
songElement.className = 'related-song';
songElement.innerHTML = `
<div class="related-song-title">${song.title}</div>
<div class="related-song-artist">${song.artist}</div>
`;
songElement.addEventListener('click', () => {
document.getElementById('artistInput').value = song.artist;
document.getElementById('songInput').value = song.title;
Utils.showNotification(`Filled in "${song.title}" by ${song.artist}`);
});
this.relatedSongs.appendChild(songElement);
});
this.showCard(this.relatedSongsCard);
}
showCard(card) {
card.classList.remove('hidden');
card.classList.add('fade-in');
}
hideAllCards() {
[this.albumArtCard, this.songInfoCard, this.artistInfoCard, this.lyricsCard, this.relatedSongsCard]
.forEach(card => card.classList.add('hidden'));
}
handleFavorite() {
if (!AppState.currentSearch) return;
const { artist, song } = AppState.currentSearch;
const favoriteKey = `${artist}_${song}`;
const favoriteBtn = document.getElementById('favoriteBtn');
const existingIndex = AppState.favorites.findIndex(fav => fav.key === favoriteKey);
if (existingIndex >= 0) {
// Remove from favorites
AppState.favorites.splice(existingIndex, 1);
favoriteBtn.classList.remove('active');
favoriteBtn.innerHTML = '❤️ Add to Favorites';
Utils.showNotification('Removed from favorites');
} else {
// Add to favorites
AppState.favorites.push({
key: favoriteKey,
artist,
song,
timestamp: Date.now()
});
favoriteBtn.classList.add('active');
favoriteBtn.innerHTML = '💔 Remove from Favorites';
Utils.showNotification('Added to favorites');
}
localStorage.setItem('musicapp_favorites', JSON.stringify(AppState.favorites));
favoritesController.updateFavoritesList();
}
updateFavoriteButton() {
if (!AppState.currentSearch) return;
const { artist, song } = AppState.currentSearch;
const favoriteKey = `${artist}_${song}`;
const favoriteBtn = document.getElementById('favoriteBtn');
const isFavorite = AppState.favorites.some(fav => fav.key === favoriteKey);
if (isFavorite) {
favoriteBtn.classList.add('active');
favoriteBtn.innerHTML = '💔 Remove from Favorites';
} else {
favoriteBtn.classList.remove('active');
favoriteBtn.innerHTML = '❤️ Add to Favorites';
}
}
downloadLyrics() {
if (!AppState.currentSearch) return;
const lyrics = this.lyricsContent.textContent;
const filename = `${AppState.currentSearch.artist} - ${AppState.currentSearch.song} - Lyrics.txt`;
Utils.downloadFile(lyrics, filename);
Utils.showNotification('Lyrics downloaded!');
}
downloadArtwork() {
if (!this.albumArt.src) return;
const link = document.createElement('a');
link.href = this.albumArt.src;
link.download = `${AppState.currentSearch?.artist || 'Unknown'} - ${AppState.currentSearch?.song || 'Unknown'} - Artwork.jpg`;
link.click();
Utils.showNotification('Artwork download started!');
}
shareTrack() {
if (!AppState.currentSearch) return;
const { artist, song } = AppState.currentSearch;
const shareText = `Check out "${song}" by ${artist}! 🎵`;
if (navigator.share) {
navigator.share({
title: 'MusiSync',
text: shareText,
url: window.location.href
});
} else {
navigator.clipboard.writeText(shareText);
Utils.showNotification('Track info copied to clipboard!');
}
}
shareLyrics() {
if (!AppState.currentSearch) return;
const lyrics = this.lyricsContent.textContent;
const shareText = `🎵 "${AppState.currentSearch.song}" by ${AppState.currentSearch.artist}\n\n${lyrics}`;
if (navigator.share) {
navigator.share({
title: 'Song Lyrics',
text: shareText
});
} else {
navigator.clipboard.writeText(shareText);
Utils.showNotification('Lyrics copied to clipboard!');
}
}
shareArtwork() {
if (!this.albumArt.src) return;
if (navigator.share) {
navigator.share({
title: 'Album Artwork',
text: `Album artwork for "${AppState.currentSearch?.song}" by ${AppState.currentSearch?.artist}`,
url: this.albumArt.src
});
} else {
navigator.clipboard.writeText(this.albumArt.src);
Utils.showNotification('Artwork URL copied to clipboard!');
}
}
}
class FavoritesController {
constructor() {
this.favoritesList = document.getElementById('favoritesList');
this.updateFavoritesList();
}
updateFavoritesList() {
if (AppState.favorites.length === 0) {
this.favoritesList.innerHTML = '<div class="loading">No favorites yet. Search for songs and add them to your favorites!</div>';
return;
}
this.favoritesList.innerHTML = '';
AppState.favorites.slice(0, 10).forEach(favorite => { // Show last 10 favorites
const item = document.createElement('div');
item.className = 'favorite-item';
item.innerHTML = `
<div>
<div style="font-weight: 600;">${favorite.song}</div>
<div style="color: var(--text-secondary); font-size: 0.9rem;">${favorite.artist}</div>
</div>
<span class="remove-favorite" title="Remove favorite">✕</span>
`;
// Click to search
item.querySelector('div').addEventListener('click', () => {
document.getElementById('artistInput').value = favorite.artist;
document.getElementById('songInput').value = favorite.song;
Utils.showNotification(`Loaded "${favorite.song}" by ${favorite.artist}`);
});
// Remove favorite
item.querySelector('.remove-favorite').addEventListener('click', (e) => {
e.stopPropagation();
this.removeFavorite(favorite.key);
});
this.favoritesList.appendChild(item);
});
}
removeFavorite(key) {
AppState.favorites = AppState.favorites.filter(fav => fav.key !== key);
localStorage.setItem('musicapp_favorites', JSON.stringify(AppState.favorites));
this.updateFavoritesList();
Utils.showNotification('Removed from favorites');
// Update favorite button if current search matches
if (AppState.currentSearch) {
const currentKey = `${AppState.currentSearch.artist}_${AppState.currentSearch.song}`;
if (currentKey === key) {
resultsController.updateFavoriteButton();
}
}
}
}
class ThemeController {
constructor() {
this.themeToggle = document.getElementById('themeToggle');
this.themeIcon = document.getElementById('themeIcon');
this.themeText = document.getElementById('themeText');
this.initTheme();
this.bindEvents();
}
initTheme() {
document.documentElement.setAttribute('data-theme', AppState.theme);
this.updateThemeUI();
}
bindEvents() {
this.themeToggle.addEventListener('click', this.toggleTheme.bind(this));
}
toggleTheme() {
AppState.theme = AppState.theme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', AppState.theme);
localStorage.setItem('musicapp_theme', AppState.theme);
this.updateThemeUI();
Utils.showNotification(`Switched to ${AppState.theme} theme`);
}
updateThemeUI() {
if (AppState.theme === 'dark') {
this.themeIcon.textContent = '🌙';
this.themeText.textContent = 'Dark Mode';
} else {
this.themeIcon.textContent = '☀️';
this.themeText.textContent = 'Light Mode';
}
}
}
// Initialize Application
class App {
constructor() {
this.init();
}
init() {
// Initialize controllers
this.searchController = new SearchController();
this.resultsController = new ResultsController();
this.favoritesController = new FavoritesController();
this.themeController = new ThemeController();
// Global error handler
window.addEventListener('error', this.handleGlobalError.bind(this));
window.addEventListener('unhandledrejection', this.handleGlobalError.bind(this));
// Add accessibility features
this.setupAccessibility();
console.log('🎵 MusiSync App initialized!');
Utils.showNotification('Welcome to MusiSync! Search for your favorite songs.');
}
handleGlobalError(event) {
console.error('Global error:', event.error || event.reason);
Utils.showNotification('An unexpected error occurred. Please try again.', 'error');
}
setupAccessibility() {
// Add keyboard navigation for autocomplete
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Close any open dropdowns
document.querySelectorAll('.autocomplete-dropdown').forEach(dropdown => {
dropdown.style.display = 'none';
});
}
});
// Add ARIA labels
document.querySelectorAll('input').forEach(input => {
if (!input.getAttribute('aria-label')) {
input.setAttribute('aria-label', input.placeholder);
}
});
// Add focus indicators
document.querySelectorAll('button, input').forEach(element => {
element.addEventListener('focus', () => {
element.style.outline = '2px solid var(--accent-primary)';
element.style.outlineOffset = '2px';
});
element.addEventListener('blur', () => {
element.style.outline = 'none';
});
});
}
}
// Global references for easier access
let app, searchController, resultsController, favoritesController, themeController;
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
app = new App();
// Make controllers globally accessible
searchController = app.searchController;
resultsController = app.resultsController;
favoritesController = app.favoritesController;
themeController = app.themeController;
});
// Service Worker Registration (optional, for offline support)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
// You could register a service worker here for offline functionality
console.log('Service Worker support detected');
});
}
</script>
<style>
#minimax-floating-ball {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 12px;
background: #222222;
border-radius: 12px;
display: flex;
align-items: center;
color: #F8F8F8;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
z-index: 9999;
transition: all 0.3s ease;
overflow: hidden;
cursor: pointer;
}
#minimax-floating-ball:hover {
transform: translateY(-2px);
background: #383838;
}
.minimax-ball-content {
display: flex;
align-items: center;
gap: 8px;
}
.minimax-logo-wave {
width: 26px;
height: 22px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='22' viewBox='0 0 26 22' fill='none'%3E%3Cg clip-path='url(%23clip0_3442_102412)'%3E%3Cpath d='M12.8405 14.6775C12.8405 14.9897 13.0932 15.2424 13.4055 15.2424C13.7178 15.2424 13.9705 14.9897 13.9705 14.6775V2.98254C13.9705 1.88957 13.0809 1 11.9879 1C10.895 1 10.0054 1.88957 10.0054 2.98254V11.566V17.1068C10.0054 17.5773 9.62327 17.9594 9.1528 17.9594C8.68233 17.9594 8.30021 17.5773 8.30021 17.1068V8.04469C8.30021 6.95172 7.41063 6.06215 6.31767 6.06215C5.22471 6.06215 4.33513 6.95172 4.33513 8.04469V11.8855C4.33513 12.3559 3.953 12.7381 3.48254 12.7381C3.01207 12.7381 2.62994 12.3559 2.62994 11.8855V10.4936C2.62994 10.1813 2.37725 9.92861 2.06497 9.92861C1.7527 9.92861 1.5 10.1813 1.5 10.4936V11.8855C1.5 12.9784 2.38957 13.868 3.48254 13.868C4.5755 13.868 5.46508 12.9784 5.46508 11.8855V8.04469C5.46508 7.57422 5.8472 7.19209 6.31767 7.19209C6.78814 7.19209 7.17026 7.57422 7.17026 8.04469V17.1068C7.17026 18.1998 8.05984 19.0894 9.1528 19.0894C10.2458 19.0894 11.1353 18.1998 11.1353 17.1068V2.98254C11.1353 2.51207 11.5175 2.12994 11.9879 2.12994C12.4584 2.12994 12.8405 2.51207 12.8405 2.98254V14.6775Z' fill='%23F8F8F8'/%3E%3Cpath d='M23.3278 6.06215C22.2348 6.06215 21.3452 6.95172 21.3452 8.04469V15.6143C21.3452 16.0847 20.9631 16.4669 20.4926 16.4669C20.0222 16.4669 19.6401 16.0847 19.6401 15.6143V2.98254C19.6401 1.88957 18.7505 1 17.6575 1C16.5645 1 15.675 1.88957 15.675 2.98254V19.0175C15.675 19.4879 15.2928 19.8701 14.8224 19.8701C14.3519 19.8701 13.9698 19.4879 13.9698 19.0175V17.0329C13.9698 16.7206 13.7171 16.4679 13.4048 16.4679C13.0925 16.4679 12.8398 16.7206 12.8398 17.0329V19.0175C12.8398 20.1104 13.7294 21 14.8224 21C15.9153 21 16.8049 20.1104 16.8049 19.0175V2.98254C16.8049 2.51207 17.187 2.12994 17.6575 2.12994C18.128 2.12994 18.5101 2.51207 18.5101 2.98254V15.6143C18.5101 16.7072 19.3997 17.5968 20.4926 17.5968C21.5856 17.5968 22.4752 16.7072 22.4752 15.6143V8.04469C22.4752 7.57422 22.8573 7.19209 23.3278 7.19209C23.7982 7.19209 24.1804 7.57422 24.1804 8.04469V14.6775C24.1804 14.9897 24.4331 15.2424 24.7453 15.2424C25.0576 15.2424 25.3103 14.9897 25.3103 14.6775V8.04469C25.3103 6.95172 24.4207 6.06215 23.3278 6.06215Z' fill='%23F8F8F8'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_3442_102412'%3E%3Crect width='25' height='22' fill='white' transform='translate(0.5)'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
}
.minimax-ball-text {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.minimax-close-icon {
margin-left: 8px;
font-size: 16px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.minimax-close-icon:hover {
opacity: 1;
}
</style>
<div id="minimax-floating-ball">
<div class="minimax-ball-content">
<div class="minimax-logo-wave"></div>
<span class="minimax-ball-text">Created by MiniMax Agent</span>
</div>
<div class="minimax-close-icon">×</div>
</div>
<script>
// Initialize floating ball functionality
function initFloatingBall() {
const ball = document.getElementById('minimax-floating-ball');
if (!ball) return;
// Initial animation
ball.style.opacity = '0';
ball.style.transform = 'translateY(20px)';
setTimeout(() => {
ball.style.opacity = '1';
ball.style.transform = 'translateY(0)';
}, 500);
// Handle logo click
const ballContent = ball.querySelector('.minimax-ball-content');
ballContent.addEventListener('click', function (e) {
e.stopPropagation();
window.open('https://agent.minimax.io/agent', '_blank');
ball.style.transform = 'scale(0.95)';
setTimeout(() => {
ball.style.transform = 'scale(1)';
}, 100);
});
// Handle close button click
const closeIcon = ball.querySelector('.minimax-close-icon');
closeIcon.addEventListener('click', function (e) {
e.stopPropagation();
ball.style.opacity = '0';
ball.style.transform = 'translateY(20px)';
setTimeout(() => {
ball.style.display = 'none';
}, 300);
});
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initFloatingBall);
</script>
</body>
</html>