2026-01-17 08:49:53 -05:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > 16-Bit Cassette Player< / title >
< style >
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(180deg, #0a0a0a 0%, #1a1a1a 100%);
font-family: 'Courier New', monospace;
image-rendering: pixelated;
}
.player {
width: 600px;
background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%);
border: 8px solid #0a0a0a;
box-shadow:
2026-01-17 13:15:21 -05:00
/* inset 0 0 0 2px #3a3a3a, */
inset 0 0 10px rgba(0,0,0,0.1),
2026-01-17 08:49:53 -05:00
0 30px 60px rgba(0,0,0,0.8),
5px 5px 0 rgba(0,0,0,0.3);
/* Extra top padding to accommodate eject/lightning buttons */
padding: 60px 30px 30px 30px;
position: relative;
transform: perspective(1000px) rotateX(1deg);
}
/* Heavy damage and scratches */
.player::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
2026-01-17 13:15:21 -05:00
background-image: url('background.png');
background-size: 100%;
background-position: center;
background-repeat: repeat;
2026-01-17 08:49:53 -05:00
pointer-events: none;
2026-01-17 13:37:46 -05:00
opacity: 0.20;
2026-01-17 13:15:21 -05:00
mix-blend-mode: normal;
2026-01-17 08:49:53 -05:00
}
/* Eject button - positioned top-left of player */
.eject-btn {
position: absolute;
top: 15px;
left: 30px;
width: 50px;
height: 35px;
background: linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 100%);
border: 4px solid #0a0a0a;
cursor: pointer;
box-shadow:
inset 0 -3px 0 rgba(0,0,0,0.5),
inset 2px 0 0 rgba(80, 50, 20, 0.3),
0 3px 6px rgba(0,0,0,0.6);
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
/* Lightning button - positioned top-right of player */
.lightning-btn {
position: absolute;
top: 15px;
right: 30px;
width: 50px;
height: 35px;
background: linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 100%);
border: 4px solid #0a0a0a;
cursor: pointer;
box-shadow:
inset 0 -3px 0 rgba(0,0,0,0.5),
inset 2px 0 0 rgba(80, 50, 20, 0.3),
0 3px 6px rgba(0,0,0,0.6);
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #cc8800;
text-shadow: 0 0 3px rgba(255, 170, 0, 0.3);
}
.lightning-btn::before,
.lightning-btn::after {
content: none;
}
.lightning-btn:active,
.eject-btn:active {
transform: translateY(2px);
box-shadow:
inset 0 -1px 0 rgba(0,0,0,0.5),
0 1px 3px rgba(0,0,0,0.6);
}
/* Eject icon: triangle pointing up */
.eject-btn::before {
content: '';
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid #666;
position: absolute;
top: 6px;
filter: drop-shadow(0 0 2px rgba(255, 0, 255, 0.3));
}
/* Eject icon: bar underneath triangle */
.eject-btn::after {
content: '';
position: absolute;
bottom: 6px;
width: 18px;
height: 3px;
background: #666;
}
.eject-btn:active {
transform: translateY(2px);
box-shadow:
inset 0 -1px 0 rgba(0,0,0,0.5),
0 1px 3px rgba(0,0,0,0.6);
}
.display {
background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%);
border: 4px solid #000;
padding: 15px;
/* Spacing: top margin matches bottom margin for symmetry with buttons above */
margin-top: 15px;
margin-bottom: 25px;
box-shadow:
inset 0 0 30px rgba(0,0,0,0.9),
inset 0 0 5px rgba(0,255,0,0.1);
position: relative;
overflow: hidden;
}
/* Cracked display effect */
.display::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(120deg, transparent 0%, transparent 48%, rgba(255,255,255,0.05) 48.5%, transparent 49%),
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);
pointer-events: none;
2026-01-17 15:12:54 -05:00
z-index: 2;
2026-01-17 08:49:53 -05:00
}
/* Flickering scanlines */
.display::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
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;
animation: flicker 4s infinite;
2026-01-17 15:12:54 -05:00
z-index: 3;
2026-01-17 08:49:53 -05:00
}
@keyframes flicker {
0%, 100% { opacity: 0.8; }
50% { opacity: 0.6; }
51% { opacity: 1; }
52% { opacity: 0.7; }
}
2026-01-17 15:12:54 -05:00
/* ========================================
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
======================================== */
2026-01-17 08:49:53 -05:00
/* Track name text - scrolls when playing (JS controlled) */
.display-text {
color: #00ff00;
font-size: 18px;
text-shadow:
0 0 10px #00ff00,
0 0 20px #00ff00,
0 0 30px #00ff00;
letter-spacing: 2px;
margin-bottom: 10px;
position: relative;
z-index: 1;
white-space: nowrap;
overflow: hidden;
}
/* Inner span - position controlled by JavaScript for bounce effect */
.display-text-inner {
display: inline-block;
position: relative;
left: 0;
}
.time-display {
color: #00ff00;
font-size: 24px;
font-weight: bold;
text-shadow:
0 0 10px #00ff00,
0 0 20px #00ff00;
letter-spacing: 3px;
position: relative;
z-index: 1;
}
.cassette {
background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%);
border: 6px solid #000;
border-radius: 4px;
padding: 20px;
margin-bottom: 30px;
position: relative;
box-shadow:
inset 0 0 40px rgba(0,0,0,0.9),
inset 5px 5px 20px rgba(0,0,0,0.8);
transform: perspective(800px) rotateX(2deg);
/* Prevent text selection during reel drag */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Damage on cassette housing */
2026-01-17 10:38:45 -05:00
/* Removed by popular demand.
2026-01-17 08:49:53 -05:00
.cassette::before {
content: '';
position: absolute;
top: 10px;
right: 20px;
width: 40px;
height: 30px;
background: radial-gradient(ellipse, rgba(255, 0, 255, 0.2) 0%, transparent 70%);
border: 2px solid rgba(255, 0, 255, 0.3);
border-radius: 3px;
pointer-events: none;
}
2026-01-17 10:38:45 -05:00
*/
2026-01-17 08:49:53 -05:00
.cassette-label {
background: linear-gradient(180deg, #d0d0d0 0%, #a0a0a0 100%);
border: 3px solid #000;
padding: 15px;
text-align: center;
margin-bottom: 20px;
font-size: 14px;
color: #1a1a1a;
font-weight: bold;
box-shadow:
inset 0 0 30px rgba(0,0,0,0.3),
inset 3px 3px 10px rgba(0,0,0,0.4);
position: relative;
overflow: hidden;
/* Prevent text selection during reel drag */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Faded neon cyan showing through wear */
.cassette-label::before {
content: '';
position: absolute;
top: 5px;
left: 10px;
width: 50px;
height: 20px;
background: linear-gradient(90deg, transparent, rgba(0, 255, 255, 0.4), transparent);
pointer-events: none;
filter: blur(2px);
}
/* Faded magenta showing through wear */
.cassette-label::after {
content: '';
position: absolute;
bottom: 8px;
right: 15px;
width: 40px;
height: 15px;
background: linear-gradient(90deg, transparent, rgba(255, 0, 255, 0.3), transparent);
pointer-events: none;
filter: blur(2px);
}
.reel {
width: 80px;
height: 80px;
border: 1px solid #000;
border-radius: 50%;
background: #0a0a0a;
position: absolute;
top: 50%;
transform: translateY(-50%);
box-shadow:
inset 0 0 20px rgba(0,0,0,0.9),
inset 0 0 5px rgba(139, 69, 19, 0.3);
/* Prevent text selection during drag */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: ns-resize;
}
.reel-left {
left: 30px;
}
.reel-right {
right: 30px;
}
.tape-wound {
position: absolute;
2026-01-17 14:09:42 -05:00
/* Center using top/left 50% with negative margins (Safari-safe) */
2026-01-17 08:49:53 -05:00
top: 50%;
left: 50%;
2026-01-17 14:09:42 -05:00
/* Use negative margins for centering instead of transform */
margin-left: calc(var(--tape-size, 72px) / -2);
margin-top: calc(var(--tape-size, 72px) / -2);
2026-01-17 08:49:53 -05:00
border-radius: 50%;
/* Darker tape color */
background: linear-gradient(135deg, #1a1815 0%, #0f0d0a 50%, #050403 100%);
box-shadow:
inset 0 0 10px rgba(0,0,0,0.9),
0 0 3px rgba(0,0,0,0.7);
z-index: 1;
/* Size controlled by JavaScript via CSS custom property */
width: var(--tape-size, 72px);
height: var(--tape-size, 72px);
2026-01-17 14:09:42 -05:00
/* Safari animation optimizations */
transform-origin: center center;
will-change: transform;
2026-01-17 08:49:53 -05:00
/* Prevent text selection during drag */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: ns-resize;
}
.tape-wound.spinning {
animation: spinTape 2s linear infinite;
}
@keyframes spinTape {
2026-01-17 14:09:42 -05:00
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
2026-01-17 08:49:53 -05:00
}
.reel-inner {
width: 60px;
height: 60px;
border-radius: 50%;
background: conic-gradient(
from 0deg,
#2a2a2a 0deg 45deg,
#1a1a1a 45deg 90deg,
#2a2a2a 90deg 135deg,
#1a1a1a 135deg 180deg,
#2a2a2a 180deg 225deg,
#1a1a1a 225deg 270deg,
#2a2a2a 270deg 315deg,
#1a1a1a 315deg 360deg
);
box-shadow: inset 0 0 10px rgba(0,0,0,0.8);
z-index: 2;
2026-01-17 14:09:42 -05:00
/* Center the spool within the reel container (Safari-safe) */
2026-01-17 08:49:53 -05:00
position: absolute;
top: 50%;
left: 50%;
2026-01-17 14:09:42 -05:00
/* 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;
2026-01-17 08:49:53 -05:00
}
.reel-inner.spinning {
animation: spinSpool 2s linear infinite;
}
@keyframes spinSpool {
2026-01-17 14:09:42 -05:00
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
2026-01-17 08:49:53 -05:00
}
.reel-inner.spinning {
animation: spinSpool 2s linear infinite;
}
.tape-window {
position: relative;
width: 380px;
height: 80px;
margin: 0 auto;
background: rgba(60, 30, 10, 0.4);
border: 3px solid #000;
box-shadow: inset 0 0 15px rgba(0,0,0,0.9);
overflow: hidden;
}
.controls {
display: flex;
justify-content: center;
gap: 15px;
margin-bottom: 20px;
}
.btn {
width: 70px;
height: 70px;
border: 5px solid #000;
background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%);
cursor: pointer;
position: relative;
box-shadow:
inset 0 -4px 0 rgba(0,0,0,0.5),
inset 0 0 10px rgba(0,0,0,0.6),
0 4px 8px rgba(0,0,0,0.6);
transition: all 0.1s;
}
.btn:active {
transform: translateY(2px);
box-shadow:
inset 0 -2px 0 rgba(0,0,0,0.5),
0 2px 4px rgba(0,0,0,0.6);
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* Worn colored icons on buttons */
.btn-prev::before {
width: 0;
height: 0;
border-right: 12px solid #00a0a0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
margin-right: 8px;
filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.4));
opacity: 0.7;
}
.btn-prev::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 3px;
height: 16px;
background: #00a0a0;
margin-left: -12px;
opacity: 0.7;
}
.btn-play::before {
width: 0;
height: 0;
border-left: 20px solid #00aa00;
border-top: 12px solid transparent;
border-bottom: 12px solid transparent;
margin-left: 3px;
filter: drop-shadow(0 0 3px rgba(0, 255, 0, 0.3));
opacity: 0.8;
}
.btn-pause::before {
width: 20px;
height: 24px;
border-left: 7px solid #cc8800;
border-right: 7px solid #cc8800;
filter: drop-shadow(0 0 3px rgba(255, 170, 0, 0.3));
opacity: 0.7;
}
.btn-stop::before {
width: 20px;
height: 20px;
background: #aa0000;
filter: drop-shadow(0 0 3px rgba(255, 0, 0, 0.3));
opacity: 0.7;
}
.btn-next::before {
width: 0;
height: 0;
border-left: 12px solid #00a0a0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
margin-left: -8px;
filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.4));
opacity: 0.7;
}
.btn-next::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 3px;
height: 16px;
background: #00a0a0;
margin-left: 12px;
opacity: 0.7;
}
.volume-control {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.volume-label {
color: #00ff00;
font-size: 16px;
text-shadow:
0 0 5px #00ff00,
0 0 10px #00ff00;
opacity: 0.9;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 200px;
height: 12px;
background: linear-gradient(180deg, #1a1a1a 0%, #0a0a0a 100%);
border: 3px solid #000;
box-shadow:
inset 0 0 10px rgba(0,0,0,0.8),
inset 0 0 5px rgba(139, 69, 19, 0.2);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px;
height: 24px;
background: linear-gradient(145deg, #00ff00 0%, #00aa00 100%);
border: 3px solid #000;
cursor: pointer;
box-shadow:
0 0 10px rgba(0, 255, 0, 0.5),
inset 0 -2px 5px rgba(0,0,0,0.4);
}
input[type="range"]::-moz-range-thumb {
width: 24px;
height: 24px;
background: linear-gradient(145deg, #00ff00 0%, #00aa00 100%);
border: 3px solid #000;
cursor: pointer;
box-shadow:
0 0 10px rgba(0, 255, 0, 0.5),
inset 0 -2px 5px rgba(0,0,0,0.4);
}
.video-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 1000;
justify-content: center;
align-items: center;
}
.video-overlay.active {
display: flex;
}
.video-overlay video {
width: 100%;
height: 100%;
object-fit: contain;
}
.close-video {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(0,0,0,0.8);
border: 2px solid #fff;
color: #fff;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1003;
text-shadow: none;
}
.close-video:hover {
background: rgba(0,0,0,0.9);
border-color: #fff;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
}
/* ========================================
ALBY LIGHTNING PANEL STYLES - START
Panel overlay and slide-in panel for Lightning payments
Styled to match the cassette player's retro 16-bit aesthetic
======================================== */
/* Semi-transparent backdrop overlay - click to close panel */
.alby-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
}
.alby-overlay.active {
display: block;
}
2026-01-17 10:17:24 -05:00
/* Main floating modal container */
2026-01-17 08:49:53 -05:00
.alby-panel {
position: fixed;
2026-01-17 10:17:24 -05:00
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
opacity: 0;
2026-01-17 08:49:53 -05:00
width: 380px;
2026-01-17 10:17:24 -05:00
max-height: 90vh;
2026-01-17 08:49:53 -05:00
background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%);
2026-01-17 10:17:24 -05:00
border: 6px solid #0a0a0a;
border-radius: 8px;
2026-01-17 08:49:53 -05:00
box-shadow:
inset 0 0 0 2px #3a3a3a,
inset 0 0 50px rgba(0,0,0,0.9),
2026-01-17 10:17:24 -05:00
0 20px 60px rgba(0,0,0,0.8);
2026-01-17 08:49:53 -05:00
z-index: 1001;
2026-01-17 10:17:24 -05:00
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
2026-01-17 08:49:53 -05:00
overflow-y: auto;
font-family: 'Courier New', monospace;
2026-01-17 10:17:24 -05:00
pointer-events: none;
2026-01-17 08:49:53 -05:00
}
.alby-panel.active {
2026-01-17 10:17:24 -05:00
transform: translate(-50%, -50%) scale(1);
opacity: 1;
pointer-events: auto;
2026-01-17 08:49:53 -05:00
}
/* Panel header with amber Lightning accent */
.alby-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%);
border-bottom: 4px solid #000;
2026-01-17 10:17:24 -05:00
border-radius: 4px 4px 0 0;
2026-01-17 08:49:53 -05:00
position: relative;
}
.alby-panel-title {
color: #cc8800;
2026-01-17 10:17:24 -05:00
font-size: 18px;
2026-01-17 08:49:53 -05:00
font-weight: bold;
text-shadow:
0 0 10px rgba(255, 170, 0, 0.5),
0 0 20px rgba(255, 170, 0, 0.3);
letter-spacing: 2px;
display: flex;
align-items: center;
gap: 10px;
}
.alby-panel-title .lightning-icon {
font-size: 20px;
}
/* Close button styled like cassette buttons */
.alby-close-btn {
width: 36px;
height: 36px;
background: linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 100%);
border: 3px solid #000;
color: #666;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
inset 0 -2px 0 rgba(0,0,0,0.5),
0 2px 4px rgba(0,0,0,0.6);
transition: all 0.1s;
}
.alby-close-btn:hover {
color: #aa0000;
}
.alby-close-btn:active {
transform: translateY(2px);
box-shadow:
inset 0 -1px 0 rgba(0,0,0,0.5),
0 1px 2px rgba(0,0,0,0.6);
}
/* Panel body content area */
.alby-panel-body {
padding: 20px;
position: relative;
}
/* Section labels with green CRT glow */
.alby-label {
color: #00ff00;
2026-01-17 10:17:24 -05:00
font-size: 14px;
2026-01-17 08:49:53 -05:00
text-shadow:
0 0 5px #00ff00,
0 0 10px #00ff00;
letter-spacing: 2px;
margin-bottom: 8px;
opacity: 0.9;
}
/* Lightning address display - styled like cassette display */
.alby-address-box {
background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%);
border: 3px solid #000;
padding: 12px;
margin-bottom: 15px;
box-shadow:
inset 0 0 20px rgba(0,0,0,0.9),
inset 0 0 5px rgba(255, 170, 0, 0.1);
position: relative;
}
/* Scanlines effect on address box */
.alby-address-box::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,0.4) 1px, rgba(0,0,0,0.4) 2px);
pointer-events: none;
}
.alby-address {
color: #cc8800;
2026-01-17 10:17:24 -05:00
font-size: 15px;
2026-01-17 08:49:53 -05:00
text-shadow: 0 0 8px rgba(255, 170, 0, 0.5);
word-break: break-all;
position: relative;
z-index: 1;
}
/* Node ID styled as terminal text */
.alby-node-id {
color: #00ff00;
font-size: 9px;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.3);
word-break: break-all;
margin-top: 10px;
opacity: 0.7;
position: relative;
z-index: 1;
}
/* Divider line */
.alby-divider {
height: 2px;
background: linear-gradient(90deg, transparent, #333, transparent);
margin: 20px 0;
}
/* Amount controls container */
.alby-amount-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
/* Increment/decrement buttons styled like cassette buttons */
.alby-btn {
width: 40px;
height: 40px;
background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%);
border: 3px solid #000;
color: #00ff00;
font-size: 20px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
inset 0 -3px 0 rgba(0,0,0,0.5),
0 3px 6px rgba(0,0,0,0.6);
transition: all 0.1s;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
.alby-btn:hover {
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
.alby-btn:active {
transform: translateY(2px);
box-shadow:
inset 0 -1px 0 rgba(0,0,0,0.5),
0 1px 3px rgba(0,0,0,0.6);
}
/* Amount input styled like CRT display */
.alby-input {
flex: 1;
background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%);
border: 3px solid #000;
padding: 10px;
color: #00ff00;
font-family: 'Courier New', monospace;
2026-01-17 10:17:24 -05:00
font-size: 20px;
2026-01-17 08:49:53 -05:00
text-align: center;
text-shadow: 0 0 10px #00ff00;
box-shadow: inset 0 0 15px rgba(0,0,0,0.9);
outline: none;
}
.alby-input:focus {
box-shadow:
inset 0 0 15px rgba(0,0,0,0.9),
0 0 5px rgba(0, 255, 0, 0.3);
}
/* Remove spinner arrows from number input */
.alby-input::-webkit-outer-spin-button,
.alby-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.alby-input[type=number] {
-moz-appearance: textfield;
appearance: textfield;
}
/* Memo textarea styled like CRT */
.alby-textarea {
width: 100%;
background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%);
border: 3px solid #000;
padding: 12px;
color: #00ff00;
font-family: 'Courier New', monospace;
2026-01-17 10:17:24 -05:00
font-size: 16px;
2026-01-17 08:49:53 -05:00
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
box-shadow: inset 0 0 15px rgba(0,0,0,0.9);
resize: none;
outline: none;
min-height: 80px;
}
.alby-textarea:focus {
box-shadow:
inset 0 0 15px rgba(0,0,0,0.9),
0 0 5px rgba(0, 255, 0, 0.3);
}
/* Character count display */
.alby-char-count {
color: #00ff00;
font-size: 10px;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.3);
text-align: right;
margin-top: 5px;
opacity: 0.7;
}
2026-01-17 10:38:45 -05:00
/* Current track section - shown when audio is playing */
.alby-track-section {
margin-top: 15px;
display: none; /* Hidden by default, shown via JS when audio playing */
}
.alby-track-section.visible {
display: block;
}
/* Track display box - styled like address box with CRT look */
.alby-track-box {
background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%);
border: 3px solid #000;
padding: 12px;
margin-bottom: 10px;
box-shadow:
inset 0 0 20px rgba(0,0,0,0.9),
inset 0 0 5px rgba(255, 170, 0, 0.1);
position: relative;
}
/* Scanlines effect on track box */
.alby-track-box::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,0.4) 1px, rgba(0,0,0,0.4) 2px);
pointer-events: none;
}
/* Track name text - amber like lightning address */
.alby-track-name {
color: #cc8800;
font-size: 13px;
text-shadow: 0 0 8px rgba(255, 170, 0, 0.5);
word-break: break-word;
position: relative;
z-index: 1;
}
/* Checkbox label container */
.alby-checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 5px 0;
}
/* Custom checkbox styling to match retro theme */
.alby-checkbox-label input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: linear-gradient(180deg, #0a1a0a 0%, #050f05 100%);
border: 3px solid #000;
cursor: pointer;
position: relative;
box-shadow: inset 0 0 10px rgba(0,0,0,0.8);
}
/* Checkbox checked state - green checkmark */
.alby-checkbox-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 6px;
height: 10px;
border: solid #00ff00;
border-width: 0 3px 3px 0;
transform: rotate(45deg);
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
/* Checkbox text - green CRT style */
.alby-checkbox-text {
color: #00ff00;
font-size: 12px;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
letter-spacing: 1px;
}
2026-01-17 08:49:53 -05:00
/* Main boost button - amber Lightning theme */
.alby-boost-btn {
width: 100%;
padding: 15px 20px;
margin-top: 20px;
background: linear-gradient(180deg, #cc8800 0%, #996600 100%);
border: 4px solid #000;
color: #000;
font-family: 'Courier New', monospace;
2026-01-17 10:17:24 -05:00
font-size: 18px;
2026-01-17 08:49:53 -05:00
font-weight: bold;
letter-spacing: 2px;
cursor: pointer;
box-shadow:
inset 0 -4px 0 rgba(0,0,0,0.3),
0 0 15px rgba(255, 170, 0, 0.3),
0 4px 8px rgba(0,0,0,0.6);
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.alby-boost-btn:hover {
box-shadow:
inset 0 -4px 0 rgba(0,0,0,0.3),
0 0 25px rgba(255, 170, 0, 0.5),
0 4px 8px rgba(0,0,0,0.6);
}
.alby-boost-btn:active {
transform: translateY(2px);
box-shadow:
inset 0 -2px 0 rgba(0,0,0,0.3),
0 0 15px rgba(255, 170, 0, 0.3),
0 2px 4px rgba(0,0,0,0.6);
}
/* Hide the simple-boost default styling, we use our own button */
simple-boost {
position: absolute !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
/* ========================================
ALBY LIGHTNING PANEL STYLES - END
======================================== */
< / style >
< / head >
< body >
< div class = "player" >
< button class = "eject-btn" id = "ejectBtn" title = "Eject" > < / button >
2026-01-17 08:59:24 -05:00
< button class = "lightning-btn" id = "lightningBtn" title = "Menu" > ⚡ < / button >
2026-01-17 08:49:53 -05:00
2026-01-17 15:12:54 -05:00
< 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 >
2026-01-17 08:49:53 -05:00
<!-- 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 = "time-display" id = "timeDisplay" > 00:00 / 00:00< / div >
< / div >
< div class = "cassette" >
< div class = "cassette-label" > ECHO REALITY< / div >
< div class = "tape-window" >
< div class = "reel reel-left" id = "reelContainerLeft" >
< div class = "tape-wound" id = "tapeLeft" > < / div >
< div class = "reel-inner" id = "reelLeft" > < / div >
< / div >
< div class = "reel reel-right" id = "reelContainerRight" >
< div class = "tape-wound" id = "tapeRight" > < / div >
< div class = "reel-inner" id = "reelRight" > < / div >
< / div >
< / div >
< / div >
< div class = "controls" >
< button class = "btn btn-prev" id = "prevBtn" > < / button >
< button class = "btn btn-play" id = "playBtn" > < / button >
< button class = "btn btn-pause" id = "pauseBtn" > < / button >
< button class = "btn btn-stop" id = "stopBtn" > < / button >
< button class = "btn btn-next" id = "nextBtn" > < / button >
< / div >
< div class = "volume-control" >
< span class = "volume-label" > VOL< / span >
< input type = "range" id = "volumeSlider" min = "0" max = "100" value = "70" >
< / div >
< / div >
< div class = "video-overlay" id = "videoOverlay" >
2026-01-17 08:59:24 -05:00
< button class = "close-video" id = "closeVideo" > × < / button >
2026-01-17 08:49:53 -05:00
< video id = "videoPlayer" autoplay > < / video >
< / div >
<!-- ========================================
ALBY LIGHTNING PANEL - START
Slide-out panel for Lightning payments/boosts
2026-01-17 08:59:24 -05:00
Triggered by the lightning button (⚡ ) on the player
2026-01-17 08:49:53 -05:00
======================================== -->
<!-- Backdrop overlay - clicking closes the panel -->
< div class = "alby-overlay" id = "albyOverlay" > < / div >
<!-- Main panel container -->
< div class = "alby-panel" id = "albyPanel" >
<!-- Panel header with title and close button -->
< div class = "alby-panel-header" >
< div class = "alby-panel-title" >
2026-01-17 08:59:24 -05:00
< span class = "lightning-icon" > ⚡ < / span >
2026-01-17 08:49:53 -05:00
< span > LIGHTNING BOOST< / span >
< / div >
2026-01-17 08:59:24 -05:00
< button class = "alby-close-btn" id = "albyCloseBtn" title = "Close" > × < / button >
2026-01-17 08:49:53 -05:00
< / div >
<!-- Panel body content -->
< div class = "alby-panel-body" >
<!-- Lightning address display section -->
< div class = "alby-label" > LIGHTNING ADDRESS< / div >
< div class = "alby-address-box" >
< div class = "alby-address" > hyperspaceout@getalby.com< / div >
< div class = "alby-node-id" > NODE: 0325ab94e785e40877fe7421ec9a523bbb6021663dfb5c18987f40e17d5d507921< / div >
< / div >
< div class = "alby-divider" > < / div >
<!-- Amount input with increment/decrement controls -->
< div class = "alby-label" > AMOUNT (USD)< / div >
< div class = "alby-amount-controls" >
2026-01-17 08:59:24 -05:00
< button class = "alby-btn" id = "albyDecrementBtn" title = "Decrease" > -< / button >
2026-01-17 08:49:53 -05:00
< input type = "number" class = "alby-input" id = "albyAmount" value = "1.0" min = "0.1" step = "0.1" >
< button class = "alby-btn" id = "albyIncrementBtn" title = "Increase" > +< / button >
< / div >
<!-- Memo textarea -->
< div class = "alby-label" > MEMO< / div >
< textarea class = "alby-textarea" id = "albyMemo" maxlength = "400" placeholder = "Your message..." > < / textarea >
< div class = "alby-char-count" > < span id = "albyCharCount" > 0< / span > /400< / div >
2026-01-17 10:38:45 -05:00
<!-- Current track info section (visible when audio playing) -->
< div class = "alby-track-section" id = "albyTrackSection" >
< div class = "alby-label" > CURRENT TRACK< / div >
< div class = "alby-track-box" >
< div class = "alby-track-name" id = "albyTrackName" > TRACK 1 - PINK FLIGHT ATTENDANT< / div >
< / div >
< label class = "alby-checkbox-label" >
< input type = "checkbox" id = "albyIncludeTrack" checked >
< span class = "alby-checkbox-text" > Include current track?< / span >
< / label >
< / div >
2026-01-17 08:49:53 -05:00
<!-- Boost button - triggers simple - boost payment -->
< button class = "alby-boost-btn" id = "albyBoostBtn" >
2026-01-17 08:59:24 -05:00
< span > ⚡ < / span >
2026-01-17 08:49:53 -05:00
< span > BOOST $< span id = "albyDisplayAmount" > 1.00< / span > < / span >
< / button >
<!-- Hidden simple - boost component - handles actual Lightning payment -->
< simple-boost
id="albySimpleBoost"
noconfetti
memo=""
currency="usd"
amount="1.0"
address="hyperspaceout@getalby.com">
< / simple-boost >
< / div >
< / div >
<!-- Load simple - boost web component for Lightning payments -->
< script type = "module" src = "https://esm.sh/simple-boost@latest" defer > < / script >
<!-- ALBY LIGHTNING PANEL - END -->
2026-01-17 13:37:46 -05:00
< audio id = "audio" preload = "metadata" > < / audio >
2026-01-17 08:49:53 -05:00
< script >
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/02.%20NOW.mp3', name: 'TRACK 2 - NOW' },
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/03.%20MAZES.mp3', name: 'TRACK 3 - MAZES' },
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/04.%20FAMILY%20MAN.mp3', name: 'TRACK 4 - FAMILY MAN' },
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/05.%20TOLLBOOTH%20SAINTS.mp3', name: 'TRACK 5 - TOLLBOOTH SAINTS' }
];
let currentTrack = 0;
const videoUrl = 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MAZES%20HB.mp4';
const audio = document.getElementById('audio');
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const volumeSlider = document.getElementById('volumeSlider');
const timeDisplay = document.getElementById('timeDisplay');
const trackName = document.getElementById('trackName');
const trackNameInner = document.getElementById('trackNameInner');
const reelLeft = document.getElementById('reelLeft');
const reelRight = document.getElementById('reelRight');
const tapeLeft = document.getElementById('tapeLeft');
const tapeRight = document.getElementById('tapeRight');
const reelContainerLeft = document.getElementById('reelContainerLeft');
const reelContainerRight = document.getElementById('reelContainerRight');
// Tape size constants (in pixels)
const TAPE_MIN_SIZE = 62; // Minimum tape size (just larger than reel-inner)
const TAPE_MAX_SIZE = 166; // Maximum tape size (fills most of reel)
2026-01-17 12:12:44 -05:00
// ========================================
// CONTINUOUS TAPE MODEL - STATE
// Tracks are treated as sequential positions on a single tape
// ========================================
let trackDurations = []; // Duration of each track in seconds
let trackStartPositions = []; // Cumulative start position of each track on tape
let totalTapeDuration = 0; // Total length of entire tape
let durationsLoaded = false; // Flag indicating when all durations are known
/**
* Load metadata for all tracks to get their durations
* Uses a temporary audio element to fetch duration without full download
* Falls back to 240 seconds (4 min) if metadata can't be loaded
*/
async function loadAllDurations() {
console.log('Loading track durations...');
trackDurations = [];
for (let i = 0; i < playlist.length ; i + + ) {
try {
2026-01-17 13:37:46 -05:00
// For track 0, use the main audio element since loadTrack(0) already loads it
if (i === 0 & & currentTrack === 0) {
// 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])}`);
2026-01-17 12:12:44 -05:00
} catch (e) {
console.warn(`Failed to get duration for track ${i + 1}, using fallback`);
trackDurations[i] = 240; // 4 minute fallback
}
}
// Calculate cumulative start positions
trackStartPositions = [0];
for (let i = 1; i < trackDurations.length ; i + + ) {
trackStartPositions[i] = trackStartPositions[i - 1] + trackDurations[i - 1];
}
// Calculate total tape duration
totalTapeDuration = trackStartPositions[trackDurations.length - 1] + trackDurations[trackDurations.length - 1];
durationsLoaded = true;
console.log(`Total tape duration: ${formatTime(totalTapeDuration)}`);
console.log('Track start positions:', trackStartPositions.map(formatTime));
// Update tape sizes now that we have durations
updateTapeSizes();
}
/**
* Get duration of a single track using a temporary audio element
* @param {string} url - URL of the audio track
* @returns {Promise< number > } Duration in seconds
*/
function getTrackDuration(url) {
return new Promise((resolve, reject) => {
const tempAudio = new Audio();
2026-01-17 13:15:21 -05:00
tempAudio.preload = 'metadata'; // Only fetch headers, not entire file
2026-01-17 12:12:44 -05:00
2026-01-17 13:37:46 -05:00
// 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
}
2026-01-17 12:12:44 -05:00
// Set timeout for slow loads
const timeout = setTimeout(() => {
2026-01-17 13:37:46 -05:00
cleanup();
2026-01-17 12:12:44 -05:00
reject(new Error('Timeout loading metadata'));
}, 10000); // 10 second timeout
2026-01-17 13:37:46 -05:00
function onMetadata() {
2026-01-17 12:12:44 -05:00
clearTimeout(timeout);
const duration = tempAudio.duration;
2026-01-17 13:37:46 -05:00
cleanup();
2026-01-17 12:12:44 -05:00
resolve(duration);
2026-01-17 13:37:46 -05:00
}
2026-01-17 12:12:44 -05:00
2026-01-17 13:37:46 -05:00
function onError(e) {
2026-01-17 12:12:44 -05:00
clearTimeout(timeout);
2026-01-17 13:37:46 -05:00
cleanup();
2026-01-17 12:12:44 -05:00
reject(e);
2026-01-17 13:37:46 -05:00
}
tempAudio.addEventListener('loadedmetadata', onMetadata);
tempAudio.addEventListener('error', onError);
2026-01-17 12:12:44 -05:00
2026-01-17 13:15:21 -05:00
// Direct URL - browser handles caching via HTTP headers
tempAudio.src = url;
2026-01-17 12:12:44 -05:00
});
}
// ========================================
// TAPE POSITION HELPER FUNCTIONS
// Convert between track-local and global tape positions
// ========================================
/**
* Get the current position on the entire tape (global position)
* @returns {number} Position in seconds from tape start
*/
function getCurrentTapePosition() {
if (!durationsLoaded) return 0;
return trackStartPositions[currentTrack] + (audio.currentTime || 0);
}
/**
* Get the current tape progress as a value from 0 to 1
* @returns {number} Progress through entire tape (0 = start, 1 = end)
*/
function getTapeProgress() {
if (!durationsLoaded || !totalTapeDuration) return 0;
return getCurrentTapePosition() / totalTapeDuration;
}
/**
* Find which track contains a given global tape position
* @param {number} tapePosition - Position in seconds from tape start
* @returns {Object} { trackIndex, positionInTrack }
*/
function findTrackAtPosition(tapePosition) {
if (!durationsLoaded) return { trackIndex: 0, positionInTrack: 0 };
// Clamp to valid range
tapePosition = Math.max(0, Math.min(totalTapeDuration, tapePosition));
// Find the track that contains this position
for (let i = trackStartPositions.length - 1; i >= 0; i--) {
if (tapePosition >= trackStartPositions[i]) {
const positionInTrack = tapePosition - trackStartPositions[i];
// Clamp position within track duration
const clampedPosition = Math.min(positionInTrack, trackDurations[i] - 0.01);
return {
trackIndex: i,
positionInTrack: Math.max(0, clampedPosition)
};
}
}
// Fallback to start
return { trackIndex: 0, positionInTrack: 0 };
}
/**
* Get the global tape position for the start of a specific track
* @param {number} trackIndex - Index of the track
* @returns {number} Position in seconds from tape start
*/
function getTrackStartPosition(trackIndex) {
if (!durationsLoaded) return 0;
return trackStartPositions[trackIndex] || 0;
}
2026-01-17 08:49:53 -05:00
const ejectBtn = document.getElementById('ejectBtn');
const lightningBtn = document.getElementById('lightningBtn');
const videoOverlay = document.getElementById('videoOverlay');
const videoPlayer = document.getElementById('videoPlayer');
const closeVideo = document.getElementById('closeVideo');
2026-01-17 10:17:24 -05:00
// ========================================
// SOUND EFFECTS MODULE - START
// Web Audio API synthesized sounds for tactile feedback
// ========================================
const SoundEffects = {
ctx: null,
scrubOscillator: null,
scrubGain: null,
// Initialize AudioContext (must be called after user gesture)
init() {
if (!this.ctx) {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
}
// Resume if suspended (browser autoplay policy)
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
},
// Play a mechanical button click sound
playButtonClick() {
this.init();
const now = this.ctx.currentTime;
// Create nodes for the click sound
const clickOsc = this.ctx.createOscillator();
const clickGain = this.ctx.createGain();
const noiseGain = this.ctx.createGain();
// Low frequency "thunk" component (80-150Hz)
clickOsc.type = 'sine';
clickOsc.frequency.setValueAtTime(150, now);
clickOsc.frequency.exponentialRampToValueAtTime(80, now + 0.05);
// Gain envelope for the thunk - fast attack, quick decay
clickGain.gain.setValueAtTime(0.6, now);
clickGain.gain.exponentialRampToValueAtTime(0.01, now + 0.08);
clickOsc.connect(clickGain);
clickGain.connect(this.ctx.destination);
// High frequency "click" transient using noise
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();
noise.buffer = noiseBuffer;
// Bandpass filter to shape the noise into a click
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 2000;
filter.Q.value = 1;
noiseGain.gain.setValueAtTime(0.35, now);
noiseGain.gain.exponentialRampToValueAtTime(0.01, now + 0.03);
noise.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(this.ctx.destination);
// Start and stop
clickOsc.start(now);
clickOsc.stop(now + 0.1);
noise.start(now);
noise.stop(now + 0.03);
},
// Play tape wind/fast-forward sound for a specified duration
// Uses filtered noise to simulate tape rushing past heads
playTapeWind(direction = 'forward', duration = 0.5) {
this.init();
const now = this.ctx.currentTime;
// Create noise buffer for tape wind sound
const sampleRate = this.ctx.sampleRate;
const bufferSize = sampleRate * duration;
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate);
const output = noiseBuffer.getChannelData(0);
// Generate noise with slight amplitude modulation for realism
for (let i = 0; i < bufferSize ; i + + ) {
// Base noise
const noise = Math.random() * 2 - 1;
// Subtle amplitude wobble to simulate motor variation
const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2);
output[i] = noise * wobble;
}
const noiseSource = this.ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
// Bandpass filter to shape noise into tape wind character
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 1200; // Center frequency for tape hiss
filter.Q.value = 0.5; // Wide band for natural sound
// High shelf to add some brightness
const highShelf = this.ctx.createBiquadFilter();
highShelf.type = 'highshelf';
highShelf.frequency.value = 3000;
highShelf.gain.value = -6; // Reduce harshness
// Gain with envelope
const gainNode = this.ctx.createGain();
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(0.07, now + 0.03); // Quick attack
gainNode.gain.setValueAtTime(0.07, now + duration - 0.05);
gainNode.gain.linearRampToValueAtTime(0, now + duration); // Fade out
// Connect chain
noiseSource.connect(filter);
filter.connect(highShelf);
highShelf.connect(gainNode);
gainNode.connect(this.ctx.destination);
// Start and stop
noiseSource.start(now);
noiseSource.stop(now + duration);
},
// Start continuous tape wind sound (for reel dragging)
// Uses looping noise buffer for seamless continuous playback
startTapeWindLoop() {
this.init();
// Stop any existing loop
this.stopTapeWindLoop();
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;
}
this.tapeWindSource = this.ctx.createBufferSource();
this.tapeWindSource.buffer = noiseBuffer;
this.tapeWindSource.loop = true;
// Bandpass filter for tape character
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 1200;
filter.Q.value = 0.5;
// High shelf to reduce harshness
const highShelf = this.ctx.createBiquadFilter();
highShelf.type = 'highshelf';
highShelf.frequency.value = 3000;
highShelf.gain.value = -6;
// Gain with fade in
this.tapeWindGain = this.ctx.createGain();
this.tapeWindGain.gain.setValueAtTime(0, now);
this.tapeWindGain.gain.linearRampToValueAtTime(0.07, now + 0.05);
// Connect chain
this.tapeWindSource.connect(filter);
filter.connect(highShelf);
highShelf.connect(this.tapeWindGain);
this.tapeWindGain.connect(this.ctx.destination);
this.tapeWindSource.start(now);
},
// Stop the continuous tape wind sound
stopTapeWindLoop() {
if (this.tapeWindGain & & this.ctx) {
const now = this.ctx.currentTime;
this.tapeWindGain.gain.linearRampToValueAtTime(0, now + 0.1);
// Clean up after fade
const sourceToStop = this.tapeWindSource;
setTimeout(() => {
if (sourceToStop) {
try {
sourceToStop.stop();
} catch(e) {}
}
}, 150);
this.tapeWindSource = null;
this.tapeWindGain = null;
}
}
};
// ========================================
// SOUND EFFECTS MODULE - END
// ========================================
2026-01-17 15:12:54 -05:00
// ========================================
// DISPLAY GLITCH MODULE - START
// Random visual glitches for dystopian CRT effect
// ========================================
const DisplayGlitch = {
display: null,
vhsTracking: null,
isEnabled: true,
// 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 glitch module
* Gets DOM references and starts random glitch intervals
*/
init() {
this.display = document.getElementById('display');
this.vhsTracking = document.getElementById('vhsTracking');
if (!this.display || !this.vhsTracking) {
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');
setTimeout(() => {
this.display.classList.remove('blackout');
currentFlicker++;
// If more flickers needed, do them after a short gap
if (currentFlicker < flickerCount ) {
setTimeout(doFlicker, this.randomBetween(30, 80));
}
}, this.BLACKOUT_DURATION);
};
doFlicker();
},
/**
* Trigger VHS tracking lines
* Randomly shows vertical (scrolling up) or horizontal (scrolling left) tracking lines
*/
triggerVhsTracking() {
if (!this.isEnabled || !this.vhsTracking) return;
// Remove all classes to reset
this.vhsTracking.classList.remove('active', 'vertical', 'horizontal');
// Force reflow to restart animation
void this.vhsTracking.offsetWidth;
// Randomly choose direction (50/50 chance)
const direction = Math.random() < 0.5 ? ' vertical ' : ' horizontal ' ;
this.vhsTracking.classList.add(direction);
this.vhsTracking.classList.add('active');
// Remove classes after animation completes
setTimeout(() => {
this.vhsTracking.classList.remove('active', 'vertical', 'horizontal');
// 30% chance of a second burst shortly after
if (Math.random() < 0.3 ) {
setTimeout(() => {
this.triggerVhsTracking();
}, this.randomBetween(100, 300));
}
}, this.VHS_DURATION);
},
/**
* Schedule the next occurrence of a glitch type
* @param {string} type - 'rgb', 'blackout', or 'vhs'
*/
scheduleNext(type) {
let minInterval, maxInterval, triggerFn;
switch (type) {
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);
// ========================================
// DISPLAY GLITCH MODULE - END
// ========================================
2026-01-17 10:17:24 -05:00
// ========================================
// BUTTON CLICK SOUNDS - Add to all buttons
// Uses mousedown for immediate tactile feedback
// ========================================
[playBtn, pauseBtn, stopBtn, prevBtn, nextBtn, ejectBtn, lightningBtn].forEach(btn => {
btn.addEventListener('mousedown', () => {
SoundEffects.playButtonClick();
});
});
2026-01-17 08:49:53 -05:00
// Lightning button - opens Alby Lightning panel
// (toggleAlbyPanel function defined in ALBY PANEL FUNCTIONALITY section below)
lightningBtn.addEventListener('click', () => {
toggleAlbyPanel();
});
// ========================================
// TITLE SCROLL ANIMATION (JavaScript controlled bounce)
// Moves 1px at a time, bounces at edges, pauses with player
// ========================================
let titleScrollPosition = 0;
let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right
let titleScrollInterval = null;
const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels)
function startTitleScroll() {
if (titleScrollInterval) return; // Already scrolling
titleScrollInterval = setInterval(updateTitleScroll, SCROLL_SPEED);
}
function stopTitleScroll() {
if (titleScrollInterval) {
clearInterval(titleScrollInterval);
titleScrollInterval = null;
}
}
function resetTitleScroll() {
stopTitleScroll();
titleScrollPosition = 0;
titleScrollDirection = 1;
trackNameInner.style.left = '0px';
}
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
titleScrollPosition += titleScrollDirection;
// Bounce at edges
if (titleScrollPosition >= maxScroll) {
titleScrollPosition = maxScroll;
titleScrollDirection = -1; // Start moving back
} else if (titleScrollPosition < = 0) {
titleScrollPosition = 0;
titleScrollDirection = 1; // Start moving forward
}
// Apply position
// If text is longer: shift text LEFT (negative value) so we see the end
// If text is shorter: shift text RIGHT (positive value) to scroll across display
if (textWidth > containerWidth) {
trackNameInner.style.left = -titleScrollPosition + 'px';
} else {
trackNameInner.style.left = titleScrollPosition + 'px';
}
}
// Load initial track
2026-01-17 11:05:06 -05:00
// Now async to support cache-first loading
async function loadTrack(index) {
2026-01-17 08:49:53 -05:00
currentTrack = index;
// Update the inner span for marquee scrolling
trackNameInner.textContent = playlist[index].name;
// Reset scroll position for new track
resetTitleScroll();
2026-01-17 11:05:06 -05:00
2026-01-17 13:15:21 -05:00
// Direct URL - browser handles caching via HTTP headers
audio.src = playlist[index].url;
2026-01-17 08:49:53 -05:00
}
2026-01-17 11:05:06 -05:00
// Initial track load (async, no await needed for initial load)
2026-01-17 08:49:53 -05:00
loadTrack(0);
2026-01-17 12:12:44 -05:00
// Load all track durations for continuous tape model
loadAllDurations();
2026-01-17 08:49:53 -05:00
// Set initial volume
audio.volume = 0.7;
// Play button
playBtn.addEventListener('click', () => {
audio.play();
reelLeft.classList.add('spinning');
reelRight.classList.add('spinning');
tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning');
startTitleScroll();
});
// Pause button - keeps title at current position
pauseBtn.addEventListener('click', () => {
audio.pause();
reelLeft.classList.remove('spinning');
reelRight.classList.remove('spinning');
tapeLeft.classList.remove('spinning');
tapeRight.classList.remove('spinning');
stopTitleScroll();
});
2026-01-17 12:12:44 -05:00
// Stop button - rewinds tape to the beginning (Track 1, position 0) with animation
stopBtn.addEventListener('click', async () => {
2026-01-17 08:49:53 -05:00
audio.pause();
2026-01-17 10:17:24 -05:00
2026-01-17 12:12:44 -05:00
// Check if we need to rewind (not already at beginning)
const needsRewind = currentTrack !== 0 || audio.currentTime > 0.5;
2026-01-17 10:17:24 -05:00
2026-01-17 12:12:44 -05:00
if (needsRewind & & durationsLoaded) {
// Animate rewind to beginning
const animationDuration = 500;
const startProgress = getTapeProgress();
const endProgress = 0;
// Start rewind sound and animation
SoundEffects.startTapeWindLoop();
reelLeft.style.animationDuration = '0.3s';
reelRight.style.animationDuration = '0.3s';
tapeLeft.style.animationDuration = '0.3s';
tapeRight.style.animationDuration = '0.3s';
reelLeft.classList.add('spinning');
reelRight.classList.add('spinning');
tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning');
// Animate tape sizes
const startTime = performance.now();
await new Promise(resolve => {
function animate(time) {
const elapsed = time - startTime;
const t = Math.min(elapsed / animationDuration, 1);
const easedT = easeInOutQuad(t);
const currentProgress = startProgress + (endProgress - startProgress) * easedT;
setTapeSizesAtProgress(currentProgress);
if (t < 1 ) {
requestAnimationFrame(animate);
} else {
resolve();
}
}
requestAnimationFrame(animate);
});
// Stop sound and reset animation speed
SoundEffects.stopTapeWindLoop();
2026-01-17 10:17:24 -05:00
reelLeft.style.animationDuration = '';
reelRight.style.animationDuration = '';
tapeLeft.style.animationDuration = '';
tapeRight.style.animationDuration = '';
2026-01-17 12:12:44 -05:00
}
// Load track 0 if not already on it
if (currentTrack !== 0) {
currentTrack = 0;
trackNameInner.textContent = playlist[0].name;
2026-01-17 11:05:06 -05:00
2026-01-17 13:15:21 -05:00
// Direct URL - browser handles caching via HTTP headers
audio.src = playlist[0].url;
2026-01-17 13:59:33 -05:00
// Wait for audio to be ready before setting currentTime
await new Promise(resolve => {
audio.addEventListener('loadedmetadata', function onMeta() {
audio.removeEventListener('loadedmetadata', onMeta);
resolve();
});
audio.load();
});
2026-01-17 12:12:44 -05:00
}
audio.currentTime = 0;
reelLeft.classList.remove('spinning');
reelRight.classList.remove('spinning');
tapeLeft.classList.remove('spinning');
tapeRight.classList.remove('spinning');
resetTitleScroll();
resetTapeSizes(); // Ensure tape visuals are at beginning
2026-01-17 13:59:33 -05:00
// Manually update time display since timeupdate doesn't fire when paused
const duration = formatTime(audio.duration);
timeDisplay.textContent = `00:00 / ${duration}`;
2026-01-17 12:12:44 -05:00
});
// Previous track (prev button with bar)
// Uses animated seek to smoothly wind tape to previous track position
prevBtn.addEventListener('click', () => {
console.log('Prev button clicked');
const wasPlaying = !audio.paused;
const targetTrack = (currentTrack - 1 + playlist.length) % playlist.length;
animatedSeekToTrack(targetTrack, wasPlaying);
2026-01-17 08:49:53 -05:00
});
// Next track (next button with bar)
2026-01-17 12:12:44 -05:00
// Uses animated seek to smoothly wind tape to next track position
2026-01-17 08:49:53 -05:00
nextBtn.addEventListener('click', () => {
console.log('Next button clicked');
2026-01-17 10:17:24 -05:00
const wasPlaying = !audio.paused;
2026-01-17 12:12:44 -05:00
const targetTrack = (currentTrack + 1) % playlist.length;
animatedSeekToTrack(targetTrack, wasPlaying);
2026-01-17 08:49:53 -05:00
});
2026-01-17 12:12:44 -05:00
// Drag on reels to scrub audio (supports cross-track scrubbing)
2026-01-17 08:49:53 -05:00
let isDragging = false;
let startY = 0;
2026-01-17 12:12:44 -05:00
let startTapePosition = 0; // Global tape position at drag start
2026-01-17 08:49:53 -05:00
let wasPlayingBeforeDrag = false;
2026-01-17 10:17:24 -05:00
let lastDragY = 0;
let tapeWindPlaying = false;
2026-01-17 12:12:44 -05:00
let scrubTrackChangeInProgress = false; // Prevents multiple track loads during scrub
2026-01-17 10:17:24 -05:00
2026-01-17 08:49:53 -05:00
function startDrag(e) {
2026-01-17 12:12:44 -05:00
// Allow drag if we have duration info (either from track or global tape)
if (durationsLoaded || audio.duration) {
2026-01-17 08:49:53 -05:00
e.preventDefault(); // Prevent text selection during drag
isDragging = true;
startY = e.clientY || e.touches[0].clientY;
2026-01-17 10:17:24 -05:00
lastDragY = startY;
2026-01-17 12:12:44 -05:00
startTapePosition = getCurrentTapePosition();
2026-01-17 08:49:53 -05:00
wasPlayingBeforeDrag = !audio.paused;
2026-01-17 10:17:24 -05:00
tapeWindPlaying = false;
2026-01-17 12:12:44 -05:00
scrubTrackChangeInProgress = false;
2026-01-17 08:49:53 -05:00
// Don't pause - let audio continue playing while scrubbing
}
}
function drag(e) {
2026-01-17 12:12:44 -05:00
if (!isDragging) return;
if (scrubTrackChangeInProgress) return; // Wait for track change to complete
const currentY = e.clientY || e.touches[0].clientY;
const deltaY = startY - currentY;
const instantDeltaY = lastDragY - currentY;
lastDragY = currentY;
// Calculate scrub speed for audio effects
const scrubSpeed = Math.min(Math.abs(instantDeltaY) / 20, 1); // Normalize to 0-1
// Start tape wind sound only when actually moving
if (scrubSpeed > 0.1 & & !tapeWindPlaying) {
SoundEffects.startTapeWindLoop();
tapeWindPlaying = true;
} else if (scrubSpeed < = 0.1 & & tapeWindPlaying) {
SoundEffects.stopTapeWindLoop();
tapeWindPlaying = false;
}
// Calculate target tape position based on drag distance
// Sensitivity: dragging 100px moves 10% of total tape (or current track if durations not loaded)
let targetTapePosition;
if (durationsLoaded & & totalTapeDuration > 0) {
// Cross-track scrubbing mode
const scrubAmount = (deltaY / 100) * totalTapeDuration * 0.05;
targetTapePosition = Math.max(0, Math.min(totalTapeDuration - 0.01, startTapePosition + scrubAmount));
2026-01-17 10:17:24 -05:00
2026-01-17 12:12:44 -05:00
// Find which track this position falls in
const target = findTrackAtPosition(targetTapePosition);
2026-01-17 10:17:24 -05:00
2026-01-17 12:12:44 -05:00
if (target.trackIndex !== currentTrack) {
// Need to switch tracks
scrubTrackChangeInProgress = true;
loadTrackForScrub(target.trackIndex, target.positionInTrack).then(() => {
scrubTrackChangeInProgress = false;
updateTapeSizes();
});
} else {
// Same track, just seek
audio.currentTime = target.positionInTrack;
2026-01-17 08:49:53 -05:00
}
2026-01-17 12:12:44 -05:00
} else if (audio.duration) {
// Fallback to single-track scrubbing
const scrubAmount = (deltaY / 100) * audio.duration * 0.1;
const newTime = Math.max(0, Math.min(audio.duration, audio.currentTime + scrubAmount));
audio.currentTime = newTime;
}
// Adjust playback rate for sped-up audio effect
if (wasPlayingBeforeDrag & & scrubSpeed > 0.1) {
audio.playbackRate = 1 + (scrubSpeed * 3); // 1x to 4x
} else if (wasPlayingBeforeDrag) {
audio.playbackRate = 1;
}
// Keep playing during scrub
if (audio.paused & & wasPlayingBeforeDrag) {
audio.play();
}
// Update tape visual
updateTapeSizes();
}
/**
* Load a track during scrubbing and seek to position
* @param {number} trackIndex - Index of track to load
* @param {number} seekPosition - Position within track to seek to
*/
async function loadTrackForScrub(trackIndex, seekPosition) {
currentTrack = trackIndex;
trackNameInner.textContent = playlist[trackIndex].name;
resetTitleScroll();
2026-01-17 13:15:21 -05:00
// Direct URL - browser handles caching via HTTP headers
audio.src = playlist[trackIndex].url;
2026-01-17 12:12:44 -05:00
return new Promise((resolve) => {
audio.onloadedmetadata = () => {
audio.currentTime = seekPosition;
if (wasPlayingBeforeDrag) {
audio.play().catch(() => {});
}
audio.onloadedmetadata = null;
resolve();
};
audio.load();
});
2026-01-17 08:49:53 -05:00
}
function endDrag() {
if (isDragging) {
isDragging = false;
2026-01-17 12:12:44 -05:00
scrubTrackChangeInProgress = false;
2026-01-17 10:17:24 -05:00
// Stop tape wind sound and reset playback rate
if (tapeWindPlaying) {
SoundEffects.stopTapeWindLoop();
tapeWindPlaying = false;
}
audio.playbackRate = 1;
2026-01-17 08:49:53 -05:00
// Continue playing if it was playing before
if (wasPlayingBeforeDrag & & audio.paused) {
audio.play();
reelLeft.classList.add('spinning');
reelRight.classList.add('spinning');
tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning');
}
}
}
2026-01-17 12:12:44 -05:00
// ========================================
// ANIMATED SEEK TO TRACK
// Smoothly animates tape reels when changing tracks via Next/Prev
// ========================================
let animatedSeekInProgress = false;
/**
* Easing function for smooth animation
*/
function easeInOutQuad(t) {
return t < 0.5 ? 2 * t * t : 1 - Math . pow ( -2 * t + 2 , 2 ) / 2 ;
}
/**
* Animate tape reels to a target track with visual fast-forward/rewind effect
* @param {number} targetTrackIndex - Index of the track to seek to
* @param {boolean} wasPlaying - Whether audio was playing before seek
* @returns {Promise} Resolves when animation and track load complete
*/
async function animatedSeekToTrack(targetTrackIndex, wasPlaying) {
if (animatedSeekInProgress) return;
animatedSeekInProgress = true;
const animationDuration = 500; // ms
// Calculate start and end tape progress
const startProgress = durationsLoaded ? getTapeProgress() : 0;
const endProgress = durationsLoaded ? getTrackStartPosition(targetTrackIndex) / totalTapeDuration : 0;
// Determine direction
const direction = endProgress > startProgress ? 'forward' : 'backward';
// Start tape wind sound
SoundEffects.startTapeWindLoop();
// Speed up reel animation during transition
reelLeft.style.animationDuration = '0.3s';
reelRight.style.animationDuration = '0.3s';
tapeLeft.style.animationDuration = '0.3s';
tapeRight.style.animationDuration = '0.3s';
reelLeft.classList.add('spinning');
reelRight.classList.add('spinning');
tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning');
// Speed up audio during transition if playing
if (wasPlaying) {
audio.playbackRate = 4;
}
// Animate tape sizes from current to target position
const startTime = performance.now();
await new Promise(resolve => {
function animate(time) {
const elapsed = time - startTime;
const t = Math.min(elapsed / animationDuration, 1);
const easedT = easeInOutQuad(t);
// Interpolate progress
const currentProgress = startProgress + (endProgress - startProgress) * easedT;
// Update tape sizes
setTapeSizesAtProgress(currentProgress);
if (t < 1 ) {
requestAnimationFrame(animate);
} else {
resolve();
}
}
requestAnimationFrame(animate);
});
// Stop tape wind sound
SoundEffects.stopTapeWindLoop();
// Reset animation speed
reelLeft.style.animationDuration = '';
reelRight.style.animationDuration = '';
tapeLeft.style.animationDuration = '';
tapeRight.style.animationDuration = '';
audio.playbackRate = 1;
// Load the new track
currentTrack = targetTrackIndex;
trackNameInner.textContent = playlist[currentTrack].name;
resetTitleScroll();
2026-01-17 13:15:21 -05:00
// Direct URL - browser handles caching via HTTP headers
audio.src = playlist[currentTrack].url;
2026-01-17 12:12:44 -05:00
return new Promise(resolve => {
audio.oncanplay = function() {
if (wasPlaying) {
audio.play().then(() => {
reelLeft.classList.add('spinning');
reelRight.classList.add('spinning');
tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning');
startTitleScroll();
}).catch(e => console.log('Play failed:', e));
} else {
reelLeft.classList.remove('spinning');
reelRight.classList.remove('spinning');
tapeLeft.classList.remove('spinning');
tapeRight.classList.remove('spinning');
}
audio.oncanplay = null;
animatedSeekInProgress = false;
resolve();
};
audio.load();
});
}
2026-01-17 08:49:53 -05:00
// Add drag listeners to all reel elements (containers, tape, and inner spools)
[reelLeft, reelRight, tapeLeft, tapeRight, reelContainerLeft, reelContainerRight].forEach(el => {
el.addEventListener('mousedown', startDrag);
el.addEventListener('touchstart', startDrag, { passive: false });
});
document.addEventListener('mousemove', drag);
document.addEventListener('touchmove', drag);
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchend', endDrag);
// Auto-play next track when current ends
2026-01-17 11:05:06 -05:00
audio.addEventListener('ended', async () => {
2026-01-17 08:49:53 -05:00
currentTrack = (currentTrack + 1) % playlist.length;
2026-01-17 11:05:06 -05:00
await loadTrack(currentTrack);
2026-01-17 12:12:44 -05:00
// Tape position continues naturally - don't reset
2026-01-17 08:49:53 -05:00
audio.play();
reelLeft.classList.add('spinning');
reelRight.classList.add('spinning');
tapeLeft.classList.add('spinning');
tapeRight.classList.add('spinning');
startTitleScroll();
});
// Volume control
volumeSlider.addEventListener('input', (e) => {
audio.volume = e.target.value / 100;
});
// Update tape wound sizes based on playback progress
2026-01-17 12:12:44 -05:00
// Update tape wound sizes based on global tape position
2026-01-17 08:49:53 -05:00
function updateTapeSizes() {
2026-01-17 12:12:44 -05:00
// Use global tape progress if durations are loaded, otherwise fall back to current track
let progress;
if (durationsLoaded & & totalTapeDuration > 0) {
progress = getTapeProgress();
} else if (audio.duration) {
// Fallback to per-track progress while durations are loading
progress = audio.currentTime / audio.duration;
} else {
return;
}
2026-01-17 08:49:53 -05:00
const tapeRange = TAPE_MAX_SIZE - TAPE_MIN_SIZE;
2026-01-17 12:12:44 -05:00
// Left spool: starts full, decreases as tape plays
2026-01-17 08:49:53 -05:00
const leftSize = TAPE_MAX_SIZE - (progress * tapeRange);
2026-01-17 12:12:44 -05:00
// Right spool: starts empty, increases as tape plays
2026-01-17 08:49:53 -05:00
const rightSize = TAPE_MIN_SIZE + (progress * tapeRange);
tapeLeft.style.setProperty('--tape-size', leftSize + 'px');
tapeRight.style.setProperty('--tape-size', rightSize + 'px');
}
2026-01-17 12:12:44 -05:00
// Initialize tape sizes - rewind to beginning of tape
2026-01-17 08:49:53 -05:00
function resetTapeSizes() {
tapeLeft.style.setProperty('--tape-size', TAPE_MAX_SIZE + 'px');
tapeRight.style.setProperty('--tape-size', TAPE_MIN_SIZE + 'px');
}
2026-01-17 12:12:44 -05:00
// Set tape sizes to a specific progress value (0 to 1)
function setTapeSizesAtProgress(progress) {
const tapeRange = TAPE_MAX_SIZE - TAPE_MIN_SIZE;
const leftSize = TAPE_MAX_SIZE - (progress * tapeRange);
const rightSize = TAPE_MIN_SIZE + (progress * tapeRange);
tapeLeft.style.setProperty('--tape-size', leftSize + 'px');
tapeRight.style.setProperty('--tape-size', rightSize + 'px');
}
2026-01-17 08:49:53 -05:00
resetTapeSizes();
// Update time display and tape sizes
audio.addEventListener('timeupdate', () => {
const current = formatTime(audio.currentTime);
const duration = formatTime(audio.duration);
timeDisplay.textContent = `${current} / ${duration}`;
updateTapeSizes();
});
// Format time helper
function formatTime(seconds) {
if (isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
// Stop spinning when audio pauses
// Stop animations when audio pauses (title stays at current position)
audio.addEventListener('pause', () => {
reelLeft.classList.remove('spinning');
reelRight.classList.remove('spinning');
tapeLeft.classList.remove('spinning');
tapeRight.classList.remove('spinning');
stopTitleScroll();
});
// Eject button - opens fullscreen video
ejectBtn.addEventListener('click', () => {
audio.pause();
reelLeft.classList.remove('spinning');
reelRight.classList.remove('spinning');
tapeLeft.classList.remove('spinning');
tapeRight.classList.remove('spinning');
videoPlayer.src = videoUrl;
videoOverlay.classList.add('active');
videoPlayer.play();
});
// Close video
closeVideo.addEventListener('click', () => {
videoPlayer.pause();
videoPlayer.src = '';
videoOverlay.classList.remove('active');
});
// Close video on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' & & videoOverlay.classList.contains('active')) {
closeVideo.click();
}
});
// ========================================
// ALBY LIGHTNING PANEL FUNCTIONALITY - START
// Handles panel open/close, amount controls, and boost button
// ========================================
// DOM element references for Alby panel
const albyOverlay = document.getElementById('albyOverlay');
const albyPanel = document.getElementById('albyPanel');
const albyCloseBtn = document.getElementById('albyCloseBtn');
const albyAmount = document.getElementById('albyAmount');
const albyMemo = document.getElementById('albyMemo');
const albyIncrementBtn = document.getElementById('albyIncrementBtn');
const albyDecrementBtn = document.getElementById('albyDecrementBtn');
const albyBoostBtn = document.getElementById('albyBoostBtn');
const albyDisplayAmount = document.getElementById('albyDisplayAmount');
const albyCharCount = document.getElementById('albyCharCount');
const albySimpleBoost = document.getElementById('albySimpleBoost');
2026-01-17 10:38:45 -05:00
const albyTrackSection = document.getElementById('albyTrackSection');
const albyTrackName = document.getElementById('albyTrackName');
const albyIncludeTrack = document.getElementById('albyIncludeTrack');
2026-01-17 08:49:53 -05:00
/**
* Toggle the Alby Lightning panel open/closed
* Also handles the backdrop overlay visibility
2026-01-17 10:38:45 -05:00
* Shows current track info when audio is loaded (playing or paused, not stopped)
2026-01-17 08:49:53 -05:00
*/
function toggleAlbyPanel() {
const isActive = albyPanel.classList.contains('active');
if (isActive) {
// Close panel
albyPanel.classList.remove('active');
albyOverlay.classList.remove('active');
} else {
// Open panel
albyPanel.classList.add('active');
albyOverlay.classList.add('active');
2026-01-17 10:38:45 -05:00
// Check if audio is loaded (has duration and currentTime > 0, meaning not at start/stopped)
// Show track section if audio has been played or is playing
const audioIsLoaded = audio.src & & (audio.currentTime > 0 || !audio.paused);
if (audioIsLoaded) {
// Show track section and populate with current track name
albyTrackSection.classList.add('visible');
albyTrackName.textContent = playlist[currentTrack].name;
albyIncludeTrack.checked = true;
} else {
// Hide track section if no audio loaded/played
albyTrackSection.classList.remove('visible');
albyIncludeTrack.checked = false;
}
}
}
/**
* Build the final memo including track info if checkbox is checked
* Returns the complete memo string to be sent with the boost
*/
function buildFinalMemo() {
let finalMemo = albyMemo.value;
// Append track info if checkbox is checked and track section is visible
if (albyIncludeTrack.checked & & albyTrackSection.classList.contains('visible')) {
const trackInfo = ` | Now Playing: ${playlist[currentTrack].name}`;
finalMemo = finalMemo + trackInfo;
2026-01-17 08:49:53 -05:00
}
2026-01-17 10:38:45 -05:00
return finalMemo;
2026-01-17 08:49:53 -05:00
}
/**
* Update the simple-boost component attributes and display amount
* Called whenever amount or memo values change
*/
function updateAlbyBoostButton() {
const amount = parseFloat(albyAmount.value).toFixed(1);
2026-01-17 10:38:45 -05:00
const finalMemo = buildFinalMemo();
2026-01-17 08:49:53 -05:00
// Update simple-boost component attributes
albySimpleBoost.setAttribute('amount', amount);
2026-01-17 10:38:45 -05:00
albySimpleBoost.setAttribute('memo', finalMemo);
2026-01-17 08:49:53 -05:00
// Update displayed amount on our custom button
albyDisplayAmount.textContent = parseFloat(amount).toFixed(2);
}
/**
* Update the memo character count display
*/
function updateAlbyCharCount() {
albyCharCount.textContent = albyMemo.value.length;
}
// Close button click handler
albyCloseBtn.addEventListener('click', toggleAlbyPanel);
// Overlay click handler - close panel when clicking backdrop
albyOverlay.addEventListener('click', toggleAlbyPanel);
// Increment amount button (+$1.00)
albyIncrementBtn.addEventListener('click', () => {
const currentValue = parseFloat(albyAmount.value);
albyAmount.value = (currentValue + 1.0).toFixed(1);
updateAlbyBoostButton();
});
// Decrement amount button (-$1.00, minimum $0.10)
albyDecrementBtn.addEventListener('click', () => {
const currentValue = parseFloat(albyAmount.value);
const newValue = Math.max(0.1, currentValue - 1.0);
albyAmount.value = newValue.toFixed(1);
updateAlbyBoostButton();
});
// Amount input change handler
albyAmount.addEventListener('input', () => {
if (albyAmount.value & & albyAmount.value >= 0.1) {
updateAlbyBoostButton();
}
});
// Amount input blur handler - validate minimum value
albyAmount.addEventListener('blur', () => {
if (!albyAmount.value || albyAmount.value < 0.1 ) {
albyAmount.value = '0.1';
}
updateAlbyBoostButton();
});
// Memo textarea input handler
albyMemo.addEventListener('input', () => {
updateAlbyCharCount();
updateAlbyBoostButton();
});
2026-01-17 10:38:45 -05:00
// Include track checkbox change handler
albyIncludeTrack.addEventListener('change', () => {
updateAlbyBoostButton();
});
2026-01-17 08:49:53 -05:00
// Boost button click - trigger the simple-boost component
albyBoostBtn.addEventListener('click', () => {
2026-01-17 10:38:45 -05:00
// Build final memo and validate length
const finalMemo = buildFinalMemo();
// Check if total memo exceeds 400 characters
if (finalMemo.length > 400) {
alert(`Memo too long! Your message with track info is ${finalMemo.length} characters. Maximum is 400.\n\nPlease shorten your message or uncheck "Include current track?"`);
return; // Don't send the boost
}
2026-01-17 08:49:53 -05:00
// Ensure latest form values are synced to simple-boost before triggering
updateAlbyBoostButton();
// Click the inner button div inside the shadow DOM
const innerButton = albySimpleBoost.shadowRoot?.querySelector('.simple-boost-button');
if (innerButton) {
innerButton.click();
}
});
2026-01-17 10:17:24 -05:00
// Listen for successful payment, reset form fields, and close modal
2026-01-17 08:49:53 -05:00
albySimpleBoost.addEventListener('success', (e) => {
// Reset form to default values
albyAmount.value = '1.0';
albyMemo.value = '';
2026-01-17 10:38:45 -05:00
// Reset track checkbox (will be re-evaluated when panel reopens)
albyIncludeTrack.checked = true;
albyTrackSection.classList.remove('visible');
2026-01-17 08:49:53 -05:00
updateAlbyCharCount();
updateAlbyBoostButton();
2026-01-17 10:17:24 -05:00
// Close the modal after successful boost
toggleAlbyPanel();
2026-01-17 08:49:53 -05:00
});
// Escape key handler for Alby panel
// (Added to existing keydown listener scope)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' & & albyPanel.classList.contains('active')) {
toggleAlbyPanel();
}
});
// Initialize character count and sync simple-boost attributes on page load
updateAlbyCharCount();
updateAlbyBoostButton();
// ========================================
// ALBY LIGHTNING PANEL FUNCTIONALITY - END
// ========================================
< / script >
< / body >
< / html >