Compare commits

..

No commits in common. "master" and "v0.1.2-beta" have entirely different histories.

18 changed files with 148 additions and 774 deletions

View File

@ -1,4 +1,4 @@
const APP_VERSION = '0.3.0'; const APP_VERSION = '0.1.2-beta';
const playlist = [ const playlist = [
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/01.%20PINK%20FLIGHT%20ATTENDANT.mp3', name: 'TRACK 1 - PINK FLIGHT ATTENDANT' }, { url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/01.%20PINK%20FLIGHT%20ATTENDANT.mp3', name: 'TRACK 1 - PINK FLIGHT ATTENDANT' },
{ url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/02.%20NOW.mp3', name: 'TRACK 2 - NOW' }, { url: 'https://feed.falsefinish.club/Echo%20Reality/PINK%20FLIGHT/MP3%20BOUNCE/02.%20NOW.mp3', name: 'TRACK 2 - NOW' },
@ -63,20 +63,23 @@ let durationsLoaded = false; // Flag indicating when all durations are known
/** /**
* Load metadata for all tracks to get their durations * Load metadata for all tracks to get their durations
* Uses parallel loading for better performance * Uses a temporary audio element to fetch duration without full download
* Falls back to 240 seconds (4 min) if metadata can't be loaded * Falls back to 240 seconds (4 min) if metadata can't be loaded
*/ */
async function loadAllDurations() { async function loadAllDurations() {
console.log('Loading track durations...'); console.log('Loading track durations...');
trackDurations = new Array(playlist.length); trackDurations = [];
// Load track 0 from main audio element first for (let i = 0; i < playlist.length; i++) {
try { try {
if (currentTrack === 0) { // 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)) { if (audio.duration && !isNaN(audio.duration)) {
trackDurations[0] = audio.duration; trackDurations[i] = audio.duration;
} else { } else {
trackDurations[0] = await new Promise((resolve) => { // Wait for main audio metadata
trackDurations[i] = await new Promise((resolve) => {
if (audio.duration && !isNaN(audio.duration)) { if (audio.duration && !isNaN(audio.duration)) {
resolve(audio.duration); resolve(audio.duration);
} else { } else {
@ -88,31 +91,13 @@ async function loadAllDurations() {
}); });
} }
} else { } else {
trackDurations[0] = await getTrackDuration(playlist[0].url); // For other tracks, use temp audio element
trackDurations[i] = await getTrackDuration(playlist[i].url);
} }
console.log(`Track 1 duration: ${formatTime(trackDurations[0])}`); console.log(`Track ${i + 1} duration: ${formatTime(trackDurations[i])}`);
} catch (e) { } catch (e) {
console.warn('Failed to get duration for track 1, using fallback'); console.warn(`Failed to get duration for track ${i + 1}, using fallback`);
trackDurations[0] = 240; trackDurations[i] = 240; // 4 minute fallback
}
// Load remaining tracks in parallel for faster loading
if (playlist.length > 1) {
const otherTrackPromises = playlist.slice(1).map(async (track, index) => {
const trackIndex = index + 1;
try {
const duration = await getTrackDuration(track.url);
console.log(`Track ${trackIndex + 1} duration: ${formatTime(duration)}`);
return duration;
} catch (e) {
console.warn(`Failed to get duration for track ${trackIndex + 1}, using fallback`);
return 240; // 4 minute fallback
}
});
const otherDurations = await Promise.all(otherTrackPromises);
for (let i = 0; i < otherDurations.length; i++) {
trackDurations[i + 1] = otherDurations[i];
} }
} }
@ -242,20 +227,14 @@ function getTrackStartPosition(trackIndex) {
// ======================================== // ========================================
// SOUND EFFECTS MODULE - START // SOUND EFFECTS MODULE - START
// Web Audio API synthesized sounds for tactile feedback // Web Audio API synthesized sounds for tactile feedback
// Pre-generated noise buffers for better performance
// ======================================== // ========================================
const SoundEffects = { const SoundEffects = {
ctx: null, ctx: null,
// Cached noise buffers - generated once, reused for all sounds
clickNoiseBuffer: null,
tapeWindNoiseBuffer: null,
// Initialize AudioContext and pre-generate noise buffers (must be called after user gesture) // Initialize AudioContext (must be called after user gesture)
init() { init() {
if (!this.ctx) { if (!this.ctx) {
this.ctx = new (window.AudioContext || window.webkitAudioContext)(); this.ctx = new (window.AudioContext || window.webkitAudioContext)();
// Pre-generate noise buffers on first init
this.generateNoiseBuffers();
} }
// Resume if suspended (browser autoplay policy) // Resume if suspended (browser autoplay policy)
if (this.ctx.state === 'suspended') { if (this.ctx.state === 'suspended') {
@ -263,29 +242,6 @@ const SoundEffects = {
} }
}, },
// Pre-generate all noise buffers once for reuse
generateNoiseBuffers() {
const sampleRate = this.ctx.sampleRate;
// Generate click noise buffer (20ms)
const clickBufferSize = Math.floor(sampleRate * 0.02);
this.clickNoiseBuffer = this.ctx.createBuffer(1, clickBufferSize, sampleRate);
const clickOutput = this.clickNoiseBuffer.getChannelData(0);
for (let i = 0; i < clickBufferSize; i++) {
clickOutput[i] = Math.random() * 2 - 1;
}
// Generate tape wind noise buffer (1 second with wobble)
const windBufferSize = sampleRate * 1;
this.tapeWindNoiseBuffer = this.ctx.createBuffer(1, windBufferSize, sampleRate);
const windOutput = this.tapeWindNoiseBuffer.getChannelData(0);
for (let i = 0; i < windBufferSize; i++) {
const noise = Math.random() * 2 - 1;
const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2);
windOutput[i] = noise * wobble;
}
},
// Play a mechanical button click sound // Play a mechanical button click sound
playButtonClick() { playButtonClick() {
this.init(); this.init();
@ -308,9 +264,16 @@ const SoundEffects = {
clickOsc.connect(clickGain); clickOsc.connect(clickGain);
clickGain.connect(this.ctx.destination); clickGain.connect(this.ctx.destination);
// High frequency "click" transient using cached noise buffer // 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(); const noise = this.ctx.createBufferSource();
noise.buffer = this.clickNoiseBuffer; noise.buffer = noiseBuffer;
// Bandpass filter to shape the noise into a click // Bandpass filter to shape the noise into a click
const filter = this.ctx.createBiquadFilter(); const filter = this.ctx.createBiquadFilter();
@ -333,7 +296,7 @@ const SoundEffects = {
}, },
// Start continuous tape wind sound (for reel dragging) // Start continuous tape wind sound (for reel dragging)
// Uses cached looping noise buffer for seamless continuous playback // Uses looping noise buffer for seamless continuous playback
startTapeWindLoop() { startTapeWindLoop() {
this.init(); this.init();
@ -341,10 +304,22 @@ const SoundEffects = {
this.stopTapeWindLoop(); this.stopTapeWindLoop();
const now = this.ctx.currentTime; const now = this.ctx.currentTime;
const sampleRate = this.ctx.sampleRate;
// Create 1 second of loopable noise
const bufferSize = sampleRate * 1;
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate);
const output = noiseBuffer.getChannelData(0);
// Generate noise with amplitude modulation
for (let i = 0; i < bufferSize; i++) {
const noise = Math.random() * 2 - 1;
const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2);
output[i] = noise * wobble;
}
// Use cached tape wind noise buffer
this.tapeWindSource = this.ctx.createBufferSource(); this.tapeWindSource = this.ctx.createBufferSource();
this.tapeWindSource.buffer = this.tapeWindNoiseBuffer; this.tapeWindSource.buffer = noiseBuffer;
this.tapeWindSource.loop = true; this.tapeWindSource.loop = true;
// Bandpass filter for tape character // Bandpass filter for tape character
@ -636,49 +611,22 @@ lightningBtn.addEventListener('click', () => {
// ======================================== // ========================================
// TITLE SCROLL ANIMATION (JavaScript controlled bounce) // TITLE SCROLL ANIMATION (JavaScript controlled bounce)
// Uses requestAnimationFrame for better performance
// Moves 1px at a time, bounces at edges, pauses with player // Moves 1px at a time, bounces at edges, pauses with player
// ======================================== // ========================================
let titleScrollPosition = 0; let titleScrollPosition = 0;
let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right
let titleScrollRAF = null; let titleScrollInterval = null;
let titleScrollLastTime = 0;
const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels) const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels)
// Cached dimensions - updated only when track changes or on resize
let cachedContainerWidth = 0;
let cachedTextWidth = 0;
let cachedMaxScroll = 0;
let cachedTextLonger = false;
/**
* Cache the container and text dimensions for title scroll
* Call this when track changes or window resizes
*/
function cacheTitleDimensions() {
cachedContainerWidth = trackName.offsetWidth;
cachedTextWidth = trackNameInner.offsetWidth;
cachedTextLonger = cachedTextWidth > cachedContainerWidth;
// Calculate max scroll
if (cachedTextLonger) {
cachedMaxScroll = cachedTextWidth - cachedContainerWidth;
} else {
cachedMaxScroll = cachedContainerWidth - cachedTextWidth;
}
}
function startTitleScroll() { function startTitleScroll() {
if (titleScrollRAF) return; // Already scrolling if (titleScrollInterval) return; // Already scrolling
cacheTitleDimensions(); titleScrollInterval = setInterval(updateTitleScroll, SCROLL_SPEED);
titleScrollLastTime = performance.now();
titleScrollRAF = requestAnimationFrame(titleScrollLoop);
} }
function stopTitleScroll() { function stopTitleScroll() {
if (titleScrollRAF) { if (titleScrollInterval) {
cancelAnimationFrame(titleScrollRAF); clearInterval(titleScrollInterval);
titleScrollRAF = null; titleScrollInterval = null;
} }
} }
@ -687,52 +635,46 @@ function resetTitleScroll() {
titleScrollPosition = 0; titleScrollPosition = 0;
titleScrollDirection = 1; titleScrollDirection = 1;
trackNameInner.style.left = '0px'; trackNameInner.style.left = '0px';
cacheTitleDimensions();
}
/**
* RAF-based scroll loop - tracks elapsed time to maintain consistent speed
*/
function titleScrollLoop(currentTime) {
const elapsed = currentTime - titleScrollLastTime;
// Only update position when enough time has passed (maintains same speed as before)
if (elapsed >= SCROLL_SPEED) {
titleScrollLastTime = currentTime - (elapsed % SCROLL_SPEED);
updateTitleScroll();
}
titleScrollRAF = requestAnimationFrame(titleScrollLoop);
} }
function updateTitleScroll() { function updateTitleScroll() {
const containerWidth = trackName.offsetWidth;
const textWidth = trackNameInner.offsetWidth;
// Calculate max scroll - text scrolls until its right edge hits container's right edge
// If text is shorter than container, scroll until text's left edge hits container's right edge
let maxScroll;
if (textWidth > containerWidth) {
// Text is longer - scroll until right edge of text reaches right edge of container
maxScroll = textWidth - containerWidth;
} else {
// Text is shorter - scroll across the container width but keep text visible
// Text starts at left (0), scrolls right until left edge is at (containerWidth - textWidth)
maxScroll = containerWidth - textWidth;
}
// Move 1px in current direction // Move 1px in current direction
titleScrollPosition += titleScrollDirection; titleScrollPosition += titleScrollDirection;
// Bounce at edges // Bounce at edges
if (titleScrollPosition >= cachedMaxScroll) { if (titleScrollPosition >= maxScroll) {
titleScrollPosition = cachedMaxScroll; titleScrollPosition = maxScroll;
titleScrollDirection = -1; // Start moving back titleScrollDirection = -1; // Start moving back
} else if (titleScrollPosition <= 0) { } else if (titleScrollPosition <= 0) {
titleScrollPosition = 0; titleScrollPosition = 0;
titleScrollDirection = 1; // Start moving forward titleScrollDirection = 1; // Start moving forward
} }
// Apply position using cached text length comparison // Apply position
if (cachedTextLonger) { // 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'; trackNameInner.style.left = -titleScrollPosition + 'px';
} else { } else {
trackNameInner.style.left = titleScrollPosition + 'px'; trackNameInner.style.left = titleScrollPosition + 'px';
} }
} }
// Recache dimensions on window resize
window.addEventListener('resize', () => {
if (titleScrollRAF) {
cacheTitleDimensions();
}
});
// ======================================== // ========================================
// AUDIO LOADING HELPERS - START // AUDIO LOADING HELPERS - START
// Unified helpers for loading tracks and waiting for audio ready // Unified helpers for loading tracks and waiting for audio ready
@ -1196,27 +1138,11 @@ function setTapeSizesAtProgress(progress) {
resetTapeSizes(); resetTapeSizes();
// Throttle timeupdate for better performance // Update time display and tape sizes
let lastTimeUpdateTime = 0;
let lastFormattedTime = '';
const TIME_UPDATE_THROTTLE = 200; // Update at most 5 times per second
// Update time display and tape sizes (throttled)
audio.addEventListener('timeupdate', () => { audio.addEventListener('timeupdate', () => {
const now = performance.now();
if (now - lastTimeUpdateTime < TIME_UPDATE_THROTTLE) return;
lastTimeUpdateTime = now;
const current = formatTime(audio.currentTime); const current = formatTime(audio.currentTime);
const duration = formatTime(audio.duration); const duration = formatTime(audio.duration);
const formatted = `${current} / ${duration}`; timeDisplay.textContent = `${current} / ${duration}`;
// Skip DOM update if time display hasn't changed
if (formatted !== lastFormattedTime) {
lastFormattedTime = formatted;
timeDisplay.textContent = formatted;
}
updateTapeSizes(); updateTapeSizes();
}); });

BIN
apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

16
favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 316 KiB

View File

@ -3,16 +3,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ECHO REALITY</title> <title>ECHO REALITY - 16-Bit Cassette Player</title>
<link rel="icon" type="image/png" href="/assets/favicon-96x96.png" sizes="96x96" /> <link rel="stylesheet" href="styles.css">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="/assets/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" /> <link rel="shortcut icon" href="/favicon.ico" />
<meta name="apple-mobile-web-app-title" content="ECHO REALITY" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/assets/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="assets/styles.css">
<!-- Preconnect to esm.sh for faster simple-boost loading -->
<link rel="preconnect" href="https://esm.sh">
</head> </head>
<body> <body>
<div class="player"> <div class="player">
@ -20,11 +17,13 @@
<button class="lightning-btn" id="lightningBtn" title="Menu">&#9889;</button> <button class="lightning-btn" id="lightningBtn" title="Menu">&#9889;</button>
<div class="display" id="display"> <div class="display" id="display">
<!-- Dim spots - worn phosphor effect (slowly drifting) - reduced to 3 for performance --> <!-- Dim spots - worn phosphor effect (slowly drifting) -->
<div class="dim-spots-container"> <div class="dim-spots-container">
<div class="dim-spot dim-spot-1"></div> <div class="dim-spot dim-spot-1"></div>
<div class="dim-spot dim-spot-2"></div> <div class="dim-spot dim-spot-2"></div>
<div class="dim-spot dim-spot-3"></div> <div class="dim-spot dim-spot-3"></div>
<div class="dim-spot dim-spot-4"></div>
<div class="dim-spot dim-spot-5"></div>
</div> </div>
<!-- VHS tracking lines overlay (controlled by DisplayGlitch module) --> <!-- VHS tracking lines overlay (controlled by DisplayGlitch module) -->
<div class="vhs-tracking" id="vhsTracking"></div> <div class="vhs-tracking" id="vhsTracking"></div>
@ -162,6 +161,6 @@
<audio id="audio" preload="metadata"></audio> <audio id="audio" preload="metadata"></audio>
<script src="src/app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -1,21 +1,21 @@
{ {
"name": "ECHO REALITY", "name": "ECHO REALITY - 16-Bit Cassette Player",
"short_name": "ECHO REALITY", "short_name": "ECHO REALITY",
"icons": [ "icons": [
{ {
"src": "/assets/web-app-manifest-192x192.png", "src": "/web-app-manifest-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/assets/web-app-manifest-512x512.png", "src": "/web-app-manifest-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
} }
], ],
"theme_color": "#ffffff", "theme_color": "#000000",
"background_color": "#ffffff", "background_color": "#000000",
"display": "standalone" "display": "standalone"
} }

View File

@ -36,7 +36,7 @@ body {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: url('background.webp'); background-image: url('background.png');
background-size: 100%; background-size: 100%;
background-position: center; background-position: center;
background-repeat: repeat; background-repeat: repeat;
@ -167,7 +167,6 @@ body {
pointer-events: none; pointer-events: none;
animation: flicker 4s infinite; animation: flicker 4s infinite;
z-index: 3; z-index: 3;
will-change: opacity; /* Performance: GPU-accelerated opacity animation */
} }
@keyframes flicker { @keyframes flicker {
@ -278,13 +277,11 @@ body {
.vhs-tracking.active.vertical { .vhs-tracking.active.vertical {
opacity: 1; opacity: 1;
animation: vhsScrollVertical 0.4s linear; animation: vhsScrollVertical 0.4s linear;
will-change: transform, opacity; /* Performance: GPU-accelerated animation */
} }
.vhs-tracking.active.horizontal { .vhs-tracking.active.horizontal {
opacity: 1; opacity: 1;
animation: vhsScrollHorizontal 0.4s linear; animation: vhsScrollHorizontal 0.4s linear;
will-change: transform, opacity; /* Performance: GPU-accelerated animation */
} }
@keyframes vhsScrollVertical { @keyframes vhsScrollVertical {
@ -307,7 +304,6 @@ body {
pointer-events: none; pointer-events: none;
z-index: 20; z-index: 20;
overflow: hidden; overflow: hidden;
contain: strict; /* Performance: isolates paint scope for animated children */
} }
.dim-spot { .dim-spot {
@ -344,6 +340,24 @@ body {
animation: dimSpotDrift3 50s ease-in-out infinite; animation: dimSpotDrift3 50s ease-in-out infinite;
} }
.dim-spot-4 {
bottom: 0%;
right: 0%;
width: 45%;
height: 55%;
background: radial-gradient(ellipse 80% 90% at center, rgba(0, 0, 0, 0.5) 0%, transparent 65%);
animation: dimSpotDrift4 60s ease-in-out infinite;
}
.dim-spot-5 {
top: 35%;
left: 55%;
width: 30%;
height: 35%;
background: radial-gradient(circle at center, rgba(0, 0, 0, 0.35) 0%, transparent 60%);
animation: dimSpotDrift5 40s ease-in-out infinite;
}
/* Slow drifting animations - each spot moves in a different pattern */ /* Slow drifting animations - each spot moves in a different pattern */
@keyframes dimSpotDrift1 { @keyframes dimSpotDrift1 {
0%, 100% { transform: translate(0%, 0%); } 0%, 100% { transform: translate(0%, 0%); }
@ -366,6 +380,20 @@ body {
75% { transform: translate(-4%, -10%); } 75% { transform: translate(-4%, -10%); }
} }
@keyframes dimSpotDrift4 {
0%, 100% { transform: translate(0%, 0%); }
25% { transform: translate(-8%, -6%); }
50% { transform: translate(-12%, 4%); }
75% { transform: translate(4%, -8%); }
}
@keyframes dimSpotDrift5 {
0%, 100% { transform: translate(0%, 0%); }
25% { transform: translate(-15%, 10%); }
50% { transform: translate(10%, 15%); }
75% { transform: translate(15%, -10%); }
}
/* ======================================== /* ========================================
DISPLAY GLITCH EFFECTS - END DISPLAY GLITCH EFFECTS - END
======================================== */ ======================================== */
@ -1397,595 +1425,3 @@ simple-boost {
/* ======================================== /* ========================================
VERSION BUTTON & MODAL STYLES - END VERSION BUTTON & MODAL STYLES - END
======================================== */ ======================================== */
/* ========================================
MOBILE RESPONSIVE STYLES - START
Supports portrait (rotated) and landscape modes
======================================== */
/* ----------------------------------------
MOBILE LANDSCAPE MODE
For phones held sideways (short height, moderate width)
Scale player proportionally to fit viewport
---------------------------------------- */
@media (max-width: 900px) and (max-height: 500px) {
body {
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
min-height: 100vh;
overflow: hidden;
}
.player {
/* Scale entire player to fit viewport height */
transform: perspective(1000px) rotateX(1deg) scale(0.85);
transform-origin: center center;
width: calc(100vw - 10px);
max-width: 700px; /* Slightly larger since we're scaling down */
padding: 50px 20px 20px 20px;
}
/* Scale display area */
.display {
padding: 10px;
margin-top: 10px;
margin-bottom: 12px;
}
.display-text {
font-size: 14px;
}
.time-display {
font-size: 18px;
letter-spacing: 2px;
}
/* Scale cassette */
.cassette {
padding: 12px;
margin-bottom: 12px;
}
.cassette-label {
padding: 8px;
margin-bottom: 10px;
font-size: 11px;
}
/* Scale tape window and reels */
.tape-window {
width: 280px;
height: 60px;
}
.reel {
width: 60px;
height: 60px;
}
.reel-left {
left: 20px;
}
.reel-right {
right: 20px;
}
.reel-inner {
width: 45px;
height: 45px;
margin-left: -22.5px;
margin-top: -22.5px;
}
/* Scale control buttons - maintain touch-friendly size */
.controls {
gap: 8px;
margin-bottom: 10px;
}
.btn {
width: 50px;
height: 50px;
border-width: 4px;
}
/* Scale button icons */
.btn-prev::before {
border-right-width: 10px;
border-top-width: 6px;
border-bottom-width: 6px;
margin-right: 6px;
}
.btn-prev::after {
height: 12px;
margin-left: -10px;
}
.btn-play::before {
border-left-width: 16px;
border-top-width: 10px;
border-bottom-width: 10px;
}
.btn-pause::before {
width: 16px;
height: 20px;
border-left-width: 5px;
border-right-width: 5px;
}
.btn-stop::before {
width: 16px;
height: 16px;
}
.btn-next::before {
border-left-width: 10px;
border-top-width: 6px;
border-bottom-width: 6px;
margin-left: -6px;
}
.btn-next::after {
height: 12px;
margin-left: 10px;
}
/* Scale volume control */
.volume-control {
gap: 10px;
}
.volume-label {
font-size: 14px;
}
input[type="range"] {
width: 150px;
height: 10px;
}
input[type="range"]::-webkit-slider-thumb {
width: 20px;
height: 20px;
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
}
/* Scale top buttons */
.eject-btn,
.lightning-btn {
top: 10px;
width: 40px;
height: 28px;
}
.eject-btn {
left: 20px;
}
.lightning-btn {
right: 20px;
font-size: 14px;
}
.eject-btn::before {
border-left-width: 6px;
border-right-width: 6px;
border-bottom-width: 8px;
top: 5px;
}
.eject-btn::after {
width: 14px;
height: 2px;
bottom: 5px;
}
/* Scale version button */
.version-btn {
bottom: 5px;
right: 8px;
font-size: 8px;
}
/* Modals in landscape */
.alby-panel {
width: 320px;
max-height: 85vh;
}
.version-modal {
width: 240px;
}
}
/* ----------------------------------------
MOBILE PORTRAIT MODE
For phones held upright (narrow width, tall height)
Only the cassette/tape rotates 90 degrees - everything else stays horizontal
Layout: [E][L] -> Screen -> ROTATED TAPE -> Controls -> Volume
---------------------------------------- */
@media (max-width: 500px) and (min-height: 600px) {
html, body {
overflow-x: hidden;
overflow-y: auto;
}
body {
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
min-height: 100vh;
}
.player {
/* Player stays horizontal, just scaled to fit width */
width: calc(100vw - 20px);
max-width: 390px;
/* Increased vertical padding to fill screen better */
padding: 55px 15px 25px 15px;
/* Remove the 3D perspective tilt on mobile for cleaner look */
transform: none;
/* Scale down border for mobile */
border-width: 6px;
/* Contain the ::before pseudo-element */
overflow: hidden;
}
/* Fix background texture scaling */
.player::before {
background-size: cover;
}
/* Scale display for portrait - stays horizontal */
.display {
padding: 12px;
margin-top: 12px;
margin-bottom: 45px;
}
.display-text {
font-size: 12px;
letter-spacing: 1px;
}
.time-display {
font-size: 16px;
letter-spacing: 2px;
}
/* ----------------------------------------
CASSETTE - SCALED TO FIT WIDTH
Horizontal layout, scaled down for portrait
---------------------------------------- */
.cassette {
padding: 12px;
margin-bottom: 45px;
}
.cassette-label {
padding: 10px;
margin-bottom: 12px;
font-size: 10px;
}
/* Tape window scaled to fit portrait width */
.tape-window {
width: calc(100% - 20px);
max-width: 280px;
height: 55px;
}
.reel {
width: 55px;
height: 55px;
}
.reel-left {
left: 18px;
}
.reel-right {
right: 18px;
}
.reel-inner {
width: 42px;
height: 42px;
margin-left: -21px;
margin-top: -21px;
}
/* Scale the entire reel (including tape-wound set by JS) */
.reel {
transform: translateY(-50%) scale(0.7);
}
/* Control buttons - stays horizontal, scaled for touch */
.controls {
gap: 10px;
margin-bottom: 20px;
}
.btn {
width: 52px;
height: 52px;
border-width: 4px;
}
/* Scale button icons for portrait */
.btn-prev::before {
border-right-width: 9px;
border-top-width: 6px;
border-bottom-width: 6px;
margin-right: 6px;
}
.btn-prev::after {
height: 12px;
margin-left: -9px;
}
.btn-play::before {
border-left-width: 14px;
border-top-width: 9px;
border-bottom-width: 9px;
}
.btn-pause::before {
width: 14px;
height: 18px;
border-left-width: 5px;
border-right-width: 5px;
}
.btn-stop::before {
width: 14px;
height: 14px;
}
.btn-next::before {
border-left-width: 9px;
border-top-width: 6px;
border-bottom-width: 6px;
margin-left: -6px;
}
.btn-next::after {
height: 12px;
margin-left: 9px;
}
/* Volume control - stays horizontal */
.volume-control {
margin-top: 30px;
gap: 10px;
}
.volume-label {
font-size: 12px;
}
input[type="range"] {
width: 120px;
height: 10px;
}
input[type="range"]::-webkit-slider-thumb {
width: 24px;
height: 24px;
}
input[type="range"]::-moz-range-thumb {
width: 24px;
height: 24px;
}
/* Top buttons - scaled */
.eject-btn,
.lightning-btn {
top: 10px;
width: 40px;
height: 28px;
}
.eject-btn {
left: 15px;
}
.lightning-btn {
right: 15px;
font-size: 14px;
}
.eject-btn::before {
border-left-width: 6px;
border-right-width: 6px;
border-bottom-width: 8px;
top: 5px;
}
.eject-btn::after {
width: 14px;
height: 2px;
bottom: 5px;
}
/* Version button */
.version-btn {
bottom: 4px;
right: 6px;
font-size: 8px;
}
/* ----------------------------------------
MODALS IN PORTRAIT MODE
---------------------------------------- */
.alby-panel {
width: calc(100vw - 30px);
max-width: 360px;
max-height: calc(100vh - 40px);
}
.version-modal {
width: calc(100vw - 40px);
max-width: 280px;
}
.close-video {
top: 15px;
right: 15px;
width: 44px;
height: 44px;
font-size: 28px;
}
}
/* ----------------------------------------
VERY SMALL PORTRAIT (iPhone SE, etc.)
Additional scaling for smaller devices
---------------------------------------- */
@media (max-width: 380px) and (min-height: 600px) {
.player {
width: calc(100vw - 16px);
padding: 40px 10px 10px 10px;
}
/* Smaller cassette for tiny screens */
.cassette {
padding: 8px;
margin-bottom: 12px;
}
.tape-window {
width: calc(100% - 16px);
max-width: 240px;
height: 48px;
}
.reel {
width: 48px;
height: 48px;
transform: translateY(-50%) scale(0.6);
}
.reel-left {
left: 15px;
}
.reel-right {
right: 15px;
}
.reel-inner {
width: 36px;
height: 36px;
margin-left: -18px;
margin-top: -18px;
}
.btn {
width: 44px;
height: 44px;
}
.controls {
gap: 5px;
}
input[type="range"] {
width: 90px;
}
.eject-btn,
.lightning-btn {
width: 34px;
height: 24px;
}
.eject-btn {
left: 10px;
}
.lightning-btn {
right: 12px;
font-size: 12px;
}
}
/* ----------------------------------------
TABLET PORTRAIT MODE
For tablets held upright - no rotation, just scale
---------------------------------------- */
@media (min-width: 501px) and (max-width: 768px) {
.player {
width: calc(100vw - 40px);
max-width: 600px;
}
}
/* ----------------------------------------
TOUCH DEVICE ENHANCEMENTS
Better hover states and touch feedback
---------------------------------------- */
@media (hover: none) and (pointer: coarse) {
/* Touch devices don't need hover state changes */
.btn:hover,
.eject-btn:hover,
.lightning-btn:hover,
.alby-btn:hover,
.alby-boost-btn:hover,
.alby-close-btn:hover,
.version-btn:hover,
.version-close-btn:hover {
/* Reset hover-specific box-shadow to default state */
box-shadow: inherit;
}
/* Larger touch targets for interactive elements */
.alby-close-btn,
.version-close-btn {
min-width: 44px;
min-height: 44px;
}
.alby-btn {
min-width: 44px;
min-height: 44px;
}
/* Ensure checkbox is easily tappable */
.alby-checkbox-label {
padding: 10px 5px;
}
.alby-checkbox-label input[type="checkbox"] {
width: 24px;
height: 24px;
}
}
/* ----------------------------------------
PREVENT ZOOM ON INPUT FOCUS (iOS)
iOS zooms when focusing inputs with font-size < 16px
---------------------------------------- */
@media (max-width: 900px) {
.alby-input,
.alby-textarea {
font-size: 16px;
}
}
/* ========================================
MOBILE RESPONSIVE STYLES - END
======================================== */

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB