2026-01-17 21:13:58 -05:00

1405 lines
48 KiB
JavaScript

const APP_VERSION = '0.1.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 a temporary audio element to fetch duration without full download
* Falls back to 240 seconds (4 min) if metadata can't be loaded
*/
async function loadAllDurations() {
console.log('Loading track durations...');
trackDurations = [];
for (let i = 0; i < playlist.length; i++) {
try {
// For track 0, use the main audio element since loadTrack(0) already loads it
if (i === 0 && currentTrack === 0) {
// Wait for main audio to load metadata if not already
if (audio.duration && !isNaN(audio.duration)) {
trackDurations[i] = audio.duration;
} else {
// Wait for main audio metadata
trackDurations[i] = 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 {
// For other tracks, use temp audio element
trackDurations[i] = await getTrackDuration(playlist[i].url);
}
console.log(`Track ${i + 1} duration: ${formatTime(trackDurations[i])}`);
} catch (e) {
console.warn(`Failed to get duration for track ${i + 1}, using fallback`);
trackDurations[i] = 240; // 4 minute fallback
}
}
// 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<number>} 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
// ========================================
const SoundEffects = {
ctx: null,
// Initialize AudioContext (must be called after user gesture)
init() {
if (!this.ctx) {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
}
// Resume if suspended (browser autoplay policy)
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
},
// 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 noise
const bufferSize = this.ctx.sampleRate * 0.02; // 20ms of noise
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const noise = this.ctx.createBufferSource();
noise.buffer = noiseBuffer;
// 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 looping noise buffer for seamless continuous playback
startTapeWindLoop() {
this.init();
// Stop any existing loop
this.stopTapeWindLoop();
const now = this.ctx.currentTime;
const sampleRate = this.ctx.sampleRate;
// Create 1 second of loopable noise
const bufferSize = sampleRate * 1;
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate);
const output = noiseBuffer.getChannelData(0);
// Generate noise with amplitude modulation
for (let i = 0; i < bufferSize; i++) {
const noise = Math.random() * 2 - 1;
const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2);
output[i] = noise * wobble;
}
this.tapeWindSource = this.ctx.createBufferSource();
this.tapeWindSource.buffer = noiseBuffer;
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)
// 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 titleScrollInterval = null;
const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels)
function startTitleScroll() {
if (titleScrollInterval) return; // Already scrolling
titleScrollInterval = setInterval(updateTitleScroll, SCROLL_SPEED);
}
function stopTitleScroll() {
if (titleScrollInterval) {
clearInterval(titleScrollInterval);
titleScrollInterval = null;
}
}
function resetTitleScroll() {
stopTitleScroll();
titleScrollPosition = 0;
titleScrollDirection = 1;
trackNameInner.style.left = '0px';
}
function updateTitleScroll() {
const containerWidth = trackName.offsetWidth;
const textWidth = trackNameInner.offsetWidth;
// Calculate max scroll - text scrolls until its right edge hits container's right edge
// If text is shorter than container, scroll until text's left edge hits container's right edge
let maxScroll;
if (textWidth > containerWidth) {
// Text is longer - scroll until right edge of text reaches right edge of container
maxScroll = textWidth - containerWidth;
} else {
// Text is shorter - scroll across the container width but keep text visible
// Text starts at left (0), scrolls right until left edge is at (containerWidth - textWidth)
maxScroll = containerWidth - textWidth;
}
// Move 1px in current direction
titleScrollPosition += titleScrollDirection;
// Bounce at edges
if (titleScrollPosition >= maxScroll) {
titleScrollPosition = maxScroll;
titleScrollDirection = -1; // Start moving back
} else if (titleScrollPosition <= 0) {
titleScrollPosition = 0;
titleScrollDirection = 1; // Start moving forward
}
// Apply position
// If text is longer: shift text LEFT (negative value) so we see the end
// If text is shorter: shift text RIGHT (positive value) to scroll across display
if (textWidth > containerWidth) {
trackNameInner.style.left = -titleScrollPosition + 'px';
} else {
trackNameInner.style.left = titleScrollPosition + 'px';
}
}
// ========================================
// 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();
// Update time display and tape sizes
audio.addEventListener('timeupdate', () => {
const current = formatTime(audio.currentTime);
const duration = formatTime(audio.duration);
timeDisplay.textContent = `${current} / ${duration}`;
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
// ========================================