2026-01-17 19:09:53 -05:00
const APP _VERSION = '0.1.0-beta' ;
2026-01-17 18:26:36 -05:00
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 ( ) ;
} ) ;
// 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 ( ) ;
2026-01-17 19:09:53 -05:00
} else if ( versionModal && versionModal . classList . contains ( 'active' ) ) {
toggleVersionModal ( ) ;
2026-01-17 18:26:36 -05:00
}
}
} ) ;
// ========================================
// 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 \n Please 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
// ========================================
2026-01-17 19:09:53 -05:00
// ========================================
// 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
// ========================================