Files
pivoine.art/assets/js/main.js
2025-11-29 17:51:00 +01:00

258 lines
6.5 KiB
JavaScript

/**
* Pivoine.art - Main JavaScript
* Audio blog with WebGL visualizers
*/
import { Visualizer } from './visualizer/scene.js';
import { ReactiveLogo } from './logo/reactive-logo.js';
// Audio Manager - Web Audio API wrapper
class AudioManager {
constructor() {
this.audio = document.createElement('audio');
this.audio.crossOrigin = 'anonymous';
this.audioContext = null;
this.analyser = null;
this.source = null;
this.frequencyData = null;
this.isInitialized = false;
}
async init() {
if (this.isInitialized) return;
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
this.analyser.smoothingTimeConstant = 0.8;
this.source = this.audioContext.createMediaElementSource(this.audio);
this.source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
this.isInitialized = true;
// Update Alpine store on audio events
this.audio.addEventListener('timeupdate', () => {
if (window.Alpine) {
Alpine.store('audio').progress = this.audio.currentTime;
Alpine.store('audio').duration = this.audio.duration || 0;
}
});
this.audio.addEventListener('ended', () => {
if (window.Alpine) {
Alpine.store('audio').isPlaying = false;
}
});
this.audio.addEventListener('play', () => {
if (window.Alpine) {
Alpine.store('audio').isPlaying = true;
}
});
this.audio.addEventListener('pause', () => {
if (window.Alpine) {
Alpine.store('audio').isPlaying = false;
}
});
} catch (e) {
console.error('Failed to initialize audio context:', e);
}
}
async play(url) {
await this.init();
if (this.audioContext?.state === 'suspended') {
await this.audioContext.resume();
}
if (url && url !== this.audio.src) {
this.audio.src = url;
}
await this.audio.play();
}
pause() {
this.audio.pause();
}
toggle() {
if (this.audio.paused) {
this.audio.play();
} else {
this.audio.pause();
}
}
seek(time) {
this.audio.currentTime = time;
}
setVolume(v) {
this.audio.volume = Math.max(0, Math.min(1, v));
}
getFrequencyData() {
if (this.analyser) {
this.analyser.getByteFrequencyData(this.frequencyData);
}
return this.frequencyData;
}
getFrequencyBands() {
const data = this.getFrequencyData();
if (!data) return { low: 0, mid: 0, high: 0 };
const len = data.length;
const low = this._avg(data, 0, len * 0.1) / 255;
const mid = this._avg(data, len * 0.1, len * 0.5) / 255;
const high = this._avg(data, len * 0.5, len) / 255;
return { low, mid, high };
}
_avg(data, start, end) {
let sum = 0;
for (let i = Math.floor(start); i < Math.floor(end); i++) {
sum += data[i];
}
return sum / (end - start);
}
}
// Initialize global instances
if (!window.__pivoine) {
const audioManager = new AudioManager();
window.__pivoine = {
audioManager,
visualizer: null,
logo: null
};
// Initialize WebGL components after DOM is ready
const initWebGL = () => {
// Main visualizer (fullscreen background)
const visualizerCanvas = document.getElementById('visualizer');
if (visualizerCanvas && !window.__pivoine.visualizer) {
window.__pivoine.visualizer = new Visualizer(visualizerCanvas, audioManager);
}
// Logo in header
const logoCanvas = document.getElementById('logo-canvas');
if (logoCanvas && !window.__pivoine.logo) {
window.__pivoine.logo = new ReactiveLogo(logoCanvas, audioManager);
}
};
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWebGL);
} else {
initWebGL();
}
}
// Alpine.js initialization
document.addEventListener('alpine:init', () => {
// Global audio store
Alpine.store('audio', {
currentTrack: null,
isPlaying: false,
progress: 0,
duration: 0,
volume: 0.8
});
// Main app component
Alpine.data('app', () => ({
init() {
// Restore volume from localStorage
const savedVolume = localStorage.getItem('pivoine-volume');
if (savedVolume) {
Alpine.store('audio').volume = parseFloat(savedVolume);
window.__pivoine.audioManager.setVolume(parseFloat(savedVolume));
}
}
}));
// Player UI component
Alpine.data('playerUI', () => ({
togglePlay() {
window.__pivoine.audioManager.toggle();
},
seek(time) {
window.__pivoine.audioManager.seek(parseFloat(time));
},
setVolume(v) {
const volume = parseFloat(v);
Alpine.store('audio').volume = volume;
window.__pivoine.audioManager.setVolume(volume);
localStorage.setItem('pivoine-volume', volume);
},
toggleMute() {
const store = Alpine.store('audio');
if (store.volume > 0) {
this._previousVolume = store.volume;
this.setVolume(0);
} else {
this.setVolume(this._previousVolume || 0.8);
}
},
formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
},
_previousVolume: 0.8
}));
});
// htmx lifecycle hooks
document.body.addEventListener('htmx:beforeSwap', () => {
// Pause visualizer during page transition for performance
window.__pivoine?.visualizer?.pause();
});
document.body.addEventListener('htmx:afterSwap', () => {
// Resume visualizer after swap
window.__pivoine?.visualizer?.resume();
// Re-initialize scroll animations
window.dispatchEvent(new CustomEvent('page:loaded'));
});
// Scroll animations with Intersection Observer
function initScrollAnimations() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
document.querySelectorAll('.fade-in-up').forEach((el) => {
observer.observe(el);
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', initScrollAnimations);
window.addEventListener('page:loaded', initScrollAnimations);
export { AudioManager };