Spaces:
Running
Running
| // ===== Initialize Lucide Icons ===== | |
| lucide.createIcons(); | |
| // ===== Navbar Scroll Effect ===== | |
| const navbar = document.getElementById('navbar'); | |
| window.addEventListener('scroll', () => { | |
| if (window.scrollY > 50) { | |
| navbar.classList.add('scrolled'); | |
| } else { | |
| navbar.classList.remove('scrolled'); | |
| } | |
| }); | |
| // ===== Mobile Menu Toggle ===== | |
| const mobileMenuBtn = document.getElementById('mobileMenuBtn'); | |
| const mobileMenu = document.getElementById('mobileMenu'); | |
| let menuOpen = false; | |
| mobileMenuBtn.addEventListener('click', () => { | |
| menuOpen = !menuOpen; | |
| mobileMenu.classList.toggle('hidden', !menuOpen); | |
| }); | |
| // Close mobile menu when clicking a link | |
| document.querySelectorAll('.mobile-nav-link').forEach(link => { | |
| link.addEventListener('click', () => { | |
| menuOpen = false; | |
| mobileMenu.classList.add('hidden'); | |
| }); | |
| }); | |
| // ===== Section Reveal Animation ===== | |
| const revealElements = document.querySelectorAll('.reveal'); | |
| const revealObserver = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| entry.target.classList.add('visible'); | |
| } | |
| }); | |
| }, { threshold: 0.1 }); | |
| revealElements.forEach(el => revealObserver.observe(el)); | |
| // ===== Fetch YouTube Videos ===== | |
| const CHANNEL_HANDLE = 'binarybeats_ai'; | |
| const loadingState = document.getElementById('loadingState'); | |
| const errorState = document.getElementById('errorState'); | |
| const featuredVideo = document.getElementById('featuredVideo'); | |
| const videoGridRest = document.getElementById('videoGridRest'); | |
| const PIPED_INSTANCES = [ | |
| 'https://pipedapi.kavin.rocks', | |
| 'https://pipedapi.adminforge.de', | |
| 'https://pipedapi.in.projectsegfau.lt', | |
| 'https://pipedapi.r4fo.com', | |
| 'https://pipedapi.darkness.services', | |
| ]; | |
| const INVIDIOUS_INSTANCES = [ | |
| 'https://vid.puffyan.us', | |
| 'https://inv.nadeko.net', | |
| 'https://invidious.nerdvpn.de', | |
| 'https://iv.ggtyler.dev', | |
| 'https://invidious.privacyredirect.com', | |
| 'https://yewtu.be', | |
| ]; | |
| const CORS_PROXIES = [ | |
| 'https://api.allorigins.win/raw?url=', | |
| 'https://corsproxy.io/?', | |
| 'https://api.codetabs.com/v1/proxy?quest=', | |
| ]; | |
| async function fetchWithTimeout(url, timeout = 10000) { | |
| const controller = new AbortController(); | |
| const id = setTimeout(() => controller.abort(), timeout); | |
| try { | |
| const response = await fetch(url, { signal: controller.signal }); | |
| clearTimeout(id); | |
| return response; | |
| } catch (e) { | |
| clearTimeout(id); | |
| throw e; | |
| } | |
| } | |
| function normalizePipedStream(stream) { | |
| return { | |
| videoId: stream.url?.replace('/watch?v=', '') || '', | |
| title: stream.title || 'Untitled', | |
| description: '', | |
| videoThumbnails: stream.thumbnail ? [ | |
| { url: stream.thumbnail, quality: 'maxres' }, | |
| { url: stream.thumbnail, quality: 'high' }, | |
| ] : [], | |
| published: stream.uploaded ? Math.floor(stream.uploaded / 1000) : null, | |
| lengthSeconds: stream.duration || 0, | |
| viewCount: stream.views || 0, | |
| }; | |
| } | |
| function normalizeInvidiousVideo(video) { | |
| return { | |
| videoId: video.videoId || '', | |
| title: video.title || 'Untitled', | |
| description: video.description || '', | |
| videoThumbnails: video.videoThumbnails || [], | |
| published: video.published || null, | |
| lengthSeconds: video.lengthSeconds || 0, | |
| viewCount: video.viewCount || 0, | |
| }; | |
| } | |
| // ===== Discover YouTube Channel ID from page HTML ===== | |
| async function discoverChannelId() { | |
| for (const proxy of CORS_PROXIES) { | |
| try { | |
| const ytUrl = encodeURIComponent(`https://www.youtube.com/@${CHANNEL_HANDLE}`); | |
| const res = await fetchWithTimeout(`${proxy}${ytUrl}`, 12000); | |
| if (!res.ok) continue; | |
| const html = await res.text(); | |
| // Try multiple patterns to extract channel ID from YouTube page | |
| const patterns = [ | |
| /\"channelId\":\"(UC[^\"]+)\"/, | |
| /channel_id=(UC[a-zA-Z0-9_-]+)/, | |
| /\"externalId\":\"(UC[^\"]+)\"/, | |
| /youtube\.com\/channel\/(UC[a-zA-Z0-9_-]+)/, | |
| /<meta\s+itemprop="channelId"\s+content="(UC[^"]+)"/, | |
| /\" channelId\":\"(UC[^\"]+)\"/, | |
| ]; | |
| for (const pattern of patterns) { | |
| const match = html.match(pattern); | |
| if (match) { | |
| console.log(`✅ Discovered channel ID: ${match[1]} via ${proxy}`); | |
| return match[1]; | |
| } | |
| } | |
| } catch (e) { | |
| console.warn(`Channel ID discovery via ${proxy} failed:`, e.message); | |
| } | |
| } | |
| return null; | |
| } | |
| // ===== Fetch videos via rss2json.com (most reliable CORS-friendly method) ===== | |
| async function fetchViaRss2Json(channelId) { | |
| try { | |
| const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`; | |
| const url = `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(rssUrl)}`; | |
| const res = await fetchWithTimeout(url, 10000); | |
| if (!res.ok) return null; | |
| const data = await res.json(); | |
| if (data.status === 'ok' && data.items?.length) { | |
| console.log(`✅ rss2json succeeded with ${data.items.length} videos`); | |
| return data.items.slice(0, 10).map(item => { | |
| const videoId = item.link?.match(/[?&]v=([^&]+)/)?.[1] || | |
| item.guid?.replace('yt:video:', '') || ''; | |
| const thumbnail = item.thumbnail || | |
| `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; | |
| return { | |
| videoId, | |
| title: item.title || 'Untitled', | |
| description: '', | |
| videoThumbnails: [ | |
| { url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, quality: 'maxres' }, | |
| { url: `https://i.ytimg.com/vi/${videoId}/sddefault.jpg`, quality: 'high' }, | |
| { url: thumbnail, quality: 'medium' }, | |
| ], | |
| published: item.pubDate ? Math.floor(new Date(item.pubDate).getTime() / 1000) : null, | |
| lengthSeconds: 0, | |
| viewCount: 0, | |
| }; | |
| }); | |
| } | |
| } catch (e) { | |
| console.warn('rss2json failed:', e.message); | |
| } | |
| return null; | |
| } | |
| async function fetchChannelVideos() { | |
| // Method 1: Discover channel ID, then use rss2json.com (most reliable CORS-friendly approach) | |
| const channelId = await discoverChannelId(); | |
| if (channelId) { | |
| const videos = await fetchViaRss2Json(channelId); | |
| if (videos) return videos; | |
| } | |
| // Method 2: Piped API directly (supports channel handles) | |
| for (const instance of PIPED_INSTANCES) { | |
| try { | |
| const res = await fetchWithTimeout(`${instance}/c/${CHANNEL_HANDLE}`); | |
| if (!res.ok) continue; | |
| const data = await res.json(); | |
| if (data.relatedStreams?.length) { | |
| console.log(`✅ Piped direct succeeded: ${instance}`); | |
| return data.relatedStreams.slice(0, 10).map(normalizePipedStream); | |
| } | |
| } catch (e) { | |
| console.warn(`Piped direct ${instance} failed:`, e.message); | |
| } | |
| } | |
| // Method 3: Piped API via CORS proxy | |
| for (const instance of PIPED_INSTANCES) { | |
| for (const proxy of CORS_PROXIES) { | |
| try { | |
| const encodedUrl = encodeURIComponent(`${instance}/c/${CHANNEL_HANDLE}`); | |
| const res = await fetchWithTimeout(`${proxy}${encodedUrl}`); | |
| if (!res.ok) continue; | |
| const data = await res.json(); | |
| if (data.relatedStreams?.length) { | |
| console.log(`✅ Piped proxy succeeded: ${proxy} → ${instance}`); | |
| return data.relatedStreams.slice(0, 10).map(normalizePipedStream); | |
| } | |
| } catch (e) { | |
| console.warn(`Piped proxy ${proxy}→${instance} failed:`, e.message); | |
| } | |
| } | |
| } | |
| // Method 4: Invidious API directly | |
| for (const instance of INVIDIOUS_INSTANCES) { | |
| try { | |
| const res = await fetchWithTimeout(`${instance}/api/v1/channels/${CHANNEL_HANDLE}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| if (data?.authorId) { | |
| const videosRes = await fetchWithTimeout(`${instance}/api/v1/channels/${data.authorId}/videos?sort_by=newest`); | |
| if (videosRes.ok) { | |
| const videosData = await videosRes.json(); | |
| const videos = videosData.videos || videosData; | |
| if (videos?.length) { | |
| console.log(`✅ Invidious direct succeeded: ${instance}`); | |
| return videos.slice(0, 10).map(normalizeInvidiousVideo); | |
| } | |
| } | |
| } | |
| } | |
| // Fallback: search for the channel | |
| const searchRes = await fetchWithTimeout(`${instance}/api/v1/search?q=${CHANNEL_HANDLE}&type=channel`); | |
| if (!searchRes.ok) continue; | |
| const searchData = await searchRes.json(); | |
| const channel = searchData?.find(r => r.type === 'channel'); | |
| if (!channel) continue; | |
| const videosRes = await fetchWithTimeout(`${instance}/api/v1/channels/${channel.authorId}/videos?sort_by=newest`); | |
| if (!videosRes.ok) continue; | |
| const videosData = await videosRes.json(); | |
| const videos = videosData.videos || videosData; | |
| if (videos?.length) { | |
| console.log(`✅ Invidious search succeeded: ${instance}`); | |
| return videos.slice(0, 10).map(normalizeInvidiousVideo); | |
| } | |
| } catch (e) { | |
| console.warn(`Invidious direct ${instance} failed:`, e.message); | |
| } | |
| } | |
| // Method 5: Invidious API via CORS proxy | |
| for (const instance of INVIDIOUS_INSTANCES) { | |
| for (const proxy of CORS_PROXIES) { | |
| try { | |
| const encodedUrl = encodeURIComponent(`${instance}/api/v1/channels/${CHANNEL_HANDLE}`); | |
| const res = await fetchWithTimeout(`${proxy}${encodedUrl}`); | |
| if (!res.ok) continue; | |
| const data = await res.json(); | |
| if (data?.authorId) { | |
| const vidEncodedUrl = encodeURIComponent(`${instance}/api/v1/channels/${data.authorId}/videos?sort_by=newest`); | |
| const videosRes = await fetchWithTimeout(`${proxy}${vidEncodedUrl}`); | |
| if (!videosRes.ok) continue; | |
| const videosData = await videosRes.json(); | |
| const videos = videosData.videos || videosData; | |
| if (videos?.length) { | |
| console.log(`✅ Invidious proxy succeeded: ${proxy}→${instance}`); | |
| return videos.slice(0, 10).map(normalizeInvidiousVideo); | |
| } | |
| } | |
| } catch (e) { | |
| console.warn(`Invidious proxy ${proxy}→${instance} failed:`, e.message); | |
| } | |
| } | |
| } | |
| // Method 6: YouTube RSS feed via CORS proxy (direct XML parsing) | |
| if (channelId) { | |
| for (const proxy of CORS_PROXIES) { | |
| try { | |
| const rssUrl = encodeURIComponent(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`); | |
| const rssRes = await fetchWithTimeout(`${proxy}${rssUrl}`); | |
| if (!rssRes.ok) continue; | |
| const rssText = await rssRes.text(); | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(rssText, 'text/xml'); | |
| const entries = doc.querySelectorAll('entry'); | |
| if (entries.length) { | |
| console.log(`✅ YouTube RSS direct succeeded: ${proxy}`); | |
| return Array.from(entries).slice(0, 10).map(entry => { | |
| const videoId = entry.querySelector('videoId')?.textContent || | |
| entry.querySelector('link')?.getAttribute('href')?.match(/v=([^&]+)/)?.[1] || ''; | |
| const title = entry.querySelector('title')?.textContent || 'Untitled'; | |
| const publishedEl = entry.querySelector('published'); | |
| const published = publishedEl?.textContent | |
| ? Math.floor(new Date(publishedEl.textContent).getTime() / 1000) | |
| : null; | |
| const thumbnail = entry.querySelector('thumbnail')?.getAttribute('url') || | |
| entry.querySelectorNS('http://search.yahoo.com/mrss/', 'thumbnail')?.getAttribute('url') || | |
| `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; | |
| return { | |
| videoId, | |
| title, | |
| description: '', | |
| videoThumbnails: [ | |
| { url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, quality: 'maxres' }, | |
| { url: `https://i.ytimg.com/vi/${videoId}/sddefault.jpg`, quality: 'high' }, | |
| { url: thumbnail, quality: 'medium' }, | |
| ], | |
| published, | |
| lengthSeconds: 0, | |
| viewCount: 0, | |
| }; | |
| }); | |
| } | |
| } catch (e) { | |
| console.warn(`YouTube RSS direct ${proxy} failed:`, e.message); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function formatDate(timestamp) { | |
| if (!timestamp) return ''; | |
| const date = new Date(timestamp * 1000); | |
| const now = new Date(); | |
| const diff = now - date; | |
| const days = Math.floor(diff / (1000 * 60 * 60 * 24)); | |
| if (days === 0) return 'Today'; | |
| if (days === 1) return 'Yesterday'; | |
| if (days < 7) return `${days} days ago`; | |
| if (days < 30) return `${Math.floor(days / 7)} weeks ago`; | |
| if (days < 365) return `${Math.floor(days / 30)} months ago`; | |
| return `${Math.floor(days / 365)} years ago`; | |
| } | |
| function formatDuration(seconds) { | |
| if (!seconds) return ''; | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = seconds % 60; | |
| if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; | |
| return `${m}:${s.toString().padStart(2, '0')}`; | |
| } | |
| function formatViews(views) { | |
| if (!views) return ''; | |
| if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M views`; | |
| if (views >= 1000) return `${(views / 1000).toFixed(1)}K views`; | |
| return `${views} views`; | |
| } | |
| function createFeaturedCard(video) { | |
| return ` | |
| <a href="https://www.youtube.com/watch?v=${video.videoId}" target="_blank" class="featured-card block group"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-0"> | |
| <div class="thumbnail-wrapper aspect-video md:aspect-auto"> | |
| <img src="${video.videoThumbnails?.find(t => t.quality === 'maxres')?.url || video.videoThumbnails?.[0]?.url || ''}" | |
| alt="${video.title}" | |
| class="w-full h-full object-cover" | |
| onerror="this.src='http://static.photos/technology/640x360/1'"> | |
| <div class="play-overlay"> | |
| <div class="w-16 h-16 rounded-full bg-red-600/90 flex items-center justify-center shadow-lg shadow-red-600/30 group-hover:scale-110 transition-transform"> | |
| <svg class="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> | |
| </div> | |
| </div> | |
| ${video.lengthSeconds ? `<span class="duration-badge">${formatDuration(video.lengthSeconds)}</span>` : ''} | |
| </div> | |
| <div class="p-6 md:p-8 flex flex-col justify-center"> | |
| <div class="flex items-center gap-2 mb-3"> | |
| <span class="px-2.5 py-0.5 rounded-full bg-brand-accent/10 border border-brand-accent/30 text-brand-accent text-xs font-medium">LATEST</span> | |
| <span class="text-gray-500 text-xs">${formatDate(video.published)}</span> | |
| </div> | |
| <h3 class="font-display font-semibold text-lg sm:text-xl md:text-2xl mb-3 text-white group-hover:text-brand-accent transition-colors line-clamp-2">${video.title}</h3> | |
| <p class="text-gray-500 text-sm line-clamp-2 mb-4">${video.description || 'AI-generated music by Binary Beats.'}</p> | |
| <div class="flex items-center gap-4 text-xs text-gray-500"> | |
| ${video.viewCount ? `<span class="flex items-center gap-1"><i data-lucide="eye" class="w-3.5 h-3.5"></i> ${formatViews(video.viewCount)}</span>` : ''} | |
| ${video.lengthSeconds ? `<span class="flex items-center gap-1"><i data-lucide="clock" class="w-3.5 h-3.5"></i> ${formatDuration(video.lengthSeconds)}</span>` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| </a> | |
| `; | |
| } | |
| function createVideoCard(video, index) { | |
| const delay = index * 100; | |
| return ` | |
| <a href="https://www.youtube.com/watch?v=${video.videoId}" target="_blank" class="video-card block group" style="animation-delay: ${delay}ms"> | |
| <div class="thumbnail-wrapper aspect-video"> | |
| <img src="${video.videoThumbnails?.find(t => t.quality === 'high')?.url || video.videoThumbnails?.[0]?.url || ''}" | |
| alt="${video.title}" | |
| class="w-full h-full object-cover" | |
| onerror="this.src='http://static.photos/technology/320x240/${index + 2}'"> | |
| <div class="play-overlay"> | |
| <div class="w-12 h-12 rounded-full bg-red-600/90 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform"> | |
| <svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> | |
| </div> | |
| </div> | |
| ${video.lengthSeconds ? `<span class="duration-badge">${formatDuration(video.lengthSeconds)}</span>` : ''} | |
| </div> | |
| <div class="p-3 sm:p-4"> | |
| <h3 class="font-semibold text-sm sm:text-base text-gray-200 group-hover:text-brand-accent transition-colors line-clamp-2 mb-2">${video.title}</h3> | |
| <div class="flex items-center justify-between text-xs text-gray-500"> | |
| <span>${formatDate(video.published)}</span> | |
| ${video.viewCount ? `<span>${formatViews(video.viewCount)}</span>` : ''} | |
| </div> | |
| </div> | |
| </a> | |
| `; | |
| } | |
| async function loadVideos() { | |
| const videos = await fetchChannelVideos(); | |
| if (!videos || videos.length === 0) { | |
| loadingState.classList.add('hidden'); | |
| errorState.classList.remove('hidden'); | |
| lucide.createIcons(); | |
| return; | |
| } | |
| const latestVideos = videos.slice(0, 10); | |
| // Show featured (latest) video | |
| featuredVideo.innerHTML = createFeaturedCard(latestVideos[0]); | |
| featuredVideo.classList.remove('hidden'); | |
| // Show rest in grid | |
| if (latestVideos.length > 1) { | |
| videoGridRest.innerHTML = latestVideos.slice(1).map((v, i) => createVideoCard(v, i + 1)).join(''); | |
| videoGridRest.classList.remove('hidden'); | |
| } | |
| // Update track count | |
| document.getElementById('trackCount').textContent = `${videos.length}+`; | |
| // Hide loading, show content | |
| loadingState.classList.add('hidden'); | |
| // Re-initialize lucide icons for dynamic content | |
| lucide.createIcons(); | |
| } | |
| // Start loading videos | |
| loadVideos(); | |
| // ===== Particle Background ===== | |
| const particlesCanvas = document.getElementById('particlesCanvas'); | |
| const pCtx = particlesCanvas.getContext('2d'); | |
| let particles = []; | |
| function resizeParticles() { | |
| particlesCanvas.width = window.innerWidth; | |
| particlesCanvas.height = window.innerHeight; | |
| } | |
| class Particle { | |
| constructor() { | |
| this.reset(); | |
| } | |
| reset() { | |
| this.x = Math.random() * particlesCanvas.width; | |
| this.y = Math.random() * particlesCanvas.height; | |
| this.size = Math.random() * 2 + 0.5; | |
| this.speedX = (Math.random() - 0.5) * 0.3; | |
| this.speedY = (Math.random() - 0.5) * 0.3; | |
| this.opacity = Math.random() * 0.5 + 0.1; | |
| this.color = Math.random() > 0.5 ? '0, 240, 255' : '168, 85, 247'; | |
| } | |
| update() { | |
| this.x += this.speedX; | |
| this.y += this.speedY; | |
| if (this.x < 0 || this.x > particlesCanvas.width || this.y < 0 || this.y > particlesCanvas.height) { | |
| this.reset(); | |
| } | |
| } | |
| draw() { | |
| pCtx.beginPath(); | |
| pCtx.arc(this.x, this.y, this.size, 0, Math.PI * 2); | |
| pCtx.fillStyle = `rgba(${this.color}, ${this.opacity})`; | |
| pCtx.fill(); | |
| } | |
| } | |
| function initParticles() { | |
| resizeParticles(); | |
| const count = Math.min(Math.floor((particlesCanvas.width * particlesCanvas.height) / 15000), 80); | |
| particles = []; | |
| for (let i = 0; i < count; i++) { | |
| particles.push(new Particle()); | |
| } | |
| } | |
| function animateParticles() { | |
| pCtx.clearRect(0, 0, particlesCanvas.width, particlesCanvas.height); | |
| particles.forEach(p => { | |
| p.update(); | |
| p.draw(); | |
| }); | |
| // Draw connections | |
| for (let i = 0; i < particles.length; i++) { | |
| for (let j = i + 1; j < particles.length; j++) { | |
| const dx = particles[i].x - particles[j].x; | |
| const dy = particles[i].y - particles[j].y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist < 120) { | |
| pCtx.beginPath(); | |
| pCtx.moveTo(particles[i].x, particles[i].y); | |
| pCtx.lineTo(particles[j].x, particles[j].y); | |
| pCtx.strokeStyle = `rgba(0, 240, 255, ${0.05 * (1 - dist / 120)})`; | |
| pCtx.lineWidth = 0.5; | |
| pCtx.stroke(); | |
| } | |
| } | |
| } | |
| requestAnimationFrame(animateParticles); | |
| } | |
| window.addEventListener('resize', () => { | |
| resizeParticles(); | |
| initParticles(); | |
| }); | |
| initParticles(); | |
| animateParticles(); | |
| // ===== Audio Visualizer ===== | |
| const vizCanvas = document.getElementById('visualizerCanvas'); | |
| const vCtx = vizCanvas.getContext('2d'); | |
| let mouseX = 0; | |
| let mouseY = 0; | |
| let time = 0; | |
| function resizeVisualizer() { | |
| const rect = vizCanvas.getBoundingClientRect(); | |
| vizCanvas.width = rect.width * window.devicePixelRatio; | |
| vizCanvas.height = rect.height * window.devicePixelRatio; | |
| vCtx.scale(window.devicePixelRatio, window.devicePixelRatio); | |
| } | |
| vizCanvas.addEventListener('mousemove', (e) => { | |
| const rect = vizCanvas.getBoundingClientRect(); | |
| mouseX = e.clientX - rect.left; | |
| mouseY = e.clientY - rect.top; | |
| }); | |
| vizCanvas.addEventListener('touchmove', (e) => { | |
| const rect = vizCanvas.getBoundingClientRect(); | |
| mouseX = e.touches[0].clientX - rect.left; | |
| mouseY = e.touches[0].clientY - rect.top; | |
| }); | |
| function drawVisualizer() { | |
| const w = vizCanvas.width / window.devicePixelRatio; | |
| const h = vizCanvas.height / window.devicePixelRatio; | |
| vCtx.clearRect(0, 0, w, h); | |
| const centerY = h / 2; | |
| const barCount = 80; | |
| const barWidth = w / barCount; | |
| const maxBarHeight = h * 0.8; | |
| for (let i = 0; i < barCount; i++) { | |
| const x = i * barWidth; | |
| const normalizedX = i / barCount; | |
| // Create wave pattern influenced by mouse | |
| const distFromMouse = Math.abs(normalizedX - (mouseX / w)); | |
| const mouseInfluence = Math.max(0, 1 - distFromMouse * 3); | |
| const wave1 = Math.sin(normalizedX * Math.PI * 4 + time * 0.03) * 0.3; | |
| const wave2 = Math.sin(normalizedX * Math.PI * 8 + time * 0.05) * 0.15; | |
| const wave3 = Math.cos(normalizedX * Math.PI * 2 + time * 0.02) * 0.2; | |
| const mouseWave = mouseInfluence * 0.4; | |
| const barHeight = Math.abs(wave1 + wave2 + wave3 + mouseWave) * maxBarHeight; | |
| const finalHeight = Math.max(2, barHeight); | |
| // Gradient for each bar | |
| const gradient = vCtx.createLinearGradient(x, centerY - finalHeight / 2, x, centerY + finalHeight / 2); | |
| gradient.addColorStop(0, `rgba(0, 240, 255, ${0.6 + mouseInfluence * 0.4})`); | |
| gradient.addColorStop(0.5, `rgba(168, 85, 247, ${0.4 + mouseInfluence * 0.4})`); | |
| gradient.addColorStop(1, `rgba(236, 72, 153, ${0.3 + mouseInfluence * 0.3})`); | |
| // Top bar (mirror) | |
| vCtx.fillStyle = gradient; | |
| vCtx.fillRect(x + 1, centerY - finalHeight / 2, barWidth - 2, finalHeight / 2); | |
| // Bottom bar (mirror reflection) | |
| const reflectionGradient = vCtx.createLinearGradient(x, centerY, x, centerY + finalHeight / 2); | |
| reflectionGradient.addColorStop(0, `rgba(0, 240, 255, ${0.2 + mouseInfluence * 0.2})`); | |
| reflectionGradient.addColorStop(1, 'rgba(0, 240, 255, 0)'); | |
| vCtx.fillStyle = reflectionGradient; | |
| vCtx.fillRect(x + 1, centerY, barWidth - 2, finalHeight / 2); | |
| // Glow dot on top | |
| if (mouseInfluence > 0.3) { | |
| vCtx.beginPath(); | |
| vCtx.arc(x + barWidth / 2, centerY - finalHeight / 2, 2 + mouseInfluence * 3, 0, Math.PI * 2); | |
| vCtx.fillStyle = `rgba(0, 240, 255, ${mouseInfluence * 0.8})`; | |
| vCtx.fill(); | |
| } | |
| } | |
| // Center line | |
| vCtx.beginPath(); | |
| vCtx.moveTo(0, centerY); | |
| vCtx.lineTo(w, centerY); | |
| vCtx.strokeStyle = 'rgba(0, 240, 255, 0.1)'; | |
| vCtx.lineWidth = 1; | |
| vCtx.stroke(); | |
| time++; | |
| requestAnimationFrame(drawVisualizer); | |
| } | |
| window.addEventListener('resize', resizeVisualizer); | |
| resizeVisualizer(); | |
| drawVisualizer(); | |
| // ===== Smooth Scroll for Anchor Links ===== | |
| document.querySelectorAll('a[href^="#"]').forEach(anchor => { | |
| anchor.addEventListener('click', function (e) { | |
| e.preventDefault(); | |
| const target = document.querySelector(this.getAttribute('href')); | |
| if (target) { | |
| target.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| }); | |
| }); |