258 lines
6.5 KiB
JavaScript
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 };
|