diff --git a/assets/styles.css b/assets/styles.css index 72ad5b6..5e8e848 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -167,6 +167,7 @@ body { pointer-events: none; animation: flicker 4s infinite; z-index: 3; + will-change: opacity; /* Performance: GPU-accelerated opacity animation */ } @keyframes flicker { @@ -277,11 +278,13 @@ body { .vhs-tracking.active.vertical { opacity: 1; animation: vhsScrollVertical 0.4s linear; + will-change: transform, opacity; /* Performance: GPU-accelerated animation */ } .vhs-tracking.active.horizontal { opacity: 1; animation: vhsScrollHorizontal 0.4s linear; + will-change: transform, opacity; /* Performance: GPU-accelerated animation */ } @keyframes vhsScrollVertical { @@ -304,6 +307,7 @@ body { pointer-events: none; z-index: 20; overflow: hidden; + contain: strict; /* Performance: isolates paint scope for animated children */ } .dim-spot { @@ -340,24 +344,6 @@ body { animation: dimSpotDrift3 50s ease-in-out infinite; } -.dim-spot-4 { - bottom: 0%; - right: 0%; - width: 45%; - height: 55%; - background: radial-gradient(ellipse 80% 90% at center, rgba(0, 0, 0, 0.5) 0%, transparent 65%); - animation: dimSpotDrift4 60s ease-in-out infinite; -} - -.dim-spot-5 { - top: 35%; - left: 55%; - width: 30%; - height: 35%; - background: radial-gradient(circle at center, rgba(0, 0, 0, 0.35) 0%, transparent 60%); - animation: dimSpotDrift5 40s ease-in-out infinite; -} - /* Slow drifting animations - each spot moves in a different pattern */ @keyframes dimSpotDrift1 { 0%, 100% { transform: translate(0%, 0%); } @@ -380,20 +366,6 @@ body { 75% { transform: translate(-4%, -10%); } } -@keyframes dimSpotDrift4 { - 0%, 100% { transform: translate(0%, 0%); } - 25% { transform: translate(-8%, -6%); } - 50% { transform: translate(-12%, 4%); } - 75% { transform: translate(4%, -8%); } -} - -@keyframes dimSpotDrift5 { - 0%, 100% { transform: translate(0%, 0%); } - 25% { transform: translate(-15%, 10%); } - 50% { transform: translate(10%, 15%); } - 75% { transform: translate(15%, -10%); } -} - /* ======================================== DISPLAY GLITCH EFFECTS - END ======================================== */ diff --git a/index.html b/index.html index e2d447f..6b4796a 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,8 @@ + +
@@ -18,13 +20,11 @@
- +
-
-
diff --git a/src/app.js b/src/app.js index 59a081b..0a92226 100644 --- a/src/app.js +++ b/src/app.js @@ -63,41 +63,56 @@ let durationsLoaded = false; // Flag indicating when all durations are known /** * Load metadata for all tracks to get their durations - * Uses a temporary audio element to fetch duration without full download + * Uses parallel loading for better performance * Falls back to 240 seconds (4 min) if metadata can't be loaded */ async function loadAllDurations() { console.log('Loading track durations...'); - trackDurations = []; + trackDurations = new Array(playlist.length); - for (let i = 0; i < playlist.length; i++) { - try { - // For track 0, use the main audio element since loadTrack(0) already loads it - if (i === 0 && currentTrack === 0) { - // Wait for main audio to load metadata if not already - if (audio.duration && !isNaN(audio.duration)) { - trackDurations[i] = audio.duration; - } else { - // Wait for main audio metadata - trackDurations[i] = await new Promise((resolve) => { - if (audio.duration && !isNaN(audio.duration)) { - resolve(audio.duration); - } else { - audio.addEventListener('loadedmetadata', function onMeta() { - audio.removeEventListener('loadedmetadata', onMeta); - resolve(audio.duration); - }); - } - }); - } + // Load track 0 from main audio element first + try { + if (currentTrack === 0) { + if (audio.duration && !isNaN(audio.duration)) { + trackDurations[0] = audio.duration; } else { - // For other tracks, use temp audio element - trackDurations[i] = await getTrackDuration(playlist[i].url); + trackDurations[0] = await new Promise((resolve) => { + if (audio.duration && !isNaN(audio.duration)) { + resolve(audio.duration); + } else { + audio.addEventListener('loadedmetadata', function onMeta() { + audio.removeEventListener('loadedmetadata', onMeta); + resolve(audio.duration); + }); + } + }); } - console.log(`Track ${i + 1} duration: ${formatTime(trackDurations[i])}`); - } catch (e) { - console.warn(`Failed to get duration for track ${i + 1}, using fallback`); - trackDurations[i] = 240; // 4 minute fallback + } else { + trackDurations[0] = await getTrackDuration(playlist[0].url); + } + console.log(`Track 1 duration: ${formatTime(trackDurations[0])}`); + } catch (e) { + console.warn('Failed to get duration for track 1, using fallback'); + trackDurations[0] = 240; + } + + // Load remaining tracks in parallel for faster loading + if (playlist.length > 1) { + const otherTrackPromises = playlist.slice(1).map(async (track, index) => { + const trackIndex = index + 1; + try { + const duration = await getTrackDuration(track.url); + console.log(`Track ${trackIndex + 1} duration: ${formatTime(duration)}`); + return duration; + } catch (e) { + console.warn(`Failed to get duration for track ${trackIndex + 1}, using fallback`); + return 240; // 4 minute fallback + } + }); + + const otherDurations = await Promise.all(otherTrackPromises); + for (let i = 0; i < otherDurations.length; i++) { + trackDurations[i + 1] = otherDurations[i]; } } @@ -227,14 +242,20 @@ function getTrackStartPosition(trackIndex) { // ======================================== // SOUND EFFECTS MODULE - START // Web Audio API synthesized sounds for tactile feedback +// Pre-generated noise buffers for better performance // ======================================== const SoundEffects = { ctx: null, + // Cached noise buffers - generated once, reused for all sounds + clickNoiseBuffer: null, + tapeWindNoiseBuffer: null, - // Initialize AudioContext (must be called after user gesture) + // Initialize AudioContext and pre-generate noise buffers (must be called after user gesture) init() { if (!this.ctx) { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + // Pre-generate noise buffers on first init + this.generateNoiseBuffers(); } // Resume if suspended (browser autoplay policy) if (this.ctx.state === 'suspended') { @@ -242,6 +263,29 @@ const SoundEffects = { } }, + // Pre-generate all noise buffers once for reuse + generateNoiseBuffers() { + const sampleRate = this.ctx.sampleRate; + + // Generate click noise buffer (20ms) + const clickBufferSize = Math.floor(sampleRate * 0.02); + this.clickNoiseBuffer = this.ctx.createBuffer(1, clickBufferSize, sampleRate); + const clickOutput = this.clickNoiseBuffer.getChannelData(0); + for (let i = 0; i < clickBufferSize; i++) { + clickOutput[i] = Math.random() * 2 - 1; + } + + // Generate tape wind noise buffer (1 second with wobble) + const windBufferSize = sampleRate * 1; + this.tapeWindNoiseBuffer = this.ctx.createBuffer(1, windBufferSize, sampleRate); + const windOutput = this.tapeWindNoiseBuffer.getChannelData(0); + for (let i = 0; i < windBufferSize; i++) { + const noise = Math.random() * 2 - 1; + const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2); + windOutput[i] = noise * wobble; + } + }, + // Play a mechanical button click sound playButtonClick() { this.init(); @@ -264,16 +308,9 @@ const SoundEffects = { clickOsc.connect(clickGain); clickGain.connect(this.ctx.destination); - // High frequency "click" transient using noise - const bufferSize = this.ctx.sampleRate * 0.02; // 20ms of noise - const noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); - const output = noiseBuffer.getChannelData(0); - for (let i = 0; i < bufferSize; i++) { - output[i] = Math.random() * 2 - 1; - } - + // High frequency "click" transient using cached noise buffer const noise = this.ctx.createBufferSource(); - noise.buffer = noiseBuffer; + noise.buffer = this.clickNoiseBuffer; // Bandpass filter to shape the noise into a click const filter = this.ctx.createBiquadFilter(); @@ -296,7 +333,7 @@ const SoundEffects = { }, // Start continuous tape wind sound (for reel dragging) - // Uses looping noise buffer for seamless continuous playback + // Uses cached looping noise buffer for seamless continuous playback startTapeWindLoop() { this.init(); @@ -304,22 +341,10 @@ const SoundEffects = { this.stopTapeWindLoop(); const now = this.ctx.currentTime; - const sampleRate = this.ctx.sampleRate; - - // Create 1 second of loopable noise - const bufferSize = sampleRate * 1; - const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate); - const output = noiseBuffer.getChannelData(0); - - // Generate noise with amplitude modulation - for (let i = 0; i < bufferSize; i++) { - const noise = Math.random() * 2 - 1; - const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2); - output[i] = noise * wobble; - } + // Use cached tape wind noise buffer this.tapeWindSource = this.ctx.createBufferSource(); - this.tapeWindSource.buffer = noiseBuffer; + this.tapeWindSource.buffer = this.tapeWindNoiseBuffer; this.tapeWindSource.loop = true; // Bandpass filter for tape character @@ -611,22 +636,49 @@ lightningBtn.addEventListener('click', () => { // ======================================== // TITLE SCROLL ANIMATION (JavaScript controlled bounce) +// Uses requestAnimationFrame for better performance // Moves 1px at a time, bounces at edges, pauses with player // ======================================== let titleScrollPosition = 0; let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right -let titleScrollInterval = null; +let titleScrollRAF = null; +let titleScrollLastTime = 0; const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels) +// Cached dimensions - updated only when track changes or on resize +let cachedContainerWidth = 0; +let cachedTextWidth = 0; +let cachedMaxScroll = 0; +let cachedTextLonger = false; + +/** + * Cache the container and text dimensions for title scroll + * Call this when track changes or window resizes + */ +function cacheTitleDimensions() { + cachedContainerWidth = trackName.offsetWidth; + cachedTextWidth = trackNameInner.offsetWidth; + cachedTextLonger = cachedTextWidth > cachedContainerWidth; + + // Calculate max scroll + if (cachedTextLonger) { + cachedMaxScroll = cachedTextWidth - cachedContainerWidth; + } else { + cachedMaxScroll = cachedContainerWidth - cachedTextWidth; + } +} + function startTitleScroll() { - if (titleScrollInterval) return; // Already scrolling - titleScrollInterval = setInterval(updateTitleScroll, SCROLL_SPEED); + if (titleScrollRAF) return; // Already scrolling + cacheTitleDimensions(); + titleScrollLastTime = performance.now(); + titleScrollRAF = requestAnimationFrame(titleScrollLoop); } function stopTitleScroll() { - if (titleScrollInterval) { - clearInterval(titleScrollInterval); - titleScrollInterval = null; + if (titleScrollRAF) { + cancelAnimationFrame(titleScrollRAF); + titleScrollRAF = null; } } @@ -635,46 +687,52 @@ function resetTitleScroll() { titleScrollPosition = 0; titleScrollDirection = 1; trackNameInner.style.left = '0px'; + cacheTitleDimensions(); +} + +/** + * RAF-based scroll loop - tracks elapsed time to maintain consistent speed + */ +function titleScrollLoop(currentTime) { + const elapsed = currentTime - titleScrollLastTime; + + // Only update position when enough time has passed (maintains same speed as before) + if (elapsed >= SCROLL_SPEED) { + titleScrollLastTime = currentTime - (elapsed % SCROLL_SPEED); + updateTitleScroll(); + } + + titleScrollRAF = requestAnimationFrame(titleScrollLoop); } function updateTitleScroll() { - const containerWidth = trackName.offsetWidth; - const textWidth = trackNameInner.offsetWidth; - - // Calculate max scroll - text scrolls until its right edge hits container's right edge - // If text is shorter than container, scroll until text's left edge hits container's right edge - let maxScroll; - if (textWidth > containerWidth) { - // Text is longer - scroll until right edge of text reaches right edge of container - maxScroll = textWidth - containerWidth; - } else { - // Text is shorter - scroll across the container width but keep text visible - // Text starts at left (0), scrolls right until left edge is at (containerWidth - textWidth) - maxScroll = containerWidth - textWidth; - } - // Move 1px in current direction titleScrollPosition += titleScrollDirection; // Bounce at edges - if (titleScrollPosition >= maxScroll) { - titleScrollPosition = maxScroll; + if (titleScrollPosition >= cachedMaxScroll) { + titleScrollPosition = cachedMaxScroll; titleScrollDirection = -1; // Start moving back } else if (titleScrollPosition <= 0) { titleScrollPosition = 0; titleScrollDirection = 1; // Start moving forward } - // Apply position - // If text is longer: shift text LEFT (negative value) so we see the end - // If text is shorter: shift text RIGHT (positive value) to scroll across display - if (textWidth > containerWidth) { + // Apply position using cached text length comparison + if (cachedTextLonger) { trackNameInner.style.left = -titleScrollPosition + 'px'; } else { trackNameInner.style.left = titleScrollPosition + 'px'; } } +// Recache dimensions on window resize +window.addEventListener('resize', () => { + if (titleScrollRAF) { + cacheTitleDimensions(); + } +}); + // ======================================== // AUDIO LOADING HELPERS - START // Unified helpers for loading tracks and waiting for audio ready @@ -1138,11 +1196,27 @@ function setTapeSizesAtProgress(progress) { resetTapeSizes(); -// Update time display and tape sizes +// Throttle timeupdate for better performance +let lastTimeUpdateTime = 0; +let lastFormattedTime = ''; +const TIME_UPDATE_THROTTLE = 200; // Update at most 5 times per second + +// Update time display and tape sizes (throttled) audio.addEventListener('timeupdate', () => { + const now = performance.now(); + if (now - lastTimeUpdateTime < TIME_UPDATE_THROTTLE) return; + lastTimeUpdateTime = now; + const current = formatTime(audio.currentTime); const duration = formatTime(audio.duration); - timeDisplay.textContent = `${current} / ${duration}`; + const formatted = `${current} / ${duration}`; + + // Skip DOM update if time display hasn't changed + if (formatted !== lastFormattedTime) { + lastFormattedTime = formatted; + timeDisplay.textContent = formatted; + } + updateTapeSizes(); });