diff --git a/index.html b/index.html index 3d6f0ce..c9f0319 100644 --- a/index.html +++ b/index.html @@ -1251,6 +1251,152 @@ // Tape size constants (in pixels) const TAPE_MIN_SIZE = 62; // Minimum tape size (just larger than reel-inner) const TAPE_MAX_SIZE = 166; // 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 { + const duration = await getTrackDuration(playlist[i].url); + trackDurations[i] = duration; + console.log(`Track ${i + 1} duration: ${formatTime(duration)}`); + } 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(); + + // Set timeout for slow loads + const timeout = setTimeout(() => { + tempAudio.src = ''; + reject(new Error('Timeout loading metadata')); + }, 10000); // 10 second timeout + + tempAudio.addEventListener('loadedmetadata', () => { + clearTimeout(timeout); + const duration = tempAudio.duration; + tempAudio.src = ''; // Clean up + resolve(duration); + }); + + tempAudio.addEventListener('error', (e) => { + clearTimeout(timeout); + tempAudio.src = ''; + reject(e); + }); + + // Try to use cached URL if available, otherwise use direct URL + TrackCache.getTrack(url).then(blobUrl => { + tempAudio.src = blobUrl; + }).catch(() => { + // Fall back to direct URL + 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; + } + const ejectBtn = document.getElementById('ejectBtn'); const lightningBtn = document.getElementById('lightningBtn'); const videoOverlay = document.getElementById('videoOverlay'); @@ -1759,6 +1905,9 @@ // Initial track load (async, no await needed for initial load) loadTrack(0); + // Load all track durations for continuous tape model + loadAllDurations(); + // Set initial volume audio.volume = 0.7; @@ -1782,226 +1931,222 @@ stopTitleScroll(); }); - // Stop button - resets title to start position - stopBtn.addEventListener('click', () => { + // 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 + const animationDuration = 500; + const startProgress = getTapeProgress(); + const endProgress = 0; + + // Start rewind sound and animation + SoundEffects.startTapeWindLoop(); + reelLeft.style.animationDuration = '0.3s'; + reelRight.style.animationDuration = '0.3s'; + tapeLeft.style.animationDuration = '0.3s'; + tapeRight.style.animationDuration = '0.3s'; + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + + // Animate tape sizes + 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); + }); + + // Stop sound and reset animation speed + SoundEffects.stopTapeWindLoop(); + reelLeft.style.animationDuration = ''; + reelRight.style.animationDuration = ''; + tapeLeft.style.animationDuration = ''; + tapeRight.style.animationDuration = ''; + } + + // Load track 0 if not already on it + if (currentTrack !== 0) { + currentTrack = 0; + trackNameInner.textContent = playlist[0].name; + + try { + const blobUrl = await TrackCache.getTrack(playlist[0].url); + audio.src = blobUrl; + } catch (e) { + audio.src = playlist[0].url; + } + audio.load(); + } + audio.currentTime = 0; reelLeft.classList.remove('spinning'); reelRight.classList.remove('spinning'); tapeLeft.classList.remove('spinning'); tapeRight.classList.remove('spinning'); resetTitleScroll(); - resetTapeSizes(); + resetTapeSizes(); // Ensure tape visuals are at beginning }); // 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; - - // Play tape wind sound - SoundEffects.playTapeWind('forward', 0.5); - - // Speed up audio to 4x during transition (if playing) - if (wasPlaying) { - audio.playbackRate = 4; - } - - // Speed up reel animation during transition - reelLeft.style.animationDuration = '0.3s'; - reelRight.style.animationDuration = '0.3s'; - tapeLeft.style.animationDuration = '0.3s'; - tapeRight.style.animationDuration = '0.3s'; - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - - // Delay before loading new track (async for cache support) - setTimeout(async () => { - // Reset animation speed and playback rate - reelLeft.style.animationDuration = ''; - reelRight.style.animationDuration = ''; - tapeLeft.style.animationDuration = ''; - tapeRight.style.animationDuration = ''; - audio.playbackRate = 1; - - currentTrack = (currentTrack - 1 + playlist.length) % playlist.length; - trackNameInner.textContent = playlist[currentTrack].name; - resetTitleScroll(); // Reset title for new track - resetTapeSizes(); // Reset tape for new track - - // Load from cache or network - try { - const blobUrl = await TrackCache.getTrack(playlist[currentTrack].url); - audio.src = blobUrl; - } catch (e) { - console.warn('TrackCache: Caching unavailable, using direct URL'); - audio.src = playlist[currentTrack].url; - } - - audio.load(); - audio.oncanplay = function() { - if (wasPlaying) { - const playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(() => { - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - startTitleScroll(); - }).catch(e => console.log('Play failed:', e)); - } - } else { - reelLeft.classList.remove('spinning'); - reelRight.classList.remove('spinning'); - tapeLeft.classList.remove('spinning'); - tapeRight.classList.remove('spinning'); - } - audio.oncanplay = null; - }; - }, 500); + 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; - - // Play tape wind sound - SoundEffects.playTapeWind('forward', 0.5); - - // Speed up audio to 4x during transition (if playing) - if (wasPlaying) { - audio.playbackRate = 4; - } - - // Speed up reel animation during transition - reelLeft.style.animationDuration = '0.3s'; - reelRight.style.animationDuration = '0.3s'; - tapeLeft.style.animationDuration = '0.3s'; - tapeRight.style.animationDuration = '0.3s'; - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - - // Delay before loading new track (async for cache support) - setTimeout(async () => { - // Reset animation speed and playback rate - reelLeft.style.animationDuration = ''; - reelRight.style.animationDuration = ''; - tapeLeft.style.animationDuration = ''; - tapeRight.style.animationDuration = ''; - audio.playbackRate = 1; - - currentTrack = (currentTrack + 1) % playlist.length; - trackNameInner.textContent = playlist[currentTrack].name; - resetTitleScroll(); // Reset title for new track - resetTapeSizes(); // Reset tape for new track - - // Load from cache or network - try { - const blobUrl = await TrackCache.getTrack(playlist[currentTrack].url); - audio.src = blobUrl; - } catch (e) { - console.warn('TrackCache: Caching unavailable, using direct URL'); - audio.src = playlist[currentTrack].url; - } - - audio.load(); - audio.oncanplay = function() { - if (wasPlaying) { - const playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(() => { - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - startTitleScroll(); - }).catch(e => console.log('Play failed:', e)); - } - } else { - reelLeft.classList.remove('spinning'); - reelRight.classList.remove('spinning'); - tapeLeft.classList.remove('spinning'); - tapeRight.classList.remove('spinning'); - } - audio.oncanplay = null; - }; - }, 500); + const targetTrack = (currentTrack + 1) % playlist.length; + animatedSeekToTrack(targetTrack, wasPlaying); }); - // Drag on reels to scrub audio + // Drag on reels to scrub audio (supports cross-track scrubbing) let isDragging = false; let startY = 0; - let startTime = 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) { - if (audio.duration) { + // 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; - startTime = audio.currentTime; + startTapePosition = getCurrentTapePosition(); wasPlayingBeforeDrag = !audio.paused; tapeWindPlaying = false; + scrubTrackChangeInProgress = false; // Don't pause - let audio continue playing while scrubbing } } function drag(e) { - if (isDragging && audio.duration) { - const currentY = e.clientY || e.touches[0].clientY; - const deltaY = startY - currentY; - const instantDeltaY = lastDragY - currentY; - lastDragY = currentY; - - const scrubAmount = (deltaY / 100) * audio.duration * 0.1; - const newTime = Math.max(0, Math.min(audio.duration, startTime + scrubAmount)); - audio.currentTime = newTime; - - // Calculate scrub speed for audio effects - // Positive deltaY = dragging up = fast forward - // Negative deltaY = dragging down = rewind - 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; - } - - // Bonus: Adjust playback rate for sped-up audio effect (both directions) - // Speed up to 4x when scrubbing in either direction - 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(); - } + 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) { + currentTrack = trackIndex; + trackNameInner.textContent = playlist[trackIndex].name; + resetTitleScroll(); + + try { + const blobUrl = await TrackCache.getTrack(playlist[trackIndex].url); + audio.src = blobUrl; + } catch (e) { + audio.src = playlist[trackIndex].url; + } + + return new Promise((resolve) => { + audio.onloadedmetadata = () => { + audio.currentTime = seekPosition; + if (wasPlayingBeforeDrag) { + audio.play().catch(() => {}); + } + audio.onloadedmetadata = null; + resolve(); + }; + audio.load(); + }); } function endDrag() { if (isDragging) { isDragging = false; + scrubTrackChangeInProgress = false; // Stop tape wind sound and reset playback rate if (tapeWindPlaying) { @@ -2021,6 +2166,128 @@ } } + // ======================================== + // 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 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; + + // Determine direction + const direction = endProgress > startProgress ? 'forward' : 'backward'; + + // Start tape wind sound + SoundEffects.startTapeWindLoop(); + + // Speed up reel animation during transition + reelLeft.style.animationDuration = '0.3s'; + reelRight.style.animationDuration = '0.3s'; + tapeLeft.style.animationDuration = '0.3s'; + tapeRight.style.animationDuration = '0.3s'; + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + + // 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 + reelLeft.style.animationDuration = ''; + reelRight.style.animationDuration = ''; + tapeLeft.style.animationDuration = ''; + tapeRight.style.animationDuration = ''; + audio.playbackRate = 1; + + // Load the new track + currentTrack = targetTrackIndex; + trackNameInner.textContent = playlist[currentTrack].name; + resetTitleScroll(); + + try { + const blobUrl = await TrackCache.getTrack(playlist[currentTrack].url); + audio.src = blobUrl; + } catch (e) { + console.warn('TrackCache: Caching unavailable, using direct URL'); + audio.src = playlist[currentTrack].url; + } + + return new Promise(resolve => { + audio.oncanplay = function() { + if (wasPlaying) { + audio.play().then(() => { + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + startTitleScroll(); + }).catch(e => console.log('Play failed:', e)); + } else { + reelLeft.classList.remove('spinning'); + reelRight.classList.remove('spinning'); + tapeLeft.classList.remove('spinning'); + tapeRight.classList.remove('spinning'); + } + audio.oncanplay = null; + animatedSeekInProgress = false; + resolve(); + }; + audio.load(); + }); + } + // Add drag listeners to all reel elements (containers, tape, and inner spools) [reelLeft, reelRight, tapeLeft, tapeRight, reelContainerLeft, reelContainerRight].forEach(el => { el.addEventListener('mousedown', startDrag); @@ -2036,7 +2303,7 @@ audio.addEventListener('ended', async () => { currentTrack = (currentTrack + 1) % playlist.length; await loadTrack(currentTrack); - resetTapeSizes(); + // Tape position continues naturally - don't reset audio.play(); reelLeft.classList.add('spinning'); reelRight.classList.add('spinning'); @@ -2051,26 +2318,45 @@ }); // Update tape wound sizes based on playback progress + // Update tape wound sizes based on global tape position function updateTapeSizes() { - if (!audio.duration) return; + // 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; + } - const progress = audio.currentTime / audio.duration; const tapeRange = TAPE_MAX_SIZE - TAPE_MIN_SIZE; - // Left spool: starts full, decreases as track plays + // Left spool: starts full, decreases as tape plays const leftSize = TAPE_MAX_SIZE - (progress * tapeRange); - // Right spool: starts empty, increases as track plays + // Right spool: starts empty, increases as tape plays const rightSize = TAPE_MIN_SIZE + (progress * tapeRange); tapeLeft.style.setProperty('--tape-size', leftSize + 'px'); tapeRight.style.setProperty('--tape-size', rightSize + 'px'); } - // Initialize tape sizes + // 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