diff --git a/app.js b/app.js new file mode 100644 index 0000000..0f0a5bc --- /dev/null +++ b/app.js @@ -0,0 +1,1353 @@ +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} 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(); + } + } +}); + +// ======================================== +// 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 +// ======================================== diff --git a/index.html b/index.html index 3697ec3..738a04a 100644 --- a/index.html +++ b/index.html @@ -4,1286 +4,7 @@ 16-Bit Cassette Player - +
@@ -1420,1461 +141,6 @@ - + - \ No newline at end of file + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..9691986 --- /dev/null +++ b/styles.css @@ -0,0 +1,1266 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: linear-gradient(180deg, #0a0a0a 0%, #1a1a1a 100%); + font-family: 'Courier New', monospace; + image-rendering: pixelated; +} + +.player { + width: 600px; + background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%); + border: 8px solid #0a0a0a; + box-shadow: + inset 0 0 10px rgba(0,0,0,0.1), + 0 30px 60px rgba(0,0,0,0.8), + 5px 5px 0 rgba(0,0,0,0.3); + /* Extra top padding to accommodate eject/lightning buttons */ + padding: 60px 30px 30px 30px; + position: relative; + transform: perspective(1000px) rotateX(1deg); +} + +/* Heavy damage and scratches */ +.player::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: url('background.png'); + background-size: 100%; + background-position: center; + background-repeat: repeat; + pointer-events: none; + opacity: 0.20; + mix-blend-mode: normal; +} + + +/* Eject button - positioned top-left of player */ +.eject-btn { + position: absolute; + top: 15px; + left: 30px; + width: 50px; + height: 35px; + background: linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 100%); + border: 4px solid #0a0a0a; + cursor: pointer; + box-shadow: + inset 0 -3px 0 rgba(0,0,0,0.5), + inset 2px 0 0 rgba(80, 50, 20, 0.3), + 0 3px 6px rgba(0,0,0,0.6); + transition: all 0.1s; + display: flex; + align-items: center; + justify-content: center; +} + +/* Lightning button - positioned top-right of player */ +.lightning-btn { + position: absolute; + top: 15px; + right: 30px; + width: 50px; + height: 35px; + background: linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 100%); + border: 4px solid #0a0a0a; + cursor: pointer; + box-shadow: + inset 0 -3px 0 rgba(0,0,0,0.5), + inset 2px 0 0 rgba(80, 50, 20, 0.3), + 0 3px 6px rgba(0,0,0,0.6); + transition: all 0.1s; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #cc8800; + text-shadow: 0 0 3px rgba(255, 170, 0, 0.3); +} + +.lightning-btn::before, +.lightning-btn::after { + content: none; +} + +.lightning-btn:active, +.eject-btn:active { + transform: translateY(2px); + box-shadow: + inset 0 -1px 0 rgba(0,0,0,0.5), + 0 1px 3px rgba(0,0,0,0.6); +} + +/* Eject icon: triangle pointing up */ +.eject-btn::before { + content: ''; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 10px solid #666; + position: absolute; + top: 6px; + filter: drop-shadow(0 0 2px rgba(255, 0, 255, 0.3)); +} + +/* Eject icon: bar underneath triangle */ +.eject-btn::after { + content: ''; + position: absolute; + bottom: 6px; + width: 18px; + height: 3px; + background: #666; +} + +.display { + background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%); + border: 4px solid #000; + padding: 15px; + /* Spacing: top margin matches bottom margin for symmetry with buttons above */ + margin-top: 15px; + margin-bottom: 25px; + box-shadow: + inset 0 0 30px rgba(0,0,0,0.9), + inset 0 0 5px rgba(0,255,0,0.1); + position: relative; + overflow: hidden; +} + +/* Cracked display effect */ +.display::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(120deg, transparent 0%, transparent 48%, rgba(255,255,255,0.05) 48.5%, transparent 49%), + linear-gradient(60deg, transparent 0%, transparent 68%, rgba(255,255,255,0.03) 68.5%, transparent 69%), + repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.6) 2px, rgba(0,0,0,0.6) 3px); + pointer-events: none; + z-index: 2; +} + +/* Flickering scanlines */ +.display::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,0.5) 1px, rgba(0,0,0,0.5) 2px); + pointer-events: none; + animation: flicker 4s infinite; + z-index: 3; +} + +@keyframes flicker { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 0.6; } + 51% { opacity: 1; } + 52% { opacity: 0.7; } +} + +/* ======================================== + DISPLAY GLITCH EFFECTS - START + Random visual glitches for dystopian aesthetic + ======================================== */ + +/* RGB Chromatic Aberration Glitch */ +.display.glitch-rgb .display-text-inner, +.display.glitch-rgb .time-display { + animation: rgbGlitch 0.08s steps(2) infinite; +} + +@keyframes rgbGlitch { + 0% { + text-shadow: + -2px 0 #ff0000, + 2px 0 #00ffff, + 0 0 10px #00ff00, + 0 0 20px #00ff00; + transform: translateX(-1px); + } + 50% { + text-shadow: + 2px 0 #ff0000, + -2px 0 #00ffff, + 0 0 10px #00ff00, + 0 0 20px #00ff00; + transform: translateX(1px); + } + 100% { + text-shadow: + -1px 0 #ff0000, + 1px 0 #00ffff, + 0 0 10px #00ff00, + 0 0 20px #00ff00; + transform: translateX(0); + } +} + +/* Intermittent Display Failure (Blackout) */ +.display.blackout { + filter: brightness(0); +} + +.display.blackout .display-text, +.display.blackout .display-text-inner, +.display.blackout .time-display { + opacity: 0 !important; + text-shadow: none !important; +} + +/* VHS Tracking Lines Overlay */ +.vhs-tracking { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + opacity: 0; + z-index: 15; +} + +/* Vertical tracking (default) - lines scroll up */ +.vhs-tracking.vertical { + right: 0; + bottom: auto; + width: 100%; + height: 200%; + background: repeating-linear-gradient( + 0deg, + transparent 0px, + transparent 8px, + rgba(255, 255, 255, 0.06) 8px, + rgba(255, 255, 255, 0.06) 12px, + transparent 12px, + transparent 25px, + rgba(0, 255, 0, 0.03) 25px, + rgba(0, 255, 0, 0.03) 28px + ); +} + +/* Horizontal tracking - lines scroll left */ +.vhs-tracking.horizontal { + bottom: 0; + right: auto; + width: 200%; + height: 100%; + background: repeating-linear-gradient( + 90deg, + transparent 0px, + transparent 8px, + rgba(255, 255, 255, 0.06) 8px, + rgba(255, 255, 255, 0.06) 12px, + transparent 12px, + transparent 25px, + rgba(0, 255, 0, 0.03) 25px, + rgba(0, 255, 0, 0.03) 28px + ); +} + +.vhs-tracking.active.vertical { + opacity: 1; + animation: vhsScrollVertical 0.4s linear; +} + +.vhs-tracking.active.horizontal { + opacity: 1; + animation: vhsScrollHorizontal 0.4s linear; +} + +@keyframes vhsScrollVertical { + from { transform: translateY(-50%); } + to { transform: translateY(0); } +} + +@keyframes vhsScrollHorizontal { + from { transform: translateX(-50%); } + to { transform: translateX(0); } +} + +/* Dim Spots - worn/aged phosphor effect on CRT */ +.dim-spots-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 20; + overflow: hidden; +} + +.dim-spot { + position: absolute; + pointer-events: none; + will-change: transform; +} + +/* Individual spot styles and animations */ +.dim-spot-1 { + top: 10%; + left: 0%; + width: 50%; + height: 60%; + background: radial-gradient(ellipse 70% 75% at center, rgba(0, 0, 0, 0.55) 0%, transparent 70%); + animation: dimSpotDrift1 45s ease-in-out infinite; +} + +.dim-spot-2 { + top: 5%; + right: 0%; + width: 40%; + height: 50%; + background: radial-gradient(ellipse 75% 80% at center, rgba(0, 0, 0, 0.4) 0%, transparent 65%); + animation: dimSpotDrift2 55s ease-in-out infinite; +} + +.dim-spot-3 { + bottom: 5%; + left: 10%; + width: 35%; + height: 45%; + background: radial-gradient(ellipse 70% 75% at center, rgba(0, 0, 0, 0.45) 0%, transparent 60%); + animation: dimSpotDrift3 50s ease-in-out infinite; +} + +.dim-spot-4 { + bottom: 0%; + right: 0%; + width: 45%; + height: 55%; + background: radial-gradient(ellipse 80% 90% at center, rgba(0, 0, 0, 0.5) 0%, transparent 65%); + animation: dimSpotDrift4 60s ease-in-out infinite; +} + +.dim-spot-5 { + top: 35%; + left: 55%; + width: 30%; + height: 35%; + background: radial-gradient(circle at center, rgba(0, 0, 0, 0.35) 0%, transparent 60%); + animation: dimSpotDrift5 40s ease-in-out infinite; +} + +/* Slow drifting animations - each spot moves in a different pattern */ +@keyframes dimSpotDrift1 { + 0%, 100% { transform: translate(0%, 0%); } + 25% { transform: translate(8%, 5%); } + 50% { transform: translate(5%, -8%); } + 75% { transform: translate(-5%, 3%); } +} + +@keyframes dimSpotDrift2 { + 0%, 100% { transform: translate(0%, 0%); } + 25% { transform: translate(-10%, 6%); } + 50% { transform: translate(-5%, 10%); } + 75% { transform: translate(5%, -5%); } +} + +@keyframes dimSpotDrift3 { + 0%, 100% { transform: translate(0%, 0%); } + 25% { transform: translate(6%, -8%); } + 50% { transform: translate(12%, -3%); } + 75% { transform: translate(-4%, -10%); } +} + +@keyframes dimSpotDrift4 { + 0%, 100% { transform: translate(0%, 0%); } + 25% { transform: translate(-8%, -6%); } + 50% { transform: translate(-12%, 4%); } + 75% { transform: translate(4%, -8%); } +} + +@keyframes dimSpotDrift5 { + 0%, 100% { transform: translate(0%, 0%); } + 25% { transform: translate(-15%, 10%); } + 50% { transform: translate(10%, 15%); } + 75% { transform: translate(15%, -10%); } +} + +/* ======================================== + DISPLAY GLITCH EFFECTS - END + ======================================== */ + +/* Track name text - scrolls when playing (JS controlled) */ +.display-text { + color: #00ff00; + font-size: 18px; + text-shadow: + 0 0 10px #00ff00, + 0 0 20px #00ff00, + 0 0 30px #00ff00; + letter-spacing: 2px; + margin-bottom: 10px; + position: relative; + z-index: 1; + white-space: nowrap; + overflow: hidden; +} + +/* Inner span - position controlled by JavaScript for bounce effect */ +.display-text-inner { + display: inline-block; + position: relative; + left: 0; +} + +.time-display { + color: #00ff00; + font-size: 24px; + font-weight: bold; + text-shadow: + 0 0 10px #00ff00, + 0 0 20px #00ff00; + letter-spacing: 3px; + position: relative; + z-index: 1; +} + +.cassette { + background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%); + border: 6px solid #000; + border-radius: 4px; + padding: 20px; + margin-bottom: 30px; + position: relative; + box-shadow: + inset 0 0 40px rgba(0,0,0,0.9), + inset 5px 5px 20px rgba(0,0,0,0.8); + transform: perspective(800px) rotateX(2deg); + /* Prevent text selection during reel drag */ + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* Damage on cassette housing */ +/* Removed by popular demand, leave code in place, even during "dead code" audits. +.cassette::before { + content: ''; + position: absolute; + top: 10px; + right: 20px; + width: 40px; + height: 30px; + background: radial-gradient(ellipse, rgba(255, 0, 255, 0.2) 0%, transparent 70%); + border: 2px solid rgba(255, 0, 255, 0.3); + border-radius: 3px; + pointer-events: none; +} +*/ + + +.cassette-label { + background: linear-gradient(180deg, #d0d0d0 0%, #a0a0a0 100%); + border: 3px solid #000; + padding: 15px; + text-align: center; + margin-bottom: 20px; + font-size: 14px; + color: #1a1a1a; + font-weight: bold; + box-shadow: + inset 0 0 30px rgba(0,0,0,0.3), + inset 3px 3px 10px rgba(0,0,0,0.4); + position: relative; + overflow: hidden; + /* Prevent text selection during reel drag */ + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* Faded neon cyan showing through wear */ +.cassette-label::before { + content: ''; + position: absolute; + top: 5px; + left: 10px; + width: 50px; + height: 20px; + background: linear-gradient(90deg, transparent, rgba(0, 255, 255, 0.4), transparent); + pointer-events: none; + filter: blur(2px); +} + +/* Faded magenta showing through wear */ +.cassette-label::after { + content: ''; + position: absolute; + bottom: 8px; + right: 15px; + width: 40px; + height: 15px; + background: linear-gradient(90deg, transparent, rgba(255, 0, 255, 0.3), transparent); + pointer-events: none; + filter: blur(2px); +} + +.reel { + width: 80px; + height: 80px; + border: 1px solid #000; + border-radius: 50%; + background: #0a0a0a; + position: absolute; + top: 50%; + transform: translateY(-50%); + box-shadow: + inset 0 0 20px rgba(0,0,0,0.9), + inset 0 0 5px rgba(139, 69, 19, 0.3); + /* Prevent text selection during drag */ + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: ns-resize; +} + +.reel-left { + left: 30px; +} + +.reel-right { + right: 30px; +} + +.tape-wound { + position: absolute; + /* Center using top/left 50% with negative margins (Safari-safe) */ + top: 50%; + left: 50%; + /* Use negative margins for centering instead of transform */ + margin-left: calc(var(--tape-size, 72px) / -2); + margin-top: calc(var(--tape-size, 72px) / -2); + border-radius: 50%; + /* Darker tape color */ + background: linear-gradient(135deg, #1a1815 0%, #0f0d0a 50%, #050403 100%); + box-shadow: + inset 0 0 10px rgba(0,0,0,0.9), + 0 0 3px rgba(0,0,0,0.7); + z-index: 1; + /* Size controlled by JavaScript via CSS custom property */ + width: var(--tape-size, 72px); + height: var(--tape-size, 72px); + /* Safari animation optimizations */ + transform-origin: center center; + will-change: transform; + /* Prevent text selection during drag */ + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: ns-resize; +} + +.tape-wound.spinning { + animation: spinTape 2s linear infinite; +} + +@keyframes spinTape { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.reel-inner { + width: 60px; + height: 60px; + border-radius: 50%; + background: conic-gradient( + from 0deg, + #2a2a2a 0deg 45deg, + #1a1a1a 45deg 90deg, + #2a2a2a 90deg 135deg, + #1a1a1a 135deg 180deg, + #2a2a2a 180deg 225deg, + #1a1a1a 225deg 270deg, + #2a2a2a 270deg 315deg, + #1a1a1a 315deg 360deg + ); + box-shadow: inset 0 0 10px rgba(0,0,0,0.8); + z-index: 2; + /* Center the spool within the reel container (Safari-safe) */ + position: absolute; + top: 50%; + left: 50%; + /* Use negative margins for centering instead of transform */ + margin-left: -30px; /* half of 60px width */ + margin-top: -30px; /* half of 60px height */ + /* Safari animation optimizations */ + transform-origin: center center; + will-change: transform; +} + +.reel-inner.spinning { + animation: spinSpool 2s linear infinite; +} + +@keyframes spinSpool { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.tape-window { + position: relative; + width: 380px; + height: 80px; + margin: 0 auto; + background: rgba(60, 30, 10, 0.4); + border: 3px solid #000; + box-shadow: inset 0 0 15px rgba(0,0,0,0.9); + overflow: hidden; +} + +.controls { + display: flex; + justify-content: center; + gap: 15px; + margin-bottom: 20px; +} + +.btn { + width: 70px; + height: 70px; + border: 5px solid #000; + background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%); + cursor: pointer; + position: relative; + box-shadow: + inset 0 -4px 0 rgba(0,0,0,0.5), + inset 0 0 10px rgba(0,0,0,0.6), + 0 4px 8px rgba(0,0,0,0.6); + transition: all 0.1s; +} + +.btn:active { + transform: translateY(2px); + box-shadow: + inset 0 -2px 0 rgba(0,0,0,0.5), + 0 2px 4px rgba(0,0,0,0.6); +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +/* Worn colored icons on buttons */ +.btn-prev::before { + width: 0; + height: 0; + border-right: 12px solid #00a0a0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + margin-right: 8px; + filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.4)); + opacity: 0.7; +} + +.btn-prev::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 3px; + height: 16px; + background: #00a0a0; + margin-left: -12px; + opacity: 0.7; +} + +.btn-play::before { + width: 0; + height: 0; + border-left: 20px solid #00aa00; + border-top: 12px solid transparent; + border-bottom: 12px solid transparent; + margin-left: 3px; + filter: drop-shadow(0 0 3px rgba(0, 255, 0, 0.3)); + opacity: 0.8; +} + +.btn-pause::before { + width: 20px; + height: 24px; + border-left: 7px solid #cc8800; + border-right: 7px solid #cc8800; + filter: drop-shadow(0 0 3px rgba(255, 170, 0, 0.3)); + opacity: 0.7; +} + +.btn-stop::before { + width: 20px; + height: 20px; + background: #aa0000; + filter: drop-shadow(0 0 3px rgba(255, 0, 0, 0.3)); + opacity: 0.7; +} + +.btn-next::before { + width: 0; + height: 0; + border-left: 12px solid #00a0a0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + margin-left: -8px; + filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.4)); + opacity: 0.7; +} + +.btn-next::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 3px; + height: 16px; + background: #00a0a0; + margin-left: 12px; + opacity: 0.7; +} + +.volume-control { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; +} + +.volume-label { + color: #00ff00; + font-size: 16px; + text-shadow: + 0 0 5px #00ff00, + 0 0 10px #00ff00; + opacity: 0.9; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 200px; + height: 12px; + background: linear-gradient(180deg, #1a1a1a 0%, #0a0a0a 100%); + border: 3px solid #000; + box-shadow: + inset 0 0 10px rgba(0,0,0,0.8), + inset 0 0 5px rgba(139, 69, 19, 0.2); +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 24px; + height: 24px; + background: linear-gradient(145deg, #00ff00 0%, #00aa00 100%); + border: 3px solid #000; + cursor: pointer; + box-shadow: + 0 0 10px rgba(0, 255, 0, 0.5), + inset 0 -2px 5px rgba(0,0,0,0.4); +} + +input[type="range"]::-moz-range-thumb { + width: 24px; + height: 24px; + background: linear-gradient(145deg, #00ff00 0%, #00aa00 100%); + border: 3px solid #000; + cursor: pointer; + box-shadow: + 0 0 10px rgba(0, 255, 0, 0.5), + inset 0 -2px 5px rgba(0,0,0,0.4); +} + +.video-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: #000; + z-index: 1000; + justify-content: center; + align-items: center; +} + +.video-overlay.active { + display: flex; +} + +.video-overlay video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.close-video { + position: absolute; + top: 20px; + right: 20px; + width: 40px; + height: 40px; + background: rgba(0,0,0,0.8); + border: 2px solid #fff; + color: #fff; + font-size: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1003; + text-shadow: none; +} + +.close-video:hover { + background: rgba(0,0,0,0.9); + border-color: #fff; + box-shadow: 0 0 20px rgba(255, 255, 255, 0.5); +} + +/* ======================================== + ALBY LIGHTNING PANEL STYLES - START + Panel overlay and slide-in panel for Lightning payments + Styled to match the cassette player's retro 16-bit aesthetic + ======================================== */ + +/* Semi-transparent backdrop overlay - click to close panel */ +.alby-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.7); + z-index: 1000; +} + +.alby-overlay.active { + display: block; +} + +/* Main floating modal container */ +.alby-panel { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.9); + opacity: 0; + width: 380px; + max-height: 90vh; + background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%); + border: 6px solid #0a0a0a; + border-radius: 8px; + box-shadow: + inset 0 0 0 2px #3a3a3a, + inset 0 0 50px rgba(0,0,0,0.9), + 0 20px 60px rgba(0,0,0,0.8); + z-index: 1001; + transition: transform 0.2s ease-out, opacity 0.2s ease-out; + overflow-y: auto; + font-family: 'Courier New', monospace; + pointer-events: none; +} + +.alby-panel.active { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + pointer-events: auto; +} + + +/* Panel header with amber Lightning accent */ +.alby-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + background: linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%); + border-bottom: 4px solid #000; + border-radius: 4px 4px 0 0; + position: relative; +} + +.alby-panel-title { + color: #cc8800; + font-size: 18px; + font-weight: bold; + text-shadow: + 0 0 10px rgba(255, 170, 0, 0.5), + 0 0 20px rgba(255, 170, 0, 0.3); + letter-spacing: 2px; + display: flex; + align-items: center; + gap: 10px; +} + +.alby-panel-title .lightning-icon { + font-size: 20px; +} + +/* Close button styled like cassette buttons */ +.alby-close-btn { + width: 36px; + height: 36px; + background: linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 100%); + border: 3px solid #000; + color: #666; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + inset 0 -2px 0 rgba(0,0,0,0.5), + 0 2px 4px rgba(0,0,0,0.6); + transition: all 0.1s; +} + +.alby-close-btn:hover { + color: #aa0000; +} + +.alby-close-btn:active { + transform: translateY(2px); + box-shadow: + inset 0 -1px 0 rgba(0,0,0,0.5), + 0 1px 2px rgba(0,0,0,0.6); +} + +/* Panel body content area */ +.alby-panel-body { + padding: 20px; + position: relative; +} + +/* Section labels with green CRT glow */ +.alby-label { + color: #00ff00; + font-size: 14px; + text-shadow: + 0 0 5px #00ff00, + 0 0 10px #00ff00; + letter-spacing: 2px; + margin-bottom: 8px; + opacity: 0.9; +} + +/* Lightning address display - styled like cassette display */ +.alby-address-box { + background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%); + border: 3px solid #000; + padding: 12px; + margin-bottom: 15px; + box-shadow: + inset 0 0 20px rgba(0,0,0,0.9), + inset 0 0 5px rgba(255, 170, 0, 0.1); + position: relative; +} + +/* Scanlines effect on address box */ +.alby-address-box::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,0.4) 1px, rgba(0,0,0,0.4) 2px); + pointer-events: none; +} + +.alby-address { + color: #cc8800; + font-size: 15px; + text-shadow: 0 0 8px rgba(255, 170, 0, 0.5); + word-break: break-all; + position: relative; + z-index: 1; +} + +/* Node ID styled as terminal text */ +.alby-node-id { + color: #00ff00; + font-size: 9px; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.3); + word-break: break-all; + margin-top: 10px; + opacity: 0.7; + position: relative; + z-index: 1; +} + +/* Divider line */ +.alby-divider { + height: 2px; + background: linear-gradient(90deg, transparent, #333, transparent); + margin: 20px 0; +} + +/* Amount controls container */ +.alby-amount-controls { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; +} + +/* Increment/decrement buttons styled like cassette buttons */ +.alby-btn { + width: 40px; + height: 40px; + background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%); + border: 3px solid #000; + color: #00ff00; + font-size: 20px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + inset 0 -3px 0 rgba(0,0,0,0.5), + 0 3px 6px rgba(0,0,0,0.6); + transition: all 0.1s; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.5); +} + +.alby-btn:hover { + text-shadow: 0 0 10px rgba(0, 255, 0, 0.8); +} + +.alby-btn:active { + transform: translateY(2px); + box-shadow: + inset 0 -1px 0 rgba(0,0,0,0.5), + 0 1px 3px rgba(0,0,0,0.6); +} + +/* Amount input styled like CRT display */ +.alby-input { + flex: 1; + background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%); + border: 3px solid #000; + padding: 10px; + color: #00ff00; + font-family: 'Courier New', monospace; + font-size: 20px; + text-align: center; + text-shadow: 0 0 10px #00ff00; + box-shadow: inset 0 0 15px rgba(0,0,0,0.9); + outline: none; +} + +.alby-input:focus { + box-shadow: + inset 0 0 15px rgba(0,0,0,0.9), + 0 0 5px rgba(0, 255, 0, 0.3); +} + +/* Remove spinner arrows from number input */ +.alby-input::-webkit-outer-spin-button, +.alby-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.alby-input[type=number] { + -moz-appearance: textfield; + appearance: textfield; +} + +/* Memo textarea styled like CRT */ +.alby-textarea { + width: 100%; + background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%); + border: 3px solid #000; + padding: 12px; + color: #00ff00; + font-family: 'Courier New', monospace; + font-size: 16px; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.5); + box-shadow: inset 0 0 15px rgba(0,0,0,0.9); + resize: none; + outline: none; + min-height: 80px; +} + +.alby-textarea:focus { + box-shadow: + inset 0 0 15px rgba(0,0,0,0.9), + 0 0 5px rgba(0, 255, 0, 0.3); +} + +/* Character count display */ +.alby-char-count { + color: #00ff00; + font-size: 10px; + text-shadow: 0 0 3px rgba(0, 255, 0, 0.3); + text-align: right; + margin-top: 5px; + opacity: 0.7; +} + +/* Current track section - shown when audio is playing */ +.alby-track-section { + margin-top: 15px; + display: none; /* Hidden by default, shown via JS when audio playing */ +} + +.alby-track-section.visible { + display: block; +} + +/* Track display box - styled like address box with CRT look */ +.alby-track-box { + background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%); + border: 3px solid #000; + padding: 12px; + margin-bottom: 10px; + box-shadow: + inset 0 0 20px rgba(0,0,0,0.9), + inset 0 0 5px rgba(255, 170, 0, 0.1); + position: relative; +} + +/* Scanlines effect on track box */ +.alby-track-box::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,0.4) 1px, rgba(0,0,0,0.4) 2px); + pointer-events: none; +} + +/* Track name text - amber like lightning address */ +.alby-track-name { + color: #cc8800; + font-size: 13px; + text-shadow: 0 0 8px rgba(255, 170, 0, 0.5); + word-break: break-word; + position: relative; + z-index: 1; +} + +/* Checkbox label container */ +.alby-checkbox-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 5px 0; +} + +/* Custom checkbox styling to match retro theme */ +.alby-checkbox-label input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%); + border: 3px solid #000; + cursor: pointer; + position: relative; + box-shadow: inset 0 0 10px rgba(0,0,0,0.8); +} + +/* Checkbox checked state - green checkmark */ +.alby-checkbox-label input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 6px; + height: 10px; + border: solid #00ff00; + border-width: 0 3px 3px 0; + transform: rotate(45deg); + box-shadow: 0 0 5px rgba(0, 255, 0, 0.5); +} + +/* Checkbox text - green CRT style */ +.alby-checkbox-text { + color: #00ff00; + font-size: 12px; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.5); + letter-spacing: 1px; +} + +/* Main boost button - amber Lightning theme */ +.alby-boost-btn { + width: 100%; + padding: 15px 20px; + margin-top: 20px; + background: linear-gradient(180deg, #cc8800 0%, #996600 100%); + border: 4px solid #000; + color: #000; + font-family: 'Courier New', monospace; + font-size: 18px; + font-weight: bold; + letter-spacing: 2px; + cursor: pointer; + box-shadow: + inset 0 -4px 0 rgba(0,0,0,0.3), + 0 0 15px rgba(255, 170, 0, 0.3), + 0 4px 8px rgba(0,0,0,0.6); + transition: all 0.1s; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.alby-boost-btn:hover { + box-shadow: + inset 0 -4px 0 rgba(0,0,0,0.3), + 0 0 25px rgba(255, 170, 0, 0.5), + 0 4px 8px rgba(0,0,0,0.6); +} + +.alby-boost-btn:active { + transform: translateY(2px); + box-shadow: + inset 0 -2px 0 rgba(0,0,0,0.3), + 0 0 15px rgba(255, 170, 0, 0.3), + 0 2px 4px rgba(0,0,0,0.6); +} + +/* Hide the simple-boost default styling, we use our own button */ +simple-boost { + position: absolute !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; + overflow: hidden !important; + pointer-events: none !important; +} + +/* ======================================== + ALBY LIGHTNING PANEL STYLES - END + ======================================== */