6 Commits

Author SHA1 Message Date
cottongin
c10cd26e1d initial ideas 2026-01-17 14:18:25 -05:00
cottongin
38b94bde04 fix for Safari - boo Safari 2026-01-17 14:09:42 -05:00
cottongin
27b1f0213e stop button bug fix 2026-01-17 13:59:33 -05:00
cottongin
f3399a87b4 cleanup 2026-01-17 13:39:10 -05:00
cottongin
8a0e586ec1 background tweaks, CPU spike fix 2026-01-17 13:37:46 -05:00
cottongin
d30d6f28d7 remove cache system, tweak background 2026-01-17 13:15:21 -05:00
4 changed files with 186 additions and 387 deletions

89
CORS.md
View File

@@ -1,89 +0,0 @@
For DreamHost hosting, the owner of `feed.falsefinish.club` can configure CORS headers using an `.htaccess` file in the root directory of the site (or in the specific directory serving the audio files).
## Setting CORS Headers on DreamHost
Create or edit the `.htaccess` file in the web root (typically `~/falsefinish.club/feed/` or wherever the audio files are served from) and add:
```apache
# Enable CORS for echo-reality.com
<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin "https://echo-reality.com"
Header set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Header set Access-Control-Expose-Headers "ETag, Last-Modified, Content-Length"
</IfModule>
# Handle preflight OPTIONS requests
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=200,L]
</IfModule>
```
### If targeting specific file types only (recommended)
To apply CORS headers only to audio files:
```apache
<IfModule mod_headers.c>
<FilesMatch "\.(mp3|mp4|wav|ogg|m4a)$">
Header set Access-Control-Allow-Origin "https://echo-reality.com"
Header set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Header set Access-Control-Expose-Headers "ETag, Last-Modified, Content-Length"
</FilesMatch>
</IfModule>
```
### For multiple origins (if needed during development)
If you need to allow both the production domain and a local development server:
```apache
<IfModule mod_headers.c>
SetEnvIf Origin "^https://(echo-reality\.com|localhost:3000)$" CORS_ORIGIN=$0
Header set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Header set Access-Control-Expose-Headers "ETag, Last-Modified, Content-Length"
</IfModule>
```
### How to add the .htaccess file on DreamHost
1. **Via SFTP/FTP**: Connect to the server using an FTP client (like FileZilla) and upload/edit the `.htaccess` file in the appropriate directory
2. **Via DreamHost Panel File Manager**: Log into the DreamHost panel → Manage Websites → Files → navigate to the directory and create/edit `.htaccess`
3. **Via SSH** (if enabled): SSH into the server and use a text editor like `nano` or `vim`
### Important notes for DreamHost
- DreamHost's shared hosting uses Apache, so `.htaccess` files work out of the box
- The `mod_headers` module is enabled by default on DreamHost
- Make sure the `.htaccess` file has proper permissions (644)
- Changes take effect immediately—no server restart needed
### Testing the configuration
After adding the `.htaccess` file, the owner can verify it's working by checking the response headers:
```bash
curl -I -X OPTIONS -H "Origin: https://echo-reality.com" \
"https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/01.%20PINK%20FLIGHT%20ATTENDANT.mp3"
```
The response should include the `Access-Control-Allow-Origin: https://echo-reality.com` header.
### Quick test `.htaccess` for DreamHost
```apache
# TEMPORARY - Allow all origins for testing
<IfModule mod_headers.c>
<FilesMatch "\.(mp3|mp4|wav|ogg|m4a)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Header set Access-Control-Expose-Headers "ETag, Last-Modified, Content-Length"
</FilesMatch>
</IfModule>
```
This limits the wildcard CORS to just media files, which is a reasonable middle ground—your audio files are publicly accessible but you're not opening up everything on the domain.

94
IDEAS.md Normal file
View File

