Spaces:
Running
Running
| // Initialize Three.js scene and renderer | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| const renderer = new THREE.WebGLRenderer({ | |
| antialias: true, | |
| alpha: true, | |
| powerPreference: "high-performance" | |
| }); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1; | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| // Set initial background color based on theme | |
| const savedTheme = localStorage.getItem('theme') || 'dark'; | |
| document.documentElement.setAttribute('data-theme', savedTheme); | |
| renderer.setClearColor(savedTheme === 'light' ? 0xf5f5f5 : 0x000000, 0.9); | |
| document.body.appendChild(renderer.domElement); | |
| // Add fog to the scene for depth | |
| scene.fog = new THREE.FogExp2(savedTheme === 'light' ? 0xf5f5f5 : 0x000000, 0.02); | |
| // Initialize controls with enhanced settings | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| setupOrbitControls(); | |
| // Initialize other variables | |
| let audioContext; | |
| let analyser; | |
| let audioElement; | |
| let playlist = []; | |
| let currentTrackIndex = 0; | |
| let isPlaying = false; | |
| let visualizationType = 'sphere'; | |
| let visualizers = { | |
| bars: [], | |
| sphere: null, | |
| particles: null | |
| }; | |
| let isShuffleActive = false; | |
| let isRepeatActive = false; | |
| // Add visualization-specific camera positions | |
| const visualizerSettings = { | |
| bars: { | |
| cameraZ: 8, | |
| baseRadius: 2 | |
| }, | |
| sphere: { | |
| cameraZ: 5, | |
| baseRadius: 1 | |
| }, | |
| particles: { | |
| cameraZ: 20, | |
| baseRadius: 8 | |
| } | |
| }; | |
| // Theme button functionality | |
| const themeBtn = document.querySelector('.theme-btn'); | |
| if (themeBtn) { | |
| themeBtn.innerHTML = savedTheme === 'light' ? | |
| '<i class="fas fa-moon"></i>' : | |
| '<i class="fas fa-sun"></i>'; | |
| themeBtn.addEventListener('click', () => { | |
| const currentTheme = document.documentElement.getAttribute('data-theme'); | |
| const newTheme = currentTheme === 'light' ? 'dark' : 'light'; | |
| document.documentElement.setAttribute('data-theme', newTheme); | |
| localStorage.setItem('theme', newTheme); | |
| themeBtn.innerHTML = newTheme === 'light' ? | |
| '<i class="fas fa-moon"></i>' : | |
| '<i class="fas fa-sun"></i>'; | |
| renderer.setClearColor(newTheme === 'light' ? 0xf5f5f5 : 0x000000, 0.9); | |
| }); | |
| } | |
| // Initialize audio context | |
| function initAudio() { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| audioElement = new Audio(); | |
| const source = audioContext.createMediaElementSource(audioElement); | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 2048; | |
| source.connect(analyser); | |
| analyser.connect(audioContext.destination); | |
| } | |
| // Visualization creation functions | |
| function createBarsVisualization() { | |
| const numBars = 180; | |
| const geometry = new THREE.CylinderGeometry(0.05, 0.05, 1, 8); | |
| geometry.translate(0, 0.5, 0); // Move pivot to bottom | |
| // Create custom shader material for bars | |
| const material = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| time: { value: 0 }, | |
| color1: { value: new THREE.Color(0x4CAF50) }, | |
| color2: { value: new THREE.Color(0x2196F3) }, | |
| color3: { value: new THREE.Color(0xFF4081) } | |
| }, | |
| vertexShader: ` | |
| varying vec3 vPosition; | |
| varying vec3 vNormal; | |
| void main() { | |
| vPosition = position; | |
| vNormal = normalize(normalMatrix * normal); | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| uniform float time; | |
| uniform vec3 color1; | |
| uniform vec3 color2; | |
| uniform vec3 color3; | |
| varying vec3 vPosition; | |
| varying vec3 vNormal; | |
| void main() { | |
| float heightFactor = vPosition.y; | |
| // Create dynamic color gradient | |
| vec3 baseColor = mix( | |
| mix(color1, color2, heightFactor), | |
| color3, | |
| sin(time * 0.5) * 0.5 + 0.5 | |
| ); | |
| // Add fresnel effect for edge glow | |
| float fresnel = pow(1.0 - abs(dot(vNormal, vec3(0, 0, 1.0))), 2.0); | |
| vec3 finalColor = mix(baseColor, vec3(1.0), fresnel * 0.5); | |
| gl_FragColor = vec4(finalColor, 0.9); | |
| } | |
| `, | |
| transparent: true, | |
| side: THREE.DoubleSide | |
| }); | |
| const radius = 4; | |
| const angleStep = (Math.PI * 2) / numBars; | |
| for (let i = 0; i < numBars; i++) { | |
| const angle = i * angleStep; | |
| const bar = new THREE.Mesh(geometry, material.clone()); | |
| // Position in a circle | |
| bar.position.x = Math.cos(angle) * radius; | |
| bar.position.z = Math.sin(angle) * radius; | |
| // Rotate to face center | |
| bar.rotation.y = -angle; | |
| // Store initial properties | |
| bar.userData.initialY = bar.position.y; | |
| bar.userData.initialScale = 1; | |
| bar.userData.angle = angle; | |
| bar.userData.index = i; | |
| scene.add(bar); | |
| visualizers.bars.push(bar); | |
| } | |
| // Add ambient light | |
| const ambientLight = new THREE.AmbientLight(0x404040, 0.5); | |
| scene.add(ambientLight); | |
| // Add multiple point lights with different colors | |
| const colors = [0xFF4081, 0x2196F3, 0x4CAF50]; | |
| colors.forEach((color, i) => { | |
| const light = new THREE.PointLight(color, 1, 20); | |
| const angle = (i / colors.length) * Math.PI * 2; | |
| const lightRadius = radius * 1.5; | |
| light.position.set( | |
| Math.cos(angle) * lightRadius, | |
| 5, | |
| Math.sin(angle) * lightRadius | |
| ); | |
| scene.add(light); | |
| }); | |
| } | |
| function createSphereVisualization() { | |
| const geometry = new THREE.IcosahedronGeometry(1, 4); | |
| // Create a more complex material with gradient and glow effects | |
| const material = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| time: { value: 0 }, | |
| color1: { value: new THREE.Color(0x4CAF50) }, | |
| color2: { value: new THREE.Color(0x2196F3) }, | |
| color3: { value: new THREE.Color(0xFF4081) } | |
| }, | |
| vertexShader: ` | |
| varying vec3 vNormal; | |
| varying vec3 vPosition; | |
| void main() { | |
| vNormal = normalize(normalMatrix * normal); | |
| vPosition = position; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| uniform float time; | |
| uniform vec3 color1; | |
| uniform vec3 color2; | |
| uniform vec3 color3; | |
| varying vec3 vNormal; | |
| varying vec3 vPosition; | |
| void main() { | |
| float noise = sin(vPosition.x * 10.0 + time) * | |
| cos(vPosition.y * 10.0 + time) * | |
| sin(vPosition.z * 10.0 + time); | |
| vec3 color = mix( | |
| mix(color1, color2, noise * 0.5 + 0.5), | |
| color3, | |
| sin(time * 0.5) * 0.5 + 0.5 | |
| ); | |
| float fresnel = pow(1.0 + dot(vNormal, vec3(0, 0, 1.0)), 3.0); | |
| color = mix(color, vec3(1.0), fresnel * 0.7); | |
| gl_FragColor = vec4(color, 0.9); | |
| } | |
| `, | |
| transparent: true, | |
| side: THREE.DoubleSide | |
| }); | |
| visualizers.sphere = new THREE.Mesh(geometry, material); | |
| // Store original vertex positions | |
| visualizers.sphere.userData.originalPositions = | |
| geometry.attributes.position.array.slice(); | |
| scene.add(visualizers.sphere); | |
| // Add ambient light for base illumination | |
| const ambientLight = new THREE.AmbientLight(0x404040, 0.5); | |
| scene.add(ambientLight); | |
| // Add multiple point lights with different colors | |
| const colors = [0xFF4081, 0x2196F3, 0x4CAF50]; | |
| const radius = 5; | |
| colors.forEach((color, i) => { | |
| const light = new THREE.PointLight(color, 1, 20); | |
| const angle = (i / colors.length) * Math.PI * 2; | |
| light.position.set( | |
| Math.cos(angle) * radius, | |
| Math.sin(angle) * radius, | |
| radius | |
| ); | |
| scene.add(light); | |
| }); | |
| } | |
| function createParticlesVisualization() { | |
| const particleCount = 5000; | |
| const geometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(particleCount * 3); | |
| const scales = new Float32Array(particleCount); | |
| const colors = new Float32Array(particleCount * 3); | |
| const color1 = new THREE.Color(0x4CAF50); | |
| const color2 = new THREE.Color(0x2196F3); | |
| const color3 = new THREE.Color(0xFF4081); | |
| // Create a spiral galaxy formation | |
| for (let i = 0; i < particleCount; i++) { | |
| const i3 = i * 3; | |
| const radius = (Math.random() * 3) + 2; | |
| const spinAngle = (i / particleCount) * Math.PI * 24; | |
| const heightRange = Math.random() * Math.PI * 2; | |
| // Create spiral arms | |
| positions[i3] = Math.cos(spinAngle + radius) * radius; | |
| positions[i3 + 1] = Math.sin(heightRange) * (radius * 0.2); | |
| positions[i3 + 2] = Math.sin(spinAngle + radius) * radius; | |
| // Vary particle sizes | |
| scales[i] = Math.random() * 0.5 + 0.5; | |
| // Create color gradient along the spiral | |
| const colorMix = Math.abs(Math.sin(spinAngle)); | |
| const finalColor = new THREE.Color().lerpColors( | |
| color1, | |
| colorMix > 0.5 ? color2 : color3, | |
| colorMix | |
| ); | |
| colors[i3] = finalColor.r; | |
| colors[i3 + 1] = finalColor.g; | |
| colors[i3 + 2] = finalColor.b; | |
| } | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| geometry.setAttribute('scale', new THREE.BufferAttribute(scales, 1)); | |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| // Create custom shader material for particles | |
| const material = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| time: { value: 0 }, | |
| size: { value: 15.0 }, | |
| pixelRatio: { value: window.devicePixelRatio } | |
| }, | |
| vertexShader: ` | |
| attribute float scale; | |
| attribute vec3 color; | |
| uniform float time; | |
| uniform float size; | |
| uniform float pixelRatio; | |
| varying vec3 vColor; | |
| void main() { | |
| vColor = color; | |
| vec3 pos = position; | |
| // Add some movement | |
| float angle = time * 0.2; | |
| pos.x = position.x * cos(angle) - position.z * sin(angle); | |
| pos.z = position.x * sin(angle) + position.z * cos(angle); | |
| pos.y += sin(time + position.x * 0.5) * 0.3; | |
| vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); | |
| gl_Position = projectionMatrix * mvPosition; | |
| // Size attenuation | |
| gl_PointSize = size * scale * pixelRatio * (1.0 / -mvPosition.z); | |
| } | |
| `, | |
| fragmentShader: ` | |
| varying vec3 vColor; | |
| void main() { | |
| // Create circular particles | |
| vec2 xy = gl_PointCoord.xy - vec2(0.5); | |
| float radius = length(xy); | |
| float alpha = 1.0 - smoothstep(0.45, 0.5, radius); | |
| // Add glow effect | |
| vec3 glow = vColor * (1.0 - radius * 2.0); | |
| vec3 finalColor = mix(vColor, glow, 0.5); | |
| gl_FragColor = vec4(finalColor, alpha); | |
| } | |
| `, | |
| transparent: true, | |
| depthWrite: false, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| visualizers.particles = new THREE.Points(geometry, material); | |
| scene.add(visualizers.particles); | |
| } | |
| // Visualization update functions | |
| function updateBarsVisualization(dataArray) { | |
| const time = Date.now() * 0.001; | |
| const multiplier = 0.02; | |
| visualizers.bars.forEach((bar, i) => { | |
| const value = dataArray[i % dataArray.length] * multiplier; | |
| // Update shader uniforms | |
| bar.material.uniforms.time.value = time; | |
| // Calculate dynamic height | |
| const baseHeight = value + 0.1; | |
| const wave = Math.sin(time * 2 + bar.userData.angle) * 0.1; | |
| const finalHeight = baseHeight + wave; | |
| // Update bar scale and position | |
| bar.scale.y = finalHeight; | |
| // Add floating effect | |
| bar.position.y = Math.sin(time + bar.userData.angle) * 0.1; | |
| // Add subtle rotation | |
| bar.rotation.x = Math.sin(time * 0.5 + bar.userData.angle) * 0.1; | |
| bar.rotation.z = Math.cos(time * 0.5 + bar.userData.angle) * 0.1; | |
| }); | |
| } | |
| function updateSphereVisualization(dataArray) { | |
| if (!visualizers.sphere) return; | |
| const positions = visualizers.sphere.geometry.attributes.position.array; | |
| const originalPositions = visualizers.sphere.userData.originalPositions; | |
| const time = Date.now() * 0.001; | |
| // Update shader uniforms | |
| visualizers.sphere.material.uniforms.time.value = time; | |
| // Create more complex deformation based on audio data | |
| for (let i = 0; i < positions.length; i += 3) { | |
| const i3 = i / 3; | |
| const value = dataArray[i3 % dataArray.length] / 255; | |
| const deform = value * 0.5; | |
| const noise = Math.sin(time + i3 * 0.1) * 0.2; | |
| positions[i] = originalPositions[i] * (1 + deform * Math.sin(time + i3) + noise); | |
| positions[i + 1] = originalPositions[i + 1] * (1 + deform * Math.cos(time + i3) + noise); | |
| positions[i + 2] = originalPositions[i + 2] * (1 + deform * Math.sin(time * 0.5 + i3) + noise); | |
| } | |
| visualizers.sphere.geometry.attributes.position.needsUpdate = true; | |
| // Add smooth rotation | |
| visualizers.sphere.rotation.y += 0.002; | |
| visualizers.sphere.rotation.x += 0.001; | |
| } | |
| function updateParticlesVisualization(dataArray) { | |
| if (!visualizers.particles) return; | |
| const time = Date.now() * 0.001; | |
| const positions = visualizers.particles.geometry.attributes.position.array; | |
| const scales = visualizers.particles.geometry.attributes.scale.array; | |
| const colors = visualizers.particles.geometry.attributes.color.array; | |
| // Update shader uniforms | |
| visualizers.particles.material.uniforms.time.value = time; | |
| for (let i = 0; i < positions.length; i += 3) { | |
| const i3 = i / 3; | |
| const value = dataArray[i3 % dataArray.length] / 255; | |
| // Update particle scales based on audio | |
| scales[i3] = (value * 0.5 + 0.5) * (Math.sin(time + i3) * 0.2 + 0.8); | |
| // Update colors with audio reactivity | |
| const hue = (i3 / positions.length) + time * 0.1; | |
| const saturation = 0.7 + value * 0.3; | |
| const lightness = 0.4 + value * 0.2; | |
| const color = new THREE.Color().setHSL(hue, saturation, lightness); | |
| colors[i] = color.r; | |
| colors[i + 1] = color.g; | |
| colors[i + 2] = color.b; | |
| } | |
| visualizers.particles.geometry.attributes.scale.needsUpdate = true; | |
| visualizers.particles.geometry.attributes.color.needsUpdate = true; | |
| } | |
| // Animation loop | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| controls.update(); | |
| if (analyser && isPlaying) { | |
| const dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| analyser.getByteFrequencyData(dataArray); | |
| updateVisualization(dataArray); | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| // Start animation | |
| animate(); | |
| // Event listeners for window resize | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // Initialize the application | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Initialize loading screen | |
| const loadingScreen = document.querySelector('.loading-screen'); | |
| function showLoading() { | |
| if (loadingScreen) { | |
| loadingScreen.classList.remove('hidden'); | |
| } | |
| } | |
| function hideLoading() { | |
| if (loadingScreen) { | |
| loadingScreen.classList.add('hidden'); | |
| } | |
| } | |
| // Show loading screen immediately | |
| showLoading(); | |
| // Initialize all components | |
| setupUploadHandlers(); | |
| setupPlayerControls(); | |
| setupToggleHandlers(); | |
| setupProgressBar(); | |
| setupPlaylistControls(); | |
| createVisualization(); | |
| // Hide loading screen after initialization | |
| hideLoading(); | |
| }); | |
| // Export necessary functions and variables | |
| window.playTrack = playTrack; | |
| window.createPlaylist = createPlaylist; | |
| window.updateNowPlayingInfo = updateNowPlayingInfo; | |
| // Setup orbit controls | |
| function setupOrbitControls() { | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.enableZoom = true; | |
| controls.autoRotate = true; | |
| controls.autoRotateSpeed = 1.5; | |
| // Camera position is now set in createVisualization | |
| } | |
| // Enhanced file upload handling | |
| function setupUploadHandlers() { | |
| console.log('Setting up upload handlers...'); | |
| const uploadArea = document.getElementById('upload-area'); | |
| const fileInput = document.getElementById('audio-upload'); | |
| const uploadProgress = document.querySelector('.upload-progress'); | |
| const progressFill = uploadProgress?.querySelector('.progress-fill'); | |
| const progressText = uploadProgress?.querySelector('.progress-text'); | |
| const errorToast = document.querySelector('.error-toast'); | |
| if (!uploadArea || !fileInput) { | |
| console.error('Upload elements not found'); | |
| return; | |
| } | |
| // Add logging for drag and drop events | |
| uploadArea.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| uploadArea.classList.add('dragover'); | |
| console.log('File being dragged over upload area'); | |
| }); | |
| uploadArea.addEventListener('dragleave', () => { | |
| uploadArea.classList.remove('dragover'); | |
| console.log('File drag left upload area'); | |
| }); | |
| uploadArea.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| uploadArea.classList.remove('dragover'); | |
| console.log('Files dropped:', e.dataTransfer.files.length, 'files'); | |
| handleFiles(e.dataTransfer.files); | |
| }); | |
| uploadArea.addEventListener('click', () => { | |
| console.log('Upload area clicked, triggering file input'); | |
| fileInput.click(); | |
| }); | |
| fileInput.addEventListener('change', (e) => { | |
| console.log('Files selected:', e.target.files.length, 'files'); | |
| handleFiles(e.target.files); | |
| }); | |
| } | |
| // Enhanced toast notifications with null checks | |
| function showError(message) { | |
| const toast = document.querySelector('.error-toast'); | |
| if (!toast) return; | |
| toast.textContent = message; | |
| toast.className = 'error-toast error visible'; | |
| setTimeout(() => { | |
| toast.classList.remove('visible'); | |
| }, 3000); | |
| } | |
| function showSuccess(message) { | |
| const toast = document.querySelector('.error-toast'); | |
| if (!toast) return; | |
| toast.textContent = message; | |
| toast.className = 'error-toast success visible'; | |
| setTimeout(() => { | |
| toast.classList.remove('visible'); | |
| }, 3000); | |
| } | |
| // Enhanced keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space' && !e.target.matches('input, textarea')) { | |
| e.preventDefault(); | |
| togglePlayPause(); | |
| } else if (e.code === 'ArrowLeft') { | |
| playPrevious(); | |
| } else if (e.code === 'ArrowRight') { | |
| playNext(); | |
| } else if (e.code === 'KeyM') { | |
| toggleMute(); | |
| } | |
| }); | |
| // Volume control | |
| function toggleMute() { | |
| if (!audioElement) return; | |
| const volumeBtn = document.querySelector('.volume-btn i'); | |
| if (audioElement.volume > 0) { | |
| audioElement.volume = 0; | |
| volumeBtn.className = 'fas fa-volume-mute'; | |
| } else { | |
| audioElement.volume = 0.5; | |
| volumeBtn.className = 'fas fa-volume-up'; | |
| } | |
| updateVolumeUI(); | |
| } | |
| function updateVolumeUI() { | |
| const volumeProgress = document.querySelector('.volume-progress'); | |
| const volumeHandle = document.querySelector('.volume-handle'); | |
| const volumeSlider = document.getElementById('volume'); | |
| if (volumeProgress && volumeHandle && volumeSlider) { | |
| const value = audioElement ? audioElement.volume : 0.5; | |
| volumeProgress.style.width = `${value * 100}%`; | |
| volumeHandle.style.left = `${value * 100}%`; | |
| volumeSlider.value = value; | |
| } | |
| } | |
| // Enhanced progress bar interaction | |
| function setupProgressBar() { | |
| const progressContainer = document.querySelector('.progress-container'); | |
| const progressBar = document.querySelector('.progress-bar'); | |
| const progress = document.querySelector('.progress'); | |
| const progressHandle = document.querySelector('.progress-handle'); | |
| const seekSlider = document.querySelector('.seek-slider'); | |
| // Return early if required elements are not found | |
| if (!progressBar || !progress) { | |
| console.error('Progress bar elements not found'); | |
| return; | |
| } | |
| let isDragging = false; | |
| // Mouse events for desktop | |
| progressBar.addEventListener('mousedown', startDragging); | |
| document.addEventListener('mousemove', updateDragging); | |
| document.addEventListener('mouseup', stopDragging); | |
| // Touch events for mobile | |
| progressBar.addEventListener('touchstart', handleTouchStart); | |
| document.addEventListener('touchmove', handleTouchMove); | |
| document.addEventListener('touchend', handleTouchEnd); | |
| function handleTouchStart(e) { | |
| e.preventDefault(); | |
| isDragging = true; | |
| progressBar.classList.add('dragging'); | |
| updateProgress(e.touches[0]); | |
| } | |
| function handleTouchMove(e) { | |
| if (!isDragging) return; | |
| e.preventDefault(); | |
| updateProgress(e.touches[0]); | |
| } | |
| function handleTouchEnd() { | |
| isDragging = false; | |
| progressBar.classList.remove('dragging'); | |
| } | |
| function startDragging(e) { | |
| isDragging = true; | |
| progressBar.classList.add('dragging'); | |
| updateProgress(e); | |
| } | |
| function updateDragging(e) { | |
| if (!isDragging) return; | |
| updateProgress(e); | |
| } | |
| function stopDragging() { | |
| isDragging = false; | |
| progressBar.classList.remove('dragging'); | |
| } | |
| function updateProgress(e) { | |
| if (!audioElement || !audioElement.duration) return; | |
| try { | |
| const rect = progressBar.getBoundingClientRect(); | |
| const x = e.clientX || e.pageX; | |
| const percent = Math.min(Math.max((x - rect.left) / rect.width, 0), 1); | |
| // Update progress bar and handle | |
| progress.style.width = `${percent * 100}%`; | |
| if (progressHandle) { | |
| progressHandle.style.left = `${percent * 100}%`; | |
| } | |
| if (seekSlider) { | |
| seekSlider.value = percent * 100; | |
| } | |
| // Update audio time | |
| audioElement.currentTime = percent * audioElement.duration; | |
| // Force time display update | |
| updateTimeDisplay(); | |
| } catch (error) { | |
| console.error('Error updating progress:', error); | |
| } | |
| } | |
| // Add seek slider input handler | |
| if (seekSlider) { | |
| seekSlider.addEventListener('input', (e) => { | |
| if (!audioElement || !audioElement.duration) return; | |
| const percent = e.target.value; | |
| progress.style.width = `${percent}%`; | |
| if (progressHandle) { | |
| progressHandle.style.left = `${percent}%`; | |
| } | |
| audioElement.currentTime = (percent / 100) * audioElement.duration; | |
| updateTimeDisplay(); | |
| }); | |
| } | |
| } | |
| // Setup player controls | |
| function setupPlayerControls() { | |
| const playPauseBtn = document.getElementById('play-pause'); | |
| const prevBtn = document.querySelector('.previous-btn'); | |
| const nextBtn = document.querySelector('.next-btn'); | |
| const volumeSlider = document.getElementById('volume'); | |
| const seekSlider = document.querySelector('.seek-slider'); | |
| playPauseBtn.addEventListener('click', togglePlayPause); | |
| prevBtn.addEventListener('click', playPrevious); | |
| nextBtn.addEventListener('click', playNext); | |
| volumeSlider.addEventListener('input', updateVolume); | |
| // Add both input and change events for the seek slider | |
| seekSlider.addEventListener('input', seekTo); | |
| seekSlider.addEventListener('change', seekTo); | |
| // Add keyboard controls | |
| document.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space') { | |
| e.preventDefault(); | |
| togglePlayPause(); | |
| } else if (e.code === 'ArrowLeft') { | |
| playPrevious(); | |
| } else if (e.code === 'ArrowRight') { | |
| playNext(); | |
| } | |
| }); | |
| // Update time display more frequently | |
| if (audioElement) { | |
| // Remove existing listeners first | |
| audioElement.removeEventListener('timeupdate', updateTimeDisplay); | |
| audioElement.removeEventListener('loadedmetadata', updateTimeDisplay); | |
| audioElement.removeEventListener('ended', handleTrackEnd); | |
| // Add listeners | |
| audioElement.addEventListener('timeupdate', updateTimeDisplay); | |
| audioElement.addEventListener('loadedmetadata', updateTimeDisplay); | |
| audioElement.addEventListener('ended', handleTrackEnd); | |
| // Force initial update | |
| updateTimeDisplay(); | |
| } | |
| } | |
| // Toggle play/pause | |
| function togglePlayPause() { | |
| if (!audioElement) return; | |
| if (audioElement.paused) { | |
| audioElement.play(); | |
| isPlaying = true; | |
| document.getElementById('play-pause').innerHTML = '<i class="fas fa-pause"></i>'; | |
| } else { | |
| audioElement.pause(); | |
| isPlaying = false; | |
| document.getElementById('play-pause').innerHTML = '<i class="fas fa-play"></i>'; | |
| } | |
| } | |
| // Play previous track | |
| function playPrevious() { | |
| if (playlist.length === 0) return; | |
| let newIndex = currentTrackIndex - 1; | |
| if (newIndex < 0) newIndex = playlist.length - 1; | |
| playTrack(newIndex); | |
| } | |
| // Play next track | |
| function playNext() { | |
| if (playlist.length === 0) return; | |
| let newIndex = currentTrackIndex + 1; | |
| if (newIndex >= playlist.length) newIndex = 0; | |
| playTrack(newIndex); | |
| } | |
| // Update volume | |
| function updateVolume(e) { | |
| if (audioElement) { | |
| audioElement.volume = e.target.value; | |
| const volumeProgress = document.querySelector('.volume-progress'); | |
| volumeProgress.style.width = `${e.target.value * 100}%`; | |
| } | |
| } | |
| // Seek to position | |
| function seekTo(e) { | |
| if (!audioElement || !audioElement.duration) return; | |
| const seekSlider = e.target; | |
| const progress = document.querySelector('.progress'); | |
| const progressHandle = document.querySelector('.progress-handle'); | |
| const time = (seekSlider.value / 100) * audioElement.duration; | |
| // Update audio time | |
| audioElement.currentTime = time; | |
| // Update progress bar and handle | |
| if (progress) { | |
| progress.style.width = `${seekSlider.value}%`; | |
| } | |
| if (progressHandle) { | |
| progressHandle.style.left = `${seekSlider.value}%`; | |
| } | |
| // Force time display update | |
| updateTimeDisplay(); | |
| } | |
| // Update time display | |
| function updateTimeDisplay() { | |
| if (!audioElement) return; | |
| const currentTimeEl = document.querySelector('.current-time'); | |
| const totalTimeEl = document.querySelector('.total-time'); | |
| const progress = document.querySelector('.progress'); | |
| const progressHandle = document.querySelector('.progress-handle'); | |
| const seekSlider = document.querySelector('.seek-slider'); | |
| // Only update if duration is available and not NaN | |
| if (audioElement.duration && !isNaN(audioElement.duration)) { | |
| const current = formatTime(audioElement.currentTime); | |
| const total = formatTime(audioElement.duration); | |
| const progressPercent = (audioElement.currentTime / audioElement.duration) * 100; | |
| // Update time displays | |
| if (currentTimeEl) currentTimeEl.textContent = current; | |
| if (totalTimeEl) totalTimeEl.textContent = total; | |
| // Update progress bar and handle | |
| if (progress) { | |
| progress.style.width = `${progressPercent}%`; | |
| } | |
| if (progressHandle) { | |
| progressHandle.style.left = `${progressPercent}%`; | |
| } | |
| if (seekSlider && !seekSlider.matches(':active')) { | |
| seekSlider.value = progressPercent; | |
| } | |
| } else { | |
| // Reset displays if no duration available | |
| if (currentTimeEl) currentTimeEl.textContent = '0:00'; | |
| if (totalTimeEl) totalTimeEl.textContent = '0:00'; | |
| if (progress) progress.style.width = '0%'; | |
| if (progressHandle) progressHandle.style.left = '0%'; | |
| if (seekSlider) seekSlider.value = 0; | |
| } | |
| } | |
| // Format time helper function | |
| function 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')}`; | |
| } | |
| // Handle track end | |
| function handleTrackEnd() { | |
| if (isRepeatActive) { | |
| audioElement.currentTime = 0; | |
| audioElement.play(); | |
| } else { | |
| playNext(); | |
| } | |
| } | |
| // Create visualization | |
| function createVisualization() { | |
| // Clear existing visualization | |
| while(scene.children.length > 0) { | |
| scene.remove(scene.children[0]); | |
| } | |
| visualizers = { | |
| bars: [], | |
| sphere: null, | |
| particles: null | |
| }; | |
| // Set camera position based on visualization type | |
| camera.position.z = visualizerSettings[visualizationType].cameraZ; | |
| switch(visualizationType) { | |
| case 'bars': | |
| createBarsVisualization(); | |
| break; | |
| case 'sphere': | |
| createSphereVisualization(); | |
| break; | |
| case 'particles': | |
| createParticlesVisualization(); | |
| break; | |
| } | |
| } | |
| // Update visualization | |
| function updateVisualization(dataArray) { | |
| switch(visualizationType) { | |
| case 'bars': | |
| updateBarsVisualization(dataArray); | |
| break; | |
| case 'sphere': | |
| updateSphereVisualization(dataArray); | |
| break; | |
| case 'particles': | |
| updateParticlesVisualization(dataArray); | |
| break; | |
| } | |
| } | |
| // Create playlist UI | |
| function createPlaylist() { | |
| const tracksList = document.querySelector('.tracks-list'); | |
| const noTracksMessage = document.querySelector('.no-tracks-message'); | |
| // Clear existing tracks | |
| tracksList.innerHTML = ''; | |
| if (!playlist || playlist.length === 0) { | |
| // Show no tracks message if playlist is empty | |
| if (noTracksMessage) { | |
| noTracksMessage.style.display = 'flex'; | |
| } | |
| return; | |
| } | |
| // Hide no tracks message if we have tracks | |
| if (noTracksMessage) { | |
| noTracksMessage.style.display = 'none'; | |
| } | |
| // Create track elements | |
| playlist.forEach((track, index) => { | |
| const metadata = track.metadata || {}; | |
| const duration = metadata.duration ? formatTime(metadata.duration) : '0:00'; | |
| const artist = metadata.artist || 'Unknown Artist'; | |
| const title = metadata.title || track.name; | |
| const trackElement = document.createElement('div'); | |
| trackElement.className = 'playlist-item'; | |
| trackElement.innerHTML = ` | |
| <div class="track-info"> | |
| <span class="track-number">${index + 1}</span> | |
| <div class="track-artwork"> | |
| ${metadata.artwork ? | |
| `<img src="data:${metadata.artwork.mime};base64,${metadata.artwork.data}" alt="Album artwork">` : | |
| '<i class="fas fa-music"></i>' | |
| } | |
| </div> | |
| <div class="track-content"> | |
| <div class="track-title"> | |
| ${title} | |
| <span class="track-duration">${duration}</span> | |
| </div> | |
| <div class="track-metadata"> | |
| ${artist} | |
| ${metadata.album ? ` • ${metadata.album}` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| trackElement.addEventListener('click', () => playTrack(index)); | |
| tracksList.appendChild(trackElement); | |
| }); | |
| } | |
| // Update now playing information | |
| function updateNowPlayingInfo() { | |
| if (currentTrackIndex >= 0 && currentTrackIndex < playlist.length) { | |
| const track = playlist[currentTrackIndex]; | |
| const metadata = track.metadata || {}; | |
| const nowPlayingTitle = document.querySelector('.now-playing-title'); | |
| const nowPlayingArtist = document.querySelector('.now-playing-artist'); | |
| const trackArtwork = document.querySelector('.now-playing-info .track-artwork'); | |
| if (nowPlayingTitle) { | |
| nowPlayingTitle.textContent = metadata.title || track.name; | |
| } | |
| if (nowPlayingArtist) { | |
| let artistInfo = metadata.artist || 'Unknown Artist'; | |
| if (metadata.album) { | |
| artistInfo += ` • ${metadata.album}`; | |
| } | |
| nowPlayingArtist.textContent = artistInfo; | |
| } | |
| if (trackArtwork) { | |
| trackArtwork.innerHTML = metadata.artwork ? | |
| `<img src="data:${metadata.artwork.mime};base64,${metadata.artwork.data}" alt="Album artwork">` : | |
| '<i class="fas fa-music"></i>'; | |
| } | |
| } | |
| } | |
| // Update playTrack function to properly set up time updates | |
| async function playTrack(index) { | |
| if (index < 0 || index >= playlist.length) return; | |
| try { | |
| // Stop current playback | |
| if (audioElement) { | |
| audioElement.pause(); | |
| audioElement.currentTime = 0; | |
| // Remove existing listeners | |
| audioElement.removeEventListener('timeupdate', updateTimeDisplay); | |
| audioElement.removeEventListener('loadedmetadata', updateTimeDisplay); | |
| audioElement.removeEventListener('ended', handleTrackEnd); | |
| } | |
| currentTrackIndex = index; | |
| const track = playlist[currentTrackIndex]; | |
| if (!audioElement) { | |
| console.warn('Audio element not initialized'); | |
| return; | |
| } | |
| // Update source and load new track | |
| audioElement.src = track.url; | |
| await audioElement.load(); | |
| // Add event listeners for time updates | |
| audioElement.addEventListener('timeupdate', updateTimeDisplay); | |
| audioElement.addEventListener('loadedmetadata', updateTimeDisplay); | |
| audioElement.addEventListener('ended', handleTrackEnd); | |
| // Update playlist UI | |
| document.querySelectorAll('.playlist-item').forEach((item, i) => { | |
| item.classList.toggle('active', i === index); | |
| }); | |
| // Force initial time display update | |
| updateTimeDisplay(); | |
| // Attempt to play with retry logic | |
| try { | |
| await audioElement.play(); | |
| isPlaying = true; | |
| updatePlayPauseButton(); | |
| updateNowPlayingInfo(); | |
| } catch (playError) { | |
| console.warn('Play interrupted, retrying...', playError); | |
| // Add a small delay before retrying | |
| setTimeout(async () => { | |
| try { | |
| await audioElement.play(); | |
| isPlaying = true; | |
| updatePlayPauseButton(); | |
| updateNowPlayingInfo(); | |
| } catch (retryError) { | |
| console.error('Failed to play after retry:', retryError); | |
| showError('Failed to play track. Please try again.'); | |
| } | |
| }, 100); | |
| } | |
| } catch (error) { | |
| console.error('Error playing track:', error); | |
| showError('Error playing track'); | |
| } | |
| } | |
| // Add these functions after the existing initialization code | |
| function setupToggleHandlers() { | |
| const mainContent = document.querySelector('.main-content'); | |
| const uploadArea = document.querySelector('.upload-area'); | |
| const playlistContainer = document.querySelector('.playlist-container'); | |
| const uploadToggleBtn = document.querySelector('.upload-toggle-btn'); | |
| const playlistToggleBtn = document.querySelector('.playlist-toggle-btn'); | |
| const vizTypeBtn = document.querySelector('.viz-type-btn'); | |
| const vizTypeDropdown = document.querySelector('.viz-type-dropdown'); | |
| if (!mainContent || !uploadArea || !playlistContainer) { | |
| console.error('Required elements not found'); | |
| return; | |
| } | |
| // Show playlist by default | |
| mainContent.classList.add('visible'); | |
| playlistContainer.classList.add('visible'); | |
| if (playlistToggleBtn) playlistToggleBtn.classList.add('active'); | |
| // Upload button handler | |
| uploadToggleBtn?.addEventListener('click', () => { | |
| const isVisible = uploadArea.classList.contains('visible'); | |
| // Hide playlist if it's visible | |
| playlistContainer.classList.remove('visible'); | |
| playlistToggleBtn?.classList.remove('active'); | |
| // Toggle upload area | |
| uploadArea.classList.toggle('visible'); | |
| uploadToggleBtn.classList.toggle('active'); | |
| // Show/hide main content | |
| mainContent.classList.toggle('visible', !isVisible || playlistContainer.classList.contains('visible')); | |
| }); | |
| // Playlist button handler | |
| playlistToggleBtn?.addEventListener('click', () => { | |
| const isVisible = playlistContainer.classList.contains('visible'); | |
| // Hide upload area if it's visible | |
| uploadArea.classList.remove('visible'); | |
| uploadToggleBtn?.classList.remove('active'); | |
| // Toggle playlist | |
| playlistContainer.classList.toggle('visible'); | |
| playlistToggleBtn.classList.toggle('active'); | |
| // Show/hide main content | |
| mainContent.classList.toggle('visible', !isVisible || uploadArea.classList.contains('visible')); | |
| }); | |
| // Visualization type button handler | |
| let isVizDropdownVisible = false; | |
| vizTypeBtn?.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| isVizDropdownVisible = !isVizDropdownVisible; | |
| if (isVizDropdownVisible) { | |
| vizTypeDropdown?.classList.add('visible'); | |
| vizTypeBtn.classList.add('active'); | |
| } else { | |
| vizTypeDropdown?.classList.remove('visible'); | |
| vizTypeBtn.classList.remove('active'); | |
| } | |
| }); | |
| // Handle visualization type selection | |
| const vizTypeOptions = document.querySelectorAll('.viz-type-options button'); | |
| vizTypeOptions?.forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const type = button.dataset.type; | |
| // Update active state | |
| vizTypeOptions.forEach(btn => btn.classList.remove('active')); | |
| button.classList.add('active'); | |
| // Update visualization | |
| visualizationType = type; | |
| createVisualization(); | |
| // Close dropdown | |
| isVizDropdownVisible = false; | |
| vizTypeDropdown?.classList.remove('visible'); | |
| vizTypeBtn?.classList.remove('active'); | |
| }); | |
| }); | |
| // Close dropdowns when clicking outside | |
| document.addEventListener('click', (e) => { | |
| if (isVizDropdownVisible && | |
| vizTypeDropdown && | |
| !vizTypeDropdown.contains(e.target) && | |
| !vizTypeBtn?.contains(e.target)) { | |
| isVizDropdownVisible = false; | |
| vizTypeDropdown.classList.remove('visible'); | |
| vizTypeBtn?.classList.remove('active'); | |
| } | |
| }); | |
| } | |
| // Add updatePlayPauseButton function | |
| function updatePlayPauseButton() { | |
| const playPauseBtn = document.getElementById('play-pause'); | |
| if (!playPauseBtn) return; | |
| playPauseBtn.innerHTML = isPlaying ? | |
| '<i class="fas fa-pause"></i>' : | |
| '<i class="fas fa-play"></i>'; | |
| // Update button state | |
| playPauseBtn.disabled = !audioElement || !playlist.length; | |
| } | |
| // Setup shuffle and repeat buttons | |
| function setupPlaylistControls() { | |
| const shuffleBtn = document.querySelector('.shuffle-btn'); | |
| const repeatBtn = document.querySelector('.repeat-btn'); | |
| // Setup shuffle button | |
| shuffleBtn?.addEventListener('click', () => { | |
| isShuffleActive = !isShuffleActive; | |
| shuffleBtn.classList.toggle('active', isShuffleActive); | |
| if (isShuffleActive) { | |
| // Save current track index | |
| const currentTrack = playlist[currentTrackIndex]; | |
| // Shuffle playlist | |
| for (let i = playlist.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [playlist[i], playlist[j]] = [playlist[j], playlist[i]]; | |
| } | |
| // Find new index of current track | |
| currentTrackIndex = playlist.findIndex(track => track === currentTrack); | |
| // Update playlist UI | |
| createPlaylist(); | |
| updateNowPlayingInfo(); | |
| } else { | |
| // Restore original order if needed | |
| playlist.sort((a, b) => a.originalIndex - b.originalIndex); | |
| createPlaylist(); | |
| } | |
| }); | |
| // Setup repeat button | |
| repeatBtn?.addEventListener('click', () => { | |
| isRepeatActive = !isRepeatActive; | |
| repeatBtn.classList.toggle('active', isRepeatActive); | |
| }); | |
| } | |
| // Update handleFiles function for faster uploads | |
| async function handleFiles(files) { | |
| console.log('Handling files:', files.length, 'files'); | |
| if (!files || files.length === 0) { | |
| console.warn('No files selected'); | |
| showError('No files selected'); | |
| return; | |
| } | |
| if (!audioContext) { | |
| try { | |
| console.log('Initializing audio context...'); | |
| initAudio(); | |
| } catch (error) { | |
| console.error('Failed to initialize audio:', error); | |
| showError('Failed to initialize audio system'); | |
| return; | |
| } | |
| } | |
| const formData = new FormData(); | |
| const totalSize = Array.from(files).reduce((acc, file) => acc + file.size, 0); | |
| let uploadedSize = 0; | |
| // Add files to FormData with optimized chunk size | |
| Array.from(files).forEach(file => { | |
| console.log('Adding file to upload:', file.name); | |
| formData.append('files[]', file); | |
| }); | |
| // Show upload progress | |
| const uploadProgress = document.querySelector('.upload-progress'); | |
| const progressFill = uploadProgress?.querySelector('.progress-fill'); | |
| const progressText = uploadProgress?.querySelector('.progress-text'); | |
| if (uploadProgress && progressFill && progressText) { | |
| uploadProgress.classList.add('visible'); | |
| progressFill.style.width = '0%'; | |
| progressText.textContent = '0%'; | |
| } | |
| try { | |
| console.log('Starting file upload...'); | |
| const response = await fetch('/upload', { | |
| method: 'POST', | |
| body: formData, | |
| headers: sessionId ? { | |
| 'X-Session-ID': sessionId | |
| } : {} | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| console.log('Upload response:', data); | |
| if (data.success) { | |
| console.log('Files uploaded successfully'); | |
| showSuccess('Files uploaded successfully'); | |
| const successfulFiles = data.files.filter(file => file.success); | |
| console.log('Successful uploads:', successfulFiles.length, 'files'); | |
| if (successfulFiles.length === 0) { | |
| console.warn('No files were uploaded successfully'); | |
| showError('No files were uploaded successfully'); | |
| return; | |
| } | |
| // Get the next index for new tracks | |
| const startIndex = playlist.length; | |
| // Create new track objects | |
| const newTracks = successfulFiles.map((file, index) => ({ | |
| name: file.filename, | |
| url: file.filepath, | |
| metadata: file.metadata, | |
| originalIndex: startIndex + index | |
| })); | |
| // Append new tracks to existing playlist | |
| playlist = [...playlist, ...newTracks]; | |
| console.log('Updated playlist:', playlist); | |
| createPlaylist(); | |
| // Only start playing if nothing is currently playing | |
| if (playlist.length > 0 && !isPlaying) { | |
| console.log('Playing first track...'); | |
| playTrack(0); | |
| } | |
| // Enable player controls | |
| document.querySelectorAll('.control-btn').forEach(btn => { | |
| btn.disabled = false; | |
| }); | |
| // Highlight currently playing track if any | |
| if (currentTrackIndex >= 0) { | |
| document.querySelectorAll('.playlist-item').forEach((item, i) => { | |
| item.classList.toggle('active', i === currentTrackIndex); | |
| }); | |
| } | |
| // Store session ID if we got one | |
| if (data.session_id) { | |
| sessionId = data.session_id; | |
| localStorage.setItem('audioSessionId', sessionId); | |
| } | |
| } else { | |
| console.error('Upload failed:', data.error); | |
| showError(data.error || 'Upload failed'); | |
| } | |
| } catch (error) { | |
| console.error('Upload error:', error); | |
| showError('Error uploading files'); | |
| } finally { | |
| if (uploadProgress) { | |
| uploadProgress.classList.remove('visible'); | |
| } | |
| } | |
| } | |
| // Update volume control functions | |
| function setupVolumeControl() { | |
| const volumeBtn = document.querySelector('.volume-btn'); | |
| const volumeSlider = document.getElementById('volume'); | |
| const volumeProgress = document.querySelector('.volume-progress'); | |
| const volumeHandle = document.querySelector('.volume-handle'); | |
| if (!volumeBtn || !volumeSlider || !volumeProgress || !volumeHandle) { | |
| console.error('Volume control elements not found'); | |
| return; | |
| } | |
| // Initialize volume | |
| let currentVolume = localStorage.getItem('volume') || 0.5; | |
| updateVolume(currentVolume); | |
| // Update volume on slider change | |
| volumeSlider.addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| updateVolume(value); | |
| }); | |
| // Update volume on button click (mute/unmute) | |
| volumeBtn.addEventListener('click', () => { | |
| if (audioElement) { | |
| if (audioElement.volume > 0) { | |
| // Store current volume before muting | |
| localStorage.setItem('previousVolume', audioElement.volume); | |
| updateVolume(0); | |
| } else { | |
| // Restore previous volume or default to 0.5 | |
| const previousVolume = localStorage.getItem('previousVolume') || 0.5; | |
| updateVolume(previousVolume); | |
| } | |
| } | |
| }); | |
| function updateVolume(value) { | |
| // Update audio element | |
| if (audioElement) { | |
| audioElement.volume = value; | |
| } | |
| // Update UI | |
| volumeProgress.style.width = `${value * 100}%`; | |
| volumeHandle.style.left = `${value * 100}%`; | |
| volumeSlider.value = value; | |
| // Update button icon | |
| const icon = volumeBtn.querySelector('i'); | |
| if (icon) { | |
| if (value === 0) { | |
| icon.className = 'fas fa-volume-mute'; | |
| } else if (value < 0.5) { | |
| icon.className = 'fas fa-volume-down'; | |
| } else { | |
| icon.className = 'fas fa-volume-up'; | |
| } | |
| } | |
| // Save volume to localStorage | |
| localStorage.setItem('volume', value); | |
| } | |
| } | |
| // Add to initialization | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // ... other initialization code ... | |
| setupVolumeControl(); | |
| }); | |
| // Add session management | |
| let sessionId = localStorage.getItem('audioSessionId'); | |
| // Add cleanup on page unload | |
| window.addEventListener('beforeunload', async () => { | |
| if (sessionId) { | |
| try { | |
| await fetch('/end-session', { | |
| method: 'POST', | |
| headers: { | |
| 'X-Session-ID': sessionId | |
| } | |
| }); | |
| localStorage.removeItem('audioSessionId'); | |
| } catch (error) { | |
| console.error('Error ending session:', error); | |
| } | |
| } | |
| }); |