Initial commit
This commit is contained in:
257
assets/js/main.js
Normal file
257
assets/js/main.js
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user