Compare commits
9 Commits
v0.1.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992c704be2 | ||
|
|
ef9bb85d65 | ||
|
|
539f55b6e9 | ||
|
|
aae0af7c19 | ||
|
|
e6b0db7227 | ||
|
|
7ef59a57ab | ||
|
|
373fe8b835 | ||
|
|
2a839de999 | ||
|
|
bbf4d88f61 |
BIN
assets/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
BIN
assets/background.webp
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
assets/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
assets/favicon.svg
Normal file
|
After Width: | Height: | Size: 206 KiB |
21
assets/site.webmanifest
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ECHO REALITY",
|
||||||
|
"short_name": "ECHO REALITY",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
@ -36,7 +36,7 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-image: url('background.png');
|
background-image: url('background.webp');
|
||||||
background-size: 100%;
|
background-size: 100%;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
@ -167,6 +167,7 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: flicker 4s infinite;
|
animation: flicker 4s infinite;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
will-change: opacity; /* Performance: GPU-accelerated opacity animation */
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flicker {
|
@keyframes flicker {
|
||||||
@ -277,11 +278,13 @@ body {
|
|||||||
.vhs-tracking.active.vertical {
|
.vhs-tracking.active.vertical {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
animation: vhsScrollVertical 0.4s linear;
|
animation: vhsScrollVertical 0.4s linear;
|
||||||
|
will-change: transform, opacity; /* Performance: GPU-accelerated animation */
|
||||||
}
|
}
|
||||||
|
|
||||||
.vhs-tracking.active.horizontal {
|
.vhs-tracking.active.horizontal {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
animation: vhsScrollHorizontal 0.4s linear;
|
animation: vhsScrollHorizontal 0.4s linear;
|
||||||
|
will-change: transform, opacity; /* Performance: GPU-accelerated animation */
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes vhsScrollVertical {
|
@keyframes vhsScrollVertical {
|
||||||
@ -304,6 +307,7 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
contain: strict; /* Performance: isolates paint scope for animated children */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dim-spot {
|
.dim-spot {
|
||||||
@ -340,24 +344,6 @@ body {
|
|||||||
animation: dimSpotDrift3 50s ease-in-out infinite;
|
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 */
|
/* Slow drifting animations - each spot moves in a different pattern */
|
||||||
@keyframes dimSpotDrift1 {
|
@keyframes dimSpotDrift1 {
|
||||||
0%, 100% { transform: translate(0%, 0%); }
|
0%, 100% { transform: translate(0%, 0%); }
|
||||||
@ -380,20 +366,6 @@ body {
|
|||||||
75% { transform: translate(-4%, -10%); }
|
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
|
DISPLAY GLITCH EFFECTS - END
|
||||||
======================================== */
|
======================================== */
|
||||||
@ -1425,3 +1397,595 @@ simple-boost {
|
|||||||
/* ========================================
|
/* ========================================
|
||||||
VERSION BUTTON & MODAL STYLES - END
|
VERSION BUTTON & MODAL STYLES - END
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MOBILE RESPONSIVE STYLES - START
|
||||||
|
Supports portrait (rotated) and landscape modes
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
MOBILE LANDSCAPE MODE
|
||||||
|
For phones held sideways (short height, moderate width)
|
||||||
|
Scale player proportionally to fit viewport
|
||||||
|
---------------------------------------- */
|
||||||
|
@media (max-width: 900px) and (max-height: 500px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
/* Scale entire player to fit viewport height */
|
||||||
|
transform: perspective(1000px) rotateX(1deg) scale(0.85);
|
||||||
|
transform-origin: center center;
|
||||||
|
width: calc(100vw - 10px);
|
||||||
|
max-width: 700px; /* Slightly larger since we're scaling down */
|
||||||
|
padding: 50px 20px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale display area */
|
||||||
|
.display {
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale cassette */
|
||||||
|
.cassette {
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-label {
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale tape window and reels */
|
||||||
|
.tape-window {
|
||||||
|
width: 280px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-left {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-right {
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-inner {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
margin-left: -22.5px;
|
||||||
|
margin-top: -22.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale control buttons - maintain touch-friendly size */
|
||||||
|
.controls {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale button icons */
|
||||||
|
.btn-prev::before {
|
||||||
|
border-right-width: 10px;
|
||||||
|
border-top-width: 6px;
|
||||||
|
border-bottom-width: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-prev::after {
|
||||||
|
height: 12px;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play::before {
|
||||||
|
border-left-width: 16px;
|
||||||
|
border-top-width: 10px;
|
||||||
|
border-bottom-width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pause::before {
|
||||||
|
width: 16px;
|
||||||
|
height: 20px;
|
||||||
|
border-left-width: 5px;
|
||||||
|
border-right-width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop::before {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-next::before {
|
||||||
|
border-left-width: 10px;
|
||||||
|
border-top-width: 6px;
|
||||||
|
border-bottom-width: 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-next::after {
|
||||||
|
height: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale volume control */
|
||||||
|
.volume-control {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 150px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale top buttons */
|
||||||
|
.eject-btn,
|
||||||
|
.lightning-btn {
|
||||||
|
top: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightning-btn {
|
||||||
|
right: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn::before {
|
||||||
|
border-left-width: 6px;
|
||||||
|
border-right-width: 6px;
|
||||||
|
border-bottom-width: 8px;
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn::after {
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale version button */
|
||||||
|
.version-btn {
|
||||||
|
bottom: 5px;
|
||||||
|
right: 8px;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals in landscape */
|
||||||
|
.alby-panel {
|
||||||
|
width: 320px;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
MOBILE PORTRAIT MODE
|
||||||
|
For phones held upright (narrow width, tall height)
|
||||||
|
Only the cassette/tape rotates 90 degrees - everything else stays horizontal
|
||||||
|
Layout: [E][L] -> Screen -> ROTATED TAPE -> Controls -> Volume
|
||||||
|
---------------------------------------- */
|
||||||
|
@media (max-width: 500px) and (min-height: 600px) {
|
||||||
|
html, body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
/* Player stays horizontal, just scaled to fit width */
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
max-width: 390px;
|
||||||
|
/* Increased vertical padding to fill screen better */
|
||||||
|
padding: 55px 15px 25px 15px;
|
||||||
|
/* Remove the 3D perspective tilt on mobile for cleaner look */
|
||||||
|
transform: none;
|
||||||
|
/* Scale down border for mobile */
|
||||||
|
border-width: 6px;
|
||||||
|
/* Contain the ::before pseudo-element */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix background texture scaling */
|
||||||
|
.player::before {
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale display for portrait - stays horizontal */
|
||||||
|
.display {
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-text {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
CASSETTE - SCALED TO FIT WIDTH
|
||||||
|
Horizontal layout, scaled down for portrait
|
||||||
|
---------------------------------------- */
|
||||||
|
.cassette {
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-label {
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tape window scaled to fit portrait width */
|
||||||
|
.tape-window {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
max-width: 280px;
|
||||||
|
height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel {
|
||||||
|
width: 55px;
|
||||||
|
height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-left {
|
||||||
|
left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-right {
|
||||||
|
right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-inner {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
margin-left: -21px;
|
||||||
|
margin-top: -21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale the entire reel (including tape-wound set by JS) */
|
||||||
|
.reel {
|
||||||
|
transform: translateY(-50%) scale(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control buttons - stays horizontal, scaled for touch */
|
||||||
|
.controls {
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale button icons for portrait */
|
||||||
|
.btn-prev::before {
|
||||||
|
border-right-width: 9px;
|
||||||
|
border-top-width: 6px;
|
||||||
|
border-bottom-width: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-prev::after {
|
||||||
|
height: 12px;
|
||||||
|
margin-left: -9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play::before {
|
||||||
|
border-left-width: 14px;
|
||||||
|
border-top-width: 9px;
|
||||||
|
border-bottom-width: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pause::before {
|
||||||
|
width: 14px;
|
||||||
|
height: 18px;
|
||||||
|
border-left-width: 5px;
|
||||||
|
border-right-width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop::before {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-next::before {
|
||||||
|
border-left-width: 9px;
|
||||||
|
border-top-width: 6px;
|
||||||
|
border-bottom-width: 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-next::after {
|
||||||
|
height: 12px;
|
||||||
|
margin-left: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume control - stays horizontal */
|
||||||
|
.volume-control {
|
||||||
|
margin-top: 30px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 120px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top buttons - scaled */
|
||||||
|
.eject-btn,
|
||||||
|
.lightning-btn {
|
||||||
|
top: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn {
|
||||||
|
left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightning-btn {
|
||||||
|
right: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn::before {
|
||||||
|
border-left-width: 6px;
|
||||||
|
border-right-width: 6px;
|
||||||
|
border-bottom-width: 8px;
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn::after {
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version button */
|
||||||
|
.version-btn {
|
||||||
|
bottom: 4px;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
MODALS IN PORTRAIT MODE
|
||||||
|
---------------------------------------- */
|
||||||
|
.alby-panel {
|
||||||
|
width: calc(100vw - 30px);
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-modal {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-video {
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
VERY SMALL PORTRAIT (iPhone SE, etc.)
|
||||||
|
Additional scaling for smaller devices
|
||||||
|
---------------------------------------- */
|
||||||
|
@media (max-width: 380px) and (min-height: 600px) {
|
||||||
|
.player {
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
padding: 40px 10px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smaller cassette for tiny screens */
|
||||||
|
.cassette {
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tape-window {
|
||||||
|
width: calc(100% - 16px);
|
||||||
|
max-width: 240px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
transform: translateY(-50%) scale(0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-left {
|
||||||
|
left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-right {
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel-inner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin-left: -18px;
|
||||||
|
margin-top: -18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn,
|
||||||
|
.lightning-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eject-btn {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightning-btn {
|
||||||
|
right: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
TABLET PORTRAIT MODE
|
||||||
|
For tablets held upright - no rotation, just scale
|
||||||
|
---------------------------------------- */
|
||||||
|
@media (min-width: 501px) and (max-width: 768px) {
|
||||||
|
.player {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
TOUCH DEVICE ENHANCEMENTS
|
||||||
|
Better hover states and touch feedback
|
||||||
|
---------------------------------------- */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
/* Touch devices don't need hover state changes */
|
||||||
|
.btn:hover,
|
||||||
|
.eject-btn:hover,
|
||||||
|
.lightning-btn:hover,
|
||||||
|
.alby-btn:hover,
|
||||||
|
.alby-boost-btn:hover,
|
||||||
|
.alby-close-btn:hover,
|
||||||
|
.version-btn:hover,
|
||||||
|
.version-close-btn:hover {
|
||||||
|
/* Reset hover-specific box-shadow to default state */
|
||||||
|
box-shadow: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger touch targets for interactive elements */
|
||||||
|
.alby-close-btn,
|
||||||
|
.version-close-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alby-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure checkbox is easily tappable */
|
||||||
|
.alby-checkbox-label {
|
||||||
|
padding: 10px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alby-checkbox-label input[type="checkbox"] {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------
|
||||||
|
PREVENT ZOOM ON INPUT FOCUS (iOS)
|
||||||
|
iOS zooms when focusing inputs with font-size < 16px
|
||||||
|
---------------------------------------- */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.alby-input,
|
||||||
|
.alby-textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MOBILE RESPONSIVE STYLES - END
|
||||||
|
======================================== */
|
||||||
BIN
assets/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
18
index.html
@ -3,8 +3,16 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>16-Bit Cassette Player</title>
|
<title>ECHO REALITY</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="icon" type="image/png" href="/assets/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ECHO REALITY" />
|
||||||
|
<link rel="manifest" href="/assets/site.webmanifest" />
|
||||||
|
<link rel="stylesheet" href="assets/styles.css">
|
||||||
|
<!-- Preconnect to esm.sh for faster simple-boost loading -->
|
||||||
|
<link rel="preconnect" href="https://esm.sh">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="player">
|
<div class="player">
|
||||||
@ -12,13 +20,11 @@
|
|||||||
<button class="lightning-btn" id="lightningBtn" title="Menu">⚡</button>
|
<button class="lightning-btn" id="lightningBtn" title="Menu">⚡</button>
|
||||||
|
|
||||||
<div class="display" id="display">
|
<div class="display" id="display">
|
||||||
<!-- Dim spots - worn phosphor effect (slowly drifting) -->
|
<!-- Dim spots - worn phosphor effect (slowly drifting) - reduced to 3 for performance -->
|
||||||
<div class="dim-spots-container">
|
<div class="dim-spots-container">
|
||||||
<div class="dim-spot dim-spot-1"></div>
|
<div class="dim-spot dim-spot-1"></div>
|
||||||
<div class="dim-spot dim-spot-2"></div>
|
<div class="dim-spot dim-spot-2"></div>
|
||||||
<div class="dim-spot dim-spot-3"></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>
|
</div>
|
||||||
<!-- VHS tracking lines overlay (controlled by DisplayGlitch module) -->
|
<!-- VHS tracking lines overlay (controlled by DisplayGlitch module) -->
|
||||||
<div class="vhs-tracking" id="vhsTracking"></div>
|
<div class="vhs-tracking" id="vhsTracking"></div>
|
||||||
@ -156,6 +162,6 @@
|
|||||||
|
|
||||||
<audio id="audio" preload="metadata"></audio>
|
<audio id="audio" preload="metadata"></audio>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="src/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const APP_VERSION = '0.1.0-beta';
|
const APP_VERSION = '0.3.0';
|
||||||
const playlist = [
|
const playlist = [
|
||||||
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/01.%20PINK%20FLIGHT%20ATTENDANT.mp3', name: 'TRACK 1 - PINK FLIGHT ATTENDANT' },
|
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/01.%20PINK%20FLIGHT%20ATTENDANT.mp3', name: 'TRACK 1 - PINK FLIGHT ATTENDANT' },
|
||||||
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/02.%20NOW.mp3', name: 'TRACK 2 - NOW' },
|
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/02.%20NOW.mp3', name: 'TRACK 2 - NOW' },
|
||||||
@ -63,23 +63,20 @@ let durationsLoaded = false; // Flag indicating when all durations are known
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load metadata for all tracks to get their durations
|
* Load metadata for all tracks to get their durations
|
||||||
* Uses a temporary audio element to fetch duration without full download
|
* Uses parallel loading for better performance
|
||||||
* Falls back to 240 seconds (4 min) if metadata can't be loaded
|
* Falls back to 240 seconds (4 min) if metadata can't be loaded
|
||||||
*/
|
*/
|
||||||
async function loadAllDurations() {
|
async function loadAllDurations() {
|
||||||
console.log('Loading track durations...');
|
console.log('Loading track durations...');
|
||||||
trackDurations = [];
|
trackDurations = new Array(playlist.length);
|
||||||
|
|
||||||
for (let i = 0; i < playlist.length; i++) {
|
// Load track 0 from main audio element first
|
||||||
try {
|
try {
|
||||||
// For track 0, use the main audio element since loadTrack(0) already loads it
|
if (currentTrack === 0) {
|
||||||
if (i === 0 && currentTrack === 0) {
|
|
||||||
// Wait for main audio to load metadata if not already
|
|
||||||
if (audio.duration && !isNaN(audio.duration)) {
|
if (audio.duration && !isNaN(audio.duration)) {
|
||||||
trackDurations[i] = audio.duration;
|
trackDurations[0] = audio.duration;
|
||||||
} else {
|
} else {
|
||||||
// Wait for main audio metadata
|
trackDurations[0] = await new Promise((resolve) => {
|
||||||
trackDurations[i] = await new Promise((resolve) => {
|
|
||||||
if (audio.duration && !isNaN(audio.duration)) {
|
if (audio.duration && !isNaN(audio.duration)) {
|
||||||
resolve(audio.duration);
|
resolve(audio.duration);
|
||||||
} else {
|
} else {
|
||||||
@ -91,13 +88,31 @@ async function loadAllDurations() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For other tracks, use temp audio element
|
trackDurations[0] = await getTrackDuration(playlist[0].url);
|
||||||
trackDurations[i] = await getTrackDuration(playlist[i].url);
|
|
||||||
}
|
}
|
||||||
console.log(`Track ${i + 1} duration: ${formatTime(trackDurations[i])}`);
|
console.log(`Track 1 duration: ${formatTime(trackDurations[0])}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Failed to get duration for track ${i + 1}, using fallback`);
|
console.warn('Failed to get duration for track 1, using fallback');
|
||||||
trackDurations[i] = 240; // 4 minute fallback
|
trackDurations[0] = 240;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load remaining tracks in parallel for faster loading
|
||||||
|
if (playlist.length > 1) {
|
||||||
|
const otherTrackPromises = playlist.slice(1).map(async (track, index) => {
|
||||||
|
const trackIndex = index + 1;
|
||||||
|
try {
|
||||||
|
const duration = await getTrackDuration(track.url);
|
||||||
|
console.log(`Track ${trackIndex + 1} duration: ${formatTime(duration)}`);
|
||||||
|
return duration;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to get duration for track ${trackIndex + 1}, using fallback`);
|
||||||
|
return 240; // 4 minute fallback
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const otherDurations = await Promise.all(otherTrackPromises);
|
||||||
|
for (let i = 0; i < otherDurations.length; i++) {
|
||||||
|
trackDurations[i + 1] = otherDurations[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,14 +242,20 @@ function getTrackStartPosition(trackIndex) {
|
|||||||
// ========================================
|
// ========================================
|
||||||
// SOUND EFFECTS MODULE - START
|
// SOUND EFFECTS MODULE - START
|
||||||
// Web Audio API synthesized sounds for tactile feedback
|
// Web Audio API synthesized sounds for tactile feedback
|
||||||
|
// Pre-generated noise buffers for better performance
|
||||||
// ========================================
|
// ========================================
|
||||||
const SoundEffects = {
|
const SoundEffects = {
|
||||||
ctx: null,
|
ctx: null,
|
||||||
|
// Cached noise buffers - generated once, reused for all sounds
|
||||||
|
clickNoiseBuffer: null,
|
||||||
|
tapeWindNoiseBuffer: null,
|
||||||
|
|
||||||
// Initialize AudioContext (must be called after user gesture)
|
// Initialize AudioContext and pre-generate noise buffers (must be called after user gesture)
|
||||||
init() {
|
init() {
|
||||||
if (!this.ctx) {
|
if (!this.ctx) {
|
||||||
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
// Pre-generate noise buffers on first init
|
||||||
|
this.generateNoiseBuffers();
|
||||||
}
|
}
|
||||||
// Resume if suspended (browser autoplay policy)
|
// Resume if suspended (browser autoplay policy)
|
||||||
if (this.ctx.state === 'suspended') {
|
if (this.ctx.state === 'suspended') {
|
||||||
@ -242,6 +263,29 @@ const SoundEffects = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Pre-generate all noise buffers once for reuse
|
||||||
|
generateNoiseBuffers() {
|
||||||
|
const sampleRate = this.ctx.sampleRate;
|
||||||
|
|
||||||
|
// Generate click noise buffer (20ms)
|
||||||
|
const clickBufferSize = Math.floor(sampleRate * 0.02);
|
||||||
|
this.clickNoiseBuffer = this.ctx.createBuffer(1, clickBufferSize, sampleRate);
|
||||||
|
const clickOutput = this.clickNoiseBuffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < clickBufferSize; i++) {
|
||||||
|
clickOutput[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tape wind noise buffer (1 second with wobble)
|
||||||
|
const windBufferSize = sampleRate * 1;
|
||||||
|
this.tapeWindNoiseBuffer = this.ctx.createBuffer(1, windBufferSize, sampleRate);
|
||||||
|
const windOutput = this.tapeWindNoiseBuffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < windBufferSize; i++) {
|
||||||
|
const noise = Math.random() * 2 - 1;
|
||||||
|
const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2);
|
||||||
|
windOutput[i] = noise * wobble;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Play a mechanical button click sound
|
// Play a mechanical button click sound
|
||||||
playButtonClick() {
|
playButtonClick() {
|
||||||
this.init();
|
this.init();
|
||||||
@ -264,16 +308,9 @@ const SoundEffects = {
|
|||||||
clickOsc.connect(clickGain);
|
clickOsc.connect(clickGain);
|
||||||
clickGain.connect(this.ctx.destination);
|
clickGain.connect(this.ctx.destination);
|
||||||
|
|
||||||
// High frequency "click" transient using noise
|
// High frequency "click" transient using cached noise buffer
|
||||||
const bufferSize = this.ctx.sampleRate * 0.02; // 20ms of noise
|
|
||||||
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
|
||||||
const output = noiseBuffer.getChannelData(0);
|
|
||||||
for (let i = 0; i < bufferSize; i++) {
|
|
||||||
output[i] = Math.random() * 2 - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noise = this.ctx.createBufferSource();
|
const noise = this.ctx.createBufferSource();
|
||||||
noise.buffer = noiseBuffer;
|
noise.buffer = this.clickNoiseBuffer;
|
||||||
|
|
||||||
// Bandpass filter to shape the noise into a click
|
// Bandpass filter to shape the noise into a click
|
||||||
const filter = this.ctx.createBiquadFilter();
|
const filter = this.ctx.createBiquadFilter();
|
||||||
@ -296,7 +333,7 @@ const SoundEffects = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Start continuous tape wind sound (for reel dragging)
|
// Start continuous tape wind sound (for reel dragging)
|
||||||
// Uses looping noise buffer for seamless continuous playback
|
// Uses cached looping noise buffer for seamless continuous playback
|
||||||
startTapeWindLoop() {
|
startTapeWindLoop() {
|
||||||
this.init();
|
this.init();
|
||||||
|
|
||||||
@ -304,22 +341,10 @@ const SoundEffects = {
|
|||||||
this.stopTapeWindLoop();
|
this.stopTapeWindLoop();
|
||||||
|
|
||||||
const now = this.ctx.currentTime;
|
const now = this.ctx.currentTime;
|
||||||
const sampleRate = this.ctx.sampleRate;
|
|
||||||
|
|
||||||
// Create 1 second of loopable noise
|
|
||||||
const bufferSize = sampleRate * 1;
|
|
||||||
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate);
|
|
||||||
const output = noiseBuffer.getChannelData(0);
|
|
||||||
|
|
||||||
// Generate noise with amplitude modulation
|
|
||||||
for (let i = 0; i < bufferSize; i++) {
|
|
||||||
const noise = Math.random() * 2 - 1;
|
|
||||||
const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2);
|
|
||||||
output[i] = noise * wobble;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Use cached tape wind noise buffer
|
||||||
this.tapeWindSource = this.ctx.createBufferSource();
|
this.tapeWindSource = this.ctx.createBufferSource();
|
||||||
this.tapeWindSource.buffer = noiseBuffer;
|
this.tapeWindSource.buffer = this.tapeWindNoiseBuffer;
|
||||||
this.tapeWindSource.loop = true;
|
this.tapeWindSource.loop = true;
|
||||||
|
|
||||||
// Bandpass filter for tape character
|
// Bandpass filter for tape character
|
||||||
@ -611,22 +636,49 @@ lightningBtn.addEventListener('click', () => {
|
|||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// TITLE SCROLL ANIMATION (JavaScript controlled bounce)
|
// TITLE SCROLL ANIMATION (JavaScript controlled bounce)
|
||||||
|
// Uses requestAnimationFrame for better performance
|
||||||
// Moves 1px at a time, bounces at edges, pauses with player
|
// Moves 1px at a time, bounces at edges, pauses with player
|
||||||
// ========================================
|
// ========================================
|
||||||
let titleScrollPosition = 0;
|
let titleScrollPosition = 0;
|
||||||
let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right
|
let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right
|
||||||
let titleScrollInterval = null;
|
let titleScrollRAF = null;
|
||||||
|
let titleScrollLastTime = 0;
|
||||||
const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels)
|
const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels)
|
||||||
|
|
||||||
|
// Cached dimensions - updated only when track changes or on resize
|
||||||
|
let cachedContainerWidth = 0;
|
||||||
|
let cachedTextWidth = 0;
|
||||||
|
let cachedMaxScroll = 0;
|
||||||
|
let cachedTextLonger = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache the container and text dimensions for title scroll
|
||||||
|
* Call this when track changes or window resizes
|
||||||
|
*/
|
||||||
|
function cacheTitleDimensions() {
|
||||||
|
cachedContainerWidth = trackName.offsetWidth;
|
||||||
|
cachedTextWidth = trackNameInner.offsetWidth;
|
||||||
|
cachedTextLonger = cachedTextWidth > cachedContainerWidth;
|
||||||
|
|
||||||
|
// Calculate max scroll
|
||||||
|
if (cachedTextLonger) {
|
||||||
|
cachedMaxScroll = cachedTextWidth - cachedContainerWidth;
|
||||||
|
} else {
|
||||||
|
cachedMaxScroll = cachedContainerWidth - cachedTextWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startTitleScroll() {
|
function startTitleScroll() {
|
||||||
if (titleScrollInterval) return; // Already scrolling
|
if (titleScrollRAF) return; // Already scrolling
|
||||||
titleScrollInterval = setInterval(updateTitleScroll, SCROLL_SPEED);
|
cacheTitleDimensions();
|
||||||
|
titleScrollLastTime = performance.now();
|
||||||
|
titleScrollRAF = requestAnimationFrame(titleScrollLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTitleScroll() {
|
function stopTitleScroll() {
|
||||||
if (titleScrollInterval) {
|
if (titleScrollRAF) {
|
||||||
clearInterval(titleScrollInterval);
|
cancelAnimationFrame(titleScrollRAF);
|
||||||
titleScrollInterval = null;
|
titleScrollRAF = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -635,46 +687,52 @@ function resetTitleScroll() {
|
|||||||
titleScrollPosition = 0;
|
titleScrollPosition = 0;
|
||||||
titleScrollDirection = 1;
|
titleScrollDirection = 1;
|
||||||
trackNameInner.style.left = '0px';
|
trackNameInner.style.left = '0px';
|
||||||
|
cacheTitleDimensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAF-based scroll loop - tracks elapsed time to maintain consistent speed
|
||||||
|
*/
|
||||||
|
function titleScrollLoop(currentTime) {
|
||||||
|
const elapsed = currentTime - titleScrollLastTime;
|
||||||
|
|
||||||
|
// Only update position when enough time has passed (maintains same speed as before)
|
||||||
|
if (elapsed >= SCROLL_SPEED) {
|
||||||
|
titleScrollLastTime = currentTime - (elapsed % SCROLL_SPEED);
|
||||||
|
updateTitleScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
titleScrollRAF = requestAnimationFrame(titleScrollLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTitleScroll() {
|
function updateTitleScroll() {
|
||||||
const containerWidth = trackName.offsetWidth;
|
|
||||||
const textWidth = trackNameInner.offsetWidth;
|
|
||||||
|
|
||||||
// Calculate max scroll - text scrolls until its right edge hits container's right edge
|
|
||||||
// If text is shorter than container, scroll until text's left edge hits container's right edge
|
|
||||||
let maxScroll;
|
|
||||||
if (textWidth > containerWidth) {
|
|
||||||
// Text is longer - scroll until right edge of text reaches right edge of container
|
|
||||||
maxScroll = textWidth - containerWidth;
|
|
||||||
} else {
|
|
||||||
// Text is shorter - scroll across the container width but keep text visible
|
|
||||||
// Text starts at left (0), scrolls right until left edge is at (containerWidth - textWidth)
|
|
||||||
maxScroll = containerWidth - textWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move 1px in current direction
|
// Move 1px in current direction
|
||||||
titleScrollPosition += titleScrollDirection;
|
titleScrollPosition += titleScrollDirection;
|
||||||
|
|
||||||
// Bounce at edges
|
// Bounce at edges
|
||||||
if (titleScrollPosition >= maxScroll) {
|
if (titleScrollPosition >= cachedMaxScroll) {
|
||||||
titleScrollPosition = maxScroll;
|
titleScrollPosition = cachedMaxScroll;
|
||||||
titleScrollDirection = -1; // Start moving back
|
titleScrollDirection = -1; // Start moving back
|
||||||
} else if (titleScrollPosition <= 0) {
|
} else if (titleScrollPosition <= 0) {
|
||||||
titleScrollPosition = 0;
|
titleScrollPosition = 0;
|
||||||
titleScrollDirection = 1; // Start moving forward
|
titleScrollDirection = 1; // Start moving forward
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply position
|
// Apply position using cached text length comparison
|
||||||
// If text is longer: shift text LEFT (negative value) so we see the end
|
if (cachedTextLonger) {
|
||||||
// If text is shorter: shift text RIGHT (positive value) to scroll across display
|
|
||||||
if (textWidth > containerWidth) {
|
|
||||||
trackNameInner.style.left = -titleScrollPosition + 'px';
|
trackNameInner.style.left = -titleScrollPosition + 'px';
|
||||||
} else {
|
} else {
|
||||||
trackNameInner.style.left = titleScrollPosition + 'px';
|
trackNameInner.style.left = titleScrollPosition + 'px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recache dimensions on window resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (titleScrollRAF) {
|
||||||
|
cacheTitleDimensions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// AUDIO LOADING HELPERS - START
|
// AUDIO LOADING HELPERS - START
|
||||||
// Unified helpers for loading tracks and waiting for audio ready
|
// Unified helpers for loading tracks and waiting for audio ready
|
||||||
@ -1138,12 +1196,35 @@ function setTapeSizesAtProgress(progress) {
|
|||||||
|
|
||||||
resetTapeSizes();
|
resetTapeSizes();
|
||||||
|
|
||||||
// Update time display and tape sizes
|
// Throttle timeupdate for better performance
|
||||||
|
let lastTimeUpdateTime = 0;
|
||||||
|
let lastFormattedTime = '';
|
||||||
|
const TIME_UPDATE_THROTTLE = 200; // Update at most 5 times per second
|
||||||
|
|
||||||
|
// Update time display and tape sizes (throttled)
|
||||||
audio.addEventListener('timeupdate', () => {
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastTimeUpdateTime < TIME_UPDATE_THROTTLE) return;
|
||||||
|
lastTimeUpdateTime = now;
|
||||||
|
|
||||||
|
const current = formatTime(audio.currentTime);
|
||||||
|
const duration = formatTime(audio.duration);
|
||||||
|
const formatted = `${current} / ${duration}`;
|
||||||
|
|
||||||
|
// Skip DOM update if time display hasn't changed
|
||||||
|
if (formatted !== lastFormattedTime) {
|
||||||
|
lastFormattedTime = formatted;
|
||||||
|
timeDisplay.textContent = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTapeSizes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update time display when metadata loads (fixes cold start 0:00 / 0:00 issue)
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
const current = formatTime(audio.currentTime);
|
const current = formatTime(audio.currentTime);
|
||||||
const duration = formatTime(audio.duration);
|
const duration = formatTime(audio.duration);
|
||||||
timeDisplay.textContent = `${current} / ${duration}`;
|
timeDisplay.textContent = `${current} / ${duration}`;
|
||||||
updateTapeSizes();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Format time helper
|
// Format time helper
|
||||||