@@ -0,0 +1,94 @@
Let me take a look at what's currently in the project to understand the existing features and theme better.
[1 tool called]
This is a really well-crafted project with a lot of nice details already. Let me put together some ideas for easter eggs and tweaks that would fit the retro dystopian/broken cassette player theme.
Based on what I see in the code, you already have:
- CRT scanlines and flicker effects
- Cracked display overlay
- Worn/faded button icons
- Damage texture overlay
- Tape wind sound effects
- Bouncing track name marquee
Here are some ideas that would complement the existing aesthetic:
---
## Visual Glitch / Distortion Effects
1. **Random screen glitches** - Occasional RGB channel separation or horizontal displacement that happens randomly or when interacting with controls (like the display is malfunctioning)
2. **Dead pixels** - A few permanently "stuck" green pixels on the CRT display
3. **Screen burn-in** - Faint ghost image of "PLAY" or old track names that lingers on the display
4. **Intermittent display failure** - Rare chance of the display going dark for a split second then flickering back on
5. **VHS tracking lines** - Occasional horizontal noise bars that scroll up the display
---
## Audio Quirks
6. **Tape flutter/wow** - Subtle pitch wobble effect using Web Audio API's `playbackRate` with slight random variations, like a worn tape mechanism
7. **Random audio dropouts** - Very brief moments where audio cuts out or gets muffled (like dirty tape heads)
8. **Static between tracks** - White noise/hiss during track transitions
9. **"Tape eating" event** - Rare chance when pressing play/stop that triggers a tape-eating sound effect and animation where the tape visually tangles
---
## Hidden Interactions
10. **Konami code** - Entering the classic code unlocks a secret mode (different color scheme, hidden track, or "repaired" mode where everything works perfectly)
11. **Thump to fix** - Double-clicking/tapping the cassette housing "thumps" the player, which could temporarily fix glitches or cause new ones
12. **Secret button combo** - Holding certain buttons together reveals hidden content or debug info
13. **Dust accumulation** - Animated dust particles that slowly build up on the display, clickable to "wipe clean"
---
## Dystopian Theme Additions
14. **Redacted track names** - Occasional text that appears with black boxes like "[REDACTED]" or "TRACK ██ - ████████"
15. **Surveillance indicator** - Small red "REC" light in the corner that occasionally blinks, or a subtle camera icon
16. **Propaganda interruptions** - Random brief messages that flash on screen like "COMPLIANCE IS FREEDOM" or "REPORT SUSPICIOUS ACTIVITY"
17. **Emergency broadcast** - Rare event where audio cuts to an emergency alert tone with scrolling text
18. **Signal interference** - Brief moments where the display shows "NO SIGNAL" or "WEAK SIGNAL"
---
## Time-Based Behaviors
19. **Midnight mode** - Different behavior or color scheme between certain hours (dimmer, more glitchy, or eerier)
20. **Battery drain simulation** - After extended use, everything gradually slows down and gets distorted until "recharged" (refreshing the page)
---
## Fun Visual Additions
21. **Tape label graffiti/stickers** - Small worn stickers or handwritten notes on the cassette
22. **Cigarette burn marks** - Subtle burn marks on the player casing
23. **Fingerprint smudges** - Subtle smudges on the tape window or display
24. **"Property of..." label** - A worn/scratched identification label with mysterious text
---
Would you like me to create a plan to implement some of these? If so, which ones appeal to you most? I can group them into:
- **Quick wins** (CSS-only or simple JS)
- **Medium effort** (Web Audio effects, animations)
- **Larger features** (Konami code, emergency broadcast system)

