const APP_VERSION = '0.2.1-beta'; const playlist = [ { url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/01.%20PINK%20FLIGHT%20ATTENDANT.mp3', name: 'TRACK 1 - PINK FLIGHT ATTENDANT' }, { url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/02.%20NOW.mp3', name: 'TRACK 2 - NOW' }, { url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/03.%20MAZES.mp3', name: 'TRACK 3 - MAZES' }, { url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/04.%20FAMILY%20MAN.mp3', name: 'TRACK 4 - FAMILY MAN' }, { url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/05.%20TOLLBOOTH%20SAINTS.mp3', name: 'TRACK 5 - TOLLBOOTH SAINTS' } ]; let currentTrack = 0; const videoUrl = 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MAZES%20HB.mp4'; const audio = document.getElementById('audio'); const playBtn = document.getElementById('playBtn'); const pauseBtn = document.getElementById('pauseBtn'); const stopBtn = document.getElementById('stopBtn'); const prevBtn = document.getElementById('prevBtn'); const nextBtn = document.getElementById('nextBtn'); const volumeSlider = document.getElementById('volumeSlider'); const timeDisplay = document.getElementById('timeDisplay'); const trackName = document.getElementById('trackName'); const trackNameInner = document.getElementById('trackNameInner'); const reelLeft = document.getElementById('reelLeft'); const reelRight = document.getElementById('reelRight'); const tapeLeft = document.getElementById('tapeLeft'); const tapeRight = document.getElementById('tapeRight'); const reelContainerLeft = document.getElementById('reelContainerLeft'); const reelContainerRight = document.getElementById('reelContainerRight'); const ejectBtn = document.getElementById('ejectBtn'); const lightningBtn = document.getElementById('lightningBtn'); const videoOverlay = document.getElementById('videoOverlay'); const videoPlayer = document.getElementById('videoPlayer'); const closeVideo = document.getElementById('closeVideo'); // Alby Lightning panel elements const albyOverlay = document.getElementById('albyOverlay'); const albyPanel = document.getElementById('albyPanel'); const albyCloseBtn = document.getElementById('albyCloseBtn'); const albyAmount = document.getElementById('albyAmount'); const albyMemo = document.getElementById('albyMemo'); const albyIncrementBtn = document.getElementById('albyIncrementBtn'); const albyDecrementBtn = document.getElementById('albyDecrementBtn'); const albyBoostBtn = document.getElementById('albyBoostBtn'); const albyDisplayAmount = document.getElementById('albyDisplayAmount'); const albyCharCount = document.getElementById('albyCharCount'); const albySimpleBoost = document.getElementById('albySimpleBoost'); const albyTrackSection = document.getElementById('albyTrackSection'); const albyTrackName = document.getElementById('albyTrackName'); const albyIncludeTrack = document.getElementById('albyIncludeTrack'); // Tape size constants (in pixels) const TAPE_MIN_SIZE = 70; // Minimum tape size (just larger than reel-inner) const TAPE_MAX_SIZE = 180; // Maximum tape size (fills most of reel) // ======================================== // CONTINUOUS TAPE MODEL - STATE // Tracks are treated as sequential positions on a single tape // ======================================== let trackDurations = []; // Duration of each track in seconds let trackStartPositions = []; // Cumulative start position of each track on tape let totalTapeDuration = 0; // Total length of entire tape let durationsLoaded = false; // Flag indicating when all durations are known /** * Load metadata for all tracks to get their durations * Uses parallel loading for better performance * Falls back to 240 seconds (4 min) if metadata can't be loaded */ async function loadAllDurations() { console.log('Loading track durations...'); trackDurations = new Array(playlist.length); // Load track 0 from main audio element first try { if (currentTrack === 0) { if (audio.duration && !isNaN(audio.duration)) { trackDurations[0] = audio.duration; } else { trackDurations[0] = await new Promise((resolve) => { if (audio.duration && !isNaN(audio.duration)) { resolve(audio.duration); } else { audio.addEventListener('loadedmetadata', function onMeta() { audio.removeEventListener('loadedmetadata', onMeta); resolve(audio.duration); }); } }); } } else { trackDurations[0] = await getTrackDuration(playlist[0].url); } console.log(`Track 1 duration: ${formatTime(trackDurations[0])}`); } catch (e) { console.warn('Failed to get duration for track 1, using fallback'); trackDurations[0] = 240; } // Load remaining tracks in parallel for faster loading if (playlist.length > 1) { const otherTrackPromises = playlist.slice(1).map(async (track, index) => { const trackIndex = index + 1; try { const duration = await getTrackDuration(track.url); console.log(`Track ${trackIndex + 1} duration: ${formatTime(duration)}`); return duration; } catch (e) { console.warn(`Failed to get duration for track ${trackIndex + 1}, using fallback`); return 240; // 4 minute fallback } }); const otherDurations = await Promise.all(otherTrackPromises); for (let i = 0; i < otherDurations.length; i++) { trackDurations[i + 1] = otherDurations[i]; } } // Calculate cumulative start positions trackStartPositions = [0]; for (let i = 1; i < trackDurations.length; i++) { trackStartPositions[i] = trackStartPositions[i - 1] + trackDurations[i - 1]; } // Calculate total tape duration totalTapeDuration = trackStartPositions[trackDurations.length - 1] + trackDurations[trackDurations.length - 1]; durationsLoaded = true; console.log(`Total tape duration: ${formatTime(totalTapeDuration)}`); console.log('Track start positions:', trackStartPositions.map(formatTime)); // Update tape sizes now that we have durations updateTapeSizes(); } /** * Get duration of a single track using a temporary audio element * @param {string} url - URL of the audio track * @returns {Promise} Duration in seconds */ function getTrackDuration(url) { return new Promise((resolve, reject) => { const tempAudio = new Audio(); tempAudio.preload = 'metadata'; // Only fetch headers, not entire file // Cleanup function to properly abort any ongoing request function cleanup() { tempAudio.removeEventListener('loadedmetadata', onMetadata); tempAudio.removeEventListener('error', onError); tempAudio.src = ''; tempAudio.load(); // Force abort of any pending request } // Set timeout for slow loads const timeout = setTimeout(() => { cleanup(); reject(new Error('Timeout loading metadata')); }, 10000); // 10 second timeout function onMetadata() { clearTimeout(timeout); const duration = tempAudio.duration; cleanup(); resolve(duration); } function onError(e) { clearTimeout(timeout); cleanup(); reject(e); } tempAudio.addEventListener('loadedmetadata', onMetadata); tempAudio.addEventListener('error', onError); // Direct URL - browser handles caching via HTTP headers tempAudio.src = url; }); } // ======================================== // TAPE POSITION HELPER FUNCTIONS // Convert between track-local and global tape positions // ======================================== /** * Get the current position on the entire tape (global position) * @returns {number} Position in seconds from tape start */ function getCurrentTapePosition() { if (!durationsLoaded) return 0; return trackStartPositions[currentTrack] + (audio.currentTime || 0); } /** * Get the current tape progress as a value from 0 to 1 * @returns {number} Progress through entire tape (0 = start, 1 = end) */ function getTapeProgress() { if (!durationsLoaded || !totalTapeDuration) return 0; return getCurrentTapePosition() / totalTapeDuration; } /** * Find which track contains a given global tape position * @param {number} tapePosition - Position in seconds from tape start * @returns {Object} { trackIndex, positionInTrack } */ function findTrackAtPosition(tapePosition) { if (!durationsLoaded) return { trackIndex: 0, positionInTrack: 0 }; // Clamp to valid range tapePosition = Math.max(0, Math.min(totalTapeDuration, tapePosition)); // Find the track that contains this position for (let i = trackStartPositions.length - 1; i >= 0; i--) { if (tapePosition >= trackStartPositions[i]) { const positionInTrack = tapePosition - trackStartPositions[i]; // Clamp position within track duration const clampedPosition = Math.min(positionInTrack, trackDurations[i] - 0.01); return { trackIndex: i, positionInTrack: Math.max(0, clampedPosition) }; } } // Fallback to start return { trackIndex: 0, positionInTrack: 0 }; } /** * Get the global tape position for the start of a specific track * @param {number} trackIndex - Index of the track * @returns {number} Position in seconds from tape start */ function getTrackStartPosition(trackIndex) { if (!durationsLoaded) return 0; return trackStartPositions[trackIndex] || 0; } // ======================================== // SOUND EFFECTS MODULE - START // Web Audio API synthesized sounds for tactile feedback // Pre-generated noise buffers for better performance // ======================================== const SoundEffects = { ctx: null, // Cached noise buffers - generated once, reused for all sounds clickNoiseBuffer: null, tapeWindNoiseBuffer: null, // Initialize AudioContext and pre-generate noise buffers (must be called after user gesture) init() { if (!this.ctx) { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); // Pre-generate noise buffers on first init this.generateNoiseBuffers(); } // Resume if suspended (browser autoplay policy) if (this.ctx.state === 'suspended') { this.ctx.resume(); } }, // Pre-generate all noise buffers once for reuse generateNoiseBuffers() { const sampleRate = this.ctx.sampleRate; // Generate click noise buffer (20ms) const clickBufferSize = Math.floor(sampleRate * 0.02); this.clickNoiseBuffer = this.ctx.createBuffer(1, clickBufferSize, sampleRate); const clickOutput = this.clickNoiseBuffer.getChannelData(0); for (let i = 0; i < clickBufferSize; i++) { clickOutput[i] = Math.random() * 2 - 1; } // Generate tape wind noise buffer (1 second with wobble) const windBufferSize = sampleRate * 1; this.tapeWindNoiseBuffer = this.ctx.createBuffer(1, windBufferSize, sampleRate); const windOutput = this.tapeWindNoiseBuffer.getChannelData(0); for (let i = 0; i < windBufferSize; i++) { const noise = Math.random() * 2 - 1; const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2); windOutput[i] = noise * wobble; } }, // Play a mechanical button click sound playButtonClick() { this.init(); const now = this.ctx.currentTime; // Create nodes for the click sound const clickOsc = this.ctx.createOscillator(); const clickGain = this.ctx.createGain(); const noiseGain = this.ctx.createGain(); // Low frequency "thunk" component (80-150Hz) clickOsc.type = 'sine'; clickOsc.frequency.setValueAtTime(150, now); clickOsc.frequency.exponentialRampToValueAtTime(80, now + 0.05); // Gain envelope for the thunk - fast attack, quick decay clickGain.gain.setValueAtTime(0.6, now); clickGain.gain.exponentialRampToValueAtTime(0.01, now + 0.08); clickOsc.connect(clickGain); clickGain.connect(this.ctx.destination); // High frequency "click" transient using cached noise buffer const noise = this.ctx.createBufferSource(); noise.buffer = this.clickNoiseBuffer; // Bandpass filter to shape the noise into a click const filter = this.ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 2000; filter.Q.value = 1; noiseGain.gain.setValueAtTime(0.35, now); noiseGain.gain.exponentialRampToValueAtTime(0.01, now + 0.03); noise.connect(filter); filter.connect(noiseGain); noiseGain.connect(this.ctx.destination); // Start and stop clickOsc.start(now); clickOsc.stop(now + 0.1); noise.start(now); noise.stop(now + 0.03); }, // Start continuous tape wind sound (for reel dragging) // Uses cached looping noise buffer for seamless continuous playback startTapeWindLoop() { this.init(); // Stop any existing loop this.stopTapeWindLoop(); const now = this.ctx.currentTime; // Use cached tape wind noise buffer this.tapeWindSource = this.ctx.createBufferSource(); this.tapeWindSource.buffer = this.tapeWindNoiseBuffer; this.tapeWindSource.loop = true; // Bandpass filter for tape character const filter = this.ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 1200; filter.Q.value = 0.5; // High shelf to reduce harshness const highShelf = this.ctx.createBiquadFilter(); highShelf.type = 'highshelf'; highShelf.frequency.value = 3000; highShelf.gain.value = -6; // Gain with fade in this.tapeWindGain = this.ctx.createGain(); this.tapeWindGain.gain.setValueAtTime(0, now); this.tapeWindGain.gain.linearRampToValueAtTime(0.07, now + 0.05); // Connect chain this.tapeWindSource.connect(filter); filter.connect(highShelf); highShelf.connect(this.tapeWindGain); this.tapeWindGain.connect(this.ctx.destination); this.tapeWindSource.start(now); }, // Stop the continuous tape wind sound stopTapeWindLoop() { if (this.tapeWindGain && this.ctx) { const now = this.ctx.currentTime; this.tapeWindGain.gain.linearRampToValueAtTime(0, now + 0.1); // Clean up after fade const sourceToStop = this.tapeWindSource; setTimeout(() => { if (sourceToStop) { try { sourceToStop.stop(); } catch(e) {} } }, 150); this.tapeWindSource = null; this.tapeWindGain = null; } } }; // ======================================== // SOUND EFFECTS MODULE - END // ======================================== // ======================================== // DISPLAY GLITCH MODULE - START // Random visual glitches for dystopian CRT effect // ======================================== const DisplayGlitch = { display: null, vhsTracking: null, isEnabled: true, // Timing ranges (in milliseconds) RGB_GLITCH_MIN_INTERVAL: 5000, // 5 seconds RGB_GLITCH_MAX_INTERVAL: 15000, // 15 seconds RGB_GLITCH_DURATION_MIN: 80, // 80ms RGB_GLITCH_DURATION_MAX: 250, // 250ms BLACKOUT_MIN_INTERVAL: 30000, // 30 seconds BLACKOUT_MAX_INTERVAL: 90000, // 90 seconds BLACKOUT_DURATION: 60, // 60ms per flicker VHS_MIN_INTERVAL: 10000, // 10 seconds VHS_MAX_INTERVAL: 30000, // 30 seconds VHS_DURATION: 400, // 400ms /** * Initialize the glitch module * Gets DOM references and starts random glitch intervals */ init() { this.display = document.getElementById('display'); this.vhsTracking = document.getElementById('vhsTracking'); if (!this.display || !this.vhsTracking) { console.warn('DisplayGlitch: Could not find display elements'); return; } // Start the random glitch schedulers this.scheduleNext('rgb'); this.scheduleNext('blackout'); this.scheduleNext('vhs'); console.log('DisplayGlitch: Initialized'); }, /** * Get a random value between min and max */ randomBetween(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }, /** * Trigger RGB chromatic aberration glitch * Adds class that causes RGB channel separation animation */ triggerRgbGlitch() { if (!this.isEnabled || !this.display) return; const duration = this.randomBetween( this.RGB_GLITCH_DURATION_MIN, this.RGB_GLITCH_DURATION_MAX ); this.display.classList.add('glitch-rgb'); setTimeout(() => { this.display.classList.remove('glitch-rgb'); }, duration); }, /** * Trigger intermittent display failure (blackout) * Creates 1-3 quick flickers for realistic CRT failure */ triggerBlackout() { if (!this.isEnabled || !this.display) return; // Random number of flickers (1-3) const flickerCount = this.randomBetween(1, 3); let currentFlicker = 0; const doFlicker = () => { this.display.classList.add('blackout'); setTimeout(() => { this.display.classList.remove('blackout'); currentFlicker++; // If more flickers needed, do them after a short gap if (currentFlicker < flickerCount) { setTimeout(doFlicker, this.randomBetween(30, 80)); } }, this.BLACKOUT_DURATION); }; doFlicker(); }, /** * Trigger VHS tracking lines * Randomly shows vertical (scrolling up) or horizontal (scrolling left) tracking lines */ triggerVhsTracking() { if (!this.isEnabled || !this.vhsTracking) return; // Remove all classes to reset this.vhsTracking.classList.remove('active', 'vertical', 'horizontal'); // Force reflow to restart animation void this.vhsTracking.offsetWidth; // Randomly choose direction (50/50 chance) const direction = Math.random() < 0.5 ? 'vertical' : 'horizontal'; this.vhsTracking.classList.add(direction); this.vhsTracking.classList.add('active'); // Remove classes after animation completes setTimeout(() => { this.vhsTracking.classList.remove('active', 'vertical', 'horizontal'); // 30% chance of a second burst shortly after if (Math.random() < 0.3) { setTimeout(() => { this.triggerVhsTracking(); }, this.randomBetween(100, 300)); } }, this.VHS_DURATION); }, /** * Schedule the next occurrence of a glitch type * @param {string} type - 'rgb', 'blackout', or 'vhs' */ scheduleNext(type) { let minInterval, maxInterval, triggerFn; switch (type) { case 'rgb': minInterval = this.RGB_GLITCH_MIN_INTERVAL; maxInterval = this.RGB_GLITCH_MAX_INTERVAL; triggerFn = () => this.triggerRgbGlitch(); break; case 'blackout': minInterval = this.BLACKOUT_MIN_INTERVAL; maxInterval = this.BLACKOUT_MAX_INTERVAL; triggerFn = () => this.triggerBlackout(); break; case 'vhs': minInterval = this.VHS_MIN_INTERVAL; maxInterval = this.VHS_MAX_INTERVAL; triggerFn = () => this.triggerVhsTracking(); break; default: return; } const delay = this.randomBetween(minInterval, maxInterval); setTimeout(() => { triggerFn(); this.scheduleNext(type); // Schedule next occurrence }, delay); }, /** * Enable or disable glitch effects * @param {boolean} enabled */ setEnabled(enabled) { this.isEnabled = enabled; } }; // Initialize glitch effects after a short delay setTimeout(() => DisplayGlitch.init(), 1000); // ======================================== // DISPLAY GLITCH MODULE - END // ======================================== // ======================================== // REEL ANIMATION HELPERS - START // Consolidated functions for controlling reel/tape spinning // ======================================== /** * Start spinning animation on all reels and tape elements */ function startReelAnimation() { reelLeft.classList.add('spinning'); reelRight.classList.add('spinning'); tapeLeft.classList.add('spinning'); tapeRight.classList.add('spinning'); } /** * Stop spinning animation on all reels and tape elements */ function stopReelAnimation() { reelLeft.classList.remove('spinning'); reelRight.classList.remove('spinning'); tapeLeft.classList.remove('spinning'); tapeRight.classList.remove('spinning'); } /** * Set animation speed for all reel elements * @param {string} duration - CSS duration value (e.g., '0.3s') or empty string for default */ function setReelAnimationSpeed(duration = '') { reelLeft.style.animationDuration = duration; reelRight.style.animationDuration = duration; tapeLeft.style.animationDuration = duration; tapeRight.style.animationDuration = duration; } // ======================================== // REEL ANIMATION HELPERS - END // ======================================== // ======================================== // BUTTON CLICK SOUNDS - Add to all buttons // Uses mousedown for immediate tactile feedback // ======================================== [playBtn, pauseBtn, stopBtn, prevBtn, nextBtn, ejectBtn, lightningBtn].forEach(btn => { btn.addEventListener('mousedown', () => { SoundEffects.playButtonClick(); }); }); // Lightning button - opens Alby Lightning panel // (toggleAlbyPanel function defined in ALBY PANEL FUNCTIONALITY section below) lightningBtn.addEventListener('click', () => { toggleAlbyPanel(); }); // ======================================== // TITLE SCROLL ANIMATION (JavaScript controlled bounce) // Uses requestAnimationFrame for better performance // Moves 1px at a time, bounces at edges, pauses with player // ======================================== let titleScrollPosition = 0; let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right let titleScrollRAF = null; let titleScrollLastTime = 0; const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels) // Cached dimensions - updated only when track changes or on resize let cachedContainerWidth = 0; let cachedTextWidth = 0; let cachedMaxScroll = 0; let cachedTextLonger = false; /** * Cache the container and text dimensions for title scroll * Call this when track changes or window resizes */ function cacheTitleDimensions() { cachedContainerWidth = trackName.offsetWidth; cachedTextWidth = trackNameInner.offsetWidth; cachedTextLonger = cachedTextWidth > cachedContainerWidth; // Calculate max scroll if (cachedTextLonger) { cachedMaxScroll = cachedTextWidth - cachedContainerWidth; } else { cachedMaxScroll = cachedContainerWidth - cachedTextWidth; } } function startTitleScroll() { if (titleScrollRAF) return; // Already scrolling cacheTitleDimensions(); titleScrollLastTime = performance.now(); titleScrollRAF = requestAnimationFrame(titleScrollLoop); } function stopTitleScroll() { if (titleScrollRAF) { cancelAnimationFrame(titleScrollRAF); titleScrollRAF = null; } } function resetTitleScroll() { stopTitleScroll(); titleScrollPosition = 0; titleScrollDirection = 1; trackNameInner.style.left = '0px'; cacheTitleDimensions(); } /** * RAF-based scroll loop - tracks elapsed time to maintain consistent speed */ function titleScrollLoop(currentTime) { const elapsed = currentTime - titleScrollLastTime; // Only update position when enough time has passed (maintains same speed as before) if (elapsed >= SCROLL_SPEED) { titleScrollLastTime = currentTime - (elapsed % SCROLL_SPEED); updateTitleScroll(); } titleScrollRAF = requestAnimationFrame(titleScrollLoop); } function updateTitleScroll() { // Move 1px in current direction titleScrollPosition += titleScrollDirection; // Bounce at edges if (titleScrollPosition >= cachedMaxScroll) { titleScrollPosition = cachedMaxScroll; titleScrollDirection = -1; // Start moving back } else if (titleScrollPosition <= 0) { titleScrollPosition = 0; titleScrollDirection = 1; // Start moving forward } // Apply position using cached text length comparison if (cachedTextLonger) { trackNameInner.style.left = -titleScrollPosition + 'px'; } else { trackNameInner.style.left = titleScrollPosition + 'px'; } } // Recache dimensions on window resize window.addEventListener('resize', () => { if (titleScrollRAF) { cacheTitleDimensions(); } }); // ======================================== // AUDIO LOADING HELPERS - START // Unified helpers for loading tracks and waiting for audio ready // ======================================== /** * Wait for audio element to be ready * @param {string} event - Event to wait for ('loadedmetadata' or 'canplay') * @returns {Promise} Resolves when audio is ready */ function waitForAudioReady(event = 'loadedmetadata') { return new Promise(resolve => { // If already ready, resolve immediately if (event === 'loadedmetadata' && audio.readyState >= 1) { resolve(); return; } if (event === 'canplay' && audio.readyState >= 3) { resolve(); return; } function onReady() { audio.removeEventListener(event, onReady); resolve(); } audio.addEventListener(event, onReady); audio.load(); }); } /** * Load a track with options for seeking, auto-play, and waiting * @param {number} index - Track index to load * @param {Object} options - Loading options * @param {number} [options.seekTo] - Position in seconds to seek to after loading * @param {boolean} [options.autoPlay=false] - Whether to auto-play after loading * @param {string} [options.waitFor='none'] - Event to wait for ('none', 'loadedmetadata', 'canplay') * @returns {Promise} Resolves when track is loaded (and ready if waitFor specified) */ async function loadTrackAsync(index, options = {}) { const { seekTo, autoPlay = false, waitFor = 'none' } = options; currentTrack = index; trackNameInner.textContent = playlist[index].name; resetTitleScroll(); // Direct URL - browser handles caching via HTTP headers audio.src = playlist[index].url; // Wait for audio ready if requested if (waitFor !== 'none') { await waitForAudioReady(waitFor); } // Seek if specified if (typeof seekTo === 'number') { audio.currentTime = seekTo; } // Auto-play if requested if (autoPlay) { try { await audio.play(); startReelAnimation(); startTitleScroll(); } catch (e) { console.log('Auto-play failed:', e); } } } // ======================================== // AUDIO LOADING HELPERS - END // ======================================== // Initial track load (async, no await needed for initial load) loadTrackAsync(0); // Load all track durations for continuous tape model loadAllDurations(); // Set initial volume audio.volume = 0.7; // Play button playBtn.addEventListener('click', () => { audio.play(); startReelAnimation(); startTitleScroll(); }); // Pause button - keeps title at current position pauseBtn.addEventListener('click', () => { audio.pause(); stopReelAnimation(); stopTitleScroll(); }); // Stop button - rewinds tape to the beginning (Track 1, position 0) with animation stopBtn.addEventListener('click', async () => { audio.pause(); // Check if we need to rewind (not already at beginning) const needsRewind = currentTrack !== 0 || audio.currentTime > 0.5; if (needsRewind && durationsLoaded) { // Animate rewind to beginning await animateRewindToStart(); } // Load track 0 if not already on it if (currentTrack !== 0) { await loadTrackAsync(0, { waitFor: 'loadedmetadata', seekTo: 0 }); } else { audio.currentTime = 0; } stopReelAnimation(); resetTapeSizes(); // Ensure tape visuals are at beginning // Manually update time display since timeupdate doesn't fire when paused const duration = formatTime(audio.duration); timeDisplay.textContent = `00:00 / ${duration}`; }); // Previous track (prev button with bar) // Uses animated seek to smoothly wind tape to previous track position prevBtn.addEventListener('click', () => { console.log('Prev button clicked'); const wasPlaying = !audio.paused; const targetTrack = (currentTrack - 1 + playlist.length) % playlist.length; animatedSeekToTrack(targetTrack, wasPlaying); }); // Next track (next button with bar) // Uses animated seek to smoothly wind tape to next track position nextBtn.addEventListener('click', () => { console.log('Next button clicked'); const wasPlaying = !audio.paused; const targetTrack = (currentTrack + 1) % playlist.length; animatedSeekToTrack(targetTrack, wasPlaying); }); // Drag on reels to scrub audio (supports cross-track scrubbing) let isDragging = false; let startY = 0; let startTapePosition = 0; // Global tape position at drag start let wasPlayingBeforeDrag = false; let lastDragY = 0; let tapeWindPlaying = false; let scrubTrackChangeInProgress = false; // Prevents multiple track loads during scrub function startDrag(e) { // Allow drag if we have duration info (either from track or global tape) if (durationsLoaded || audio.duration) { e.preventDefault(); // Prevent text selection during drag isDragging = true; startY = e.clientY || e.touches[0].clientY; lastDragY = startY; startTapePosition = getCurrentTapePosition(); wasPlayingBeforeDrag = !audio.paused; tapeWindPlaying = false; scrubTrackChangeInProgress = false; // Don't pause - let audio continue playing while scrubbing } } function drag(e) { if (!isDragging) return; if (scrubTrackChangeInProgress) return; // Wait for track change to complete const currentY = e.clientY || e.touches[0].clientY; const deltaY = startY - currentY; const instantDeltaY = lastDragY - currentY; lastDragY = currentY; // Calculate scrub speed for audio effects const scrubSpeed = Math.min(Math.abs(instantDeltaY) / 20, 1); // Normalize to 0-1 // Start tape wind sound only when actually moving if (scrubSpeed > 0.1 && !tapeWindPlaying) { SoundEffects.startTapeWindLoop(); tapeWindPlaying = true; } else if (scrubSpeed <= 0.1 && tapeWindPlaying) { SoundEffects.stopTapeWindLoop(); tapeWindPlaying = false; } // Calculate target tape position based on drag distance // Sensitivity: dragging 100px moves 10% of total tape (or current track if durations not loaded) let targetTapePosition; if (durationsLoaded && totalTapeDuration > 0) { // Cross-track scrubbing mode const scrubAmount = (deltaY / 100) * totalTapeDuration * 0.05; targetTapePosition = Math.max(0, Math.min(totalTapeDuration - 0.01, startTapePosition + scrubAmount)); // Find which track this position falls in const target = findTrackAtPosition(targetTapePosition); if (target.trackIndex !== currentTrack) { // Need to switch tracks scrubTrackChangeInProgress = true; loadTrackForScrub(target.trackIndex, target.positionInTrack).then(() => { scrubTrackChangeInProgress = false; updateTapeSizes(); }); } else { // Same track, just seek audio.currentTime = target.positionInTrack; } } else if (audio.duration) { // Fallback to single-track scrubbing const scrubAmount = (deltaY / 100) * audio.duration * 0.1; const newTime = Math.max(0, Math.min(audio.duration, audio.currentTime + scrubAmount)); audio.currentTime = newTime; } // Adjust playback rate for sped-up audio effect if (wasPlayingBeforeDrag && scrubSpeed > 0.1) { audio.playbackRate = 1 + (scrubSpeed * 3); // 1x to 4x } else if (wasPlayingBeforeDrag) { audio.playbackRate = 1; } // Keep playing during scrub if (audio.paused && wasPlayingBeforeDrag) { audio.play(); } // Update tape visual updateTapeSizes(); } /** * Load a track during scrubbing and seek to position * @param {number} trackIndex - Index of track to load * @param {number} seekPosition - Position within track to seek to */ async function loadTrackForScrub(trackIndex, seekPosition) { await loadTrackAsync(trackIndex, { waitFor: 'loadedmetadata', seekTo: seekPosition, autoPlay: wasPlayingBeforeDrag }); } function endDrag() { if (isDragging) { isDragging = false; scrubTrackChangeInProgress = false; // Stop tape wind sound and reset playback rate if (tapeWindPlaying) { SoundEffects.stopTapeWindLoop(); tapeWindPlaying = false; } audio.playbackRate = 1; // Continue playing if it was playing before if (wasPlayingBeforeDrag && audio.paused) { audio.play(); startReelAnimation(); } } } // ======================================== // ANIMATED SEEK TO TRACK // Smoothly animates tape reels when changing tracks via Next/Prev // ======================================== let animatedSeekInProgress = false; /** * Easing function for smooth animation */ function easeInOutQuad(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; } /** * Animate tape rewind to the beginning of the tape * Used by stop button and end-of-tape auto-rewind * @returns {Promise} Resolves when animation completes */ async function animateRewindToStart() { const animationDuration = 500; const startProgress = getTapeProgress(); const endProgress = 0; SoundEffects.startTapeWindLoop(); setReelAnimationSpeed('0.3s'); startReelAnimation(); const startTime = performance.now(); await new Promise(resolve => { function animate(time) { const elapsed = time - startTime; const t = Math.min(elapsed / animationDuration, 1); const easedT = easeInOutQuad(t); const currentProgress = startProgress + (endProgress - startProgress) * easedT; setTapeSizesAtProgress(currentProgress); if (t < 1) { requestAnimationFrame(animate); } else { resolve(); } } requestAnimationFrame(animate); }); SoundEffects.stopTapeWindLoop(); setReelAnimationSpeed(''); } /** * Animate tape reels to a target track with visual fast-forward/rewind effect * @param {number} targetTrackIndex - Index of the track to seek to * @param {boolean} wasPlaying - Whether audio was playing before seek * @returns {Promise} Resolves when animation and track load complete */ async function animatedSeekToTrack(targetTrackIndex, wasPlaying) { if (animatedSeekInProgress) return; animatedSeekInProgress = true; const animationDuration = 500; // ms // Calculate start and end tape progress const startProgress = durationsLoaded ? getTapeProgress() : 0; const endProgress = durationsLoaded ? getTrackStartPosition(targetTrackIndex) / totalTapeDuration : 0; // Start tape wind sound SoundEffects.startTapeWindLoop(); // Speed up reel animation during transition setReelAnimationSpeed('0.3s'); startReelAnimation(); // Speed up audio during transition if playing if (wasPlaying) { audio.playbackRate = 4; } // Animate tape sizes from current to target position const startTime = performance.now(); await new Promise(resolve => { function animate(time) { const elapsed = time - startTime; const t = Math.min(elapsed / animationDuration, 1); const easedT = easeInOutQuad(t); // Interpolate progress const currentProgress = startProgress + (endProgress - startProgress) * easedT; // Update tape sizes setTapeSizesAtProgress(currentProgress); if (t < 1) { requestAnimationFrame(animate); } else { resolve(); } } requestAnimationFrame(animate); }); // Stop tape wind sound SoundEffects.stopTapeWindLoop(); // Reset animation speed setReelAnimationSpeed(''); audio.playbackRate = 1; // Load the new track using unified helper await loadTrackAsync(targetTrackIndex, { waitFor: 'canplay', autoPlay: wasPlaying }); // Handle animation state after track loads if (!wasPlaying) { stopReelAnimation(); } animatedSeekInProgress = false; } // Add drag listeners to all reel elements (containers, tape, and inner spools) [reelLeft, reelRight, tapeLeft, tapeRight, reelContainerLeft, reelContainerRight].forEach(el => { el.addEventListener('mousedown', startDrag); el.addEventListener('touchstart', startDrag, { passive: false }); }); document.addEventListener('mousemove', drag); document.addEventListener('touchmove', drag); document.addEventListener('mouseup', endDrag); document.addEventListener('touchend', endDrag); // Auto-play next track when current ends audio.addEventListener('ended', async () => { const wasLastTrack = currentTrack === playlist.length - 1; // End of tape - rewind to beginning with sound, then continue playing if (wasLastTrack) { if (durationsLoaded) { // Animate rewind to beginning with sound await animateRewindToStart(); } // Load track 0 and seek to start await loadTrackAsync(0, { waitFor: 'loadedmetadata', seekTo: 0 }); resetTapeSizes(); } else { // Normal track transition - advance to next track await loadTrackAsync((currentTrack + 1) % playlist.length); } // Auto-play the next track audio.play(); startReelAnimation(); startTitleScroll(); }); // Volume control volumeSlider.addEventListener('input', (e) => { audio.volume = e.target.value / 100; }); // Update tape wound sizes based on global tape position function updateTapeSizes() { // Use global tape progress if durations are loaded, otherwise fall back to current track let progress; if (durationsLoaded && totalTapeDuration > 0) { progress = getTapeProgress(); } else if (audio.duration) { // Fallback to per-track progress while durations are loading progress = audio.currentTime / audio.duration; } else { return; } setTapeSizesAtProgress(progress); } // Initialize tape sizes - rewind to beginning of tape function resetTapeSizes() { tapeLeft.style.setProperty('--tape-size', TAPE_MAX_SIZE + 'px'); tapeRight.style.setProperty('--tape-size', TAPE_MIN_SIZE + 'px'); } // Set tape sizes to a specific progress value (0 to 1) function setTapeSizesAtProgress(progress) { const tapeRange = TAPE_MAX_SIZE - TAPE_MIN_SIZE; const leftSize = TAPE_MAX_SIZE - (progress * tapeRange); const rightSize = TAPE_MIN_SIZE + (progress * tapeRange); tapeLeft.style.setProperty('--tape-size', leftSize + 'px'); tapeRight.style.setProperty('--tape-size', rightSize + 'px'); } resetTapeSizes(); // Throttle timeupdate for better performance let lastTimeUpdateTime = 0; let lastFormattedTime = ''; const TIME_UPDATE_THROTTLE = 200; // Update at most 5 times per second // Update time display and tape sizes (throttled) audio.addEventListener('timeupdate', () => { const now = performance.now(); if (now - lastTimeUpdateTime < TIME_UPDATE_THROTTLE) return; lastTimeUpdateTime = now; const current = formatTime(audio.currentTime); const duration = formatTime(audio.duration); const formatted = `${current} / ${duration}`; // Skip DOM update if time display hasn't changed if (formatted !== lastFormattedTime) { lastFormattedTime = formatted; timeDisplay.textContent = formatted; } updateTapeSizes(); }); // Update time display when metadata loads (fixes cold start 0:00 / 0:00 issue) audio.addEventListener('loadedmetadata', () => { const current = formatTime(audio.currentTime); const duration = formatTime(audio.duration); timeDisplay.textContent = `${current} / ${duration}`; }); // Format time helper function formatTime(seconds) { if (isNaN(seconds)) return '00:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } // Stop animations when audio pauses (title stays at current position) audio.addEventListener('pause', () => { stopReelAnimation(); stopTitleScroll(); }); // Eject button - opens fullscreen video ejectBtn.addEventListener('click', () => { audio.pause(); stopReelAnimation(); videoPlayer.src = videoUrl; videoOverlay.classList.add('active'); videoPlayer.play(); }); // Close video closeVideo.addEventListener('click', () => { videoPlayer.pause(); videoPlayer.src = ''; videoOverlay.classList.remove('active'); }); // Unified Escape key handler for all overlays/panels document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (videoOverlay.classList.contains('active')) { closeVideo.click(); } else if (albyPanel.classList.contains('active')) { toggleAlbyPanel(); } else if (versionModal && versionModal.classList.contains('active')) { toggleVersionModal(); } } }); // ======================================== // ALBY LIGHTNING PANEL FUNCTIONALITY - START // Handles panel open/close, amount controls, and boost button // ======================================== /** * Toggle the Alby Lightning panel open/closed * Also handles the backdrop overlay visibility * Shows current track info when audio is loaded (playing or paused, not stopped) */ function toggleAlbyPanel() { const isActive = albyPanel.classList.contains('active'); if (isActive) { // Close panel albyPanel.classList.remove('active'); albyOverlay.classList.remove('active'); } else { // Open panel albyPanel.classList.add('active'); albyOverlay.classList.add('active'); // Check if audio is loaded (has duration and currentTime > 0, meaning not at start/stopped) // Show track section if audio has been played or is playing const audioIsLoaded = audio.src && (audio.currentTime > 0 || !audio.paused); if (audioIsLoaded) { // Show track section and populate with current track name albyTrackSection.classList.add('visible'); albyTrackName.textContent = playlist[currentTrack].name; albyIncludeTrack.checked = true; } else { // Hide track section if no audio loaded/played albyTrackSection.classList.remove('visible'); albyIncludeTrack.checked = false; } } } /** * Build the final memo including track info if checkbox is checked * Returns the complete memo string to be sent with the boost */ function buildFinalMemo() { let finalMemo = albyMemo.value; // Append track info if checkbox is checked and track section is visible if (albyIncludeTrack.checked && albyTrackSection.classList.contains('visible')) { const trackInfo = ` | Now Playing: ${playlist[currentTrack].name}`; finalMemo = finalMemo + trackInfo; } return finalMemo; } /** * Update the simple-boost component attributes and display amount * Called whenever amount or memo values change */ function updateAlbyBoostButton() { const amount = parseFloat(albyAmount.value).toFixed(1); const finalMemo = buildFinalMemo(); // Update simple-boost component attributes albySimpleBoost.setAttribute('amount', amount); albySimpleBoost.setAttribute('memo', finalMemo); // Update displayed amount on our custom button albyDisplayAmount.textContent = parseFloat(amount).toFixed(2); } /** * Update the memo character count display */ function updateAlbyCharCount() { albyCharCount.textContent = albyMemo.value.length; } // Close button click handler albyCloseBtn.addEventListener('click', toggleAlbyPanel); // Overlay click handler - close panel when clicking backdrop albyOverlay.addEventListener('click', toggleAlbyPanel); // Increment amount button (+$1.00) albyIncrementBtn.addEventListener('click', () => { const currentValue = parseFloat(albyAmount.value); albyAmount.value = (currentValue + 1.0).toFixed(1); updateAlbyBoostButton(); }); // Decrement amount button (-$1.00, minimum $0.10) albyDecrementBtn.addEventListener('click', () => { const currentValue = parseFloat(albyAmount.value); const newValue = Math.max(0.1, currentValue - 1.0); albyAmount.value = newValue.toFixed(1); updateAlbyBoostButton(); }); // Amount input change handler albyAmount.addEventListener('input', () => { if (albyAmount.value && albyAmount.value >= 0.1) { updateAlbyBoostButton(); } }); // Amount input blur handler - validate minimum value albyAmount.addEventListener('blur', () => { if (!albyAmount.value || albyAmount.value < 0.1) { albyAmount.value = '0.1'; } updateAlbyBoostButton(); }); // Memo textarea input handler albyMemo.addEventListener('input', () => { updateAlbyCharCount(); updateAlbyBoostButton(); }); // Include track checkbox change handler albyIncludeTrack.addEventListener('change', () => { updateAlbyBoostButton(); }); // Boost button click - trigger the simple-boost component albyBoostBtn.addEventListener('click', () => { // Build final memo and validate length const finalMemo = buildFinalMemo(); // Check if total memo exceeds 400 characters if (finalMemo.length > 400) { alert(`Memo too long! Your message with track info is ${finalMemo.length} characters. Maximum is 400.\n\nPlease shorten your message or uncheck "Include current track?"`); return; // Don't send the boost } // Ensure latest form values are synced to simple-boost before triggering updateAlbyBoostButton(); // Click the inner button div inside the shadow DOM const innerButton = albySimpleBoost.shadowRoot?.querySelector('.simple-boost-button'); if (innerButton) { innerButton.click(); } }); // Listen for successful payment, reset form fields, and close modal albySimpleBoost.addEventListener('success', (e) => { // Reset form to default values albyAmount.value = '1.0'; albyMemo.value = ''; // Reset track checkbox (will be re-evaluated when panel reopens) albyIncludeTrack.checked = true; albyTrackSection.classList.remove('visible'); updateAlbyCharCount(); updateAlbyBoostButton(); // Close the modal after successful boost toggleAlbyPanel(); }); // Initialize character count and sync simple-boost attributes on page load updateAlbyCharCount(); updateAlbyBoostButton(); // ======================================== // ALBY LIGHTNING PANEL FUNCTIONALITY - END // ======================================== // ======================================== // VERSION MODAL FUNCTIONALITY - START // Simple modal to display app version // ======================================== const versionBtn = document.getElementById('versionBtn'); const versionOverlay = document.getElementById('versionOverlay'); const versionModal = document.getElementById('versionModal'); const versionCloseBtn = document.getElementById('versionCloseBtn'); const versionNumber = document.getElementById('versionNumber'); // Set version from APP_VERSION constant (defined at top of file) versionNumber.textContent = APP_VERSION; /** * Toggle the version modal open/closed */ function toggleVersionModal() { const isActive = versionModal.classList.contains('active'); if (isActive) { versionModal.classList.remove('active'); versionOverlay.classList.remove('active'); } else { versionModal.classList.add('active'); versionOverlay.classList.add('active'); } } // Version button click handler versionBtn.addEventListener('click', toggleVersionModal); // Close button click handler versionCloseBtn.addEventListener('click', toggleVersionModal); // Overlay click handler - close modal when clicking backdrop versionOverlay.addEventListener('click', toggleVersionModal); // ======================================== // VERSION MODAL FUNCTIONALITY - END // ========================================