Compare commits
7 Commits
full-tape-
...
display-gl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62eba51514 | ||
|
|
c10cd26e1d | ||
|
|
38b94bde04 | ||
|
|
27b1f0213e | ||
|
|
f3399a87b4 | ||
|
|
8a0e586ec1 | ||
|
|
d30d6f28d7 |
89
CORS.md
89
CORS.md
@@ -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
archive/IDEAS.md
Normal file
94
archive/IDEAS.md
Normal 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
BIN
background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
750
index.html
750
index.html
@@ -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 {
|
||||||
@@ -183,6 +167,7 @@
|
|||||||
linear-gradient(60deg, transparent 0%, transparent 68%, rgba(255,255,255,0.03) 68.5%, transparent 69%),
|
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);
|
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.6) 2px, rgba(0,0,0,0.6) 3px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flickering scanlines */
|
/* Flickering scanlines */
|
||||||
@@ -196,6 +181,7 @@
|
|||||||
background: repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,0.5) 1px, rgba(0,0,0,0.5) 2px);
|
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;
|
pointer-events: none;
|
||||||
animation: flicker 4s infinite;
|
animation: flicker 4s infinite;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flicker {
|
@keyframes flicker {
|
||||||
@@ -205,6 +191,228 @@
|
|||||||
52% { opacity: 0.7; }
|
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) */
|
/* Track name text - scrolls when playing (JS controlled) */
|
||||||
.display-text {
|
.display-text {
|
||||||
color: #00ff00;
|
color: #00ff00;
|
||||||
@@ -274,19 +482,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 +560,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 +576,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 +592,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 +613,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 +630,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 +911,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 {
|
||||||
@@ -1099,7 +1290,17 @@
|
|||||||
<button class="eject-btn" id="ejectBtn" title="Eject"></button>
|
<button class="eject-btn" id="ejectBtn" title="Eject"></button>
|
||||||
<button class="lightning-btn" id="lightningBtn" title="Menu">⚡</button>
|
<button class="lightning-btn" id="lightningBtn" title="Menu">⚡</button>
|
||||||
|
|
||||||
<div class="display">
|
<div class="display" id="display">
|
||||||
|
<!-- Dim spots - worn phosphor effect (slowly drifting) -->
|
||||||
|
<div class="dim-spots-container">
|
||||||
|
<div class="dim-spot dim-spot-1"></div>
|
||||||
|
<div class="dim-spot dim-spot-2"></div>
|
||||||
|
<div class="dim-spot dim-spot-3"></div>
|
||||||
|
<div class="dim-spot dim-spot-4"></div>
|
||||||
|
<div class="dim-spot dim-spot-5"></div>
|
||||||
|
</div>
|
||||||
|
<!-- VHS tracking lines overlay (controlled by DisplayGlitch module) -->
|
||||||
|
<div class="vhs-tracking" id="vhsTracking"></div>
|
||||||
<!-- Track name with inner span for marquee scrolling -->
|
<!-- Track name with inner span for marquee scrolling -->
|
||||||
<div class="display-text" id="trackName"><span class="display-text-inner" id="trackNameInner">TRACK 1</span></div>
|
<div class="display-text" id="trackName"><span class="display-text-inner" id="trackNameInner">TRACK 1</span></div>
|
||||||
<div class="time-display" id="timeDisplay">00:00 / 00:00</div>
|
<div class="time-display" id="timeDisplay">00:00 / 00:00</div>
|
||||||
@@ -1217,7 +1418,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 +1473,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 +1527,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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1609,193 +1837,183 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// TRACK CACHE MODULE - START
|
// DISPLAY GLITCH MODULE - START
|
||||||
// Caches audio files locally using Cache API
|
// Random visual glitches for dystopian CRT effect
|
||||||
// Uses HEAD requests to detect content changes
|
|
||||||
// ========================================
|
// ========================================
|
||||||
const TrackCache = {
|
const DisplayGlitch = {
|
||||||
CACHE_NAME: 'cassette-player-audio-v1',
|
display: null,
|
||||||
META_KEY: 'track-cache-meta',
|
vhsTracking: null,
|
||||||
// Fallback cache duration (1 hour) if no ETag/Last-Modified
|
isEnabled: true,
|
||||||
FALLBACK_MAX_AGE: 60 * 60 * 1000,
|
|
||||||
cache: null,
|
// 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 cache - opens Cache API storage
|
* Initialize the glitch module
|
||||||
* @returns {Promise<Cache>}
|
* Gets DOM references and starts random glitch intervals
|
||||||
*/
|
*/
|
||||||
async init() {
|
init() {
|
||||||
if (!this.cache) {
|
this.display = document.getElementById('display');
|
||||||
this.cache = await caches.open(this.CACHE_NAME);
|
this.vhsTracking = document.getElementById('vhsTracking');
|
||||||
}
|
|
||||||
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 {
|
if (!this.display || !this.vhsTracking) {
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
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');
|
||||||
|
|
||||||
if (!response.ok) {
|
setTimeout(() => {
|
||||||
// HEAD failed, assume cached version is still valid
|
this.display.classList.remove('blackout');
|
||||||
console.log('TrackCache: HEAD request failed, using cached version');
|
currentFlicker++;
|
||||||
return false;
|
|
||||||
}
|
// If more flickers needed, do them after a short gap
|
||||||
|
if (currentFlicker < flickerCount) {
|
||||||
const newEtag = response.headers.get('ETag');
|
setTimeout(doFlicker, this.randomBetween(30, 80));
|
||||||
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;
|
}, this.BLACKOUT_DURATION);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Fall back to Last-Modified
|
doFlicker();
|
||||||
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
|
* Trigger VHS tracking lines
|
||||||
* @param {string} url - Track URL
|
* Randomly shows vertical (scrolling up) or horizontal (scrolling left) tracking lines
|
||||||
* @returns {Promise<string>} Blob URL for the audio
|
|
||||||
*/
|
*/
|
||||||
async fetchAndCache(url) {
|
triggerVhsTracking() {
|
||||||
console.log(`TrackCache: Fetching ${url}`);
|
if (!this.isEnabled || !this.vhsTracking) return;
|
||||||
|
|
||||||
const response = await fetch(url);
|
// Remove all classes to reset
|
||||||
|
this.vhsTracking.classList.remove('active', 'vertical', 'horizontal');
|
||||||
|
|
||||||
if (!response.ok) {
|
// Force reflow to restart animation
|
||||||
throw new Error(`Failed to fetch track: ${response.status}`);
|
void this.vhsTracking.offsetWidth;
|
||||||
}
|
|
||||||
|
|
||||||
// Extract cache validation headers
|
// Randomly choose direction (50/50 chance)
|
||||||
const etag = response.headers.get('ETag');
|
const direction = Math.random() < 0.5 ? 'vertical' : 'horizontal';
|
||||||
const lastModified = response.headers.get('Last-Modified');
|
|
||||||
|
|
||||||
// Clone response before consuming it
|
this.vhsTracking.classList.add(direction);
|
||||||
const responseToCache = response.clone();
|
this.vhsTracking.classList.add('active');
|
||||||
|
|
||||||
// Store in cache
|
// Remove classes after animation completes
|
||||||
await this.init();
|
setTimeout(() => {
|
||||||
await this.cache.put(url, responseToCache);
|
this.vhsTracking.classList.remove('active', 'vertical', 'horizontal');
|
||||||
|
|
||||||
// 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) {
|
// 30% chance of a second burst shortly after
|
||||||
console.log(`TrackCache: Using cached version of ${url}`);
|
if (Math.random() < 0.3) {
|
||||||
const blob = await cachedResponse.blob();
|
setTimeout(() => {
|
||||||
return URL.createObjectURL(blob);
|
this.triggerVhsTracking();
|
||||||
|
}, this.randomBetween(100, 300));
|
||||||
}
|
}
|
||||||
|
}, this.VHS_DURATION);
|
||||||
// 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
|
* Schedule the next occurrence of a glitch type
|
||||||
* Useful for debugging or freeing storage
|
* @param {string} type - 'rgb', 'blackout', or 'vhs'
|
||||||
*/
|
*/
|
||||||
async clearAll() {
|
scheduleNext(type) {
|
||||||
await caches.delete(this.CACHE_NAME);
|
let minInterval, maxInterval, triggerFn;
|
||||||
localStorage.removeItem(this.META_KEY);
|
|
||||||
this.cache = null;
|
switch (type) {
|
||||||
console.log('TrackCache: Cleared all cached tracks');
|
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);
|
||||||
// ========================================
|
// ========================================
|
||||||
// TRACK CACHE MODULE - END
|
// DISPLAY GLITCH MODULE - END
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -1889,17 +2107,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 +2196,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 +2216,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 +2340,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 +2469,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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user