// Shared Aero JS: bubbles, sparkle cursor, music toggle, drag windows, theme switcher, helpers. function spawnBubbles(host, count = 22) { const f = document.createElement('div'); f.className = 'bubble-field'; for (let i = 0; i < count; i++) { const b = document.createElement('div'); b.className = 'bubble'; const size = 18 + Math.random() * 90; b.style.width = b.style.height = size + 'px'; b.style.left = Math.random() * 100 + '%'; b.style.setProperty('--drift', ((Math.random() - 0.5) * 120) + 'px'); b.style.animationDuration = (14 + Math.random() * 16) + 's'; b.style.animationDelay = (-Math.random() * 20) + 's'; f.appendChild(b); } host.appendChild(f); } function makeClouds(host) { const svg = ` `; const w = document.createElement('div'); w.className = 'clouds'; w.innerHTML = svg; host.appendChild(w); } function sparkleCursor() { let last = 0; window.addEventListener('mousemove', (e) => { const now = performance.now(); if (now - last < 40) return; last = now; const s = document.createElement('div'); s.className = 'sparkle'; const z = window.__uiScale || 1; s.style.left = (e.clientX / z) + 'px'; s.style.top = (e.clientY / z) + 'px'; const theme = document.body.dataset.theme; // chrome: iridescent / holographic palette (cyan→magenta→pink) // aero: warm-cool rainbow as before const hue = theme === 'chrome' ? 200 + Math.random() * 160 : 30 + Math.random() * 200; const chroma = theme === 'chrome' ? 0.22 : 0.18; s.style.background = `radial-gradient(circle, oklch(96% 0.10 ${hue}) 0%, oklch(82% ${chroma} ${hue} / 0.6) 40%, transparent 70%)`; document.body.appendChild(s); setTimeout(() => s.remove(), 900); }); } function makeDraggable(el, handle) { let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false; (handle || el).addEventListener('mousedown', (e) => { if (e.target.closest('.no-drag')) return; dragging = true; sx = e.clientX; sy = e.clientY; const z = window.__uiScale || 1; const r = el.getBoundingClientRect(); const p = el.offsetParent.getBoundingClientRect(); ox = (r.left - p.left) / z; oy = (r.top - p.top) / z; el.style.zIndex = (++window.__zTop || (window.__zTop = 100)); e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!dragging) return; const z = window.__uiScale || 1; el.style.left = (ox + (e.clientX - sx) / z) + 'px'; el.style.top = (oy + (e.clientY - sy) / z) + 'px'; }); window.addEventListener('mouseup', () => { dragging = false; }); } function makeResizable(el, options = {}) { const minW = options.minW || 280; const minH = options.minH || 180; const handles = ['e', 's', 'se']; handles.forEach(mode => { const h = document.createElement('div'); h.className = 'rs rs-' + mode + ' no-drag'; el.appendChild(h); h.addEventListener('mousedown', (e) => startResize(e, mode)); }); let mode = null, sx = 0, sy = 0, sw = 0, sh = 0; function startResize(e, m) { e.preventDefault(); e.stopPropagation(); mode = m; sx = e.clientX; sy = e.clientY; const z = window.__uiScale || 1; const r = el.getBoundingClientRect(); sw = r.width / z; sh = r.height / z; // Lock the baseline the first time this window is resized — content // scale is measured against this so text grows/shrinks with the window. if (el.__baseW == null) { el.__baseW = sw; el.__baseH = sh; } // pin current size as inline so first move doesn't snap el.style.width = sw + 'px'; el.style.height = sh + 'px'; el.classList.add('resized'); el.style.zIndex = (++window.__zTop || (window.__zTop = 100)); document.body.classList.add('rs-cursor-' + m); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); } function applyContentScale(w, h) { const cs = Math.min(w / el.__baseW, h / el.__baseH); el.querySelectorAll(':scope > .body, :scope > .browser, :scope > .titlebar') .forEach(n => { n.style.zoom = cs; }); } function onMove(e) { if (!mode) return; const z = window.__uiScale || 1; const dx = (e.clientX - sx) / z; const dy = (e.clientY - sy) / z; const w = mode.includes('e') ? Math.max(minW, sw + dx) : parseFloat(el.style.width) || sw; const h = mode.includes('s') ? Math.max(minH, sh + dy) : parseFloat(el.style.height) || sh; if (mode.includes('e')) el.style.width = w + 'px'; if (mode.includes('s')) el.style.height = h + 'px'; applyContentScale(w, h); } function onUp() { mode = null; document.body.classList.remove('rs-cursor-e', 'rs-cursor-s', 'rs-cursor-se'); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); } } function counterHTML(val = 42137, label = "visitors") { const digits = String(val).padStart(7, '0').split('').map(d => `
${d}
`).join(''); return `
${digits}
${label}
`; } function nowPlayingHTML(compact, track) { const sz = compact ? 48 : 72; const hasTrack = track && track.name; const title = hasTrack ? track.name : 'Naima'; const artist = hasTrack ? track.artist : 'John Coltrane'; const album = hasTrack ? (track.album || '') : 'Giant Steps'; let header = '♪ now playing · last.fm'; if (hasTrack && !track.nowplaying) { const now = Date.now(); const ago = Math.floor((now - track.when) / 1000); const mins = Math.floor(ago / 60); header = `♪ last played ${mins}m ago`; } let bgStyle = `linear-gradient(135deg,oklch(75% 0.16 55),oklch(60% 0.18 30) 50%,oklch(45% 0.12 280))`; if (hasTrack && track.art) { const safeArt = track.art.replace(/'/g, "%27").replace(/"/g, "%22"); bgStyle = `url('${safeArt}') center / cover`; } return `
${header}
${title}
${artist}${album ? ' — ' + album : ''}
${[0,1,2,3,4].map(i => `
`).join('')}
`; } function animateEq(root) { setInterval(() => { root.querySelectorAll('.eq div').forEach(d => { d.style.height = (3 + Math.random() * 10) + 'px'; }); }, 280); } function musicToggleHTML() { return `
music off
`; } // THEME SWITCHER — Aero (Frutiger) ↔ Chrome (Y2K) const THEME_KEY = 'tyler.theme'; const THEMES = ['aero', 'chrome']; function getTheme() { const t = localStorage.getItem(THEME_KEY); return THEMES.includes(t) ? t : 'aero'; } function setTheme(t) { if (!THEMES.includes(t)) t = 'aero'; document.body.setAttribute('data-theme', t); localStorage.setItem(THEME_KEY, t); document.querySelectorAll('.theme-switcher button').forEach(b => { b.classList.toggle('active', b.dataset.theme === t); }); try { window.dispatchEvent(new CustomEvent('themechange', { detail: t })); } catch(_){} } function mountThemeSwitcher(host) { host = host || document.body; const wrap = document.createElement('div'); wrap.className = 'theme-switcher no-drag'; wrap.innerHTML = THEMES.map(t => ` `).join(''); host.appendChild(wrap); wrap.querySelectorAll('button').forEach(b => { b.addEventListener('click', () => setTheme(b.dataset.theme)); }); setTheme(getTheme()); return wrap; } function initTheme() { document.body.setAttribute('data-theme', getTheme()); } // REAL API INTEGRATIONS async function fetchLastFm(user = 'trollshotlol', key = 'e4d5c973811037717f7603f616259cdf', limit = 4) { const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${user}&api_key=${key}&format=json&limit=${limit}`; const r = await fetch(url); if (!r.ok) throw new Error('lastfm ' + r.status); const j = await r.json(); const tracks = (j.recenttracks && j.recenttracks.track) || []; return tracks.map(t => ({ name: t.name, artist: t.artist && (t.artist['#text'] || t.artist.name), album: t.album && t.album['#text'], art: (t.image && t.image[t.image.length - 1] && t.image[t.image.length - 1]['#text']) || null, nowplaying: t['@attr'] && t['@attr'].nowplaying === 'true', when: t.date && t.date.uts ? Number(t.date.uts) * 1000 : null, })); } async function fetchFilms() { const r = await fetch('https://films.tylerhoang.xyz/tyler/api/recent'); if (!r.ok) throw new Error('films ' + r.status); return await r.json(); } async function fetchVisitorCount() { const r = await fetch('/counter.php'); if (!r.ok) throw new Error('counter ' + r.status); const j = await r.json(); return j.count; } async function fetchReelMouthFeed(limit = 6) { const r = await fetch(`/podcast.php?limit=${limit}`); if (!r.ok) throw new Error('podcast.php ' + r.status); return await r.json(); } window.Aero = { spawnBubbles, makeClouds, sparkleCursor, makeDraggable, makeResizable, counterHTML, nowPlayingHTML, animateEq, musicToggleHTML, // theme api getTheme, setTheme, mountThemeSwitcher, initTheme, THEMES, // real api integrations fetchLastFm, fetchFilms, fetchVisitorCount, fetchReelMouthFeed, };