switched to full-tape reel system
This commit is contained in:
parent
7d16bdc436
commit
b4e69b78a2
648
index.html
648
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<number>} 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user