BIN
background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -26,8 +26,8 @@
background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%); background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%);
border: 8px solid #0a0a0a; border: 8px solid #0a0a0a;
box-shadow: box-shadow:
inset 0 0 0 2px #3a3a3a, /* inset 0 0 0 2px #3a3a3a, */
inset 0 0 50px rgba(0,0,0,0.9), inset 0 0 10px rgba(0,0,0,0.1),
0 30px 60px rgba(0,0,0,0.8), 0 30px 60px rgba(0,0,0,0.8),
5px 5px 0 rgba(0,0,0,0.3); 5px 5px 0 rgba(0,0,0,0.3);
/* Extra top padding to accommodate eject/lightning buttons */ /* Extra top padding to accommodate eject/lightning buttons */
@@ -44,31 +44,15 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: background-image: url('background.png');
linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.03) 49%, rgba(255,255,255,0.03) 51%, transparent 52%), background-size: 100%;
linear-gradient(-45deg, transparent 48%, rgba(0,0,0,0.4) 49%, rgba(0,0,0,0.4) 51%, transparent 52%), background-position: center;
repeating-linear-gradient(90deg, transparent, transparent 3px, rgba(0,0,0,0.5) 3px, rgba(0,0,0,0.5) 4px), background-repeat: repeat;
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="noise"><feTurbulence type="fractalNoise" baseFrequency="2.5" numOctaves="6" /></filter><rect width="200" height="200" filter="url(%23noise)" opacity="0.4"/></svg>');
pointer-events: none; pointer-events: none;
opacity: 0.8; opacity: 0.20;
mix-blend-mode: overlay; mix-blend-mode: normal;
} }
/* Rust and corrosion patches */
.player::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(ellipse at 20% 30%, rgba(139, 69, 19, 0.3) 0%, transparent 40%),
radial-gradient(ellipse at 80% 70%, rgba(101, 67, 33, 0.2) 0%, transparent 35%),
radial-gradient(ellipse at 60% 10%, rgba(120, 81, 45, 0.25) 0%, transparent 30%),
radial-gradient(ellipse at 10% 90%, rgba(139, 90, 43, 0.2) 0%, transparent 40%);
pointer-events: none;
}
/* Eject button - positioned top-left of player */ /* Eject button - positioned top-left of player */
.eject-btn { .eject-btn {
@@ -274,19 +258,6 @@
} }
*/ */
.cassette::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="grime"><feTurbulence type="fractalNoise" baseFrequency="3" numOctaves="4" /></filter><rect width="100" height="100" filter="url(%23grime)" opacity="0.15"/></svg>');
pointer-events: none;
border-radius: 4px;
mix-blend-mode: multiply;
}
.cassette-label { .cassette-label {
background: linear-gradient(180deg, #d0d0d0 0%, #a0a0a0 100%); background: linear-gradient(180deg, #d0d0d0 0%, #a0a0a0 100%);
@@ -365,10 +336,12 @@
.tape-wound { .tape-wound {
position: absolute; position: absolute;
/* Center using top/left 50% with transform */ /* Center using top/left 50% with negative margins (Safari-safe) */
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -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%; border-radius: 50%;
/* Darker tape color */ /* Darker tape color */
background: linear-gradient(135deg, #1a1815 0%, #0f0d0a 50%, #050403 100%); background: linear-gradient(135deg, #1a1815 0%, #0f0d0a 50%, #050403 100%);
@@ -379,6 +352,9 @@
/* Size controlled by JavaScript via CSS custom property */ /* Size controlled by JavaScript via CSS custom property */
width: var(--tape-size, 72px); width: var(--tape-size, 72px);
height: var(--tape-size, 72px); height: var(--tape-size, 72px);
/* Safari animation optimizations */
transform-origin: center center;
will-change: transform;
/* Prevent text selection during drag */ /* Prevent text selection during drag */
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
@@ -392,8 +368,8 @@
} }
@keyframes spinTape { @keyframes spinTape {
from { transform: translate(-50%, -50%) rotate(0deg); } from { transform: rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); } to { transform: rotate(360deg); }
} }
.reel-inner { .reel-inner {
@@ -413,11 +389,16 @@
); );
box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-shadow: inset 0 0 10px rgba(0,0,0,0.8);
z-index: 2; z-index: 2;
/* Center the spool within the reel container */ /* Center the spool within the reel container (Safari-safe) */
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -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 { .reel-inner.spinning {
@@ -425,8 +406,8 @@
} }
@keyframes spinSpool { @keyframes spinSpool {
from { transform: translate(-50%, -50%) rotate(0deg); } from { transform: rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); } to { transform: rotate(360deg); }
} }
.reel-inner.spinning { .reel-inner.spinning {
@@ -706,20 +687,6 @@
pointer-events: auto; pointer-events: auto;
} }
/* Noise texture overlay for worn appearance */
.alby-panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="noise"><feTurbulence type="fractalNoise" baseFrequency="2.5" numOctaves="6" /></filter><rect width="200" height="200" filter="url(%23noise)" opacity="0.4"/></svg>');
pointer-events: none;
opacity: 0.5;
mix-blend-mode: overlay;
}
/* Panel header with amber Lightning accent */ /* Panel header with amber Lightning accent */
.alby-panel-header { .alby-panel-header {
@@ -1217,7 +1184,7 @@
<!-- ALBY LIGHTNING PANEL - END --> <!-- ALBY LIGHTNING PANEL - END -->
<audio id="audio"></audio> <audio id="audio" preload="metadata"></audio>
<script> <script>
const playlist = [ const playlist = [
@@ -1272,9 +1239,29 @@
for (let i = 0; i < playlist.length; i++) { for (let i = 0; i < playlist.length; i++) {
try { try {
const duration = await getTrackDuration(playlist[i].url); // For track 0, use the main audio element since loadTrack(0) already loads it
trackDurations[i] = duration; if (i === 0 && currentTrack === 0) {
console.log(`Track ${i + 1} duration: ${formatTime(duration)}`); // 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) { } catch (e) {
console.warn(`Failed to get duration for track ${i + 1}, using fallback`); console.warn(`Failed to get duration for track ${i + 1}, using fallback`);
trackDurations[i] = 240; // 4 minute fallback trackDurations[i] = 240; // 4 minute fallback
@@ -1306,33 +1293,40 @@
function getTrackDuration(url) { function getTrackDuration(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tempAudio = new Audio(); 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 // Set timeout for slow loads
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
tempAudio.src = ''; cleanup();
reject(new Error('Timeout loading metadata')); reject(new Error('Timeout loading metadata'));
}, 10000); // 10 second timeout }, 10000); // 10 second timeout
tempAudio.addEventListener('loadedmetadata', () => { function onMetadata() {
clearTimeout(timeout); clearTimeout(timeout);
const duration = tempAudio.duration; const duration = tempAudio.duration;
tempAudio.src = ''; // Clean up cleanup();
resolve(duration); resolve(duration);
}); }
tempAudio.addEventListener('error', (e) => { function onError(e) {
clearTimeout(timeout); clearTimeout(timeout);
tempAudio.src = ''; cleanup();
reject(e); reject(e);
}); }
// Try to use cached URL if available, otherwise use direct URL tempAudio.addEventListener('loadedmetadata', onMetadata);
TrackCache.getTrack(url).then(blobUrl => { tempAudio.addEventListener('error', onError);
tempAudio.src = blobUrl;
}).catch(() => { // Direct URL - browser handles caching via HTTP headers
// Fall back to direct URL tempAudio.src = url;
tempAudio.src = url;
});
}); });
} }
@@ -1608,196 +1602,6 @@
// SOUND EFFECTS MODULE - END // SOUND EFFECTS MODULE - END
// ======================================== // ========================================
// ========================================
// TRACK CACHE MODULE - START
// Caches audio files locally using Cache API
// Uses HEAD requests to detect content changes
// ========================================
const TrackCache = {
CACHE_NAME: 'cassette-player-audio-v1',
META_KEY: 'track-cache-meta',
// Fallback cache duration (1 hour) if no ETag/Last-Modified
FALLBACK_MAX_AGE: 60 * 60 * 1000,
cache: null,
/**
* Initialize the cache - opens Cache API storage
* @returns {Promise<Cache>}
*/
async init() {
if (!this.cache) {
this.cache = await caches.open(this.CACHE_NAME);
}
return this.cache;
},
/**
* Get metadata for all cached tracks from localStorage
* @returns {Object} Map of URL -> { etag, lastModified, cachedAt }
*/
getMetadata() {
try {
const data = localStorage.getItem(this.META_KEY);
return data ? JSON.parse(data) : {};
} catch (e) {
console.warn('TrackCache: Failed to read metadata', e);
return {};
}
},
/**
* Save metadata for a track
* @param {string} url - Track URL
* @param {Object} meta - { etag, lastModified, cachedAt }
*/
saveMetadata(url, meta) {
try {
const allMeta = this.getMetadata();
allMeta[url] = meta;
localStorage.setItem(this.META_KEY, JSON.stringify(allMeta));
} catch (e) {
console.warn('TrackCache: Failed to save metadata', e);
}
},
/**
* Check if cached content is stale using HEAD request
* @param {string} url - Track URL
* @returns {Promise<boolean>} True if content has changed, false if still valid
*/
async isStale(url) {
const meta = this.getMetadata()[url];
if (!meta) return true; // No metadata = treat as stale
try {
const response = await fetch(url, { method: 'HEAD' });
if (!response.ok) {
// HEAD failed, assume cached version is still valid
console.log('TrackCache: HEAD request failed, using cached version');
return false;
}
const newEtag = response.headers.get('ETag');
const newLastModified = response.headers.get('Last-Modified');
// Check ETag first (most reliable)
if (newEtag && meta.etag) {
const isChanged = newEtag !== meta.etag;
if (isChanged) {
console.log(`TrackCache: ETag changed for ${url}`);
}
return isChanged;
}
// Fall back to Last-Modified
if (newLastModified && meta.lastModified) {
const isChanged = newLastModified !== meta.lastModified;
if (isChanged) {
console.log(`TrackCache: Last-Modified changed for ${url}`);
}
return isChanged;
}
// No ETag or Last-Modified, use time-based fallback
const age = Date.now() - meta.cachedAt;
const isExpired = age > this.FALLBACK_MAX_AGE;
if (isExpired) {
console.log(`TrackCache: Cache expired (age: ${Math.round(age/1000)}s) for ${url}`);
}
return isExpired;
} catch (e) {
// Network error or CORS issue - assume cached version is valid
console.log('TrackCache: HEAD request error, using cached version', e.message);
return false;
}
},
/**
* Fetch track from network and store in cache
* @param {string} url - Track URL
* @returns {Promise<string>} Blob URL for the audio
*/
async fetchAndCache(url) {
console.log(`TrackCache: Fetching ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch track: ${response.status}`);
}
// Extract cache validation headers
const etag = response.headers.get('ETag');
const lastModified = response.headers.get('Last-Modified');
// Clone response before consuming it
const responseToCache = response.clone();
// Store in cache
await this.init();
await this.cache.put(url, responseToCache);
// Save metadata
this.saveMetadata(url, {
etag: etag,
lastModified: lastModified,
cachedAt: Date.now()
});
console.log(`TrackCache: Cached ${url} (ETag: ${etag || 'none'}, Last-Modified: ${lastModified || 'none'})`);
// Return blob URL for playback
const blob = await response.blob();
return URL.createObjectURL(blob);
},
/**
* Get track - main entry point
* Checks cache, validates with HEAD, returns blob URL
* @param {string} url - Track URL
* @returns {Promise<string>} Blob URL for the audio
*/
async getTrack(url) {
await this.init();
// Check if we have it cached
const cachedResponse = await this.cache.match(url);
if (cachedResponse) {
// Check if content has changed
const stale = await this.isStale(url);
if (!stale) {
console.log(`TrackCache: Using cached version of ${url}`);
const blob = await cachedResponse.blob();
return URL.createObjectURL(blob);
}
// Content changed, fetch new version
console.log(`TrackCache: Content changed, re-fetching ${url}`);
}
// Not cached or stale, fetch from network
return await this.fetchAndCache(url);
},
/**
* Clear all cached tracks
* Useful for debugging or freeing storage
*/
async clearAll() {
await caches.delete(this.CACHE_NAME);
localStorage.removeItem(this.META_KEY);
this.cache = null;
console.log('TrackCache: Cleared all cached tracks');
}
};
// ========================================
// TRACK CACHE MODULE - END
// ========================================
// ======================================== // ========================================
// BUTTON CLICK SOUNDS - Add to all buttons // BUTTON CLICK SOUNDS - Add to all buttons
// Uses mousedown for immediate tactile feedback // Uses mousedown for immediate tactile feedback
@@ -1889,17 +1693,8 @@
// Reset scroll position for new track // Reset scroll position for new track
resetTitleScroll(); resetTitleScroll();
try { // Direct URL - browser handles caching via HTTP headers
// Get track from cache or network audio.src = playlist[index].url;
const blobUrl = await TrackCache.getTrack(playlist[index].url);
audio.src = blobUrl;
} catch (e) {
// Caching may fail due to CORS if audio is hosted on different origin
// without Access-Control-Allow-Origin headers
console.warn('TrackCache: Caching unavailable (likely CORS), using direct URL');
// Fall back to direct URL - audio element can still play cross-origin
audio.src = playlist[index].url;
}
} }
// Initial track load (async, no await needed for initial load) // Initial track load (async, no await needed for initial load)
@@ -1987,13 +1782,17 @@
currentTrack = 0; currentTrack = 0;
trackNameInner.textContent = playlist[0].name; trackNameInner.textContent = playlist[0].name;
try { // Direct URL - browser handles caching via HTTP headers
const blobUrl = await TrackCache.getTrack(playlist[0].url); audio.src = playlist[0].url;
audio.src = blobUrl;
} catch (e) { // Wait for audio to be ready before setting currentTime
audio.src = playlist[0].url; await new Promise(resolve => {
} audio.addEventListener('loadedmetadata', function onMeta() {
audio.load(); audio.removeEventListener('loadedmetadata', onMeta);
resolve();
});
audio.load();
});
} }
audio.currentTime = 0; audio.currentTime = 0;
@@ -2003,6 +1802,10 @@
tapeRight.classList.remove('spinning'); tapeRight.classList.remove('spinning');
resetTitleScroll(); resetTitleScroll();
resetTapeSizes(); // Ensure tape visuals are at beginning 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) // Previous track (prev button with bar)
@@ -2123,12 +1926,8 @@
trackNameInner.textContent = playlist[trackIndex].name; trackNameInner.textContent = playlist[trackIndex].name;
resetTitleScroll(); resetTitleScroll();
try { // Direct URL - browser handles caching via HTTP headers
const blobUrl = await TrackCache.getTrack(playlist[trackIndex].url); audio.src = playlist[trackIndex].url;
audio.src = blobUrl;
} catch (e) {
audio.src = playlist[trackIndex].url;
}
return new Promise((resolve) => { return new Promise((resolve) => {
audio.onloadedmetadata = () => { audio.onloadedmetadata = () => {
@@ -2256,13 +2055,8 @@
trackNameInner.textContent = playlist[currentTrack].name; trackNameInner.textContent = playlist[currentTrack].name;
resetTitleScroll(); resetTitleScroll();
try { // Direct URL - browser handles caching via HTTP headers
const blobUrl = await TrackCache.getTrack(playlist[currentTrack].url); audio.src = 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 => { return new Promise(resolve => {
audio.oncanplay = function() { audio.oncanplay = function() {