2399 lines
90 KiB
HTML
2399 lines
90 KiB
HTML
<!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:
|
|
/* inset 0 0 0 2px #3a3a3a, */
|
|
inset 0 0 10px rgba(0,0,0,0.1),
|
|
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;
|
|
background-image: url('background.png');
|
|
background-size: 100%;
|
|
background-position: center;
|
|
background-repeat: repeat;
|
|
pointer-events: none;
|
|
opacity: 0.20;
|
|
mix-blend-mode: normal;
|
|
}
|
|
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
@keyframes flicker {
|
|
0%, 100% { opacity: 0.8; }
|
|
50% { opacity: 0.6; }
|
|
51% { opacity: 1; }
|
|
52% { opacity: 0.7; }
|
|
}
|
|
|
|
/* 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 */
|
|
/* Removed by popular demand.
|
|
.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;
|
|
}
|
|
*/
|
|
|
|
|
|
.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;
|
|
/* Center using top/left 50% with negative margins (Safari-safe) */
|
|
top: 50%;
|
|
left: 50%;
|
|
/* Use negative margins for centering instead of transform */
|
|
margin-left: calc(var(--tape-size, 72px) / -2);
|
|
margin-top: calc(var(--tape-size, 72px) / -2);
|
|
border-radius: 50%;
|
|
/* 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);
|
|
/* Safari animation optimizations */
|
|
transform-origin: center center;
|
|
will-change: transform;
|
|
/* 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 {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.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;
|
|
/* Center the spool within the reel container (Safari-safe) */
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
/* Use negative margins for centering instead of transform */
|
|
margin-left: -30px; /* half of 60px width */
|
|
margin-top: -30px; /* half of 60px height */
|
|
/* Safari animation optimizations */
|
|
transform-origin: center center;
|
|
will-change: transform;
|
|
}
|
|
|
|
.reel-inner.spinning {
|
|
animation: spinSpool 2s linear infinite;
|
|
}
|
|
|
|
@keyframes spinSpool {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Main floating modal container */
|
|
.alby-panel {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%) scale(0.9);
|
|
opacity: 0;
|
|
width: 380px;
|
|
max-height: 90vh;
|
|
background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%);
|
|
border: 6px solid #0a0a0a;
|
|
border-radius: 8px;
|
|
box-shadow:
|
|
inset 0 0 0 2px #3a3a3a,
|
|
inset 0 0 50px rgba(0,0,0,0.9),
|
|
0 20px 60px rgba(0,0,0,0.8);
|
|
z-index: 1001;
|
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
|
|
overflow-y: auto;
|
|
font-family: 'Courier New', monospace;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.alby-panel.active {
|
|
transform: translate(-50%, -50%) scale(1);
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
|
|
/* 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;
|
|
border-radius: 4px 4px 0 0;
|
|
position: relative;
|
|
}
|
|
|
|
.alby-panel-title {
|
|
color: #cc8800;
|
|
font-size: 18px;
|
|
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;
|
|
font-size: 14px;
|
|
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;
|
|
font-size: 15px;
|
|
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;
|
|
font-size: 20px;
|
|
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;
|
|
font-size: 16px;
|
|
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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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;
|
|
font-size: 18px;
|
|
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>
|
|
<button class="lightning-btn" id="lightningBtn" title="Menu">⚡</button>
|
|
|
|
<div class="display">
|
|
<!-- 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">
|
|
<button class="close-video" id="closeVideo">×</button>
|
|
<video id="videoPlayer" autoplay></video>
|
|
</div>
|
|
|
|
<!-- ========================================
|
|
ALBY LIGHTNING PANEL - START
|
|
Slide-out panel for Lightning payments/boosts
|
|
Triggered by the lightning button (⚡) on the player
|
|
======================================== -->
|
|
|
|
<!-- 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">
|
|
<span class="lightning-icon">⚡</span>
|
|
<span>LIGHTNING BOOST</span>
|
|
</div>
|
|
<button class="alby-close-btn" id="albyCloseBtn" title="Close">×</button>
|
|
</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">
|
|
<button class="alby-btn" id="albyDecrementBtn" title="Decrease">-</button>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Boost button - triggers simple-boost payment -->
|
|
<button class="alby-boost-btn" id="albyBoostBtn">
|
|
<span>⚡</span>
|
|
<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 -->
|
|
|
|
<audio id="audio" preload="metadata"></audio>
|
|
|
|
<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)
|
|
|
|
// ========================================
|
|
// 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 {
|
|
// 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])}`);
|
|
} 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();
|
|
tempAudio.preload = 'metadata'; // Only fetch headers, not entire file
|
|
|
|
// Cleanup function to properly abort any ongoing request
|
|
function cleanup() {
|
|
tempAudio.removeEventListener('loadedmetadata', onMetadata);
|
|
tempAudio.removeEventListener('error', onError);
|
|
tempAudio.src = '';
|
|
tempAudio.load(); // Force abort of any pending request
|
|
}
|
|
|
|
// Set timeout for slow loads
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error('Timeout loading metadata'));
|
|
}, 10000); // 10 second timeout
|
|
|
|
function onMetadata() {
|
|
clearTimeout(timeout);
|
|
const duration = tempAudio.duration;
|
|
cleanup();
|
|
resolve(duration);
|
|
}
|
|
|
|
function onError(e) {
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
reject(e);
|
|
}
|
|
|
|
tempAudio.addEventListener('loadedmetadata', onMetadata);
|
|
tempAudio.addEventListener('error', onError);
|
|
|
|
// Direct URL - browser handles caching via HTTP headers
|
|
tempAudio.src = url;
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// TAPE POSITION HELPER FUNCTIONS
|
|
// Convert between track-local and global tape positions
|
|
// ========================================
|
|
|
|
/**
|
|
* Get the current position on the entire tape (global position)
|
|
* @returns {number} Position in seconds from tape start
|
|
*/
|
|
function getCurrentTapePosition() {
|
|
if (!durationsLoaded) return 0;
|
|
return trackStartPositions[currentTrack] + (audio.currentTime || 0);
|
|
}
|
|
|
|
/**
|
|
* Get the current tape progress as a value from 0 to 1
|
|
* @returns {number} Progress through entire tape (0 = start, 1 = end)
|
|
*/
|
|
function getTapeProgress() {
|
|
if (!durationsLoaded || !totalTapeDuration) return 0;
|
|
return getCurrentTapePosition() / totalTapeDuration;
|
|
}
|
|
|
|
/**
|
|
* Find which track contains a given global tape position
|
|
* @param {number} tapePosition - Position in seconds from tape start
|
|
* @returns {Object} { trackIndex, positionInTrack }
|
|
*/
|
|
function findTrackAtPosition(tapePosition) {
|
|
if (!durationsLoaded) return { trackIndex: 0, positionInTrack: 0 };
|
|
|
|
// Clamp to valid range
|
|
tapePosition = Math.max(0, Math.min(totalTapeDuration, tapePosition));
|
|
|
|
// Find the track that contains this position
|
|
for (let i = trackStartPositions.length - 1; i >= 0; i--) {
|
|
if (tapePosition >= trackStartPositions[i]) {
|
|
const positionInTrack = tapePosition - trackStartPositions[i];
|
|
// Clamp position within track duration
|
|
const clampedPosition = Math.min(positionInTrack, trackDurations[i] - 0.01);
|
|
return {
|
|
trackIndex: i,
|
|
positionInTrack: Math.max(0, clampedPosition)
|
|
};
|
|
}
|
|
}
|
|
|
|
// Fallback to start
|
|
return { trackIndex: 0, positionInTrack: 0 };
|
|
}
|
|
|
|
/**
|
|
* Get the global tape position for the start of a specific track
|
|
* @param {number} trackIndex - Index of the track
|
|
* @returns {number} Position in seconds from tape start
|
|
*/
|
|
function getTrackStartPosition(trackIndex) {
|
|
if (!durationsLoaded) return 0;
|
|
return trackStartPositions[trackIndex] || 0;
|
|
}
|
|
|
|
const ejectBtn = document.getElementById('ejectBtn');
|
|
const lightningBtn = document.getElementById('lightningBtn');
|
|
const videoOverlay = document.getElementById('videoOverlay');
|
|
const videoPlayer = document.getElementById('videoPlayer');
|
|
const closeVideo = document.getElementById('closeVideo');
|
|
|
|
// ========================================
|
|
// 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
|
|
// ========================================
|
|
|
|
// ========================================
|
|
// 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();
|
|
});
|
|
});
|
|
|
|
// 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
|
|
// Now async to support cache-first loading
|
|
async function loadTrack(index) {
|
|
currentTrack = index;
|
|
// Update the inner span for marquee scrolling
|
|
trackNameInner.textContent = playlist[index].name;
|
|
// Reset scroll position for new track
|
|
resetTitleScroll();
|
|
|
|
// Direct URL - browser handles caching via HTTP headers
|
|
audio.src = playlist[index].url;
|
|
}
|
|
|
|
// Initial track load (async, no await needed for initial load)
|
|
loadTrack(0);
|
|
|
|
// Load all track durations for continuous tape model
|
|
loadAllDurations();
|
|
|
|
// Set initial volume
|
|
audio.volume = 0.7;
|
|
|
|
// 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();
|
|
});
|
|
|
|
// Stop button - rewinds tape to the beginning (Track 1, position 0) with animation
|
|
stopBtn.addEventListener('click', async () => {
|
|
audio.pause();
|
|
|
|
// Check if we need to rewind (not already at beginning)
|
|
const needsRewind = currentTrack !== 0 || audio.currentTime > 0.5;
|
|
|
|
if (needsRewind && durationsLoaded) {
|
|
// Animate rewind to beginning
|
|
const animationDuration = 500;
|
|
const startProgress = getTapeProgress();
|
|
const endProgress = 0;
|
|
|
|
// Start rewind sound and animation
|
|
SoundEffects.startTapeWindLoop();
|
|
reelLeft.style.animationDuration = '0.3s';
|
|
reelRight.style.animationDuration = '0.3s';
|
|
tapeLeft.style.animationDuration = '0.3s';
|
|
tapeRight.style.animationDuration = '0.3s';
|
|
reelLeft.classList.add('spinning');
|
|
reelRight.classList.add('spinning');
|
|
tapeLeft.classList.add('spinning');
|
|
tapeRight.classList.add('spinning');
|
|
|
|
// Animate tape sizes
|
|
const startTime = performance.now();
|
|
await new Promise(resolve => {
|
|
function animate(time) {
|
|
const elapsed = time - startTime;
|
|
const t = Math.min(elapsed / animationDuration, 1);
|
|
const easedT = easeInOutQuad(t);
|
|
const currentProgress = startProgress + (endProgress - startProgress) * easedT;
|
|
setTapeSizesAtProgress(currentProgress);
|
|
|
|
if (t < 1) {
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
resolve();
|
|
}
|
|
}
|
|
requestAnimationFrame(animate);
|
|
});
|
|
|
|
// Stop sound and reset animation speed
|
|
SoundEffects.stopTapeWindLoop();
|
|
reelLeft.style.animationDuration = '';
|
|
reelRight.style.animationDuration = '';
|
|
tapeLeft.style.animationDuration = '';
|
|
tapeRight.style.animationDuration = '';
|
|
}
|
|
|
|
// Load track 0 if not already on it
|
|
if (currentTrack !== 0) {
|
|
currentTrack = 0;
|
|
trackNameInner.textContent = playlist[0].name;
|
|
|
|
// Direct URL - browser handles caching via HTTP headers
|
|
audio.src = playlist[0].url;
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
|
|
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
|
|
|
|
// Manually update time display since timeupdate doesn't fire when paused
|
|
const duration = formatTime(audio.duration);
|
|
timeDisplay.textContent = `00:00 / ${duration}`;
|
|
});
|
|
|
|
// Previous track (prev button with bar)
|
|
// 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);
|
|
});
|
|
|
|
// Next track (next button with bar)
|
|
// Uses animated seek to smoothly wind tape to next track position
|
|
nextBtn.addEventListener('click', () => {
|
|
console.log('Next button clicked');
|
|
const wasPlaying = !audio.paused;
|
|
const targetTrack = (currentTrack + 1) % playlist.length;
|
|
animatedSeekToTrack(targetTrack, wasPlaying);
|
|
});
|
|
|
|
// Drag on reels to scrub audio (supports cross-track scrubbing)
|
|
let isDragging = false;
|
|
let startY = 0;
|
|
let startTapePosition = 0; // Global tape position at drag start
|
|
let wasPlayingBeforeDrag = false;
|
|
let lastDragY = 0;
|
|
let tapeWindPlaying = false;
|
|
let scrubTrackChangeInProgress = false; // Prevents multiple track loads during scrub
|
|
|
|
function startDrag(e) {
|
|
// Allow drag if we have duration info (either from track or global tape)
|
|
if (durationsLoaded || audio.duration) {
|
|
e.preventDefault(); // Prevent text selection during drag
|
|
isDragging = true;
|
|
startY = e.clientY || e.touches[0].clientY;
|
|
lastDragY = startY;
|
|
startTapePosition = getCurrentTapePosition();
|
|
wasPlayingBeforeDrag = !audio.paused;
|
|
tapeWindPlaying = false;
|
|
scrubTrackChangeInProgress = false;
|
|
// Don't pause - let audio continue playing while scrubbing
|
|
}
|
|
}
|
|
|
|
function drag(e) {
|
|
if (!isDragging) return;
|
|
if (scrubTrackChangeInProgress) return; // Wait for track change to complete
|
|
|
|
const currentY = e.clientY || e.touches[0].clientY;
|
|
const deltaY = startY - currentY;
|
|
const instantDeltaY = lastDragY - currentY;
|
|
lastDragY = currentY;
|
|
|
|
// Calculate scrub speed for audio effects
|
|
const scrubSpeed = Math.min(Math.abs(instantDeltaY) / 20, 1); // Normalize to 0-1
|
|
|
|
// Start tape wind sound only when actually moving
|
|
if (scrubSpeed > 0.1 && !tapeWindPlaying) {
|
|
SoundEffects.startTapeWindLoop();
|
|
tapeWindPlaying = true;
|
|
} else if (scrubSpeed <= 0.1 && tapeWindPlaying) {
|
|
SoundEffects.stopTapeWindLoop();
|
|
tapeWindPlaying = false;
|
|
}
|
|
|
|
// Calculate target tape position based on drag distance
|
|
// Sensitivity: dragging 100px moves 10% of total tape (or current track if durations not loaded)
|
|
let targetTapePosition;
|
|
if (durationsLoaded && totalTapeDuration > 0) {
|
|
// Cross-track scrubbing mode
|
|
const scrubAmount = (deltaY / 100) * totalTapeDuration * 0.05;
|
|
targetTapePosition = Math.max(0, Math.min(totalTapeDuration - 0.01, startTapePosition + scrubAmount));
|
|
|
|
// Find which track this position falls in
|
|
const target = findTrackAtPosition(targetTapePosition);
|
|
|
|
if (target.trackIndex !== currentTrack) {
|
|
// Need to switch tracks
|
|
scrubTrackChangeInProgress = true;
|
|
loadTrackForScrub(target.trackIndex, target.positionInTrack).then(() => {
|
|
scrubTrackChangeInProgress = false;
|
|
updateTapeSizes();
|
|
});
|
|
} else {
|
|
// Same track, just seek
|
|
audio.currentTime = target.positionInTrack;
|
|
}
|
|
} else if (audio.duration) {
|
|
// Fallback to single-track scrubbing
|
|
const scrubAmount = (deltaY / 100) * audio.duration * 0.1;
|
|
const newTime = Math.max(0, Math.min(audio.duration, audio.currentTime + scrubAmount));
|
|
audio.currentTime = newTime;
|
|
}
|
|
|
|
// Adjust playback rate for sped-up audio effect
|
|
if (wasPlayingBeforeDrag && scrubSpeed > 0.1) {
|
|
audio.playbackRate = 1 + (scrubSpeed * 3); // 1x to 4x
|
|
} else if (wasPlayingBeforeDrag) {
|
|
audio.playbackRate = 1;
|
|
}
|
|
|
|
// Keep playing during scrub
|
|
if (audio.paused && wasPlayingBeforeDrag) {
|
|
audio.play();
|
|
}
|
|
|
|
// Update tape visual
|
|
updateTapeSizes();
|
|
}
|
|
|
|
/**
|
|
* Load a track during scrubbing and seek to position
|
|
* @param {number} trackIndex - Index of track to load
|
|
* @param {number} seekPosition - Position within track to seek to
|
|
*/
|
|
async function loadTrackForScrub(trackIndex, seekPosition) {
|
|
currentTrack = trackIndex;
|
|
trackNameInner.textContent = playlist[trackIndex].name;
|
|
resetTitleScroll();
|
|
|
|
// Direct URL - browser handles caching via HTTP headers
|
|
audio.src = playlist[trackIndex].url;
|
|
|
|
return new Promise((resolve) => {
|
|
audio.onloadedmetadata = () => {
|
|
audio.currentTime = seekPosition;
|
|
if (wasPlayingBeforeDrag) {
|
|
audio.play().catch(() => {});
|
|
}
|
|
audio.onloadedmetadata = null;
|
|
resolve();
|
|
};
|
|
audio.load();
|
|
});
|
|
}
|
|
|
|
function endDrag() {
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
scrubTrackChangeInProgress = false;
|
|
|
|
// Stop tape wind sound and reset playback rate
|
|
if (tapeWindPlaying) {
|
|
SoundEffects.stopTapeWindLoop();
|
|
tapeWindPlaying = false;
|
|
}
|
|
audio.playbackRate = 1;
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 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();
|
|
|
|
// Direct URL - browser handles caching via HTTP headers
|
|
audio.src = playlist[currentTrack].url;
|
|
|
|
return new Promise(resolve => {
|
|
audio.oncanplay = function() {
|
|
if (wasPlaying) {
|
|
audio.play().then(() => {
|
|
reelLeft.classList.add('spinning');
|
|
reelRight.classList.add('spinning');
|
|
tapeLeft.classList.add('spinning');
|
|
tapeRight.classList.add('spinning');
|
|
startTitleScroll();
|
|
}).catch(e => console.log('Play failed:', e));
|
|
} else {
|
|
reelLeft.classList.remove('spinning');
|
|
reelRight.classList.remove('spinning');
|
|
tapeLeft.classList.remove('spinning');
|
|
tapeRight.classList.remove('spinning');
|
|
}
|
|
audio.oncanplay = null;
|
|
animatedSeekInProgress = false;
|
|
resolve();
|
|
};
|
|
audio.load();
|
|
});
|
|
}
|
|
|
|
// Add drag listeners to all reel elements (containers, tape, and inner spools)
|
|
[reelLeft, reelRight, tapeLeft, tapeRight, reelContainerLeft, reelContainerRight].forEach(el => {
|
|
el.addEventListener('mousedown', startDrag);
|
|
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
|
|
audio.addEventListener('ended', async () => {
|
|
currentTrack = (currentTrack + 1) % playlist.length;
|
|
await loadTrack(currentTrack);
|
|
// Tape position continues naturally - don't reset
|
|
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
|
|
// Update tape wound sizes based on global tape position
|
|
function updateTapeSizes() {
|
|
// Use global tape progress if durations are loaded, otherwise fall back to current track
|
|
let progress;
|
|
if (durationsLoaded && totalTapeDuration > 0) {
|
|
progress = getTapeProgress();
|
|
} else if (audio.duration) {
|
|
// Fallback to per-track progress while durations are loading
|
|
progress = audio.currentTime / audio.duration;
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
const tapeRange = TAPE_MAX_SIZE - TAPE_MIN_SIZE;
|
|
|
|
// Left spool: starts full, decreases as tape plays
|
|
const leftSize = TAPE_MAX_SIZE - (progress * tapeRange);
|
|
// Right spool: starts empty, increases as tape plays
|
|
const rightSize = TAPE_MIN_SIZE + (progress * tapeRange);
|
|
|
|
tapeLeft.style.setProperty('--tape-size', leftSize + 'px');
|
|
tapeRight.style.setProperty('--tape-size', rightSize + 'px');
|
|
}
|
|
|
|
// Initialize tape sizes - rewind to beginning of tape
|
|
function resetTapeSizes() {
|
|
tapeLeft.style.setProperty('--tape-size', TAPE_MAX_SIZE + 'px');
|
|
tapeRight.style.setProperty('--tape-size', TAPE_MIN_SIZE + 'px');
|
|
}
|
|
|
|
// Set tape sizes to a specific progress value (0 to 1)
|
|
function setTapeSizesAtProgress(progress) {
|
|
const tapeRange = TAPE_MAX_SIZE - TAPE_MIN_SIZE;
|
|
const leftSize = TAPE_MAX_SIZE - (progress * tapeRange);
|
|
const rightSize = TAPE_MIN_SIZE + (progress * tapeRange);
|
|
tapeLeft.style.setProperty('--tape-size', leftSize + 'px');
|
|
tapeRight.style.setProperty('--tape-size', rightSize + 'px');
|
|
}
|
|
|
|
resetTapeSizes();
|
|
|
|
// Update time display and tape sizes
|
|
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');
|
|
const albyTrackSection = document.getElementById('albyTrackSection');
|
|
const albyTrackName = document.getElementById('albyTrackName');
|
|
const albyIncludeTrack = document.getElementById('albyIncludeTrack');
|
|
|
|
/**
|
|
* Toggle the Alby Lightning panel open/closed
|
|
* Also handles the backdrop overlay visibility
|
|
* Shows current track info when audio is loaded (playing or paused, not stopped)
|
|
*/
|
|
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');
|
|
|
|
// 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;
|
|
}
|
|
|
|
return finalMemo;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
const finalMemo = buildFinalMemo();
|
|
|
|
// Update simple-boost component attributes
|
|
albySimpleBoost.setAttribute('amount', amount);
|
|
albySimpleBoost.setAttribute('memo', finalMemo);
|
|
|
|
// 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();
|
|
});
|
|
|
|
// Include track checkbox change handler
|
|
albyIncludeTrack.addEventListener('change', () => {
|
|
updateAlbyBoostButton();
|
|
});
|
|
|
|
// Boost button click - trigger the simple-boost component
|
|
albyBoostBtn.addEventListener('click', () => {
|
|
// 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
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
});
|
|
|
|
// Listen for successful payment, reset form fields, and close modal
|
|
albySimpleBoost.addEventListener('success', (e) => {
|
|
// Reset form to default values
|
|
albyAmount.value = '1.0';
|
|
albyMemo.value = '';
|
|
// Reset track checkbox (will be re-evaluated when panel reopens)
|
|
albyIncludeTrack.checked = true;
|
|
albyTrackSection.classList.remove('visible');
|
|
updateAlbyCharCount();
|
|
updateAlbyBoostButton();
|
|
// Close the modal after successful boost
|
|
toggleAlbyPanel();
|
|
});
|
|
|
|
// 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> |