// Shared Aero JS: bubbles, sparkle cursor, music toggle, drag windows, 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';
s.style.left = e.clientX + 'px';
s.style.top = e.clientY + 'px';
const hue = 30 + Math.random() * 200;
s.style.background = `radial-gradient(circle, oklch(95% 0.12 ${hue}) 0%, oklch(85% 0.18 ${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 r = el.getBoundingClientRect();
const p = el.offsetParent.getBoundingClientRect();
ox = r.left - p.left; oy = r.top - p.top;
el.style.zIndex = (++window.__zTop || (window.__zTop = 100));
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
el.style.left = (ox + e.clientX - sx) + 'px';
el.style.top = (oy + e.clientY - sy) + 'px';
});
window.addEventListener('mouseup', () => { dragging = false; });
}
function counterHTML(val = 42137, label = "visitors") {
const digits = String(val).padStart(7, '0').split('').map(d => `
${d}
`).join('');
return ``;
}
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
`;
}
function bindMusicToggle(root) {
const btn = root.querySelector('.mt-btn');
const lbl = root.querySelector('.mt-label');
if (!btn) return;
let on = false;
btn.addEventListener('click', () => {
on = !on;
btn.textContent = on ? '❚❚' : '▶';
lbl.textContent = on ? '♪ kero kero bonito — flamingo' : 'music off';
btn.style.background = on
? 'radial-gradient(circle at 30% 25%,white,oklch(75% 0.14 55) 60%,oklch(55% 0.15 35))'
: 'radial-gradient(circle at 30% 25%,white,oklch(82% 0.12 220) 60%,oklch(50% 0.13 240))';
});
}
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 url = `https://itunes.apple.com/lookup?id=1709836497&entity=podcastEpisode&limit=${limit + 1}`;
const r = await fetch(url);
if (!r.ok) throw new Error('itunes ' + r.status);
const j = await r.json();
const podcast = j.results.find(x => x.kind === 'podcast');
const episodes = j.results.filter(x => x.kind === 'podcast-episode').slice(0, limit);
return {
art: podcast ? podcast.artworkUrl600 : null,
episodes: episodes.map(e => {
const ms = e.trackTimeMillis || 0;
const mins = Math.floor(ms / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return {
title: e.trackName,
url: e.trackViewUrl,
duration: h ? `${h}:${m.toString().padStart(2, '0')}` : `${m}m`,
};
}),
};
}
window.Aero = { spawnBubbles, makeClouds, sparkleCursor, makeDraggable, counterHTML, nowPlayingHTML, animateEq, musicToggleHTML, bindMusicToggle, fetchLastFm, fetchFilms, fetchVisitorCount, fetchReelMouthFeed };