1// script.js 2document.addEventListener("DOMContentLoaded", () => { 3 /** @type {HTMLElement | null} */ 4 const header = document.querySelector(".header"); 5 /** @type {HTMLElement | null} */ 6 const typingText = document.getElementById("typing-text"); 7 8 // --- Typing effect --- 9 /** 10 * Immutable list of phrases cycled by the typewriter effect. 11 * @constant {readonly string[]} 12 */ 13 const phrases = Object.freeze([ 14 "Free. Open Source. Built for music lovers.", 15 "No ads. No subscription. No limits.", 16 "Smart autoplay. Clean UI. Instant play." 17 ]); 18 19 /** @type {number} Current phrase index (0..phrases.length-1) */ 20 let phraseIndex = 0; 21 /** @type {number} Current character index within the active phrase */ 22 let charIndex = 0; 23 /** @type {boolean} Whether the loop is currently in the deleting state */ 24 let deleting = false; 25 26 /** 27 * Configuration object for {@link typeLoop}. 28 * All properties are optional; sensible defaults are applied when omitted. 29 * 30 * @typedef {Object} TypeLoopOptions 31 * @property {number} [typingDelay=55] Delay between typing characters, in milliseconds. 32 * @property {number} [deletingDelay=35] Delay between deleting characters, in milliseconds. 33 * @property {number} [pauseDelay=1200] Pause after a phrase is fully typed, before deletion begins, in milliseconds. 34 */ 35 36 /** 37 * Drives the typing/deleting animation loop for the hero text. 38 * 39 * Cycles through {@link phrases} with a typewriter effect, alternating between 40 * typing and deleting states with configurable delays. The function schedules 41 * itself recursively via `setTimeout`, so it must be invoked once to start 42 * and will continue indefinitely until the page unloads. 43 * 44 * @param {TypeLoopOptions} [options={}] - Animation timing options. 45 * @returns {void} 46 * @throws {Error} If the `#typing-text` element is not found in the DOM or if phrases array is empty. 47 * @example 48 * // Default usage 49 * typeLoop(); 50 * 51 * // Custom timing 52 * typeLoop({ typingDelay: 60, deletingDelay: 40, pauseDelay: 1500 }); 53 */ 54 function typeLoop(options = /** @type {TypeLoopOptions} */ ({})) { 55 const { 56 typingDelay = 55, 57 deletingDelay = 35, 58 pauseDelay = 1200 59 } = /** @type {TypeLoopOptions} */ (options); 60 61 if (!typingText) { 62 throw new Error("typingText element not found"); 63 } 64 65 if (phrases.length === 0) { 66 throw new Error("phrases array cannot be empty"); 67 } 68 69 const current = phrases[phraseIndex]; 70 if (phraseIndex < 0 || phraseIndex >= phrases.length) { 71 phraseIndex = 0; 72 } 73 74 if (charIndex < 0 || charIndex > current.length) { 75 charIndex = deleting ? current.length : 0; 76 } 77 78 if (!deleting) { 79 charIndex++; 80 typingText.textContent = current.slice(0, charIndex); 81 82 if (charIndex === current.length) { 83 deleting = true; 84 setTimeout(typeLoop, pauseDelay, options); 85 return; 86 } 87 } else { 88 charIndex--; 89 typingText.textContent = current.slice(0, charIndex); 90 91 if (charIndex === 0) { 92 deleting = false; 93 phraseIndex = (phraseIndex + 1) % phrases.length; 94 } 95 } 96 97 setTimeout(typeLoop, deleting ? deletingDelay : typingDelay, options); 98 } 99 100 // Start the typing animation with default options. 101 typeLoop(); 102 103 // --- Smooth anchor scroll --- 104 document.querySelectorAll('a[href^="#"]').forEach(anchor => { 105 anchor.addEventListener("click", function (e) { 106 e.preventDefault(); 107 /** @type {HTMLElement | null} */ 108 const target = document.querySelector(this.getAttribute("href")); 109 if (target) target.scrollIntoView({ behavior: "smooth", block: "start" }); 110 }); 111 }); 112 113 // --- Scroll reveal --- 114 /** @type {NodeListOf<Element>} */ 115 const revealItems = document.querySelectorAll("section, .card, .contact-card, .screenshots img, .video-box, .hero-logo"); 116 117 revealItems.forEach((el) => el.classList.add("reveal")); 118 119 /** @type {IntersectionObserver} */ 120 const observer = new IntersectionObserver((entries) => { 121 entries.forEach((entry) => { 122 if (entry.isIntersecting) { 123 entry.target.classList.add("show"); 124 } 125 }); 126 }, { threshold: 0.15 }); 127
128 revealItems.forEach((el) => observer.observe(el)); 129 130 // --- Sticky glass nav on scroll --- 131 window.addEventListener("scroll", () => { 132 if (window.scrollY > 20) { 133 header?.classList.add("glass-nav"); 134 } else { 135 header?.classList.remove("glass-nav"); 136 } 137 }); 138 139 /** 140 * Creates and animates a single floating particle element with randomized 141 * visual properties, then removes it from the DOM once its animation 142 * completes. 143 * 144 * Particle characteristics: 145 * - Size: random between 3â8 px (square) 146 * - Horizontal position: random across the viewport width 147 * - Vertical start: 20 px below the viewport bottom 148 * - Animation duration: random between 6â11 s 149 * - Cleanup: removed from the DOM after `duration * 1000` ms 150 * 151 * @returns {void} 152 * @example 153 * // Spawn a single particle 154 * spawnParticle(); 155 * 156 * // Spawn particles continuously 157 * setInterval(spawnParticle, 300); 158 */ 159 function spawnParticle() { 160 /** @type {HTMLDivElement} */ 161 const particle = document.createElement("div"); 162 particle.className = "particle"; 163 164 /** @type {number} Random size between 3â8 px */ 165 const size = Math.random() * 5 + 3; 166 /** @type {number} Random horizontal position in px */ 167 const left = Math.random() * window.innerWidth; 168 /** @type {number} Random animation duration between 6â11 s */ 169 const duration = Math.random() * 5 + 6; 170 171 particle.style.width = `${size}px`; 172 particle.style.height = `${size}px`; 173 particle.style.left = `${left}px`; 174 particle.style.bottom = `-20px`; 175 particle.style.animationDuration = `${duration}s`; 176 177 document.body.appendChild(particle); 178 179 setTimeout(() => { 180 particle.remove(); 181 }, duration * 1000); 182 } 183 184 // Continuously spawn particles. 185 setInterval(spawnParticle, 300); 186 187 // --- Magnetic hover for buttons and cards --- 188 /** @type {NodeListOf<Element>} */ 189 const hoverTargets = document.querySelectorAll(".btn, .card, .contact-card"); 190 191 hoverTargets.forEach((el) => { 192 el.addEventListener("mousemove", (e) => { 193 const rect = el.getBoundingClientRect(); 194 const x = e.clientX - rect.left - rect.width / 2; 195 const y = e.clientY - rect.top - rect.height / 2; 196 197 el.style.transform = `translate(${x * 0.04}px, ${y * 0.04}px) scale(1.02)`; 198 }); 199 200 el.addEventListener("mouseleave", () => { 201 el.style.transform = ""; 202 }); 203 }); 204 205 // --- Mouse glow --- 206 /** @type {HTMLDivElement} */ 207 const glow = document.createElement("div"); 208 glow.style.position = "fixed"; 209 glow.style.width = "320px"; 210 glow.style.height = "320px"; 211 glow.style.borderRadius = "50%"; 212 glow.style.pointerEvents = "none"; 213 glow.style.zIndex = "0"; 214 glow.style.background = "radial-gradient(circle, rgba(255,122,24,0.12) 0%, rgba(255,122,24,0.04) 35%, transparent 70%)"; 215 glow.style.transform = "translate(-50%, -50%)"; 216 glow.style.filter = "blur(10px)"; 217 document.body.appendChild(glow); 218 219 window.addEventListener("mousemove", (e) => { 220 glow.style.left = `${e.clientX}px`; 221 glow.style.top = `${e.clientY}px`; 222 }); 223});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.