Fish / index.html
VSPAN's picture
Upload 4 files
e23dff9 verified
Raw History Blame Contribute Delete
197 kB
<!DOCTYPE html>
<!-- Rybka Web 1.0.1: external personal audio only; no embedded audio, no automatic build. -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="Песня и личное письмо ко дню рождения.">
<meta name="robots" content="noindex, nofollow, noarchive">
<meta name="referrer" content="no-referrer">
<meta name="theme-color" content="#04080c">
<title>Рыбка — С Днём рождения!</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><path d=%22M3 16 C8 10 18 10 26 14 C30 11 31 8 31 8 C31 14 28 17 26 18 C28 19 31 22 31 28 C31 28 30 25 26 22 C18 26 8 26 3 16 Z%22 fill=%22%231ed7a6%22/></svg>">
<style>
/* ====================================================================
«ГЛУБИНА» — SPOTIFY & CLAUDE EXPERIENTIAL DESIGN SYSTEM
Режимы атмосферы, 3D-параллакс, живая вода, караоке и печать Claude
==================================================================== */
:root {
/* Dynamic Realtime Accent (интерполируется плавно каждый кадр) */
--current-dynamic-accent: #1ed7a6;
--current-dynamic-glow: rgba(30, 215, 166, 0.4);
/* Default: Theme Night (Холодный ночной дождь и бирюза) */
--sp-black: #121212;
--sp-dark-surface: #181818;
--sp-card: #222222;
--sp-accent: #1ed7a6;
--sp-accent-glow: rgba(30, 215, 166, 0.35);
--sp-mesh-center: rgba(30, 215, 166, 0.08);
--sp-mesh-edge: #0a0a0a;
--sp-text: #ffffff;
--sp-text-sub: #b3b3b3;
--sp-border: rgba(255, 255, 255, 0.08);
--rain-color: rgba(230, 240, 255, 1);
/* Claude Editorial Accents */
--cl-terracotta: #d97757;
--cl-terracotta-soft: rgba(217, 119, 87, 0.18);
--cl-amber-glow: rgba(220, 165, 110, 0.14);
--cl-warm-bg: #0e0d0b;
--cl-warm-card: rgba(28, 25, 22, 0.76);
--cl-warm-title: #f8f4ec;
--cl-warm-body: #ded7cb;
--cl-warm-btn: #f5f0e6;
--cl-warm-btn-text: #181512;
/* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: "Georgia", "Playfair Display", "Times New Roman", serif;
}
/* ------------------------------------------------------------------
3 АТМОСФЕРНЫХ РЕЖИМА (THEMES)
------------------------------------------------------------------ */
body.theme-abyss {
--sp-black: #061116;
--sp-dark-surface: #0a1820;
--sp-accent: #00f2fe;
--sp-accent-glow: rgba(0, 242, 254, 0.35);
--sp-mesh-center: rgba(0, 242, 254, 0.12);
--sp-mesh-edge: #04090d;
--rain-color: rgba(180, 245, 255, 1);
}
body.theme-warm {
--sp-black: #14100c;
--sp-dark-surface: #1c1712;
--sp-accent: #ffb443;
--sp-accent-glow: rgba(255, 180, 67, 0.4);
--sp-mesh-center: rgba(255, 180, 67, 0.14);
--sp-mesh-edge: #0c0906;
--rain-color: rgba(255, 235, 215, 1);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--sp-black);
color: var(--sp-text);
font-family: var(--font-sans);
user-select: none;
-webkit-user-select: none;
transition: background-color 1.2s ease;
}
.hidden {
display: none !important;
}
/* ====================================================================
ХОЛСТЫ И ДИНАМИЧЕСКИЙ ФОН
==================================================================== */
.ambient-backdrop {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 1;
pointer-events: none;
opacity: 0;
will-change: clip-path, opacity;
transition: opacity 1.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.ambient-backdrop.active {
opacity: 1;
}
.ambient-backdrop.wave-incoming {
z-index: 3 !important;
opacity: 1 !important;
}
.ambient-backdrop.wave-outgoing {
z-index: 2 !important;
opacity: 1 !important;
}
.ambient-backdrop.night {
background: radial-gradient(circle at 50% 38%, rgba(20, 42, 58, 0.55) 0%, rgba(5, 12, 18, 0.98) 70%, #03060a 100%);
}
.ambient-backdrop.abyss {
background: radial-gradient(circle at 50% 40%, rgba(0, 115, 130, 0.52) 0%, rgba(2, 24, 32, 0.98) 65%, #010a0e 100%);
}
.ambient-backdrop.warm {
background: radial-gradient(circle at 50% 36%, rgba(130, 60, 30, 0.52) 0%, rgba(26, 14, 10, 0.98) 65%, #0b0604 100%);
}
.ambient-canvas-mesh {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 2;
pointer-events: none;
background: radial-gradient(circle at 50% 36%, var(--sp-mesh-center) 0%, rgba(18, 18, 18, 0.95) 60%, var(--sp-mesh-edge) 100%);
transition: background 3.5s cubic-bezier(0.35, 0, 0.25, 1);
}
/* Динамический жидкий шёлк (Fluid Silk Design Canvas) */
.fluid-silk-canvas {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 3;
pointer-events: none;
opacity: 0.92;
mix-blend-mode: screen;
will-change: transform;
}
/* Аудиореактивная волновая лента */
.waveform-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 4;
pointer-events: none;
opacity: 0.65;
}
/* Дождь на заднем плане, капли на стекле и круги на воде */
.fx-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
}
.rain-canvas {
z-index: 8;
opacity: 0;
transition: opacity 2.5s ease;
}
.glass-canvas {
z-index: 9;
opacity: 0.85;
}
.ripple-canvas {
z-index: 12;
}
.atmo-fx-canvas {
z-index: 10;
opacity: 1;
pointer-events: none;
transition: opacity 1.5s ease;
}
/* ====================================================================
ВЕРХНИЙ ХЕДЕР (SPOTIFY & CLAUDE TOPBAR)
==================================================================== */
.app-topbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 60px;
z-index: 70;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
padding-top: max(8px, env(safe-area-inset-top));
background: linear-gradient(180deg, rgba(18, 18, 18, 0.85) 0%, rgba(18, 18, 18, 0) 100%);
pointer-events: auto;
transition: opacity 0.5s ease, transform 0.5s ease;
}
.topbar-left {
display: flex;
align-items: center;
gap: 14px;
}
.brand-track-badge {
display: inline-flex;
align-items: center;
gap: 7px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.09);
padding: 5px 12px;
border-radius: 500px;
backdrop-filter: blur(12px);
}
.track-title-text {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #ffffff;
}
.track-artist-sep {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.35);
}
.track-artist-text {
font-size: 0.74rem;
color: var(--sp-text-sub);
}
.pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--sp-accent);
box-shadow: 0 0 8px var(--sp-accent);
animation: pulseDot 2s infinite;
}
@keyframes pulseDot {
0%, 100% { opacity: 0.4; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.2); }
}
/* Переключатель атмосферы */
.atmosphere-switcher {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 3px;
border-radius: 500px;
backdrop-filter: blur(10px);
}
.atmo-btn {
background: none;
border: none;
color: var(--sp-text-sub);
padding: 4px 10px;
border-radius: 500px;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.atmo-btn:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
}
.atmo-btn.active {
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.atmo-auto-pill {
display: inline-flex;
align-items: center;
gap: 5px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--sp-text-sub);
padding: 4px 8px;
border-radius: 500px;
font-size: 0.70rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.atmo-auto-pill:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.12);
}
.atmo-auto-pill.active {
background: rgba(30, 215, 166, 0.2);
border-color: rgba(30, 215, 166, 0.45);
color: var(--sp-accent);
box-shadow: 0 0 10px rgba(30, 215, 166, 0.25);
}
.auto-indicator-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 6px currentColor;
}
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.topbar-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #ffffff;
padding: 6px 14px;
border-radius: 500px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
}
.topbar-action-btn:hover {
background: rgba(255, 255, 255, 0.14);
transform: scale(1.03);
}
.topbar-action-btn.highlight {
background: rgba(30, 215, 166, 0.15);
border-color: rgba(30, 215, 166, 0.4);
color: var(--sp-accent);
}
.topbar-action-btn.highlight:hover {
background: rgba(30, 215, 166, 0.3);
}
.topbar-action-btn.icon-only {
padding: 8px;
border-radius: 50%;
}
/* ====================================================================
ФАЗА 0: ЭКРАН ОЖИДАНИЯ
==================================================================== */
/* ====================================================================
ФАЗА 0: ЭКРАН ОЖИДАНИЯ (МИНИМАЛИЗМ)
==================================================================== */
.waiting-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100;
background: radial-gradient(ellipse at center, rgba(12, 17, 23, 0.96) 0%, rgba(5, 7, 10, 0.99) 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 1.2s cubic-bezier(0.4, 0, 0.2, 1), visibility 1.2s ease;
}
.waiting-screen.fade-out {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.minimal-portal-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 32px 24px;
max-width: 540px;
width: 100%;
z-index: 2;
user-select: none;
}
.minimal-main-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1), transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), filter 0.5s ease;
}
.minimal-main-view.fade-out-morph {
opacity: 0;
transform: translateY(-10px) scale(0.97);
filter: blur(10px);
pointer-events: none;
}
.minimal-typography {
margin-bottom: 36px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.minimal-title {
font-family: var(--font-serif);
font-size: clamp(2.5rem, 6vw, 3.4rem);
font-weight: 400;
letter-spacing: 0.03em;
line-height: 1.1;
color: #ffffff;
margin: 0;
}
.minimal-subtitle {
font-family: var(--font-sans);
font-size: clamp(0.85rem, 2vw, 0.95rem);
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
margin: 0;
}
.minimal-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
}
.minimal-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 48px;
padding: 0 28px;
border-radius: 500px;
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
outline: none;
transition: all 0.25s cubic-bezier(0.2, 0, 0, 1);
}
@keyframes playfulShake {
0%, 100% { transform: translateX(0); }
18% { transform: translateX(-8px) rotate(-1.5deg); }
36% { transform: translateX(8px) rotate(1.5deg); }
54% { transform: translateX(-5px) rotate(-1deg); }
72% { transform: translateX(5px) rotate(1deg); }
90% { transform: translateX(-2px); }
}
.minimal-btn.playful-shake {
animation: playfulShake 0.45s ease-in-out;
border-color: rgba(223, 177, 91, 0.7);
color: #dfb15b;
}
.minimal-btn-icon {
margin-left: -2px;
}
.minimal-btn-primary {
background: #ffffff;
color: #07090c;
border: 1px solid #ffffff;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.minimal-btn-primary:hover {
background: #e6e6e6;
border-color: #e6e6e6;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.minimal-btn-primary:active {
transform: translateY(0);
}
.minimal-btn-secondary {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.minimal-btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.35);
transform: translateY(-2px);
}
.minimal-btn-secondary:active {
transform: translateY(0);
}
/* ====================================================================
РОМАНТИЧЕСКИЙ ЭКРАН РЕАКЦИИ («Ай-яй-яй... Нет, нет, нет, милая :)»)
==================================================================== */
.playful-letter-notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
max-width: 520px;
width: 100%;
padding: 10px;
animation: playfulAppear 0.5s ease forwards;
}
@keyframes playfulAppear {
0% { opacity: 0; transform: scale(0.96); filter: blur(8px); }
100% { opacity: 1; transform: scale(1); filter: blur(0); }
}
.playful-lines-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-bottom: 34px;
}
.playful-line {
margin: 0;
opacity: 0;
transform: translateY(14px);
filter: blur(10px);
transition: opacity 0.85s cubic-bezier(0.2, 0, 0, 1), transform 0.85s cubic-bezier(0.2, 0, 0, 1), filter 0.85s cubic-bezier(0.2, 0, 0, 1);
will-change: opacity, transform, filter;
}
.playful-line.visible {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.playful-line.line-1 {
font-family: var(--font-serif);
font-style: italic;
font-size: clamp(1.22rem, 3.6vw, 1.5rem);
color: #e5b869;
letter-spacing: 0.02em;
text-shadow: 0 0 24px rgba(229, 184, 105, 0.4);
}
.playful-line.line-2 {
font-family: var(--font-sans);
font-size: clamp(1.02rem, 2.9vw, 1.22rem);
font-weight: 300;
color: #f0f4f8;
line-height: 1.55;
letter-spacing: 0.01em;
}
.playful-line.line-3 {
font-family: var(--font-serif);
font-size: clamp(1.18rem, 3.3vw, 1.38rem);
font-weight: 400;
color: var(--sp-accent, #1ed7a6);
letter-spacing: 0.04em;
text-shadow: 0 0 25px rgba(30, 215, 166, 0.45);
}
.playful-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
visibility: hidden;
pointer-events: none;
opacity: 0;
transform: translateY(12px);
filter: blur(8px);
transition: opacity 0.75s ease, transform 0.75s ease, filter 0.75s ease;
}
.playful-actions.visible {
visibility: visible;
pointer-events: auto;
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.force-letter-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.38);
font-family: var(--font-sans);
font-size: 0.82rem;
letter-spacing: 0.03em;
cursor: pointer;
padding: 6px 16px;
border-radius: 500px;
transition: all 0.25s ease;
outline: none;
}
.force-letter-btn:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.07);
}
.audio-error-notice {
margin-top: 24px;
padding: 12px 18px;
background: rgba(220, 50, 50, 0.15);
border: 1px solid rgba(220, 50, 50, 0.3);
border-radius: 12px;
font-size: 0.82rem;
color: #ffb4b4;
}
.error-action-btn {
margin-top: 8px;
padding: 8px 16px;
background: #ffffff;
color: #000000;
border: none;
border-radius: 500px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
/* ====================================================================
ГЛАВНАЯ СЦЕНА (CANVAS STAGE & 3D ПАРАЛЛАКС)
==================================================================== */
.experience-stage {
position: relative;
width: 100vw;
height: 100vh;
height: 100svh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding-bottom: 90px;
z-index: 5;
perspective: 1200px;
}
/* Центральная композиция с 3D-параллаксом */
.cover-container {
position: relative;
z-index: 10;
width: min(68vw, 44vh, 380px);
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.98);
transition: opacity 3.5s cubic-bezier(0.25, 1, 0.5, 1);
transform-style: preserve-3d;
cursor: pointer;
}
/* Аудиореактивный холст многослойного пульса и акустических волн */
.aura-pulse-canvas {
position: absolute;
top: -55%;
left: -55%;
width: 210%;
height: 210%;
z-index: 1;
pointer-events: none;
}
/* Бирюзовое свечение позади обложки */
.cover-glow {
position: absolute;
top: -16%;
left: -16%;
width: 132%;
height: 132%;
border-radius: 50%;
background: radial-gradient(circle, var(--sp-accent-glow) 0%, rgba(30, 215, 166, 0.05) 55%, transparent 75%);
filter: blur(55px);
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 3s ease, background 0.5s ease;
}
.cover-art-box {
position: relative;
z-index: 3;
width: 100%;
height: 100%;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.85), 0 0 1px rgba(255, 255, 255, 0.15);
background: #ffffff;
transition: box-shadow 0.3s ease;
transform-style: preserve-3d;
}
/* Динамический световой блик, скользящий по воде и ладоням */
.cover-light-sheen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 4;
background: radial-gradient(circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255, 255, 255, 0.24) 0%, transparent 60%);
mix-blend-mode: overlay;
transition: opacity 0.3s ease;
}
.cover-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Интерактивный холст микро-ряби на воде */
.fluid-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5;
pointer-events: none;
}
.cover-water-hint {
position: absolute;
bottom: 12px;
right: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(18, 18, 18, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 4px 10px;
border-radius: 500px;
font-size: 0.68rem;
color: #ffffff;
opacity: 0.8;
backdrop-filter: blur(8px);
pointer-events: none;
transition: opacity 1.5s ease;
z-index: 6;
}
/* ====================================================================
ТИПОГРАФИЧЕСКИЙ СЛОЙ (СТРОФЫ ПЕСНИ)
==================================================================== */
.typography-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.caption-text {
position: absolute;
text-align: center;
padding: 0 24px;
max-width: 820px;
font-weight: 300;
letter-spacing: 0.04em;
line-height: 1.65;
color: var(--sp-text);
text-shadow: 0 4px 28px rgba(0, 0, 0, 0.95), 0 0 10px rgba(0, 0, 0, 0.8);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 1.8s cubic-bezier(0.2, 0.9, 0.3, 1),
visibility 1.8s;
will-change: opacity, transform;
}
.caption-text.visible {
opacity: 1;
visibility: visible;
}
/* Волновое появление каждой буковки как набегающая морская волна */
.wave-char {
display: inline-block;
opacity: 0;
filter: blur(10px);
transform: translateY(14px) scale(0.94);
transition: opacity 1.2s cubic-bezier(0.2, 0.9, 0.3, 1),
transform 1.2s cubic-bezier(0.2, 0.9, 0.3, 1),
filter 1.2s cubic-bezier(0.2, 0.9, 0.3, 1);
transition-delay: calc(var(--char-i, 0) * 0.032s);
will-change: transform, opacity, filter;
}
.caption-text.visible .wave-char {
opacity: 1;
filter: blur(0px);
transform: translateY(0) scale(1);
}
.caption-text:not(.visible) .wave-char {
opacity: 0;
filter: blur(10px);
transform: translateY(-8px) scale(0.97);
transition-delay: calc(var(--char-i, 0) * 0.012s);
}
/* Фаза 1: Вступление */
.intro-phrase {
font-size: clamp(1.2rem, 3.2vw, 1.8rem);
color: rgba(240, 245, 255, 0.95);
font-style: italic;
font-family: var(--font-serif);
}
/* Фаза 2: Куплет */
.verse-phrase {
font-size: clamp(1.15rem, 3.0vw, 1.7rem);
color: var(--sp-text);
}
/* Фаза 3а: Появление ладоней (00:31) */
.swim-phrase {
bottom: clamp(105px, 15vh, 140px);
font-size: clamp(1.15rem, 2.6vw, 1.55rem);
font-family: var(--font-serif);
font-style: italic;
letter-spacing: 0.06em;
color: #a8f7ff;
text-shadow: 0 0 28px rgba(0, 242, 254, 0.7), 0 4px 18px rgba(0, 0, 0, 0.9);
}
/* Фаза 3б: Подпись под обложкой (00:40.5) */
.harbor-phrase {
bottom: clamp(105px, 15vh, 140px);
font-size: clamp(0.98rem, 2.3vw, 1.3rem);
color: rgba(220, 240, 250, 0.95);
max-width: 640px;
}
/* Фаза 5: Кульминация перед битом */
.climax-phrase {
bottom: clamp(100px, 14vh, 135px);
font-size: clamp(0.98rem, 2.3vw, 1.35rem);
line-height: 1.55;
max-width: 720px;
color: #ffffff;
font-weight: 400;
text-shadow: 0 4px 25px rgba(0, 0, 0, 0.95);
}
/* Фаза 7: Последний шёпот */
.whisper-phrase {
font-size: clamp(1.3rem, 3.8vw, 2.2rem);
color: #ffffff;
font-style: italic;
font-family: var(--font-serif);
letter-spacing: 0.05em;
}
/* ====================================================================
SPOTIFY SYNCED LYRICS VIEW (КИНОМАТИЧЕСКИЙ КАРАОКЕ-РЕЖИМ)
==================================================================== */
.spotify-lyrics-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 90px;
width: min(100vw, 540px);
z-index: 85;
background: radial-gradient(circle at 40% 30%, rgba(10, 24, 30, 0.94) 0%, rgba(6, 10, 14, 0.98) 100%);
border-left: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(40px);
display: flex;
flex-direction: column;
padding: 30px 36px 30px;
box-shadow: -16px 0 60px rgba(0, 0, 0, 0.85);
transform: translateX(0);
transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}
.spotify-lyrics-overlay.hidden {
transform: translateX(100%);
display: flex !important;
visibility: hidden;
pointer-events: none;
}
.lyrics-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 16px;
margin-bottom: 10px;
}
.lyrics-label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--sp-accent);
}
.lyrics-song-name {
font-size: 1.05rem;
font-weight: 700;
color: #ffffff;
}
.close-lyrics-btn {
background: none;
border: none;
color: var(--sp-text-sub);
font-size: 1.8rem;
cursor: pointer;
line-height: 1;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 10;
transition: color 0.2s ease, transform 0.2s ease;
}
.close-lyrics-btn:hover {
color: #ffffff;
transform: scale(1.15);
}
.lyrics-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 34px;
padding: 40px 10px 120px 0;
scrollbar-width: none;
scroll-behavior: smooth;
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
}
.lyrics-container::-webkit-scrollbar {
display: none;
}
.lyric-line {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif;
font-size: clamp(1.45rem, 3.4vw, 2.15rem);
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.35;
color: rgba(255, 255, 255, 0.28);
filter: blur(0.5px);
cursor: pointer;
transform-origin: left center;
transform: scale(0.96);
transition: all 0.45s cubic-bezier(0.2, 0.9, 0.3, 1);
user-select: none;
-webkit-user-select: none;
}
.lyric-line:hover {
color: rgba(255, 255, 255, 0.75);
filter: blur(0);
transform: scale(1.0);
}
.lyric-line.active {
color: #ffffff;
font-size: clamp(1.65rem, 3.8vw, 2.45rem);
font-weight: 900;
filter: blur(0);
transform: scale(1.06) translateX(8px);
text-shadow: 0 0 28px rgba(0, 242, 254, 0.75), 0 0 55px rgba(0, 242, 254, 0.35);
background: linear-gradient(135deg, #ffffff 0%, #d0f7ff 60%, #ffffff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.lyric-line.vocalise {
font-family: Georgia, serif;
font-style: italic;
font-weight: 400;
letter-spacing: 0.02em;
}
.lyric-line.active.vocalise {
background: linear-gradient(135deg, #ffffff 0%, #b8f5ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.lyric-line.climax {
font-size: clamp(1.35rem, 3.0vw, 1.95rem);
}
.lyric-line.active.climax {
font-size: clamp(1.55rem, 3.5vw, 2.25rem);
}
.lyric-line.whisper {
font-family: Georgia, serif;
font-style: italic;
font-weight: 400;
letter-spacing: 0.03em;
}
.lyric-line.active.whisper {
background: linear-gradient(135deg, #ffffff 0%, #ffd4b8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 30px rgba(255, 180, 140, 0.8);
}
/* ====================================================================
ФАЗА 8: ФИНАЛЬНОЕ ПИСЬМО (CLAUDE EDITORIAL & СУРГУЧНАЯ ПЕЧАТЬ)
==================================================================== */
.final-letter-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
height: 100svh;
z-index: 85;
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(220, 185, 145, 0.2) transparent;
padding: 40px 20px 130px;
background-color: var(--cl-warm-bg);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 1.6s cubic-bezier(0.25, 1, 0.5, 1), visibility 1.6s;
}
.final-letter-screen.active {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.warm-light-veil {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 50% 36%, var(--cl-amber-glow) 0%, rgba(16, 14, 12, 0.98) 65%, #0a0908 100%);
pointer-events: none;
z-index: 1;
}
.letter-card {
position: relative;
z-index: 2;
max-width: 640px;
width: 100%;
margin: 16px auto 36px;
padding: clamp(20px, 4vw, 36px);
border-radius: 24px;
background: var(--cl-warm-card);
border: 1px solid rgba(220, 185, 145, 0.15);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.7);
backdrop-filter: blur(14px);
display: flex;
flex-direction: column;
gap: 14px;
transform: translateY(14px);
transition: transform 2.2s cubic-bezier(0.25, 1, 0.5, 1);
}
.final-letter-screen.active .letter-card {
transform: translateY(0);
}
.claude-editorial-tag {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
background: var(--cl-terracotta-soft);
color: var(--cl-terracotta);
border: 1px solid rgba(217, 119, 87, 0.3);
padding: 4px 12px;
border-radius: 500px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.letter-header {
border-bottom: 1px solid rgba(220, 185, 145, 0.12);
padding-bottom: 10px;
}
.letter-title {
font-family: var(--font-serif);
font-size: clamp(1.35rem, 3.8vw, 1.95rem);
font-weight: 400;
color: var(--cl-warm-title);
letter-spacing: 0.02em;
line-height: 1.3;
}
.claude-heart {
color: #e26d75;
display: inline-block;
margin-left: 4px;
}
/* Сургучная печать Claude */
.wax-seal-container {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 8px 0;
min-height: 120px;
transition: opacity 0.5s ease,
transform 0.5s ease,
max-height 0.65s cubic-bezier(0.4, 0, 0.2, 1),
min-height 0.65s cubic-bezier(0.4, 0, 0.2, 1),
margin 0.65s ease,
padding 0.65s ease;
}
.wax-seal-container.broken {
opacity: 0;
transform: scale(0.65);
max-height: 0;
min-height: 0;
margin: 0;
padding: 0;
overflow: hidden;
pointer-events: none;
}
.seal-particle-canvas {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 320px;
height: 220px;
pointer-events: none;
z-index: 10;
}
.wax-seal-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.4s ease, transform 0.4s ease;
}
.wax-seal-wrapper.broken {
opacity: 0;
transform: scale(0.6);
}
.seal-ribbon-tail {
position: absolute;
bottom: -16px;
width: 32px;
height: 36px;
background: linear-gradient(135deg, #741b18 0%, #4a100e 100%);
clip-path: polygon(0 0, 100% 0, 85% 100%, 50% 82%, 15% 100%);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
z-index: 4;
pointer-events: none;
transition: opacity 0.4s ease, transform 0.4s ease;
}
.wax-seal-wrapper.broken .seal-ribbon-tail {
opacity: 0;
transform: translateY(8px) scale(0.5);
}
.wax-seal-btn {
position: relative;
z-index: 5;
width: 92px;
height: 92px;
border-radius: 50%;
background: radial-gradient(circle at 36% 34%, #82221e 0%, #681714 45%, #470f0d 80%, #300908 100%);
border: 2px solid rgba(223, 177, 91, 0.4);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.65),
inset 0 2px 5px rgba(255, 230, 200, 0.35),
inset 0 -5px 12px rgba(0, 0, 0, 0.85);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.3s ease;
user-select: none;
-webkit-user-select: none;
}
.wax-seal-btn:hover {
transform: scale(1.06);
box-shadow: 0 14px 40px rgba(104, 23, 20, 0.7),
inset 0 3px 6px rgba(255, 230, 200, 0.5),
inset 0 -5px 12px rgba(0, 0, 0, 0.9);
border-color: rgba(223, 177, 91, 0.7);
}
.wax-seal-btn:active {
transform: scale(0.96);
}
.wax-seal-btn.broken {
transform: scale(0.2) rotate(25deg);
opacity: 0;
pointer-events: none;
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease;
}
.seal-inner-emboss {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
pointer-events: none;
}
.seal-stamp-svg {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.7));
transition: transform 0.3s ease;
}
.wax-seal-btn:hover .seal-stamp-svg {
transform: scale(1.08);
}
.seal-label-text {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #e8c688;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
margin-top: 2px;
}
.seal-broken-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(217, 119, 87, 0.14);
border: 1px solid rgba(217, 119, 87, 0.35);
color: #e8c688;
padding: 6px 14px;
border-radius: 500px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
animation: badgeFadeIn 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes badgeFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.letter-body {
display: flex;
flex-direction: column;
gap: 16px;
line-height: 1.8;
margin-top: 8px;
transition: filter 1.4s cubic-bezier(0.2, 0.9, 0.3, 1), opacity 1.4s ease;
}
.letter-body.sealed-blur {
filter: blur(14px);
opacity: 0.22;
user-select: none;
pointer-events: none;
}
.letter-body p {
font-size: clamp(1.02rem, 2.5vw, 1.18rem);
color: var(--cl-warm-text);
font-weight: 300;
letter-spacing: 0.02em;
}
.letter-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 6px;
}
.telegram-action-btn.hidden {
display: none !important;
opacity: 0;
pointer-events: none;
}
.telegram-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 360px;
padding: 13px 22px;
border-radius: 500px;
background-color: var(--cl-warm-btn);
color: var(--cl-warm-btn-text);
text-decoration: none;
font-weight: 700;
font-size: clamp(0.9rem, 2.2vw, 1.0rem);
letter-spacing: 0.01em;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.5);
transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.4);
animation: telegramReveal 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes telegramReveal {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.letter-body p {
font-size: clamp(0.92rem, 2.2vw, 1.05rem);
font-weight: 300;
line-height: 1.7;
color: var(--cl-warm-body);
letter-spacing: 0.015em;
}
.letter-signature-block {
text-align: right;
padding-top: 2px;
}
.letter-signature {
font-family: var(--font-serif);
font-style: italic;
font-size: clamp(1.0rem, 2.4vw, 1.18rem);
color: #d6ccb8;
}
.letter-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 4px;
}
.telegram-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 360px;
padding: 15px 24px;
border-radius: 500px;
background-color: var(--cl-warm-btn);
color: var(--cl-warm-btn-text);
text-decoration: none;
font-weight: 700;
font-size: clamp(0.95rem, 2.3vw, 1.05rem);
letter-spacing: 0.01em;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.5);
transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.4);
}
.telegram-action-btn:hover {
background-color: #ffffff;
transform: scale(1.03);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
}
.replay-link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: rgba(220, 205, 185, 0.55);
font-size: 0.9rem;
font-weight: 400;
cursor: pointer;
padding: 8px 14px;
border-radius: 500px;
transition: color 0.2s, background-color 0.2s;
}
.replay-link-btn:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.06);
}
/* ====================================================================
SPOTIFY PLAYER BAR (НИЖНИЙ ПЛЕЕР)
==================================================================== */
.spotify-player-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 90px;
z-index: 80;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
padding: 0 24px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
background-color: var(--sp-black);
border-top: 1px solid var(--sp-border);
box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.6);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.player-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 180px;
}
.track-thumb {
width: 52px;
height: 52px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.track-meta {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.meta-title {
font-size: 0.9rem;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta-artist {
font-size: 0.74rem;
color: var(--sp-text-sub);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spotify-icon-btn {
background: none;
border: none;
color: var(--sp-text-sub);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
transition: all 0.2s ease;
}
.spotify-icon-btn:hover {
color: #ffffff;
transform: scale(1.08);
}
/* ====================================================================
LIKE BUTTON & BURST PARTICLES
==================================================================== */
.like-btn-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.like-btn {
position: relative;
color: var(--sp-text-sub);
transition: transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275), color 1.2s cubic-bezier(0.25, 1, 0.5, 1);
}
.like-btn.liked {
color: var(--current-dynamic-accent, var(--sp-accent)) !important;
}
.like-btn.liked svg {
fill: var(--current-dynamic-accent, var(--sp-accent)) !important;
stroke: var(--current-dynamic-accent, var(--sp-accent)) !important;
filter: drop-shadow(0 0 12px var(--current-dynamic-glow, var(--sp-accent-glow))) !important;
transition: fill 1.2s cubic-bezier(0.25, 1, 0.5, 1), stroke 1.2s cubic-bezier(0.25, 1, 0.5, 1), filter 1.2s cubic-bezier(0.25, 1, 0.5, 1);
}
.like-btn.popping {
animation: heartPop 0.55s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes heartPop {
0% { transform: scale(1); }
25% { transform: scale(1.42); }
50% { transform: scale(0.9); }
75% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.like-burst-container {
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
}
.micro-heart-particle {
position: absolute;
left: 50%;
top: 50%;
pointer-events: none;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
animation: floatMicroHeart 0.95s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.micro-heart-particle svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 2px 6px rgba(255, 45, 85, 0.65));
}
@keyframes floatMicroHeart {
0% {
opacity: 1;
transform: translate(-50%, -50%) translate(0, 0) scale(0.4);
}
50% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translate(-50%, -50%) translate(var(--dx), var(--dy)) scale(1.15);
}
}
/* ====================================================================
HEART CENTER EXPERIENCE (3 WAVES & BLOOMING CENTER HEART)
==================================================================== */
.heart-fx-overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 85;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.5s ease;
}
.heart-fx-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.center-heart-card {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
transform: scale(0);
opacity: 0;
transition: transform 0.75s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.4s ease;
}
.center-heart-card.blooming {
opacity: 1;
transform: scale(1);
animation: centerHeartPulse 1.8s ease-in-out infinite alternate;
}
.center-heart-card.dissolving {
opacity: 0 !important;
transform: translateY(-35px) scale(1.12) !important;
transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1), transform 0.8s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
@keyframes centerHeartPulse {
0% { transform: scale(1.0); }
15% { transform: scale(1.08); }
30% { transform: scale(1.02); }
45% { transform: scale(1.06); }
60% { transform: scale(1.0); }
100% { transform: scale(1.0); }
}
.heart-ambient-glow {
position: absolute;
inset: -60px;
border-radius: 50%;
background: radial-gradient(circle, var(--current-dynamic-glow, var(--sp-accent-glow)) 0%, rgba(0, 0, 0, 0) 70%);
pointer-events: none;
animation: glowPulse 2s ease-in-out infinite alternate;
}
@keyframes glowPulse {
0% { transform: scale(0.92); opacity: 0.65; }
100% { transform: scale(1.15); opacity: 1; }
}
.center-heart-svg {
position: relative;
z-index: 3;
overflow: visible;
filter: drop-shadow(0 14px 28px rgba(0, 0, 0, 0.45)) drop-shadow(0 0 20px var(--current-dynamic-glow, var(--sp-accent-glow)));
}
.heart-sparkle-star {
position: absolute;
top: 14px;
right: 22px;
color: #fff2b2;
font-size: 1.4rem;
z-index: 4;
animation: sparkleStar 1.2s ease-in-out infinite alternate;
filter: drop-shadow(0 0 8px #ffdf70);
}
@keyframes sparkleStar {
0% { transform: scale(0.7) rotate(0deg); opacity: 0.5; }
100% { transform: scale(1.3) rotate(25deg); opacity: 1; }
}
.player-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: 680px;
width: 100%;
margin: 0 auto;
}
.player-controls {
display: flex;
align-items: center;
gap: 18px;
}
.spotify-icon-btn.small {
width: 28px;
height: 28px;
}
.spotify-play-btn {
width: 38px;
height: 38px;
border-radius: 50%;
background-color: #ffffff;
color: #000000;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.15s ease, background-color 0.15s ease;
}
.spotify-play-btn:hover {
transform: scale(1.08);
background-color: var(--sp-accent);
}
.spotify-play-btn:active {
transform: scale(0.96);
}
/* ====================================================================
ПОДЛИННЫЙ ПЛЕЕР SPOTIFY — СКРАББЕР И ГРОМКОСТЬ
==================================================================== */
.scrubber-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 580px;
}
.time-label {
font-size: 0.72rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--sp-text-sub);
min-width: 36px;
text-align: center;
user-select: none;
}
.scrubber-wrapper {
position: relative;
flex: 1;
height: 18px;
display: flex;
align-items: center;
cursor: pointer;
}
.spotify-scrubber {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.22);
outline: none;
border: none;
margin: 0;
padding: 0;
cursor: pointer;
transition: height 0.15s ease;
}
.scrubber-wrapper:hover .spotify-scrubber {
height: 6px;
}
.spotify-scrubber::-webkit-slider-runnable-track {
height: 100%;
border-radius: 4px;
background: transparent;
}
.spotify-scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 0 10px var(--current-dynamic-accent, var(--sp-accent)), 0 2px 6px rgba(0, 0, 0, 0.7);
cursor: pointer;
margin-top: -3px;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease, box-shadow 0.8s ease;
}
.scrubber-wrapper:hover .spotify-scrubber::-webkit-slider-thumb,
.spotify-scrubber:active::-webkit-slider-thumb {
opacity: 1;
transform: scale(1.15);
}
.spotify-scrubber::-moz-range-track {
height: 4px;
border-radius: 4px;
background: transparent;
border: none;
}
.spotify-scrubber::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
background: #ffffff;
box-shadow: 0 0 10px var(--current-dynamic-accent, var(--sp-accent)), 0 2px 6px rgba(0, 0, 0, 0.7);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease, box-shadow 0.8s ease;
}
.scrubber-wrapper:hover .spotify-scrubber::-moz-range-thumb,
.spotify-scrubber:active::-moz-range-thumb {
opacity: 1;
transform: scale(1.15);
}
.player-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
min-width: 180px;
}
/* Блок регулировки громкости */
.volume-block {
display: flex;
align-items: center;
gap: 8px;
width: 130px;
height: 32px;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 90px;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.22);
outline: none;
border: none;
margin: 0;
padding: 0;
cursor: pointer;
transition: height 0.15s ease;
}
.volume-block:hover .volume-slider {
height: 6px;
}
.volume-slider::-webkit-slider-runnable-track {
height: 100%;
border-radius: 4px;
background: transparent;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 0 10px var(--current-dynamic-accent, var(--sp-accent)), 0 2px 6px rgba(0, 0, 0, 0.7);
cursor: pointer;
margin-top: -3px;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease, box-shadow 0.8s ease;
}
.volume-block:hover .volume-slider::-webkit-slider-thumb,
.volume-slider:active::-webkit-slider-thumb {
opacity: 1;
transform: scale(1.15);
}
.volume-slider::-moz-range-track {
height: 4px;
border-radius: 4px;
background: transparent;
border: none;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
background: #ffffff;
box-shadow: 0 0 10px var(--current-dynamic-accent, var(--sp-accent)), 0 2px 6px rgba(0, 0, 0, 0.7);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease, box-shadow 0.8s ease;
}
.volume-block:hover .volume-slider::-moz-range-thumb,
.volume-slider:active::-moz-range-thumb {
opacity: 1;
transform: scale(1.15);
}
/* ====================================================================
РЕЖИМ «ТОЛЬКО МЫ С ТОБОЙ» (ZEN FOCUS MODE)
==================================================================== */
body.zen-mode .app-topbar,
body.zen-mode .spotify-player-bar {
opacity: 0 !important;
transform: translateY(-20px) !important;
pointer-events: none !important;
transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
body.zen-mode .spotify-player-bar {
transform: translateY(20px) !important;
}
body.zen-mode.show-ui .app-topbar,
body.zen-mode.show-ui .spotify-player-bar {
opacity: 0.95 !important;
transform: translateY(0) !important;
pointer-events: auto !important;
}
/* ====================================================================
МУЗЫКАЛЬНАЯ ЛАБОРАТОРИЯ (DEV & TRACK MANAGER)
==================================================================== */
.dev-panel {
position: fixed;
top: 70px;
right: 20px;
z-index: 120;
width: 330px;
background: rgba(20, 20, 22, 0.94);
border: 1px solid rgba(217, 119, 87, 0.35);
border-radius: 20px;
padding: 18px 20px;
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.85);
backdrop-filter: blur(20px);
color: #ded7cb;
display: flex;
flex-direction: column;
gap: 14px;
}
.dev-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 10px;
}
.dev-title-block {
display: flex;
align-items: center;
gap: 6px;
}
.dev-sparkle {
color: var(--cl-terracotta);
}
.dev-title {
font-weight: 600;
font-size: 0.84rem;
color: #ffffff;
}
.dev-close-btn {
background: none;
border: none;
color: var(--sp-text-sub);
font-size: 1.3rem;
cursor: pointer;
line-height: 1;
}
.dev-upload-box {
background: rgba(0, 0, 0, 0.4);
border: 1px dashed rgba(217, 119, 87, 0.4);
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.spotify-upload-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--cl-terracotta);
color: #ffffff;
border: none;
border-radius: 500px;
padding: 9px 14px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.spotify-upload-cta:hover {
background: #e08365;
transform: scale(1.02);
}
.dev-track-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-label {
font-size: 0.68rem;
color: var(--sp-text-sub);
}
.dev-audio-label {
font-size: 0.75rem;
font-family: monospace;
color: var(--sp-accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dev-reset-btn {
background: rgba(255, 255, 255, 0.08);
color: var(--sp-text-sub);
border: none;
border-radius: 6px;
padding: 4px 8px;
font-size: 0.72rem;
cursor: pointer;
width: fit-content;
}
.dev-status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 10px;
}
.dev-metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-label {
font-size: 0.68rem;
color: var(--sp-text-sub);
}
.metric-val {
font-family: monospace;
font-size: 0.95rem;
font-weight: 700;
color: var(--sp-accent);
}
.jump-title {
font-size: 0.74rem;
color: var(--sp-text-sub);
margin-bottom: 6px;
}
.jump-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.jump-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 6px 8px;
color: #ffffff;
font-size: 0.74rem;
cursor: pointer;
text-align: left;
transition: all 0.15s;
}
.jump-btn:hover {
background: rgba(30, 215, 166, 0.25);
border-color: rgba(30, 215, 166, 0.5);
}
.adapt-modal {
background: rgba(18, 18, 20, 0.98);
border: 1px solid var(--sp-accent);
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
}
.adapt-title {
font-weight: 600;
color: var(--sp-accent);
font-size: 0.8rem;
}
.adapt-desc {
font-size: 0.74rem;
color: #ffffff;
line-height: 1.4;
}
.adapt-actions {
display: flex;
gap: 6px;
}
.adapt-btn {
flex: 1;
padding: 6px;
font-size: 0.72rem;
border-radius: 500px;
cursor: pointer;
border: none;
}
.adapt-btn.primary {
background: var(--sp-accent);
color: #000000;
font-weight: 700;
}
.adapt-btn.secondary {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.dev-actions-footer {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 8px;
}
.zip-btn {
width: 100%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #ffffff;
border-radius: 500px;
padding: 9px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.zip-btn:hover {
background: rgba(255, 255, 255, 0.18);
}
/* ====================================================================
АДАПТИВНОСТЬ ДЛЯ СМАРТФОНОВ
==================================================================== */
@media (max-width: 768px) {
.app-topbar {
padding: 0 12px;
gap: 8px;
}
.brand-track-badge {
padding: 4px 8px;
gap: 4px;
}
.track-artist-sep,
.track-artist-text {
display: none;
}
.atmosphere-switcher {
padding: 2px;
gap: 2px;
}
.atmo-btn {
padding: 4px 7px;
font-size: 0.65rem;
}
.topbar-action-btn {
padding: 6px 9px;
}
.topbar-action-btn span {
display: none;
}
.spotify-player-bar {
grid-template-columns: 1fr auto;
height: 76px;
padding: 0 16px;
}
.player-right {
display: none;
}
.player-center {
width: auto;
}
.scrubber-row {
display: none;
}
.cover-container {
width: min(76vw, 38vh, 290px);
margin-top: -6vh;
}
.harbor-phrase {
bottom: 95px;
font-size: 0.95rem;
}
.climax-phrase {
bottom: 90px;
font-size: 0.95rem;
line-height: 1.45;
}
.spotify-lyrics-overlay {
width: 100vw;
top: 0;
bottom: 76px;
}
.dev-panel {
top: auto;
bottom: 85px;
right: 12px;
left: 12px;
width: auto;
}
}
/* Клавиатурная навигация без постоянных декоративных рамок. */
button:focus-visible, a:focus-visible, input:focus-visible {
outline: 2px solid var(--current-dynamic-accent, #1ed7a6);
outline-offset: 4px;
}
</style>
</head>
<body class="theme-night">
<!-- Главный аудиоэлемент -->
<audio id="mainAudio" preload="auto" playsinline webkit-playsinline></audio>
<!-- Трёхслойный кинематографический фон с супер-плавным растворением -->
<div id="bgNight" class="ambient-backdrop night active" aria-hidden="true"></div>
<div id="bgAbyss" class="ambient-backdrop abyss" aria-hidden="true"></div>
<div id="bgWarm" class="ambient-backdrop warm" aria-hidden="true"></div>
<!-- Динамический жидкий шелк / Fluid Design холст -->
<canvas id="fluidSilkCanvas" class="fluid-silk-canvas" aria-hidden="true"></canvas>
<!-- Аудиореактивная волновая лента -->
<canvas id="waveformCanvas" class="waveform-canvas" aria-hidden="true"></canvas>
<!-- Холсты спецэффектов: Дождь, Стекло, Атмосферная волна, Рябь -->
<canvas id="rainCanvas" class="fx-canvas rain-canvas" aria-hidden="true"></canvas>
<canvas id="glassDropletsCanvas" class="fx-canvas glass-canvas" aria-hidden="true"></canvas>
<canvas id="atmosphereFxCanvas" class="fx-canvas atmo-fx-canvas" aria-hidden="true"></canvas>
<canvas id="rippleCanvas" class="fx-canvas ripple-canvas" aria-hidden="true"></canvas>
<!-- СПЕЦЭФФЕКТЫ СЕРДЕЧКА: 3 ВОЛНЫ ОТ ЦЕНТРА И ПОЯВЛЕНИЕ С ЭФФЕКТОМ -->
<div id="heartFxOverlay" class="heart-fx-overlay hidden" aria-hidden="true">
<canvas id="heartFxCanvas" class="heart-fx-canvas"></canvas>
<div id="centerHeartCard" class="center-heart-card hidden">
<div class="heart-ambient-glow" aria-hidden="true"></div>
<svg class="center-heart-svg" viewBox="0 0 100 100" width="136" height="136">
<path d="M50,86 C50,86 14,60 14,33 C14,19 25,11 36.5,11 C43.5,11 48,15 50,19 C52,15 56.5,11 63.5,11 C75,11 86,19 86,33 C86,60 50,86 50,86 Z" fill="var(--current-dynamic-accent, var(--sp-accent))"/>
<path d="M36.5,15 C28,15 20,21 19,32 C18,39 23,48 31,55" fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="3" stroke-linecap="round"/>
<circle cx="34" cy="20" r="2.8" fill="rgba(255,255,255,0.95)"/>
</svg>
<span class="heart-sparkle-star"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z"/></svg></span>
</div>
</div>
<!-- ВЕРХНИЙ ХЕДЕР SPOTIFY -->
<header id="appTopbar" class="app-topbar">
<div class="topbar-left">
<div class="brand-track-badge">
<span class="pulse-dot"></span>
<span class="track-title-text">Рыбка</span>
<span class="track-artist-sep">•</span>
<span class="track-artist-text">С Днём рождения!</span>
</div>
</div>
<div class="topbar-right">
<button id="toggleLyricsBtn" class="topbar-action-btn" title="Синхронизированный текст песни (L)" aria-label="Текст песни">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>
<span class="action-text">Текст</span>
</button>
<button id="zenModeNavBtn" class="topbar-action-btn icon-only" title="Режим «Только мы с тобой» (Z)" aria-label="Режим погружения">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
</button>
</div>
</header>
<!-- ====================================================================
ФАЗА 0: ЭКРАН ОЖИДАНИЯ (МИНИМАЛИЗМ: 1-2 КНОПКИ И ЧИСТАЯ НАДПИСЬ)
==================================================================== -->
<div id="waitingScreen" class="waiting-screen" aria-label="Экран начала">
<div class="minimal-portal-container">
<!-- Основной экран ожидания: минимализм -->
<div id="minimalMainView" class="minimal-main-view">
<div class="minimal-typography">
<h1 class="minimal-title">Рыбка</h1>
<p class="minimal-subtitle">С Днём рождения</p>
</div>
<div class="minimal-actions">
<button id="startPortalBtn" class="minimal-btn minimal-btn-primary" type="button" aria-label="Слушать песню">
<svg class="minimal-btn-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Слушать</span>
</button>
<button id="directLetterBtn" class="minimal-btn minimal-btn-secondary" type="button" aria-label="Открыть письмо">
<span>Открыть письмо</span>
</button>
</div>
</div>
<!-- Игровой романтический экран: «Ай-яй-яй... Нет, нет, нет, милая :)» -->
<div id="playfulLetterNotice" class="playful-letter-notice hidden" aria-live="polite">
<div class="playful-lines-wrap">
<p class="playful-line line-1" id="pLine1">Ай-яй-яй... Нет, нет, нет, милая :)</p>
<p class="playful-line line-2" id="pLine2">Сперва послушай то, что я написал для тебя.</p>
<p class="playful-line line-3" id="pLine3">Здесь вся моя душа...</p>
</div>
<div class="playful-actions" id="pActions">
<button id="naughtyListenBtn" class="minimal-btn minimal-btn-primary" type="button" aria-label="Слушать">
<svg class="minimal-btn-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Тогда давай послушаем</span>
</button>
<button id="forceLetterBtn" class="force-letter-btn" type="button" aria-label="Всё же сразу открыть письмо">
<span>и всё же сразу к письму &rarr;</span>
</button>
</div>
</div>
<div id="audioErrorMessage" class="audio-error-notice hidden" role="alert">
<p>Не удалось включить музыку. Попробуем ещё раз?</p>
<button id="retryAudioBtn" class="error-action-btn" type="button">Попробовать снова</button>
</div>
</div>
</div>
<!-- ОСНОВНАЯ СЦЕНА (КИНОМАТИЧЕСКИЙ CANVAS & 3D ПАРАЛЛАКС) -->
<main id="stage" class="experience-stage" aria-live="polite">
<!-- Центральная композиция с 3D-параллаксом и световым отблеском -->
<div id="coverWrapper" class="cover-container" aria-hidden="true" title="Подвигайте мышью">
<!-- Аудиореактивный холст многослойного пульса и акустических волн -->
<canvas id="auraPulseCanvas" class="aura-pulse-canvas"></canvas>
<!-- Аудиореактивное свечение позади обложки -->
<div id="coverGlow" class="cover-glow"></div>
<!-- Карточка оригинального арта -->
<div id="coverArtBox" class="cover-art-box">
<!-- Световой блик -->
<div id="coverLightSheen" class="cover-light-sheen" aria-hidden="true"></div>
<picture>
<source srcset="cover.webp" type="image/webp">
<img id="coverImg" src="cover.png" alt="Оригинальная обложка трека: бирюзовая рыбка в ладонях" class="cover-image" decoding="async">
</picture>
<!-- Интерактивный холст микро-ряби на воде -->
<canvas id="fluidCanvas" class="fluid-canvas"></canvas>
</div>
</div>
<!-- ТИПОГРАФИЧЕСКИЙ СЛОЙ (СИНХРОНИЗИРОВАННЫЕ ВОЛНОВЫЕ СТРОФЫ) -->
<div class="typography-layer">
<!-- Фаза 1: Вступление -->
<div id="introText" class="caption-text intro-phrase" aria-hidden="true">
Для тебя...
</div>
<!-- Фаза 2: Первый куплет -->
<div id="verseLine1" class="caption-text verse-phrase" aria-hidden="true">
Этот уставший город уже засыпает...
</div>
<div id="verseLine2" class="caption-text verse-phrase" aria-hidden="true">
...и в них я вижу дом, в который я могу вернуться.
</div>
<!-- Фаза 3а: Появление обложки и ладоней (00:31) -->
<div id="swimText" class="caption-text swim-phrase" aria-hidden="true">
Плыви в моей ладони...
</div>
<!-- Фаза 3б: Подпись под обложкой в тихой гавани (00:40.5) -->
<div id="harborText" class="caption-text harbor-phrase" aria-hidden="true">
В этой тихой гавани есть только мы с тобой.
</div>
<!-- Фаза 5: Кульминация перед битом -->
<div id="climaxText" class="caption-text climax-phrase" aria-hidden="true">
В мутном потоке чужих и ненужных дней я нашел то единственное, за что держусь...
</div>
<!-- Фаза 7: Последний шёпот -->
<div id="whisperText" class="caption-text whisper-phrase" aria-hidden="true">
Я всегда рядом.
</div>
</div>
</main>
<!-- SPOTIFY SYNCED LYRICS VIEW (КАРАОКЕ-РЕЖИМ) -->
<aside id="spotifyLyricsOverlay" class="spotify-lyrics-overlay hidden" aria-label="Синхронизированный текст песни">
<div class="lyrics-header">
<div class="lyrics-title-block">
<span class="lyrics-label">СИНХРОНИЗИРОВАННЫЙ ТЕКСТ</span>
<h3 id="lyricsTrackTitle" class="lyrics-song-name">Рыбка — С Днём рождения!</h3>
</div>
<button id="closeLyricsBtn" class="close-lyrics-btn" type="button" aria-label="Закрыть текст">&times;</button>
</div>
<div id="lyricsScrollContainer" class="lyrics-container">
<!-- Строки создаются из config.js: один источник текста и временных отметок. -->
</div>
</aside>
<!-- ФАЗА 8: ФИНАЛЬНОЕ ПИСЬМО (CLAUDE EDITORIAL MASTERPIECE) -->
<section id="finalLetterScreen" class="final-letter-screen hidden" aria-labelledby="letterHeading">
<div class="warm-light-veil" aria-hidden="true"></div>
<div class="letter-card">
<div class="claude-editorial-tag">
<span class="tag-gem"><svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z"/></svg></span>
<span id="editorialTagText">ПЕРСОНАЛЬНОЕ ПОСЛАНИЕ</span>
</div>
<header class="letter-header">
<h1 id="letterHeading" class="letter-title">С днём рождения, моя рыбка <svg class="claude-heart-svg" viewBox="0 0 24 24" width="22" height="22" fill="#ff2d55" style="display:inline-block; vertical-align:middle; margin-left:6px;"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg></h1>
</header>
<!-- Интерактивная сургучная печать -->
<div id="waxSealBlock" class="wax-seal-container">
<canvas id="sealParticleCanvas" class="seal-particle-canvas" aria-hidden="true"></canvas>
<div class="wax-seal-wrapper">
<div class="seal-ribbon-tail" aria-hidden="true"></div>
<button id="waxSealBtn" class="wax-seal-btn" type="button" title="Распечатать личное послание" aria-label="Распечатать письмо">
<span class="seal-wax-contour" aria-hidden="true"></span>
<span class="seal-inner-emboss">
<svg class="seal-stamp-svg" viewBox="0 0 64 64" width="38" height="38" fill="none" aria-hidden="true">
<circle cx="32" cy="32" r="28" stroke="#dfb15b" stroke-width="1.2" stroke-dasharray="2 3" opacity="0.6"/>
<path d="M32 18 C28 12, 18 13, 18 23 C18 33, 32 42, 32 42 C32 42, 46 33, 46 23 C46 13, 36 12, 32 18 Z" fill="url(#goldGradient)" filter="drop-shadow(0 2px 4px rgba(0,0,0,0.5))"/>
<path d="M32 36 C28 40, 24 43, 20 42 C22 46, 27 48, 32 47 C37 48, 42 46, 44 42 C40 43, 36 40, 32 36 Z" fill="url(#goldGradient)" opacity="0.9"/>
<defs>
<linearGradient id="goldGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fff2b2"/>
<stop offset="50%" stop-color="#dfb15b"/>
<stop offset="100%" stop-color="#a47c2a"/>
</linearGradient>
</defs>
</svg>
<span class="seal-label-text">Распечатать</span>
</span>
<span class="seal-ember-halo" aria-hidden="true"></span>
</button>
</div>
</div>
<div id="letterContent" class="letter-body sealed-blur">
<!-- Текст формируется динамически из config.js -->
</div>
<div class="letter-signature-block">
<p id="letterSignature" class="letter-signature">С любовью, навсегда.</p>
</div>
<div class="letter-actions">
<!-- Кнопка Telegram открывается ТОЛЬКО после распечатки письма -->
<a id="telegramBtn" href="#" class="telegram-action-btn hidden" target="_blank" rel="noopener noreferrer">
<svg class="tg-icon" viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.75-.55 2.92-1.27 4.86-2.11 5.83-2.52 2.77-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .37z"/>
</svg>
<span id="telegramBtnText">Забрать песню себе в Telegram</span>
</a>
<button id="replayBtn" class="replay-link-btn" type="button">
<svg class="replay-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
<span>Слушать сначала</span>
</button>
</div>
</div>
</section>
<!-- ФИРМЕННЫЙ ПЛЕЕР SPOTIFY (НИЖНЯЯ ПАНЕЛЬ) -->
<footer id="playerBar" class="spotify-player-bar" aria-label="Музыкальный плеер">
<div class="player-left">
<div class="track-thumb">
<img src="cover.webp" alt="Обложка" class="thumb-img">
</div>
<div class="track-meta">
<span id="playerTrackTitle" class="meta-title">Рыбка</span>
<span id="playerTrackArtist" class="meta-artist">С Днём рождения!</span>
</div>
<div class="like-btn-wrapper">
<button id="likeTrackBtn" class="spotify-icon-btn like-btn" title="В избранное" aria-label="В избранное">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</button>
<div id="likeBurstContainer" class="like-burst-container" aria-hidden="true"></div>
</div>
</div>
<div class="player-center">
<div class="player-controls">
<button id="seekBackBtn" class="spotify-icon-btn small" title="Назад на 5 секунд (←)" aria-label="Назад на 5 секунд">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/>
</svg>
</button>
<button id="playPauseBtn" class="spotify-play-btn" type="button" aria-label="Воспроизведение / Пауза (Пробел)">
<svg id="playIcon" viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg id="pauseIcon" class="hidden" viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button id="seekForwardBtn" class="spotify-icon-btn small" title="Вперёд на 5 секунд (→)" aria-label="Вперёд на 5 секунд">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/>
</svg>
</button>
</div>
<div class="scrubber-row">
<span id="timeCurrent" class="time-label">00:00</span>
<div class="scrubber-wrapper">
<input type="range" id="timelineSlider" class="spotify-scrubber" min="0" max="137.68" step="0.1" value="0" aria-label="Прогресс воспроизведения">
</div>
<span id="timeDuration" class="time-label">02:17</span>
</div>
</div>
<div class="player-right">
<button id="toggleLyricsPlayerBtn" class="spotify-icon-btn" title="Текст песни" aria-label="Текст песни">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>
</button>
<button id="zenModeBtn" class="spotify-icon-btn" title="Режим без лишнего" aria-label="Режим погружения">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2a10 10 0 1010 10A10 10 0 0012 2zm0 18a8 8 0 118-8 8 8 0 01-8 8z"/>
</svg>
</button>
<div class="volume-block">
<button id="muteBtn" class="spotify-icon-btn" title="Звук (M)" aria-label="Без звука">
<svg id="volumeOnIcon" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg id="volumeMuteIcon" class="hidden" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27l4.73 4.73H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<input type="range" id="volumeSlider" class="volume-slider" min="0" max="1" step="0.01" value="0.85" aria-label="Громкость">
</div>
<button id="fullscreenBtn" class="spotify-icon-btn" title="Полноэкранный режим (F)" aria-label="На весь экран">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg>
</button>
</div>
</footer>
<script>
window.EXPERIENCE_CONFIG = {"page":{"title":"Рыбка — С Днём рождения!","description":"Песня и личное письмо ко дню рождения."},"welcome":{"title":"Рыбка","subtitle":"С Днём рождения!","listenButtonText":"Слушать","letterButtonText":"Открыть письмо"},"letterPrompt":{"lines":["Ай-яй-яй... Нет, нет, нет, милая :)","Сперва послушай то, что я написал для тебя.","Здесь вся моя душа..."],"listenButtonText":"Тогда давай послушаем","skipButtonText":"и всё же сразу к письму →"},"letter":{"heading":"С днём рождения, моя рыбка","paragraphs":["Пусть этот трек и эта тихая гавань всегда согревают тебя и напоминают, как много ты для меня значишь.","Плыви легко и свободно навстречу своим самым заветным мечтам, а я всегда буду рядом."],"signature":"С любовью, навсегда.","telegramButtonText":"Забрать песню себе в Telegram","telegramUrl":"","sealedTag":"ПЕРСОНАЛЬНОЕ ПОСЛАНИЕ","openedTag":"ПИСЬМО РАСПЕЧАТАНО","unsealButtonText":"Распечатать"},"audio":{"src":"song.mp3?v=1","expectedDuration":137.68,"title":"Рыбка","subtitle":"С Днём рождения!","initialVolume":0.85},"cover":{"webpSrc":"assets/cover.webp","pngSrc":"assets/Рыбка.png","alt":"Оригинальная обложка трека: бирюзовая рыбка в ладонях"},"timeline":{"introText":{"start":4,"end":12.5,"text":"Для тебя..."},"verseLine1":{"start":16,"end":22.8,"text":"Этот уставший город уже засыпает..."},"verseLine2":{"start":23.3,"end":30.5,"text":"...и в них я вижу дом, в который я могу вернуться."},"coverReveal":{"start":31,"duration":3.5},"swimText":{"start":31,"end":40,"text":"Плыви в моей ладони..."},"harborText":{"start":40.5,"end":58,"text":"В этой тихой гавани есть только мы с тобой."},"calmPhase":{"start":65,"end":92,"text":"М-м-м... (тишина и покой)"},"climaxText":{"start":92,"end":104.5,"text":"В мутном потоке чужих и ненужных дней я нашел то единственное, за что держусь..."},"waterRipple":{"time":103,"duration":3.8},"dropPhase":{"start":106,"end":130,"text":"[Инструментальный дроп ♫]"},"coverFadeOut":{"start":130.5,"duration":2.8},"whisperText":{"start":131.5,"end":137.2,"text":"Я всегда рядом.","lyricsText":"Спи, моя рыбка. Я рядом. Теперь я всегда рядом."},"letterDelayAfterAudio":2.2},"atmosphereTimeline":[{"start":0,"mood":"night","label":"Ночь"},{"start":40.5,"mood":"abyss","label":"Бездна"},{"start":92,"mood":"warm","label":"Тепло"}],"visuals":{"autoAtmosphere":true,"defaultAtmosphere":"night","rain":{"count":95,"speedMultiplier":1,"windAngle":0.12},"glassDroplets":{"count":14,"speed":0.35},"glow":{"blurRadius":55,"intensity":0.9},"breathing":{"minScale":1,"maxScale":1.018,"speed":0.8},"beatPulse":{"maxScaleAddition":0.024},"parallaxStrength":15}};
</script>
<script src="config.js?v=1.0.1"></script>
<script>
/**
* ====================================================================
* «РЫБКА» — RELEASE 1.0.0
* Автоматические атмосферы, дождь за окном, синхронная типографика,
* музыка до естественного конца и личное письмо с сургучной печатью
* ====================================================================
*/
(function () {
'use strict';
const CONFIG = window.EXPERIENCE_CONFIG;
if (!CONFIG) {
console.error('Конфигурация не найдена!');
return;
}
// DOM Элементы
const audio = document.getElementById('mainAudio');
const waitingScreen = document.getElementById('waitingScreen');
const minimalMainView = document.getElementById('minimalMainView');
const startPortalBtn = document.getElementById('startPortalBtn');
const directLetterBtn = document.getElementById('directLetterBtn');
const playfulLetterNotice = document.getElementById('playfulLetterNotice');
const pLine1 = document.getElementById('pLine1');
const pLine2 = document.getElementById('pLine2');
const pLine3 = document.getElementById('pLine3');
const pActions = document.getElementById('pActions');
const naughtyListenBtn = document.getElementById('naughtyListenBtn');
const forceLetterBtn = document.getElementById('forceLetterBtn');
const audioErrorMessage = document.getElementById('audioErrorMessage');
// Хедер и плеер (для авто-скрытия)
const appTopbar = document.getElementById('appTopbar');
const spotifyPlayerBar = document.getElementById('playerBar');
const wakeMenuBtn = document.getElementById('wakeMenuBtn');
// Сцена и 3D-обложка
const stage = document.getElementById('stage');
const coverWrapper = document.getElementById('coverWrapper');
const coverGlow = document.getElementById('coverGlow');
const coverArtBox = document.getElementById('coverArtBox');
const coverLightSheen = document.getElementById('coverLightSheen');
const coverImg = document.getElementById('coverImg');
const ambientMesh = document.getElementById('ambientMesh');
// Типографика сцены
const introText = document.getElementById('introText');
const verseLine1 = document.getElementById('verseLine1');
const verseLine2 = document.getElementById('verseLine2');
const swimText = document.getElementById('swimText');
const harborText = document.getElementById('harborText');
const climaxText = document.getElementById('climaxText');
const whisperText = document.getElementById('whisperText');
// Spotify Synced Lyrics
const spotifyLyricsOverlay = document.getElementById('spotifyLyricsOverlay');
const toggleLyricsBtn = document.getElementById('toggleLyricsBtn');
const toggleLyricsPlayerBtn = document.getElementById('toggleLyricsPlayerBtn');
const closeLyricsBtn = document.getElementById('closeLyricsBtn');
const lyricsScrollContainer = document.getElementById('lyricsScrollContainer');
let lyricLines = [];
// Финальное письмо Claude
const finalLetterScreen = document.getElementById('finalLetterScreen');
const letterHeading = document.getElementById('letterHeading');
const letterContent = document.getElementById('letterContent');
const letterSignature = document.getElementById('letterSignature');
const editorialTagText = document.getElementById('editorialTagText');
const waxSealBlock = document.getElementById('waxSealBlock');
const waxSealBtn = document.getElementById('waxSealBtn');
const sealBrokenBadge = document.getElementById('sealBrokenBadge');
const telegramBtn = document.getElementById('telegramBtn');
const telegramBtnText = document.getElementById('telegramBtnText');
const replayBtn = document.getElementById('replayBtn');
// Spotify Player Bar Controls
const playPauseBtn = document.getElementById('playPauseBtn');
const playIcon = document.getElementById('playIcon');
const pauseIcon = document.getElementById('pauseIcon');
const seekBackBtn = document.getElementById('seekBackBtn');
const seekForwardBtn = document.getElementById('seekForwardBtn');
const likeTrackBtn = document.getElementById('likeTrackBtn');
const playerTrackTitle = document.getElementById('playerTrackTitle');
const playerTrackArtist = document.getElementById('playerTrackArtist');
const lyricsTrackTitle = document.getElementById('lyricsTrackTitle');
const timeCurrent = document.getElementById('timeCurrent');
const timeDuration = document.getElementById('timeDuration');
const timelineSlider = document.getElementById('timelineSlider');
const scrubberProgress = document.getElementById('scrubberFill') || document.getElementById('scrubberProgress');
const muteBtn = document.getElementById('muteBtn');
const volumeOnIcon = document.getElementById('volumeOnIcon');
const volumeMuteIcon = document.getElementById('volumeMuteIcon');
const volumeSlider = document.getElementById('volumeSlider');
const scrubberWrapper = document.getElementById('scrubberWrapper') || (timelineSlider ? timelineSlider.parentElement : null);
const volumeBlock = document.getElementById('volumeBlock') || (volumeSlider ? volumeSlider.parentElement : null);
const fullscreenBtn = document.getElementById('fullscreenBtn');
const zenModeBtn = document.getElementById('zenModeBtn');
const zenModeNavBtn = document.getElementById('zenModeNavBtn');
const atmoButtons = document.querySelectorAll('.atmo-btn');
const atmoAutoBtn = document.getElementById('atmoAutoBtn');
// Фоновые атмосферные слои
const bgNight = document.getElementById('bgNight');
const bgAbyss = document.getElementById('bgAbyss');
const bgWarm = document.getElementById('bgWarm');
// Холсты
const rainCanvas = document.getElementById('rainCanvas');
const rainCtx = rainCanvas.getContext('2d');
const glassCanvas = document.getElementById('glassDropletsCanvas');
const glassCtx = glassCanvas.getContext('2d');
const atmoFxCanvas = document.getElementById('atmosphereFxCanvas');
const atmoFxCtx = atmoFxCanvas ? atmoFxCanvas.getContext('2d') : null;
const fluidSilkCanvas = document.getElementById('fluidSilkCanvas');
const fluidSilkCtx = fluidSilkCanvas ? fluidSilkCanvas.getContext('2d') : null;
const rippleCanvas = document.getElementById('rippleCanvas');
const rippleCtx = rippleCanvas.getContext('2d');
const fluidCanvas = document.getElementById('fluidCanvas');
const fluidCtx = fluidCanvas.getContext('2d');
const waveformCanvas = document.getElementById('waveformCanvas');
const waveCtx = waveformCanvas.getContext('2d');
const auraCanvas = document.getElementById('auraPulseCanvas');
const auraCtx = auraCanvas ? auraCanvas.getContext('2d') : null;
const sealParticleCanvas = document.getElementById('sealParticleCanvas');
const sealParticleCtx = sealParticleCanvas ? sealParticleCanvas.getContext('2d') : null;
// Холст и элементы спецэффектов сердечка
const heartOverlay = document.getElementById('heartFxOverlay');
const heartCanvas = document.getElementById('heartFxCanvas');
const heartCtx = heartCanvas ? heartCanvas.getContext('2d') : null;
const centerHeartCard = document.getElementById('centerHeartCard');
const likeBurstContainer = document.getElementById('likeBurstContainer');
let isLiked = false;
let activeHeartWaves = [];
let activeHeartSparkles = [];
let heartSeqTimer1 = null;
let heartSeqTimer2 = null;
let heartSeqTimer3 = null;
let heartSeqTimer4 = null;
let heartSeqTimer5 = null;
// ------------------------------------------------------------------
// ДИНАМИЧЕСКИЙ ЖИДКИЙ ШЁЛК (FLUID DESIGN ENGINE) & ВАРИАЦИИ ВОЛН
// ------------------------------------------------------------------
let silkPhase = 0;
let activeWaveVariation = 0; // 0: Liquid Silk Aurora, 1: Celestial Prism, 2: Acoustic Tidal Pulse
// Динамическая цветовая интерполяция жидкого шёлка
const silkColorCurrent = {
pR: 8, pG: 22, pB: 38,
sR: 3, sG: 8, sB: 16,
hR: 30, hG: 215, hB: 166,
aR: 140, aG: 210, aB: 255
};
const silkColorTarget = {
pR: 8, pG: 22, pB: 38,
sR: 3, sG: 8, sB: 16,
hR: 30, hG: 215, hB: 166,
aR: 140, aG: 210, aB: 255
};
function updateSilkTargetColors(mood) {
if (mood === 'abyss') {
silkColorTarget.pR = 2; silkColorTarget.pG = 32; silkColorTarget.pB = 46;
silkColorTarget.sR = 1; silkColorTarget.sG = 12; silkColorTarget.sB = 20;
silkColorTarget.hR = 0; silkColorTarget.hG = 242; silkColorTarget.hB = 254;
silkColorTarget.aR = 0; silkColorTarget.aG = 255; silkColorTarget.aB = 200;
} else if (mood === 'warm') {
silkColorTarget.pR = 42; silkColorTarget.pG = 18; silkColorTarget.pB = 8;
silkColorTarget.sR = 16; silkColorTarget.sG = 7; silkColorTarget.sB = 3;
silkColorTarget.hR = 255; silkColorTarget.hG = 175; silkColorTarget.hB = 75;
silkColorTarget.aR = 255; silkColorTarget.aG = 215; silkColorTarget.aB = 110;
} else if (mood === 'heart') {
silkColorTarget.pR = 48; silkColorTarget.pG = 6; silkColorTarget.pB = 20;
silkColorTarget.sR = 20; silkColorTarget.sG = 2; silkColorTarget.sB = 8;
silkColorTarget.hR = 255; silkColorTarget.hG = 45; silkColorTarget.hB = 85;
silkColorTarget.aR = 255; silkColorTarget.aG = 180; silkColorTarget.aB = 200;
} else {
// night (default)
silkColorTarget.pR = 8; silkColorTarget.pG = 22; silkColorTarget.pB = 38;
silkColorTarget.sR = 3; silkColorTarget.sG = 8; silkColorTarget.sB = 16;
silkColorTarget.hR = 30; silkColorTarget.hG = 215; silkColorTarget.hB = 166;
silkColorTarget.aR = 140; silkColorTarget.aG = 210; silkColorTarget.aB = 255;
}
}
// ------------------------------------------------------------------
// ДИНАМИЧЕСКИЙ АКЦЕНТ ИНТЕРФЕЙСА (СЕРДЕЧКО, СКРАББЕР, ГРОМКОСТЬ)
// ------------------------------------------------------------------
const uiAccentColorCurrent = { r: 30, g: 215, b: 166 };
const uiAccentColorTarget = { r: 30, g: 215, b: 166 };
function updateUiAccentTargets(mood) {
if (mood === 'abyss') {
uiAccentColorTarget.r = 0; uiAccentColorTarget.g = 242; uiAccentColorTarget.b = 254; // #00f2fe
} else if (mood === 'warm') {
uiAccentColorTarget.r = 255; uiAccentColorTarget.g = 180; uiAccentColorTarget.b = 67; // #ffb443
} else {
// night (default)
uiAccentColorTarget.r = 30; uiAccentColorTarget.g = 215; uiAccentColorTarget.b = 166; // #1ed7a6
}
}
function getActiveDynamicAccentString() {
return `rgb(${Math.round(uiAccentColorCurrent.r)}, ${Math.round(uiAccentColorCurrent.g)}, ${Math.round(uiAccentColorCurrent.b)})`;
}
function getActiveDynamicGlowString() {
return `rgba(${Math.round(uiAccentColorCurrent.r)}, ${Math.round(uiAccentColorCurrent.g)}, ${Math.round(uiAccentColorCurrent.b)}, 0.45)`;
}
// ------------------------------------------------------------------
// СОСТОЯНИЕ СИСТЕМЫ
// ------------------------------------------------------------------
let isStarted = false;
let isStarting = false;
let isEnded = false;
let animFrameId = null;
let isScrubbing = false;
let letterRevealed = false;
let isLetterSealed = true;
let sealAnimationEpoch = 0;
let endTransitionTimer = null;
let isZenMode = false;
// Атмосфера (Ночь, Бездна, Тепло)
let currentMood = 'night';
let isAutoAtmosphere = true;
// Авто-хайд интерфейса при бездействии
let idleTimer = null;
// 3D-параллакс и океанический дрейф
let targetRotX = 0;
let targetRotY = 0;
let rotX = 0;
let rotY = 0;
let baseScale = 1.0;
let currentScale = 1.0;
let ambientDriftTime = 0;
// Водная микро-рябь и брызги
let coverRipples = [];
let coverSplashes = [];
let globalRipples = [];
// Капли на стекле экрана
let glassDroplets = [];
// Спецэффекты атмосфер: морской снег и тёплые искры
let marineSnow = [];
let warmEmbers = [];
let causticsPhase = 0;
// Web Audio API & Плавная громкость macOS
let audioCtx = null;
let analyser = null;
let gainNode = null;
let dataArray = null;
let isWebAudioReady = false;
let smoothedBass = 0.0;
let smoothedMid = 0.0;
let smoothedTreble = 0.0;
let lastKickTime = 0;
let audioTargetVolume = 0.85;
let isVolumeMuted = false;
let preMuteVolumeLevel = 0.85;
let volumeGlideRaf = null;
// Аудио-пульс и акустические кольца
let acousticRings = [];
let auraSpores = [];
let auraRotation = 0.0;
// Сургучные частицы
let sealParticles = [];
let isSealParticlesActive = false;
const PHASES = {
WAITING: 'WAITING',
INTRO: 'INTRO',
VERSE: 'VERSE',
COVER_REVEAL: 'COVER_REVEAL',
CALM: 'CALM',
CLIMAX: 'CLIMAX',
DROP: 'DROP',
WHISPER: 'WHISPER',
ENDING: 'ENDING'
};
let currentPhase = PHASES.WAITING;
// ------------------------------------------------------------------
// 1. ИНИЦИАЛИЗАЦИЯ
// ------------------------------------------------------------------
function init() {
setupPresentation();
setupLetterContent();
setupLyrics();
setupCanvases();
setupAtmosphere();
setupAudioSource();
// Поля частиц создаются в resizeCanvases, только когда у окна есть размер.
initAuraSpores();
bindEvents();
renderTimelineState(0);
if (!animFrameId) {
lastFrameTime = performance.now();
animFrameId = requestAnimationFrame(animationLoop);
}
}
function setWaveTypography(el, text) {
if (!el || !text) return;
el.innerHTML = '';
const chars = Array.from(text);
chars.forEach((char, i) => {
const span = document.createElement('span');
span.className = 'wave-char';
span.style.setProperty('--char-i', i);
span.textContent = char === ' ' ? '\u00a0' : char;
el.appendChild(span);
});
}
function setButtonText(button, text) {
if (!button || typeof text !== 'string') return;
const label = button.querySelector('span');
if (label) label.textContent = text;
button.setAttribute('aria-label', text);
}
function setupPresentation() {
const welcome = CONFIG.welcome || {};
const page = CONFIG.page || {};
const prompt = CONFIG.letterPrompt || {};
if (page.title) document.title = page.title;
const description = document.querySelector('meta[name="description"]');
if (description && page.description) description.content = page.description;
const title = document.querySelector('.minimal-title');
const subtitle = document.querySelector('.minimal-subtitle');
if (title) title.textContent = welcome.title || CONFIG.audio.title;
if (subtitle) subtitle.textContent = welcome.subtitle || CONFIG.audio.subtitle;
document.querySelectorAll('.track-title-text').forEach(el => { el.textContent = CONFIG.audio.title; });
document.querySelectorAll('.track-artist-text').forEach(el => { el.textContent = CONFIG.audio.subtitle; });
setButtonText(startPortalBtn, welcome.listenButtonText);
setButtonText(directLetterBtn, welcome.letterButtonText);
[pLine1, pLine2, pLine3].forEach((line, i) => {
if (line && prompt.lines && typeof prompt.lines[i] === 'string') line.textContent = prompt.lines[i];
});
setButtonText(naughtyListenBtn, prompt.listenButtonText);
setButtonText(forceLetterBtn, prompt.skipButtonText);
if (coverImg && CONFIG.cover.alt) coverImg.alt = CONFIG.cover.alt;
if (coverGlow && CONFIG.visuals.glow) {
coverGlow.style.filter = `blur(${CONFIG.visuals.glow.blurRadius}px)`;
}
}
function getTelegramUrl() {
const raw = (CONFIG.letter.telegramUrl || '').trim();
if (!raw) return '';
try {
const url = new URL(raw);
if (url.protocol !== 'https:' || !['t.me', 'telegram.me'].includes(url.hostname.toLowerCase())) return '';
if (!url.pathname || url.pathname === '/') return '';
return url.href;
} catch (_) {
return '';
}
}
function setupLyrics() {
if (!lyricsScrollContainer) return;
const parts = [
['introText', ''], ['verseLine1', ''], ['verseLine2', ''],
['swimText', ''], ['harborText', ''], ['calmPhase', 'vocalise'],
['climaxText', 'climax'], ['dropPhase', 'drop'], ['whisperText', 'whisper']
];
lyricsScrollContainer.replaceChildren();
parts.forEach(([key, className]) => {
const segment = CONFIG.timeline[key];
if (!segment) return;
const text = segment.lyricsText ?? segment.text;
if (typeof text !== 'string' || !text.trim()) return;
const line = document.createElement('div');
line.className = `lyric-line ${className}`.trim();
line.dataset.start = segment.start;
line.dataset.end = segment.end;
line.textContent = text;
lyricsScrollContainer.appendChild(line);
});
lyricLines = Array.from(lyricsScrollContainer.querySelectorAll('.lyric-line'));
}
function setupLetterContent() {
const rawHeading = CONFIG.letter.heading || 'С днём рождения, моя рыбка';
const cleanHeading = rawHeading.replace(/\u2764\uFE0F?/g, '').trim();
letterHeading.textContent = cleanHeading + ' ';
const heart = document.createElement('span');
heart.innerHTML = '<svg class="claude-heart-svg" viewBox="0 0 24 24" width="22" height="22" fill="#ff2d55" style="display:inline-block; vertical-align:middle; margin-left:6px;"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>';
letterHeading.appendChild(heart.firstElementChild);
letterSignature.textContent = CONFIG.letter.signature;
telegramBtnText.textContent = CONFIG.letter.telegramButtonText;
if (telegramBtn) {
const url = getTelegramUrl();
if (url) telegramBtn.href = url;
else telegramBtn.removeAttribute('href');
telegramBtn.classList.add('hidden');
}
if (editorialTagText) editorialTagText.textContent = CONFIG.letter.sealedTag || 'ПЕРСОНАЛЬНОЕ ПОСЛАНИЕ';
const sealLabel = document.querySelector('.seal-label-text');
if (sealLabel) sealLabel.textContent = CONFIG.letter.unsealButtonText || 'Распечатать';
letterContent.replaceChildren();
CONFIG.letter.paragraphs.forEach(text => {
if (!text.trim()) return;
const p = document.createElement('p');
p.className = 'letter-paragraph';
p.textContent = text;
letterContent.appendChild(p);
});
['introText', 'verseLine1', 'verseLine2', 'swimText', 'harborText', 'climaxText', 'whisperText'].forEach(key => {
const segment = CONFIG.timeline[key];
if (segment) setWaveTypography(document.getElementById(key), segment.text);
});
if (playerTrackTitle) playerTrackTitle.textContent = CONFIG.audio.title;
if (playerTrackArtist) playerTrackArtist.textContent = CONFIG.audio.subtitle;
if (lyricsTrackTitle) lyricsTrackTitle.textContent = `${CONFIG.audio.title} — ${CONFIG.audio.subtitle}`;
}
let nightFade = 1.0;
let abyssFade = 0.0;
let warmFade = 0.0;
let activeAtmoWave = null;
function getBackdropElement(mood) {
if (mood === 'abyss') return bgAbyss;
if (mood === 'warm') return bgWarm;
return bgNight;
}
// Требование 1: Точка старта строго из верхнего правого угла (winW, 0)
function getCornerOrigin(fromMood, toMood) {
const w = window.innerWidth || winW || 1440;
return { x: w, y: 0, name: 'top-right' };
}
function triggerAtmosphereWave(fromMood, toMood, forcedVariation = null) {
if (fromMood === toMood && forcedVariation === null) return;
const origin = getCornerOrigin(fromMood, toMood);
const incomingEl = getBackdropElement(toMood);
const outgoingEl = getBackdropElement(fromMood);
// Сбрасываем предыдущие временные классы волны
[bgNight, bgAbyss, bgWarm].forEach(el => {
if (!el) return;
el.classList.remove('wave-incoming', 'wave-outgoing');
});
if (outgoingEl) {
outgoingEl.classList.add('wave-outgoing');
}
if (incomingEl) {
incomingEl.classList.add('wave-incoming');
incomingEl.style.clipPath = `circle(0px at ${origin.x}px ${origin.y}px)`;
incomingEl.style.webkitClipPath = `circle(0px at ${origin.x}px ${origin.y}px)`;
}
// Выбираем вариацию анимации (0: Жидкий шёлк Aurora, 1: Призматическая звёздная пыль, 2: Акустический прилив)
let variation = forcedVariation;
if (variation === null || variation === undefined) {
if (toMood === 'abyss') variation = 0;
else if (toMood === 'warm') variation = 1;
else variation = 2;
}
activeWaveVariation = variation;
// Синхронизируем палитру динамического жидкого шёлка
updateSilkTargetColors(toMood);
const w = window.innerWidth || winW || 1440;
const h = window.innerHeight || winH || 900;
// Максимальный радиус от верхнего правого угла до самого дальнего нижнего левого (0, h)
const maxR = Math.hypot(w, h) * 1.15;
// Генерируем частицы для выбранной вариации
const particles = [];
if (variation === 0) {
// Вариация 1: Морская пена и каустические пузыри
for (let i = 0; i < 48; i++) {
particles.push({
angleOffset: (Math.random() - 0.5) * 0.45,
radiusOffset: (Math.random() - 0.5) * 45,
size: 1.5 + Math.random() * 3.0,
alpha: 0.4 + Math.random() * 0.6,
speed: 0.8 + Math.random() * 1.2,
type: 'bubble'
});
}
} else if (variation === 1) {
// Вариация 2: Векторные 4-конечные звёзды и кристаллическая пыль
for (let i = 0; i < 38; i++) {
particles.push({
angleOffset: (Math.random() - 0.5) * 0.5,
radiusOffset: (Math.random() - 0.5) * 55,
size: 2.5 + Math.random() * 4.2,
rotation: Math.random() * Math.PI,
vRot: (Math.random() - 0.5) * 2.8,
alpha: 0.5 + Math.random() * 0.5,
color: Math.random() > 0.4 ? '#ffdf70' : '#ffffff',
type: 'star'
});
}
} else {
// Вариация 3: Акустические био-лепестки и дифракционные капли
for (let i = 0; i < 32; i++) {
particles.push({
angleOffset: (Math.random() - 0.5) * 0.4,
radiusOffset: (Math.random() - 0.5) * 35,
size: 2.0 + Math.random() * 3.5,
alpha: 0.5 + Math.random() * 0.5,
rotation: Math.random() * Math.PI,
vRot: (Math.random() - 0.5) * 2.5,
type: 'diamond'
});
}
}
activeAtmoWave = {
progress: 0,
duration: 3.8, // Органическая волна: 3.8s без рывков
fromMood,
toMood,
variation,
originX: origin.x,
originY: origin.y,
maxRadius: maxR,
currentRadius: 0,
colorRgb: toMood === 'abyss' ? '0, 242, 254' : (toMood === 'warm' ? '255, 175, 75' : '30, 215, 166'),
accentHex: toMood === 'abyss' ? '#00f2fe' : (toMood === 'warm' ? '#ffb443' : '#1ed7a6'),
incomingEl,
outgoingEl,
particles,
sheenTriggered: false
};
}
function setupAtmosphere() {
isAutoAtmosphere = CONFIG.visuals.autoAtmosphere !== false;
if (atmoAutoBtn) {
atmoAutoBtn.classList.toggle('active', isAutoAtmosphere);
}
setAtmosphere('night', true);
}
function setAtmosphere(atmo, fromAuto) {
const prevMood = currentMood;
currentMood = atmo;
document.body.className = document.body.className.replace(/theme-\w+/g, '').trim() + ' theme-' + atmo;
// Плавная смена целевых цветов для интерфейса и фона
updateUiAccentTargets(atmo);
updateSilkTargetColors(atmo);
if (prevMood !== atmo) {
triggerAtmosphereWave(prevMood, atmo);
} else {
if (bgNight) bgNight.classList.toggle('active', atmo === 'night');
if (bgAbyss) bgAbyss.classList.toggle('active', atmo === 'abyss');
if (bgWarm) bgWarm.classList.toggle('active', atmo === 'warm');
}
atmoButtons.forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-atmo') === atmo);
});
if (!fromAuto) {
// Пользователь кликнул на кнопку вручную — отключаем авто-режим
isAutoAtmosphere = false;
if (atmoAutoBtn) atmoAutoBtn.classList.remove('active');
}
}
function checkAutoAtmosphere(t) {
if (!isAutoAtmosphere || !CONFIG.atmosphereTimeline) return;
let targetMood = 'night';
for (let i = CONFIG.atmosphereTimeline.length - 1; i >= 0; i--) {
if (t >= CONFIG.atmosphereTimeline[i].start) {
targetMood = CONFIG.atmosphereTimeline[i].mood;
break;
}
}
if (targetMood !== currentMood) {
setAtmosphere(targetMood, true);
}
}
let isScrubberHovered = false;
let isVolumeHovered = false;
function updateSliderBackground(slider, val, max, isHovered) {
if (!slider) return;
const maxVal = max > 0 ? max : 1;
const pct = Math.max(0, Math.min(100, (val / maxVal) * 100));
const accent = getActiveDynamicAccentString();
slider.style.background = `linear-gradient(to right, ${accent} ${pct}%, rgba(255, 255, 255, 0.20) ${pct}%)`;
}
function applySmoothAudioVolume(targetVal, rampDuration = 0.04) {
audioTargetVolume = Math.max(0, Math.min(1, targetVal));
if (gainNode && audioCtx && audioCtx.state === 'running') {
try {
gainNode.gain.cancelScheduledValues(audioCtx.currentTime);
gainNode.gain.setTargetAtTime(audioTargetVolume, audioCtx.currentTime, rampDuration);
} catch (e) {}
}
audio.volume = audioTargetVolume;
}
function glideVolume(startVal, endVal, durationMs, onDone) {
if (volumeGlideRaf) cancelAnimationFrame(volumeGlideRaf);
const startTime = performance.now();
function stepGlide(now) {
const elapsed = now - startTime;
const p = Math.min(1.0, elapsed / durationMs);
const ease = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
const currentVal = startVal + (endVal - startVal) * ease;
applySmoothAudioVolume(currentVal, 0.035);
if (volumeSlider) {
volumeSlider.value = currentVal;
updateSliderBackground(volumeSlider, currentVal * 100, 100, isVolumeHovered);
}
updateVolumeIcons();
if (p < 1.0) {
volumeGlideRaf = requestAnimationFrame(stepGlide);
} else {
volumeGlideRaf = null;
if (onDone) onDone();
}
}
volumeGlideRaf = requestAnimationFrame(stepGlide);
}
function updateVolumeIcons() {
const isSilenced = isVolumeMuted || audioTargetVolume <= 0.01;
if (isSilenced) {
if (volumeOnIcon) volumeOnIcon.classList.add('hidden');
if (volumeMuteIcon) volumeMuteIcon.classList.remove('hidden');
} else {
if (volumeOnIcon) volumeOnIcon.classList.remove('hidden');
if (volumeMuteIcon) volumeMuteIcon.classList.add('hidden');
}
}
function setupAudioSource() {
audio.src = encodeURI(CONFIG.audio.src);
timelineSlider.max = CONFIG.audio.expectedDuration;
timeDuration.textContent = formatTime(CONFIG.audio.expectedDuration);
updateSliderBackground(timelineSlider, 0, CONFIG.audio.expectedDuration, false);
if (volumeSlider) {
volumeSlider.value = Math.max(0, Math.min(1, CONFIG.audio.initialVolume ?? 0.85));
audioTargetVolume = parseFloat(volumeSlider.value);
preMuteVolumeLevel = audioTargetVolume;
audio.volume = audioTargetVolume;
updateSliderBackground(volumeSlider, audioTargetVolume * 100, 100, false);
updateVolumeIcons();
}
audio.addEventListener('loadedmetadata', () => {
const dur = audio.duration || CONFIG.audio.expectedDuration;
timelineSlider.max = dur;
timeDuration.textContent = formatTime(dur);
updateSliderBackground(timelineSlider, audio.currentTime, dur, isScrubberHovered);
});
audio.addEventListener('error', (e) => {
console.warn('Ошибка загрузки оригинального аудио:', e);
if (!isStarted) {
audioErrorMessage.classList.remove('hidden');
}
});
audio.addEventListener('play', () => {
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
});
audio.addEventListener('pause', () => {
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
});
audio.addEventListener('ended', handleTrackEnded);
}
// ------------------------------------------------------------------
// 2. ИНТЕРФЕЙС ВСЕГДА ВИДИМ (БЕЗ АВТО-СКРЫТИЯ НА IDLE)
// ------------------------------------------------------------------
function restoreUi() {
if (idleTimer) clearTimeout(idleTimer);
document.body.classList.remove('ui-idle');
}
// ------------------------------------------------------------------
// 4. WEB AUDIO API & ЧАСТОТНЫЙ АНАЛИЗАТОР (CORS-SAFE)
// ------------------------------------------------------------------
function initWebAudio() {
if (isWebAudioReady) return;
// Защита от блокировки CORS при локальном запуске (file:/// protocol):
// В Chromium вызов createMediaElementSource для file:// выдает ошибку:
// "MediaElementAudioSource outputs zeroes due to CORS access restrictions"
// и полностью приглушает вывод звука на динамики.
// Поэтому в режиме file:// оставляем прямое нативное воспроизведение <audio>!
if (window.location.protocol === 'file:') {
isWebAudioReady = false;
return;
}
try {
const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
if (!AudioCtxClass) return;
audioCtx = new AudioCtxClass();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.85;
gainNode = audioCtx.createGain();
gainNode.gain.setValueAtTime(audioTargetVolume, audioCtx.currentTime);
const source = audioCtx.createMediaElementSource(audio);
source.connect(analyser);
analyser.connect(gainNode);
gainNode.connect(audioCtx.destination);
dataArray = new Uint8Array(analyser.frequencyBinCount);
isWebAudioReady = true;
} catch (err) {
isWebAudioReady = false;
}
}
// Процедурный синхронизированный музыкальный анализ (для file:// и fallback)
function getProceduralAudioDynamics(t) {
if (audio.paused || t <= 0) {
return { bass: 0.05, mid: 0.10, treble: 0.08, sub: 0.02 };
}
if (t < 16.0) {
// Intro: мягкое спокойное дыхание
const b = 0.12 + Math.sin(t * 1.8) * 0.08;
const m = 0.22 + Math.cos(t * 2.1) * 0.10;
const tr = 0.20 + Math.sin(t * 2.8) * 0.08;
return { bass: b, mid: m, treble: tr, sub: b * 0.8 };
} else if (t < 31.0) {
// Куплет 1
const b = 0.22 + Math.sin(t * 2.2) * 0.12;
const m = 0.38 + Math.sin(t * 3.1) * 0.15;
const tr = 0.28 + Math.cos(t * 2.6) * 0.10;
return { bass: b, mid: m, treble: tr, sub: b * 0.85 };
} else if (t < 65.0) {
// Ритмическая часть / Плыви в моей ладони (90 BPM -> период 0.667с)
const beatPhase = ((t - 31.0) % 0.667) / 0.667;
const kick = Math.pow(Math.max(0, 1 - beatPhase * 1.8), 2.5);
const b = 0.32 + kick * 0.48;
const m = 0.42 + Math.sin(t * 3.4) * 0.20;
const tr = 0.35 + kick * 0.28;
return { bass: b, mid: m, treble: tr, sub: kick };
} else if (t < 92.0) {
// Вокализ / покой
const b = 0.16 + Math.sin(t * 1.6) * 0.09;
const m = 0.45 + Math.sin(t * 2.6) * 0.20;
const tr = 0.26 + Math.cos(t * 2.2) * 0.10;
return { bass: b, mid: m, treble: tr, sub: b * 0.7 };
} else if (t < 106.0) {
// Нарастание к дропу
const prog = (t - 92.0) / 14.0;
const b = 0.28 + prog * 0.35 + Math.sin(t * 3.5) * 0.15;
const m = 0.45 + prog * 0.25;
const tr = 0.35 + prog * 0.30;
return { bass: b, mid: m, treble: tr, sub: b };
} else if (t < 130.0) {
// Инструментальный дроп (120 BPM -> период 0.5с)
const dropBeat = ((t - 106.0) % 0.5) / 0.5;
const dropKick = Math.pow(Math.max(0, 1 - dropBeat * 2.0), 3.0);
const b = 0.48 + dropKick * 0.48;
const m = 0.52 + Math.sin(t * 4.2) * 0.22;
const tr = 0.50 + dropKick * 0.38;
return { bass: b, mid: m, treble: tr, sub: dropKick };
} else {
// Шёпот и затухание
return { bass: 0.05, mid: 0.12, treble: 0.08, sub: 0.02 };
}
}
function updateAudioAnalysis() {
if (audio.paused) {
smoothedBass *= 0.92;
smoothedMid *= 0.92;
smoothedTreble *= 0.92;
return;
}
if (!isWebAudioReady || !analyser) {
// Режим file:// или fallback без Web Audio API
const dyn = getProceduralAudioDynamics(audio.currentTime);
smoothedBass = smoothedBass * 0.78 + dyn.bass * 0.22;
smoothedMid = smoothedMid * 0.80 + dyn.mid * 0.20;
smoothedTreble = smoothedTreble * 0.82 + dyn.treble * 0.18;
if (dyn.sub > 0.48 && dyn.sub > smoothedBass * 1.30) {
const nowMs = performance.now();
if (nowMs - lastKickTime > 320) {
lastKickTime = nowMs;
spawnAcousticRing(dyn.sub);
}
}
return;
}
analyser.getByteFrequencyData(dataArray);
let isAllZeroes = true;
for (let i = 0; i < 20; i++) {
if (dataArray[i] > 0) {
isAllZeroes = false;
break;
}
}
// Если браузер вернул нули из-за CORS ограничений:
if (isAllZeroes && audio.currentTime > 0.5) {
const dyn = getProceduralAudioDynamics(audio.currentTime);
smoothedBass = smoothedBass * 0.78 + dyn.bass * 0.22;
smoothedMid = smoothedMid * 0.80 + dyn.mid * 0.20;
smoothedTreble = smoothedTreble * 0.82 + dyn.treble * 0.18;
return;
}
let subSum = 0;
for (let i = 1; i <= 4; i++) subSum += dataArray[i];
const rawSub = subSum / (4 * 255);
let bassSum = 0;
for (let i = 4; i <= 12; i++) bassSum += dataArray[i];
const rawBass = bassSum / (9 * 255);
let midSum = 0;
for (let i = 12; i <= 45; i++) midSum += dataArray[i];
const rawMid = midSum / (34 * 255);
let trebleSum = 0;
for (let i = 45; i <= 90; i++) trebleSum += dataArray[i];
const rawTreble = trebleSum / (46 * 255);
smoothedBass = smoothedBass * 0.72 + rawBass * 0.28;
smoothedMid = smoothedMid * 0.75 + rawMid * 0.25;
smoothedTreble = smoothedTreble * 0.8 + rawTreble * 0.2;
if (rawSub > 0.46 && rawSub > smoothedBass * 1.32) {
const now = performance.now();
if (now - lastKickTime > 340) {
lastKickTime = now;
spawnAcousticRing(rawSub);
}
}
}
// ------------------------------------------------------------------
// 5. МНОГОСЛОЙНЫЙ АУДИОРЕАКТИВНЫЙ ПУЛЬС И АКУСТИЧЕСКИЕ КОЛЬЦА
// ------------------------------------------------------------------
function initAuraSpores() {
auraSpores = [];
const count = 28;
for (let i = 0; i < count; i++) {
auraSpores.push({
angle: Math.random() * Math.PI * 2,
angularSpeed: (Math.random() - 0.5) * 0.45,
distFactor: 0.95 + Math.random() * 0.38,
size: 1.6 + Math.random() * 2.4,
alpha: 0.25 + Math.random() * 0.5
});
}
}
function spawnAcousticRing(power) {
acousticRings.push({
radiusFactor: 0.5,
maxFactor: 2.2,
power: power || 1.0,
createdAt: performance.now(),
duration: 1600
});
}
function updateAndDrawAuraPulse(dt) {
if (!auraCtx || !auraCanvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = auraCanvas.width / dpr;
const h = auraCanvas.height / dpr;
auraCtx.clearRect(0, 0, w, h);
if (!isStarted || parseFloat(coverWrapper.style.opacity || '0') < 0.05) return;
auraRotation += dt * (0.2 + smoothedBass * 0.5);
const cx = w / 2;
const cy = h / 2;
const baseR = (Math.min(w, h) * 0.23);
let colorCore, colorMid, colorEdge, ringColor;
if (currentMood === 'abyss') {
colorCore = `rgba(0, 242, 254, ${0.45 + smoothedBass * 0.45})`;
colorMid = `rgba(5, 120, 220, ${0.25 + smoothedMid * 0.35})`;
colorEdge = 'rgba(0, 20, 50, 0)';
ringColor = 'rgba(0, 242, 254, ';
} else if (currentMood === 'warm') {
colorCore = `rgba(255, 180, 70, ${0.45 + smoothedBass * 0.45})`;
colorMid = `rgba(217, 119, 87, ${0.25 + smoothedMid * 0.35})`;
colorEdge = 'rgba(40, 15, 5, 0)';
ringColor = 'rgba(255, 195, 120, ';
} else {
// night
colorCore = `rgba(30, 215, 166, ${0.42 + smoothedBass * 0.48})`;
colorMid = `rgba(0, 210, 255, ${0.22 + smoothedMid * 0.35})`;
colorEdge = 'rgba(10, 30, 40, 0)';
ringColor = 'rgba(30, 215, 166, ';
}
// 1. Дышащее объемное ядро ауры
const coreR = baseR * (1.0 + smoothedBass * 0.38);
const grad1 = auraCtx.createRadialGradient(cx, cy, baseR * 0.25, cx, cy, coreR);
grad1.addColorStop(0, colorCore);
grad1.addColorStop(0.65, colorMid);
grad1.addColorStop(1, colorEdge);
auraCtx.beginPath();
auraCtx.arc(cx, cy, coreR, 0, Math.PI * 2);
auraCtx.fillStyle = grad1;
auraCtx.fill();
// 2. Акустические кольца пульса
const now = performance.now();
for (let i = acousticRings.length - 1; i >= 0; i--) {
const ring = acousticRings[i];
const age = now - ring.createdAt;
if (age > ring.duration) {
acousticRings.splice(i, 1);
continue;
}
const progress = age / ring.duration;
const r = baseR * (0.8 + progress * (ring.maxFactor - 0.8));
const alpha = Math.sin(progress * Math.PI) * ring.power * 0.65;
auraCtx.beginPath();
auraCtx.arc(cx, cy, r, 0, Math.PI * 2);
auraCtx.lineWidth = 2.4 * (1.0 - progress);
auraCtx.strokeStyle = `${ringColor}${alpha})`;
auraCtx.stroke();
}
// 3. Биолюминесцентные мерцающие споры
for (let i = 0; i < auraSpores.length; i++) {
const sp = auraSpores[i];
sp.angle += sp.angularSpeed * dt;
const dist = baseR * (sp.distFactor + smoothedMid * 0.22);
const sx = cx + Math.cos(sp.angle) * dist;
const sy = cy + Math.sin(sp.angle) * dist;
const sporeAlpha = sp.alpha * (0.4 + smoothedTreble * 0.6);
auraCtx.beginPath();
auraCtx.arc(sx, sy, sp.size * (1.0 + smoothedMid * 0.4), 0, Math.PI * 2);
auraCtx.fillStyle = `${ringColor}${sporeAlpha})`;
auraCtx.shadowColor = ringColor + '1)';
auraCtx.shadowBlur = 8;
auraCtx.fill();
auraCtx.shadowBlur = 0;
}
}
// ------------------------------------------------------------------
// 6. ХОЛСТЫ СПЕЦЭФФЕКТОВ: ДОЖДЬ, СТЕКЛО, ПОДВОДНЫЕ КАУСТИКИ, ИСКРЫ
// ------------------------------------------------------------------
let raindrops = [];
let rainAlpha = 0.0;
let winW = 0;
let winH = 0;
let isViewportReady = false;
let particleFieldsReady = false;
let canvasResizeFrame = null;
let canvasResizeObserver = null;
function scheduleCanvasResize() {
if (canvasResizeFrame !== null) return;
canvasResizeFrame = requestAnimationFrame(() => {
canvasResizeFrame = null;
resizeCanvases();
});
}
function setupCanvases() {
resizeCanvases();
window.addEventListener('resize', scheduleCanvasResize, { passive: true });
window.addEventListener('pageshow', scheduleCanvasResize);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', scheduleCanvasResize, { passive: true });
}
// В предпросмотре iframe может открываться из ширины 0; одного window.resize
// недостаточно для всех вариантов скрытия/показа контейнера.
if ('ResizeObserver' in window) {
canvasResizeObserver = new ResizeObserver(scheduleCanvasResize);
canvasResizeObserver.observe(document.documentElement);
if (coverArtBox) canvasResizeObserver.observe(coverArtBox);
}
scheduleCanvasResize();
}
function sizeCanvas(canvas, width, height, dpr) {
if (!canvas || width <= 0 || height <= 0) return;
const pixelWidth = Math.max(1, Math.round(width * dpr));
const pixelHeight = Math.max(1, Math.round(height * dpr));
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
// Абсолютная матрица: повторный resize не накапливает масштаб Retina.
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
}
function remapParticleField(particles, oldWidth, oldHeight) {
const scaleX = winW / oldWidth;
const scaleY = winH / oldHeight;
particles.forEach(particle => {
particle.x *= scaleX;
particle.y *= scaleY;
if (!Number.isFinite(particle.x)) particle.x = Math.random() * winW;
if (!Number.isFinite(particle.y)) particle.y = Math.random() * winH;
});
}
function resizeCanvases() {
const width = Math.floor(document.documentElement.clientWidth || window.innerWidth || 0);
const height = Math.floor(document.documentElement.clientHeight || window.innerHeight || 0);
if (width < 2 || height < 2) {
// Не создаём частицы при x = 0 и не затираем последний корректный размер.
isViewportReady = false;
return;
}
const oldWidth = winW;
const oldHeight = winH;
const dimensionsChanged = width !== oldWidth || height !== oldHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
winW = width;
winH = height;
isViewportReady = true;
[fluidSilkCanvas, rainCanvas, glassCanvas, atmoFxCanvas, rippleCanvas, waveformCanvas, heartCanvas]
.forEach(canvas => sizeCanvas(canvas, winW, winH, dpr));
if (coverArtBox) {
// clientWidth/Height не включают анимированный 3D-параллакс обложки.
sizeCanvas(fluidCanvas, coverArtBox.clientWidth, coverArtBox.clientHeight, dpr);
}
if (coverWrapper) {
sizeCanvas(auraCanvas, coverWrapper.clientWidth * 2.1, coverWrapper.clientHeight * 2.1, dpr);
}
sizeCanvas(sealParticleCanvas, 320, 220, dpr);
if (!particleFieldsReady) {
createRaindrops();
initGlassDroplets();
initMarineSnow();
initWarmEmbers();
particleFieldsReady = true;
} else if (dimensionsChanged) {
// Сохраняем композицию и движение, а не пересоздаём случайный кадр.
[raindrops, glassDroplets, marineSnow, warmEmbers]
.forEach(field => remapParticleField(field, oldWidth, oldHeight));
const inset = Math.min(12, winW * 0.1);
glassDroplets.forEach(drop => {
drop.x = Math.max(inset, Math.min(winW - inset, drop.x));
});
if (activeAtmoWave) {
activeAtmoWave.originX = winW;
activeAtmoWave.originY = 0;
activeAtmoWave.maxRadius = Math.hypot(winW, winH) * 1.15;
}
}
}
function createRaindrops() {
const isMobile = window.innerWidth < 640;
const count = isMobile ? Math.floor(CONFIG.visuals.rain.count * 0.55) : CONFIG.visuals.rain.count;
raindrops = [];
for (let i = 0; i < count; i++) {
const layer = Math.random() < 0.4 ? 0 : (Math.random() < 0.75 ? 1 : 2);
raindrops.push({
x: Math.random() * winW,
y: Math.random() * winH,
layer: layer,
speed: layer === 0 ? 240 + Math.random() * 120 : (layer === 1 ? 480 + Math.random() * 200 : 750 + Math.random() * 300),
length: layer === 0 ? 10 + Math.random() * 8 : (layer === 1 ? 22 + Math.random() * 15 : 40 + Math.random() * 25),
alpha: layer === 0 ? 0.08 + Math.random() * 0.06 : (layer === 1 ? 0.16 + Math.random() * 0.09 : 0.26 + Math.random() * 0.10)
});
}
}
function initGlassDroplets() {
const count = CONFIG.visuals.glassDroplets ? CONFIG.visuals.glassDroplets.count : 14;
const inset = Math.min(12, winW * 0.1);
glassDroplets = [];
for (let i = 0; i < count; i++) {
glassDroplets.push({
// Равномерно рассеиваем капли по ширине, не обрезая их на границе окна.
x: inset + ((i + Math.random()) / count) * (winW - inset * 2),
y: Math.random() * winH,
radius: 1.6 + Math.random() * 2.3,
speedY: (0.15 + Math.random() * 0.35) * ((CONFIG.visuals.glassDroplets.speed ?? 0.35) / 0.35),
opacity: 0.25 + Math.random() * 0.4
});
}
}
function initMarineSnow() {
marineSnow = [];
for (let i = 0; i < 45; i++) {
marineSnow.push({
x: Math.random() * winW,
y: Math.random() * winH,
speedY: -(18 + Math.random() * 30),
speedX: (Math.random() - 0.5) * 14,
size: 1.2 + Math.random() * 2.5,
alpha: 0.15 + Math.random() * 0.45
});
}
}
function initWarmEmbers() {
warmEmbers = [];
for (let i = 0; i < 40; i++) {
warmEmbers.push({
x: Math.random() * winW,
y: Math.random() * winH,
speedY: -(22 + Math.random() * 40),
speedX: (Math.random() - 0.5) * 18,
size: 1.5 + Math.random() * 2.8,
alpha: 0.2 + Math.random() * 0.55,
color: Math.random() < 0.65 ? '#ffb443' : '#d97757'
});
}
}
function updateAndDrawRain(dt) {
if (rainAlpha <= 0.001 || currentMood !== 'night') {
rainCtx.clearRect(0, 0, winW, winH);
return;
}
rainCtx.clearRect(0, 0, winW, winH);
const wind = CONFIG.visuals.rain.windAngle;
const speedMult = CONFIG.visuals.rain.speedMultiplier;
const styles = getComputedStyle(document.body);
rainCtx.strokeStyle = styles.getPropertyValue('--rain-color') || 'rgba(230, 240, 255, 1)';
rainCtx.lineCap = 'round';
for (let i = 0; i < raindrops.length; i++) {
const d = raindrops[i];
d.y += d.speed * speedMult * dt;
d.x += d.speed * speedMult * wind * dt;
if (d.y > winH + d.length) {
d.y = -d.length;
d.x = Math.random() * (winW + 100) - 50;
}
rainCtx.beginPath();
rainCtx.lineWidth = d.layer === 0 ? 0.8 : (d.layer === 1 ? 1.1 : 1.4);
rainCtx.globalAlpha = d.alpha * rainAlpha;
rainCtx.moveTo(d.x, d.y);
rainCtx.lineTo(d.x + d.length * wind, d.y + d.length);
rainCtx.stroke();
}
rainCtx.globalAlpha = 1.0;
}
function updateAndDrawGlass(dt) {
glassCtx.clearRect(0, 0, winW, winH);
if (rainAlpha <= 0.05 || currentMood !== 'night') return;
for (let i = 0; i < glassDroplets.length; i++) {
const drop = glassDroplets[i];
drop.y += drop.speedY * dt * 40;
if (drop.y > winH + 12) {
const inset = Math.min(12, winW * 0.1);
drop.y = -12;
drop.x = inset + Math.random() * (winW - inset * 2);
}
// Тонкая прозрачная линза и блик вместо плоского серого кружка.
const alpha = drop.opacity * rainAlpha;
const rx = drop.radius * 0.78;
const ry = drop.radius * (1.2 + drop.speedY * 0.6);
glassCtx.beginPath();
glassCtx.ellipse(drop.x, drop.y, rx, ry, 0, 0, Math.PI * 2);
glassCtx.fillStyle = `rgba(165, 200, 218, ${alpha * 0.035})`;
glassCtx.fill();
glassCtx.lineWidth = 0.6;
glassCtx.strokeStyle = `rgba(195, 223, 238, ${alpha * 0.16})`;
glassCtx.stroke();
glassCtx.beginPath();
glassCtx.ellipse(drop.x, drop.y, rx * 0.76, ry * 0.8, 0, Math.PI * 1.05, Math.PI * 1.48);
glassCtx.lineWidth = 0.75;
glassCtx.strokeStyle = `rgba(230, 243, 250, ${alpha * 0.34})`;
glassCtx.stroke();
}
}
// ------------------------------------------------------------------
// 7. СПЕЦЭФФЕКТЫ АТМОСФЕР: ЖИДКИЙ ШЁЛК, КАУСТИКИ, ИСКРЫ И ВОЛНА
// ------------------------------------------------------------------
function drawVectorStar(ctx, cx, cy, spikes, outerRadius, innerRadius) {
let rot = (Math.PI / 2) * 3;
let x = cx;
let y = cy;
const step = Math.PI / spikes;
ctx.beginPath();
ctx.moveTo(cx, cy - outerRadius);
for (let i = 0; i < spikes; i++) {
x = cx + Math.cos(rot) * outerRadius;
y = cy + Math.sin(rot) * outerRadius;
ctx.lineTo(x, y);
rot += step;
x = cx + Math.cos(rot) * innerRadius;
y = cy + Math.sin(rot) * innerRadius;
ctx.lineTo(x, y);
rot += step;
}
ctx.lineTo(cx, cy - outerRadius);
ctx.closePath();
}
function drawVectorDiamond(ctx, cx, cy, w, h, angle) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, -h);
ctx.lineTo(w, 0);
ctx.lineTo(0, h);
ctx.lineTo(-w, 0);
ctx.closePath();
ctx.restore();
}
// ------------------------------------------------------------------
// ДИНАМИЧЕСКИЙ ЖИДКИЙ ШЁЛК (FLUID DESIGN ENGINE)
// ------------------------------------------------------------------
function updateAndDrawFluidSilk(dt) {
if (!fluidSilkCtx || !fluidSilkCanvas) return;
fluidSilkCtx.clearRect(0, 0, winW, winH);
// Плавная интерполяция цветов шёлка в реальном времени под настроение экрана
const cSpeed = Math.min(1.0, dt * 1.5);
silkColorCurrent.pR += (silkColorTarget.pR - silkColorCurrent.pR) * cSpeed;
silkColorCurrent.pG += (silkColorTarget.pG - silkColorCurrent.pG) * cSpeed;
silkColorCurrent.pB += (silkColorTarget.pB - silkColorCurrent.pB) * cSpeed;
silkColorCurrent.sR += (silkColorTarget.sR - silkColorCurrent.sR) * cSpeed;
silkColorCurrent.sG += (silkColorTarget.sG - silkColorCurrent.sG) * cSpeed;
silkColorCurrent.sB += (silkColorTarget.sB - silkColorCurrent.sB) * cSpeed;
silkColorCurrent.hR += (silkColorTarget.hR - silkColorCurrent.hR) * cSpeed;
silkColorCurrent.hG += (silkColorTarget.hG - silkColorCurrent.hG) * cSpeed;
silkColorCurrent.hB += (silkColorTarget.hB - silkColorCurrent.hB) * cSpeed;
silkColorCurrent.aR += (silkColorTarget.aR - silkColorCurrent.aR) * cSpeed;
silkColorCurrent.aG += (silkColorTarget.aG - silkColorCurrent.aG) * cSpeed;
silkColorCurrent.aB += (silkColorTarget.aB - silkColorCurrent.aB) * cSpeed;
silkPhase += dt * (0.35 + smoothedBass * 0.75);
const cp = silkColorCurrent;
const primStr = `rgba(${Math.round(cp.pR)}, ${Math.round(cp.pG)}, ${Math.round(cp.pB)}, 0.42)`;
const shadStr = `rgba(${Math.round(cp.sR)}, ${Math.round(cp.sG)}, ${Math.round(cp.sB)}, 0.65)`;
const sheenAlpha = (0.24 + smoothedBass * 0.28).toFixed(3);
const sheenStr = `rgba(${Math.round(cp.hR)}, ${Math.round(cp.hG)}, ${Math.round(cp.hB)}, ${sheenAlpha})`;
const accentAlpha = (0.30 + smoothedTreble * 0.35).toFixed(3);
const accentStr = `rgba(${Math.round(cp.aR)}, ${Math.round(cp.aG)}, ${Math.round(cp.aB)}, ${accentAlpha})`;
// Складка 1: Диагональный шёлковый драп из верхнего правого угла
fluidSilkCtx.save();
const g1 = fluidSilkCtx.createLinearGradient(winW, 0, 0, winH);
g1.addColorStop(0, sheenStr);
g1.addColorStop(0.35, primStr);
g1.addColorStop(0.75, shadStr);
g1.addColorStop(1, 'transparent');
fluidSilkCtx.beginPath();
fluidSilkCtx.moveTo(winW, 0);
const steps1 = 16;
for (let i = 0; i <= steps1; i++) {
const p = i / steps1;
const x = winW * (1 - p);
const baseY = winH * p;
const wave = Math.sin(silkPhase + p * Math.PI * 2.5) * (45 + smoothedBass * 70);
fluidSilkCtx.lineTo(x + wave * 0.6, baseY + wave);
}
fluidSilkCtx.lineTo(winW, winH);
fluidSilkCtx.closePath();
fluidSilkCtx.fillStyle = g1;
fluidSilkCtx.globalAlpha = 0.55;
fluidSilkCtx.fill();
fluidSilkCtx.restore();
// Складка 2: Центральный струящийся атлас
fluidSilkCtx.save();
const g2 = fluidSilkCtx.createLinearGradient(0, winH * 0.15, winW, winH * 0.85);
g2.addColorStop(0, 'transparent');
g2.addColorStop(0.25, sheenStr);
g2.addColorStop(0.65, accentStr);
g2.addColorStop(1, 'transparent');
fluidSilkCtx.beginPath();
const cy = winH * 0.48;
fluidSilkCtx.moveTo(0, cy - 60);
const steps2 = 18;
for (let i = 0; i <= steps2; i++) {
const x = (winW / steps2) * i;
const wave = Math.sin(silkPhase * 1.3 + i * 0.38) * (50 + smoothedBass * 85)
+ Math.cos(silkPhase * 0.8 + i * 0.28) * 30;
fluidSilkCtx.lineTo(x, cy + wave);
}
fluidSilkCtx.lineTo(winW, winH);
fluidSilkCtx.lineTo(0, winH);
fluidSilkCtx.closePath();
fluidSilkCtx.fillStyle = g2;
fluidSilkCtx.globalAlpha = 0.42;
fluidSilkCtx.fill();
fluidSilkCtx.restore();
}
function updateAndDrawAtmosphereFx(dt) {
if (!atmoFxCtx || !atmoFxCanvas) return;
atmoFxCtx.clearRect(0, 0, winW, winH);
// Плавное интерполирование видимости атмосфер (cross-fade 3.5s)
const targetAbyss = (currentMood === 'abyss') ? 1.0 : 0.0;
const targetWarm = (currentMood === 'warm') ? 1.0 : 0.0;
const targetNight = (currentMood === 'night') ? 1.0 : 0.0;
abyssFade += (targetAbyss - abyssFade) * Math.min(1.0, dt * 1.1);
warmFade += (targetWarm - warmFade) * Math.min(1.0, dt * 1.1);
nightFade += (targetNight - nightFade) * Math.min(1.0, dt * 1.1);
causticsPhase += dt * 0.8;
// 1. Подводная атмосфера (БЕЗДНА)
if (abyssFade > 0.01) {
// Подводные световые лучи (каустики)
const rayCount = 5;
for (let i = 0; i < rayCount; i++) {
const offset = i * (winW / rayCount);
const sway = Math.sin(causticsPhase + i * 1.2) * 45;
const topX = offset + sway;
const botX = offset - sway * 1.5;
const rayGrad = atmoFxCtx.createLinearGradient(topX, 0, botX, winH);
rayGrad.addColorStop(0, `rgba(0, 242, 254, ${(0.08 * abyssFade).toFixed(3)})`);
rayGrad.addColorStop(0.5, `rgba(0, 242, 254, ${(0.03 * abyssFade).toFixed(3)})`);
rayGrad.addColorStop(1, 'rgba(0, 242, 254, 0)');
atmoFxCtx.beginPath();
atmoFxCtx.moveTo(topX - 60, 0);
atmoFxCtx.lineTo(topX + 60, 0);
atmoFxCtx.lineTo(botX + 180, winH);
atmoFxCtx.lineTo(botX - 180, winH);
atmoFxCtx.closePath();
atmoFxCtx.fillStyle = rayGrad;
atmoFxCtx.fill();
}
// Морской снег (всплывающие светящиеся микро-частицы)
for (let i = 0; i < marineSnow.length; i++) {
const p = marineSnow[i];
p.y += p.speedY * dt;
p.x += p.speedX * dt;
if (p.y < -10) {
p.y = winH + 10;
p.x = Math.random() * winW;
}
atmoFxCtx.beginPath();
atmoFxCtx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
atmoFxCtx.fillStyle = `rgba(140, 245, 255, ${(p.alpha * abyssFade * (0.6 + smoothedMid * 0.4)).toFixed(3)})`;
atmoFxCtx.shadowColor = '#00f2fe';
atmoFxCtx.shadowBlur = 6;
atmoFxCtx.fill();
atmoFxCtx.shadowBlur = 0;
}
}
// 2. Тёплая атмосфера (ТЕПЛО)
if (warmFade > 0.01) {
for (let i = 0; i < warmEmbers.length; i++) {
const p = warmEmbers[i];
p.y += p.speedY * dt;
p.x += p.speedX * dt;
if (p.y < -10) {
p.y = winH + 10;
p.x = Math.random() * winW;
}
atmoFxCtx.beginPath();
atmoFxCtx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
atmoFxCtx.fillStyle = p.color;
atmoFxCtx.globalAlpha = p.alpha * warmFade * (0.5 + smoothedTreble * 0.5);
atmoFxCtx.shadowColor = '#ffb443';
atmoFxCtx.shadowBlur = 8;
atmoFxCtx.fill();
atmoFxCtx.shadowBlur = 0;
atmoFxCtx.globalAlpha = 1.0;
}
}
// 3. Настоящая угловая волна (Corner Flood Wave) из верхнего правого угла (winW, 0)
if (activeAtmoWave) {
activeAtmoWave.progress += dt / activeAtmoWave.duration;
const p = Math.min(1.0, activeAtmoWave.progress);
// Плавная квинтическая кривая (Quintic Smoothstep Ease) для абсолютной кинематографичности без дёрганий
const ease = p * p * p * (p * (p * 6 - 15) + 10);
const r = activeAtmoWave.maxRadius * ease;
activeAtmoWave.currentRadius = r;
const originX = activeAtmoWave.originX;
const originY = activeAtmoWave.originY;
const variation = activeAtmoWave.variation;
// 1) Окрашивание входящего слоя через расширяющийся круговой сектор СТРОГО из верхнего правого угла
if (activeAtmoWave.incomingEl) {
activeAtmoWave.incomingEl.style.clipPath = `circle(${r.toFixed(1)}px at ${originX}px ${originY}px)`;
activeAtmoWave.incomingEl.style.webkitClipPath = `circle(${r.toFixed(1)}px at ${originX}px ${originY}px)`;
}
// 2) Рисование эффектов волны на холсте спецэффектов согласно активной вариации
const waveAlpha = Math.sin(p * Math.PI);
if (variation === 0) {
// ============================================================
// ВАРИАЦИЯ 1: "LIQUID SILK AURORA WAVE" (Океаническая аврора и шелковые ленты)
// ============================================================
// Каустический световой ореол вдоль фронта волны
const grad = atmoFxCtx.createRadialGradient(
originX, originY, Math.max(0, r - 150),
originX, originY, r + 65
);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(0.35, `rgba(${activeAtmoWave.colorRgb}, ${(waveAlpha * 0.18).toFixed(3)})`);
grad.addColorStop(0.85, `rgba(${activeAtmoWave.colorRgb}, ${(waveAlpha * 0.48).toFixed(3)})`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
atmoFxCtx.save();
atmoFxCtx.fillStyle = grad;
atmoFxCtx.fillRect(0, 0, winW, winH);
// Гребень 1: Основная текучая шелковая волна
atmoFxCtx.beginPath();
const steps = 90;
for (let s = 0; s <= steps; s++) {
const angle = (Math.PI * 0.48) + (s / steps) * (Math.PI * 0.58);
const undulation = Math.sin(angle * 10 + causticsPhase * 3) * 11 + Math.cos(angle * 20 - causticsPhase * 2) * 6;
const waveR = Math.max(0, r + undulation);
const px = originX + Math.cos(angle) * waveR;
const py = originY + Math.sin(angle) * waveR;
if (s === 0) atmoFxCtx.moveTo(px, py);
else atmoFxCtx.lineTo(px, py);
}
atmoFxCtx.strokeStyle = `rgba(${activeAtmoWave.colorRgb}, ${(waveAlpha * 0.95).toFixed(3)})`;
atmoFxCtx.lineWidth = 4 + (1 - p) * 8;
atmoFxCtx.shadowColor = activeAtmoWave.accentHex;
atmoFxCtx.shadowBlur = 24;
atmoFxCtx.stroke();
// Гребень 2: Вторичный гармонический шлейф жидкого шёлка
atmoFxCtx.beginPath();
for (let s = 0; s <= steps; s++) {
const angle = (Math.PI * 0.48) + (s / steps) * (Math.PI * 0.58);
const undulation = Math.cos(angle * 8 + causticsPhase * 2.2) * 9;
const waveR = Math.max(0, r - 40 + undulation);
const px = originX + Math.cos(angle) * waveR;
const py = originY + Math.sin(angle) * waveR;
if (s === 0) atmoFxCtx.moveTo(px, py);
else atmoFxCtx.lineTo(px, py);
}
atmoFxCtx.strokeStyle = `rgba(${activeAtmoWave.colorRgb}, ${(waveAlpha * 0.45).toFixed(3)})`;
atmoFxCtx.lineWidth = 2.5;
atmoFxCtx.shadowBlur = 14;
atmoFxCtx.stroke();
// Каустические пузыри и морская пена
const particles = activeAtmoWave.particles;
for (let i = 0; i < particles.length; i++) {
const bp = particles[i];
const baseAngle = (Math.PI * 0.5) + (i / particles.length) * (Math.PI * 0.52);
const angle = baseAngle + bp.angleOffset;
const pr = r + bp.radiusOffset;
const bx = originX + Math.cos(angle) * pr;
const by = originY + Math.sin(angle) * pr;
if (bx >= 0 && bx <= winW && by >= 0 && by <= winH) {
atmoFxCtx.beginPath();
atmoFxCtx.arc(bx, by, bp.size, 0, Math.PI * 2);
atmoFxCtx.fillStyle = `rgba(255, 255, 255, ${(waveAlpha * bp.alpha).toFixed(3)})`;
atmoFxCtx.shadowColor = activeAtmoWave.accentHex;
atmoFxCtx.shadowBlur = 10;
atmoFxCtx.fill();
}
}
atmoFxCtx.restore();
} else if (variation === 1) {
// ============================================================
// ВАРИАЦИЯ 2: "CELESTIAL PRISM STARDUST" (Призматическая волна и звёздная пыль)
// ============================================================
atmoFxCtx.save();
// 1. Призматические радиальные лучи света из верхнего правого угла
const rayCount = 7;
for (let i = 0; i < rayCount; i++) {
const beamAngle = (Math.PI * 0.52) + (i / (rayCount - 1)) * (Math.PI * 0.5);
const sway = Math.sin(causticsPhase * 1.5 + i * 0.8) * 0.04;
const finalAngle = beamAngle + sway;
const beamLen = r + 120;
const endX = originX + Math.cos(finalAngle) * beamLen;
const endY = originY + Math.sin(finalAngle) * beamLen;
const rayGrad = atmoFxCtx.createLinearGradient(originX, originY, endX, endY);
rayGrad.addColorStop(0, `rgba(255, 240, 200, ${(waveAlpha * 0.35).toFixed(3)})`);
rayGrad.addColorStop(0.7, `rgba(${activeAtmoWave.colorRgb}, ${(waveAlpha * 0.18).toFixed(3)})`);
rayGrad.addColorStop(1, 'rgba(0,0,0,0)');
atmoFxCtx.beginPath();
atmoFxCtx.moveTo(originX, originY);
atmoFxCtx.lineTo(endX - 35, endY);
atmoFxCtx.lineTo(endX + 35, endY);
atmoFxCtx.closePath();
atmoFxCtx.fillStyle = rayGrad;
atmoFxCtx.fill();
}
// 2. Хроматическая дисперсия (3 спектральных кольца: золото, шампань, роза)
const chromaticOffsets = [
{ off: 0, col: '255, 225, 120', width: 4.5, blur: 20 },
{ off: -18, col: '255, 175, 75', width: 2.5, blur: 12 },
{ off: 16, col: '255, 130, 170', width: 2.0, blur: 15 }
];
chromaticOffsets.forEach(band => {
atmoFxCtx.beginPath();
const steps = 80;
for (let s = 0; s <= steps; s++) {
const angle = (Math.PI * 0.48) + (s / steps) * (Math.PI * 0.58);
const waveR = Math.max(0, r + band.off + Math.sin(angle * 14 + causticsPhase * 2) * 7);
const px = originX + Math.cos(angle) * waveR;
const py = originY + Math.sin(angle) * waveR;
if (s === 0) atmoFxCtx.moveTo(px, py);
else atmoFxCtx.lineTo(px, py);
}
atmoFxCtx.strokeStyle = `rgba(${band.col}, ${(waveAlpha * 0.88).toFixed(3)})`;
atmoFxCtx.lineWidth = band.width;
atmoFxCtx.shadowColor = `rgb(${band.col})`;
atmoFxCtx.shadowBlur = band.blur;
atmoFxCtx.stroke();
});
// 3. Векторные 4-конечные звёздочки и кристаллическая пыль
const particles = activeAtmoWave.particles;
for (let i = 0; i < particles.length; i++) {
const sp = particles[i];
sp.rotation += sp.vRot * dt;
const baseAngle = (Math.PI * 0.5) + (i / particles.length) * (Math.PI * 0.52);
const angle = baseAngle + sp.angleOffset;
const pr = r + sp.radiusOffset;
const sx = originX + Math.cos(angle) * pr;
const sy = originY + Math.sin(angle) * pr;
if (sx >= 0 && sx <= winW && sy >= 0 && sy <= winH) {
atmoFxCtx.save();
atmoFxCtx.fillStyle = sp.color;
atmoFxCtx.shadowColor = sp.color;
atmoFxCtx.shadowBlur = 12;
atmoFxCtx.globalAlpha = waveAlpha * sp.alpha;
drawVectorStar(atmoFxCtx, sx, sy, 4, sp.size, sp.size * 0.36);
atmoFxCtx.fill();
atmoFxCtx.restore();
}
}
atmoFxCtx.restore();
} else {
// ============================================================
// ВАРИАЦИЯ 3: "ACOUSTIC TIDAL PULSE & BIO-RIPPLES" (Акустический прилив & био-кольца)
// ============================================================
atmoFxCtx.save();
// 1. Тройной каскад акустических shockwave колец, пульсирующих с басом
const bassBoost = smoothedBass * 28;
const pulseRings = [
{ rOff: 0, alphaMul: 0.95, width: 5.0 + (1 - p) * 6 },
{ rOff: -45 - bassBoost, alphaMul: 0.55, width: 3.0 },
{ rOff: -90 - bassBoost * 2, alphaMul: 0.30, width: 2.0 }
];
pulseRings.forEach(ring => {
atmoFxCtx.beginPath();
const steps = 85;
for (let s = 0; s <= steps; s++) {
const angle = (Math.PI * 0.48) + (s / steps) * (Math.PI * 0.58);
const undulation = Math.sin(angle * 16 + causticsPhase * 4) * 8;
const waveR = Math.max(0, r + ring.rOff + undulation);
const px = originX + Math.cos(angle) * waveR;
const py = originY + Math.sin(angle) * waveR;
if (s === 0) atmoFxCtx.moveTo(px, py);
else atmoFxCtx.lineTo(px, py);
}
atmoFxCtx.strokeStyle = `rgba(${activeAtmoWave.colorRgb}, ${(waveAlpha * ring.alphaMul).toFixed(3)})`;
atmoFxCtx.lineWidth = ring.width;
atmoFxCtx.shadowColor = activeAtmoWave.accentHex;
atmoFxCtx.shadowBlur = 25;
atmoFxCtx.stroke();
});
// 2. Векторные вращающиеся био-ромбы и дифракционные капли
const particles = activeAtmoWave.particles;
for (let i = 0; i < particles.length; i++) {
const dp = particles[i];
dp.rotation += dp.vRot * dt;
const baseAngle = (Math.PI * 0.5) + (i / particles.length) * (Math.PI * 0.52);
const angle = baseAngle + dp.angleOffset;
const pr = r + dp.radiusOffset;
const dx = originX + Math.cos(angle) * pr;
const dy = originY + Math.sin(angle) * pr;
if (dx >= 0 && dx <= winW && dy >= 0 && dy <= winH) {
atmoFxCtx.save();
atmoFxCtx.fillStyle = '#ffffff';
atmoFxCtx.shadowColor = activeAtmoWave.accentHex;
atmoFxCtx.shadowBlur = 10;
atmoFxCtx.globalAlpha = waveAlpha * dp.alpha;
drawVectorDiamond(atmoFxCtx, dx, dy, dp.size * 1.4, dp.size, dp.rotation);
atmoFxCtx.fill();
atmoFxCtx.restore();
}
}
atmoFxCtx.restore();
}
// Омывание центральной карточки обложки при прохождении волны
const distToCenter = Math.hypot(winW / 2 - originX, winH / 2 - originY);
if (!activeAtmoWave.sheenTriggered && Math.abs(r - distToCenter) < 140) {
activeAtmoWave.sheenTriggered = true;
if (coverLightSheen) {
coverLightSheen.style.opacity = '0.9';
coverLightSheen.style.filter = `drop-shadow(0 0 35px ${activeAtmoWave.accentHex})`;
setTimeout(() => {
coverLightSheen.style.opacity = '';
coverLightSheen.style.filter = '';
}, 1100);
}
}
// Завершение волны: сброс clip-path и фиксация нового фона
if (activeAtmoWave.progress >= 1.0) {
if (activeAtmoWave.incomingEl) {
activeAtmoWave.incomingEl.style.clipPath = '';
activeAtmoWave.incomingEl.style.webkitClipPath = '';
activeAtmoWave.incomingEl.classList.remove('wave-incoming');
activeAtmoWave.incomingEl.classList.add('active');
}
if (activeAtmoWave.outgoingEl) {
activeAtmoWave.outgoingEl.classList.remove('active', 'wave-outgoing');
}
activeAtmoWave = null;
}
}
}
// ------------------------------------------------------------------
// 8. ШЁЛКОВАЯ БИОЛЮМИНЕСЦЕНТНАЯ ВОЛНОВАЯ ЛЕНТА
// ------------------------------------------------------------------
let wavePhase = 0;
function updateAndDrawWaveform(dt) {
waveCtx.clearRect(0, 0, winW, winH);
if (!isStarted || audio.paused) return;
wavePhase += dt * (1.2 + smoothedBass * 2.5);
const cy = winH * 0.52;
const amp1 = 12 + smoothedBass * 55;
const amp2 = 8 + smoothedMid * 40;
const styles = getComputedStyle(document.body);
const accent = styles.getPropertyValue('--sp-accent') || '#1ed7a6';
waveCtx.save();
// 1. Основная волна
waveCtx.beginPath();
waveCtx.moveTo(0, cy);
const steps = 18;
for (let i = 0; i <= steps; i++) {
const x = (winW / steps) * i;
const y = cy + Math.sin(wavePhase + i * 0.45) * amp1 * Math.cos(wavePhase * 0.4 + i * 0.25);
if (i === 0) waveCtx.moveTo(x, y);
else {
const prevX = (winW / steps) * (i - 1);
const prevY = cy + Math.sin(wavePhase + (i - 1) * 0.45) * amp1 * Math.cos(wavePhase * 0.4 + (i - 1) * 0.25);
const cpx = (prevX + x) / 2;
const cpy = (prevY + y) / 2;
waveCtx.quadraticCurveTo(prevX, prevY, cpx, cpy);
}
}
waveCtx.lineTo(winW, cy);
waveCtx.strokeStyle = accent;
waveCtx.lineWidth = 2.2;
waveCtx.shadowColor = accent;
waveCtx.shadowBlur = 22;
waveCtx.globalAlpha = 0.45 + smoothedBass * 0.4;
waveCtx.stroke();
// 2. Вторая гармоника
waveCtx.beginPath();
waveCtx.moveTo(0, cy);
for (let i = 0; i <= steps; i++) {
const x = (winW / steps) * i;
const y = cy + Math.sin(wavePhase * 1.3 + i * 0.55 + Math.PI * 0.4) * amp2;
if (i === 0) waveCtx.moveTo(x, y);
else {
const prevX = (winW / steps) * (i - 1);
const prevY = cy + Math.sin(wavePhase * 1.3 + (i - 1) * 0.55 + Math.PI * 0.4) * amp2;
waveCtx.quadraticCurveTo(prevX, prevY, (prevX + x) / 2, (prevY + y) / 2);
}
}
waveCtx.lineTo(winW, cy);
waveCtx.strokeStyle = accent;
waveCtx.lineWidth = 1.2;
waveCtx.globalAlpha = 0.22 + smoothedMid * 0.3;
waveCtx.stroke();
waveCtx.restore();
}
// ------------------------------------------------------------------
// 9. ИНТЕРАКТИВНАЯ ВОДНАЯ РЯБЬ И БРЫЗГИ
// ------------------------------------------------------------------
function spawnCoverRipple(localX, localY) {
coverRipples.push({
x: localX,
y: localY,
createdAt: performance.now(),
maxRadius: 160,
duration: 1800
});
for (let i = 0; i < 8; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 40 + Math.random() * 90;
coverSplashes.push({
x: localX,
y: localY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 35,
size: 1.2 + Math.random() * 2.2,
alpha: 0.85,
life: 0,
maxLife: 0.7 + Math.random() * 0.4
});
}
}
function spawnGlobalRipple(x, y) {
globalRipples.push({
x: x,
y: y,
createdAt: performance.now(),
maxRadius: Math.min(winW, winH) * 0.45,
duration: 2200
});
}
function updateAndDrawRipples(dt, currentTime) {
rippleCtx.clearRect(0, 0, winW, winH);
// Сценарная рябь (Фаза 5, 01:43)
const rippleConfig = CONFIG.timeline.waterRipple;
const rippleStart = rippleConfig.time;
const rippleDuration = rippleConfig.duration;
if (currentTime >= rippleStart && currentTime <= rippleStart + rippleDuration) {
const elapsed = currentTime - rippleStart;
const progress = Math.min(1.0, elapsed / rippleDuration);
const cx = winW / 2;
const cy = winH / 2 - (winH * 0.04);
const maxRadius = Math.min(winW, winH) * 0.75;
const rings = 3;
for (let r = 0; r < rings; r++) {
const ringOffset = r * 0.22;
const ringProgress = progress - ringOffset;
if (ringProgress <= 0 || ringProgress >= 1) continue;
const currentRadius = Math.pow(ringProgress, 0.75) * maxRadius;
const ringAlpha = Math.sin(ringProgress * Math.PI) * (1.0 - ringProgress * 0.5) * 0.35;
rippleCtx.beginPath();
rippleCtx.arc(cx, cy, currentRadius, 0, Math.PI * 2);
rippleCtx.lineWidth = 1.8 + (1.0 - ringProgress) * 2.2;
rippleCtx.strokeStyle = `rgba(180, 240, 248, ${ringAlpha})`;
rippleCtx.shadowColor = 'rgba(30, 215, 166, 0.5)';
rippleCtx.shadowBlur = 14;
rippleCtx.stroke();
}
rippleCtx.shadowBlur = 0;
}
const now = performance.now();
for (let i = globalRipples.length - 1; i >= 0; i--) {
const rip = globalRipples[i];
const age = now - rip.createdAt;
if (age > rip.duration) {
globalRipples.splice(i, 1);
continue;
}
const p = age / rip.duration;
const rad = Math.pow(p, 0.7) * rip.maxRadius;
const alpha = Math.sin(p * Math.PI) * 0.38;
rippleCtx.beginPath();
rippleCtx.arc(rip.x, rip.y, rad, 0, Math.PI * 2);
rippleCtx.lineWidth = 2.0 * (1.0 - p);
rippleCtx.strokeStyle = `rgba(100, 240, 225, ${alpha})`;
rippleCtx.shadowColor = 'rgba(30, 215, 166, 0.6)';
rippleCtx.shadowBlur = 10;
rippleCtx.stroke();
rippleCtx.shadowBlur = 0;
}
if (coverArtBox) {
const rect = coverArtBox.getBoundingClientRect();
fluidCtx.clearRect(0, 0, rect.width, rect.height);
for (let i = coverRipples.length - 1; i >= 0; i--) {
const r = coverRipples[i];
const age = now - r.createdAt;
if (age > r.duration) {
coverRipples.splice(i, 1);
continue;
}
const p = age / r.duration;
const rad = Math.pow(p, 0.75) * r.maxRadius;
const alpha = Math.sin(p * Math.PI) * 0.45;
fluidCtx.beginPath();
fluidCtx.arc(r.x, r.y, rad, 0, Math.PI * 2);
fluidCtx.lineWidth = 1.6 * (1.0 - p);
fluidCtx.strokeStyle = `rgba(30, 215, 166, ${alpha})`;
fluidCtx.shadowColor = '#1ed7a6';
fluidCtx.shadowBlur = 8;
fluidCtx.stroke();
fluidCtx.shadowBlur = 0;
}
const g = 380;
for (let i = coverSplashes.length - 1; i >= 0; i--) {
const s = coverSplashes[i];
s.life += dt;
if (s.life >= s.maxLife) {
coverSplashes.splice(i, 1);
continue;
}
s.vy += g * dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
const p = s.life / s.maxLife;
const alpha = (1.0 - p) * s.alpha;
fluidCtx.beginPath();
fluidCtx.arc(s.x, s.y, s.size * (1.0 - p * 0.4), 0, Math.PI * 2);
fluidCtx.fillStyle = `rgba(180, 245, 255, ${alpha})`;
fluidCtx.fill();
}
}
}
// ------------------------------------------------------------------
// 10. СПЕЦЭФФЕКТЫ СЕРДЕЧКА (3 РАСХОДЯЩИЕСЯ ВОЛНЫ + ПОЯВЛЕНИЕ В ЦЕНТРЕ)
// ------------------------------------------------------------------
function drawParametricHeart(ctx, cx, cy, size) {
ctx.beginPath();
const steps = 60;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * Math.PI * 2;
const x = 16 * Math.pow(Math.sin(t), 3);
const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
const px = cx + x * (size / 16);
const py = cy + y * (size / 16);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
function triggerHeartExperience() {
if (!heartOverlay || !heartCanvas) return;
[heartSeqTimer1, heartSeqTimer2, heartSeqTimer3, heartSeqTimer4, heartSeqTimer5].forEach(t => {
if (t) clearTimeout(t);
});
const dpr = Math.min(window.devicePixelRatio || 1, 2);
heartCanvas.width = winW * dpr;
heartCanvas.height = winH * dpr;
if (heartCtx) heartCtx.scale(dpr, dpr);
heartOverlay.classList.remove('hidden');
heartOverlay.style.opacity = '1';
if (centerHeartCard) {
centerHeartCard.classList.remove('blooming', 'dissolving');
centerHeartCard.classList.add('hidden');
}
activeHeartWaves = [];
activeHeartSparkles = [];
const now = performance.now();
const cx = winW / 2;
const cy = winH / 2;
// ТРЕБОВАНИЕ ПОЛЬЗОВАТЕЛЯ:
// «сердечко также появлялось от центра и постепенно расходилось волной 3 раза.
// А потом оно полностью появлялось с небольшим эффектом»
// 1-я волна от центра (0.0с)
activeHeartWaves.push({
startTime: now,
duration: 1.45,
maxScale: Math.max(winW, winH) * 0.65,
color: getActiveDynamicAccentString(),
baseAlpha: 0.95
});
// 2-я волна от центра (0.38с)
heartSeqTimer1 = setTimeout(() => {
activeHeartWaves.push({
startTime: performance.now(),
duration: 1.45,
maxScale: Math.max(winW, winH) * 0.75,
color: getActiveDynamicAccentString(),
baseAlpha: 0.95
});
}, 380);
// 3-я волна от центра (0.76с)
heartSeqTimer2 = setTimeout(() => {
activeHeartWaves.push({
startTime: performance.now(),
duration: 1.45,
maxScale: Math.max(winW, winH) * 0.85,
color: getActiveDynamicAccentString(),
baseAlpha: 0.95
});
}, 760);
// Появление сердечка в центре с эффектом (1.18с)
heartSeqTimer3 = setTimeout(() => {
if (centerHeartCard) {
centerHeartCard.classList.remove('hidden');
requestAnimationFrame(() => {
centerHeartCard.classList.add('blooming');
});
}
const curAccent = getActiveDynamicAccentString();
// Лучистый сноп искр под цветовую гамму интерфейса
for (let i = 0; i < 32; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = 70 + Math.random() * 200;
activeHeartSparkles.push({
x: cx,
y: cy,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
size: 2.2 + Math.random() * 3.2,
alpha: 1.0,
decay: 0.7 + Math.random() * 0.7,
color: Math.random() > 0.4 ? '#ffffff' : curAccent
});
}
}, 1180);
// Плавное растворение центрального сердца (2.8с)
heartSeqTimer4 = setTimeout(() => {
if (centerHeartCard) {
centerHeartCard.classList.add('dissolving');
}
}, 2800);
// Полное скрытие оверлея (3.5с)
heartSeqTimer5 = setTimeout(() => {
heartOverlay.style.opacity = '0';
setTimeout(() => {
heartOverlay.classList.add('hidden');
if (centerHeartCard) {
centerHeartCard.classList.remove('blooming', 'dissolving');
centerHeartCard.classList.add('hidden');
}
activeHeartWaves = [];
activeHeartSparkles = [];
}, 500);
}, 3500);
}
function spawnButtonSparkles() {
if (!likeBurstContainer) return;
likeBurstContainer.innerHTML = '';
const curAccent = getActiveDynamicAccentString();
const curGlow = getActiveDynamicGlowString();
const shapes = [
`<svg viewBox="0 0 24 24" width="16" height="16" fill="${curAccent}"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`,
`<svg viewBox="0 0 24 24" width="15" height="15" fill="#ffffff"><path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z"/></svg>`,
`<svg viewBox="0 0 24 24" width="15" height="15" fill="${curAccent}"><path d="M12 2L21 12L12 22L3 12Z"/></svg>`,
`<svg viewBox="0 0 24 24" width="14" height="14" fill="#ffffff"><circle cx="12" cy="12" r="5"/></svg>`
];
for (let i = 0; i < 9; i++) {
const p = document.createElement('span');
p.className = 'micro-heart-particle';
p.innerHTML = shapes[i % shapes.length];
const dx = (Math.random() - 0.5) * 52;
const dy = -(22 + Math.random() * 48);
p.style.setProperty('--dx', `${dx}px`);
p.style.setProperty('--dy', `${dy}px`);
p.style.filter = `drop-shadow(0 2px 6px ${curGlow})`;
likeBurstContainer.appendChild(p);
setTimeout(() => p.remove(), 950);
}
}
function renderHeartFxLoop(now, dt) {
if (!heartCtx || (!activeHeartWaves.length && !activeHeartSparkles.length)) return;
heartCtx.clearRect(0, 0, winW, winH);
const cx = winW / 2;
const cy = winH / 2;
// 1. Рисуем 3 расширяющиеся волны-сердечка от центра
for (let i = activeHeartWaves.length - 1; i >= 0; i--) {
const wave = activeHeartWaves[i];
const elapsed = (now - wave.startTime) / 1000;
const p = Math.min(1.0, elapsed / wave.duration);
const ease = 1 - Math.pow(1 - p, 2.5);
const currentSize = 25 + wave.maxScale * ease;
const alpha = Math.max(0, (1 - p) * wave.baseAlpha);
if (alpha > 0.01) {
heartCtx.save();
drawParametricHeart(heartCtx, cx, cy, currentSize);
heartCtx.strokeStyle = wave.color;
heartCtx.lineWidth = 3 + (1 - p) * 6;
heartCtx.shadowColor = wave.color;
heartCtx.shadowBlur = 24 + (1 - p) * 16;
heartCtx.stroke();
heartCtx.strokeStyle = `rgba(255, 255, 255, ${(alpha * 0.8).toFixed(3)})`;
heartCtx.lineWidth = 1.5;
heartCtx.shadowBlur = 8;
heartCtx.stroke();
heartCtx.restore();
}
if (p >= 1.0) {
activeHeartWaves.splice(i, 1);
}
}
// 2. Рисуем искры лучистого нимба
for (let i = activeHeartSparkles.length - 1; i >= 0; i--) {
const sp = activeHeartSparkles[i];
sp.x += sp.vx * dt;
sp.y += sp.vy * dt;
sp.vx *= 0.94;
sp.vy *= 0.94;
sp.alpha -= sp.decay * dt;
if (sp.alpha > 0.01) {
heartCtx.beginPath();
heartCtx.arc(sp.x, sp.y, sp.size, 0, Math.PI * 2);
heartCtx.fillStyle = sp.color;
heartCtx.globalAlpha = Math.max(0, sp.alpha);
heartCtx.shadowColor = sp.color;
heartCtx.shadowBlur = 10;
heartCtx.fill();
heartCtx.globalAlpha = 1.0;
} else {
activeHeartSparkles.splice(i, 1);
}
}
}
// ------------------------------------------------------------------
// 11. ТАКТИЛЬНАЯ СУРГУЧНАЯ ПЕЧАТЬ И ПАРТИКЛЫ
// ------------------------------------------------------------------
function breakWaxSeal() {
if (!waxSealBtn || waxSealBtn.classList.contains('broken')) return;
const epoch = ++sealAnimationEpoch;
const later = (callback, delay) => setTimeout(() => {
if (epoch === sealAnimationEpoch) callback();
}, delay);
waxSealBtn.classList.add('broken');
const sealWrap = waxSealBtn.closest('.wax-seal-wrapper');
if (sealWrap) sealWrap.classList.add('broken');
if (waxSealBlock) waxSealBlock.classList.add('broken');
if (editorialTagText) {
editorialTagText.textContent = CONFIG.letter.openedTag || 'ПИСЬМО РАСПЕЧАТАНО';
}
// Полностью убираем блок печати через 600ms — чтобы не оставалось никаких следов
later(() => {
if (waxSealBlock) {
waxSealBlock.style.display = 'none';
}
}, 600);
// Снятие блюра с письма
later(() => {
letterContent.classList.remove('sealed-blur');
isLetterSealed = false;
}, 150);
// Мягкое проявление строк письма
const paragraphs = letterContent.querySelectorAll('p');
paragraphs.forEach((p, idx) => {
p.style.opacity = '0';
p.style.transform = 'translateY(12px)';
later(() => {
p.style.transition = 'opacity 1.2s cubic-bezier(0.16, 1, 0.3, 1), transform 1.2s cubic-bezier(0.16, 1, 0.3, 1)';
p.style.opacity = '1';
p.style.transform = 'translateY(0)';
}, 300 + idx * 220);
});
// Кнопка Telegram открывается ТОЛЬКО после распечатки письма!
later(() => {
if (telegramBtn && getTelegramUrl() && !isLetterSealed && letterRevealed) {
telegramBtn.href = getTelegramUrl();
telegramBtn.classList.remove('hidden');
}
}, 950);
// 40 частиц сургуча и золотых искр
if (sealParticleCanvas && sealParticleCtx) {
sealParticles = [];
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const originX = sealParticleCanvas.width / (2 * dpr);
const originY = sealParticleCanvas.height / (2 * dpr);
for (let i = 0; i < 24; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 110 + Math.random() * 260;
sealParticles.push({
x: originX,
y: originY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
rotation: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 12,
size: 3 + Math.random() * 5.5,
color: Math.random() < 0.7 ? '#82221e' : '#571311',
alpha: 1.0,
type: 'shard',
life: 0,
maxLife: 1.2 + Math.random() * 0.5
});
}
for (let i = 0; i < 16; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 140 + Math.random() * 320;
sealParticles.push({
x: originX,
y: originY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 75,
rotation: 0,
vRot: 0,
size: 1.5 + Math.random() * 2.5,
color: '#dfb15b',
alpha: 1.0,
type: 'spark',
life: 0,
maxLife: 0.9 + Math.random() * 0.6
});
}
isSealParticlesActive = true;
}
spawnGlobalRipple(winW / 2, winH / 2);
}
function updateAndDrawSealParticles(dt) {
if (!isSealParticlesActive || !sealParticleCtx || !sealParticleCanvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = sealParticleCanvas.width / dpr;
const h = sealParticleCanvas.height / dpr;
sealParticleCtx.clearRect(0, 0, w, h);
let aliveCount = 0;
const gravity = 480;
for (let i = 0; i < sealParticles.length; i++) {
const p = sealParticles[i];
p.life += dt;
if (p.life >= p.maxLife) continue;
aliveCount++;
p.vy += gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rotation += p.vRot * dt;
const progress = p.life / p.maxLife;
const alpha = Math.max(0, (1.0 - progress) * p.alpha);
sealParticleCtx.save();
sealParticleCtx.translate(p.x, p.y);
sealParticleCtx.rotate(p.rotation);
sealParticleCtx.globalAlpha = alpha;
if (p.type === 'shard') {
sealParticleCtx.fillStyle = p.color;
sealParticleCtx.beginPath();
sealParticleCtx.moveTo(-p.size, -p.size * 0.6);
sealParticleCtx.lineTo(p.size * 0.8, -p.size * 0.4);
sealParticleCtx.lineTo(p.size * 0.4, p.size * 0.8);
sealParticleCtx.lineTo(-p.size * 0.6, p.size * 0.6);
sealParticleCtx.closePath();
sealParticleCtx.fill();
} else {
sealParticleCtx.fillStyle = p.color;
sealParticleCtx.shadowColor = '#dfb15b';
sealParticleCtx.shadowBlur = 8;
sealParticleCtx.beginPath();
sealParticleCtx.arc(0, 0, p.size, 0, Math.PI * 2);
sealParticleCtx.fill();
}
sealParticleCtx.restore();
}
if (aliveCount === 0) {
isSealParticlesActive = false;
sealParticleCtx.clearRect(0, 0, w, h);
}
}
// ------------------------------------------------------------------
// 11. 3D-ПАРАЛЛАКС И МЯГКИЙ ОКЕАНИЧЕСКИЙ ДРЕЙФ
// ------------------------------------------------------------------
function update3DParallax(dt) {
ambientDriftTime += dt * 0.6;
const driftX = Math.sin(ambientDriftTime) * 1.5;
const driftY = Math.cos(ambientDriftTime * 0.8) * 1.5;
rotX += (targetRotX + driftX - rotX) * 0.08;
rotY += (targetRotY + driftY - rotY) * 0.08;
if (coverWrapper) {
coverWrapper.style.transform = `perspective(1000px) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg) scale(${currentScale.toFixed(4)})`;
}
}
// ------------------------------------------------------------------
// 12. SPOTIFY SYNCED LYRICS SCROLLING
// ------------------------------------------------------------------
function updateLyricsHighlight(t, forceScroll = false) {
let lineToCenter = null;
lyricLines.forEach(line => {
const start = Number(line.dataset.start);
const end = Number(line.dataset.end);
const active = t >= start && t < end;
const changed = active && !line.classList.contains('active');
line.classList.toggle('active', active);
if (active && (changed || forceScroll)) lineToCenter = line;
});
if (lineToCenter && !spotifyLyricsOverlay.classList.contains('hidden')) {
// Прокручиваем только панель, никогда не сдвигаем корень страницы/iframe.
const lineRect = lineToCenter.getBoundingClientRect();
const containerRect = lyricsScrollContainer.getBoundingClientRect();
const top = lyricsScrollContainer.scrollTop + lineRect.top - containerRect.top
- (lyricsScrollContainer.clientHeight - lineRect.height) / 2;
lyricsScrollContainer.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
}
// ------------------------------------------------------------------
// 13. ЦЕНТРАЛЬНЫЙ ТАЙМЛАЙН
// ------------------------------------------------------------------
function renderTimelineState(t) {
const TL = CONFIG.timeline;
const totalDuration = audio.duration || CONFIG.audio.expectedDuration;
// Автоматическая смена атмосферы
checkAutoAtmosphere(t);
let newPhase = PHASES.WAITING;
if (!isStarted) {
newPhase = PHASES.WAITING;
} else if (t < TL.verseLine1.start) {
newPhase = PHASES.INTRO;
} else if (t < TL.coverReveal.start) {
newPhase = PHASES.VERSE;
} else if (t < TL.calmPhase.start) {
newPhase = PHASES.COVER_REVEAL;
} else if (t < TL.climaxText.start) {
newPhase = PHASES.CALM;
} else if (t < TL.dropPhase.start) {
newPhase = PHASES.CLIMAX;
} else if (t < TL.coverFadeOut.start) {
newPhase = PHASES.DROP;
} else if (!audio.ended && !isEnded) {
newPhase = PHASES.WHISPER;
} else {
newPhase = PHASES.ENDING;
}
currentPhase = newPhase;
// Интенсивность дождя (дождь идёт только в теме «Ночь»)
if (currentMood !== 'night' || newPhase === PHASES.WAITING || newPhase === PHASES.ENDING) {
rainAlpha = 0.0;
} else if (t < TL.verseLine1.start) {
rainAlpha = Math.min(1.0, t / 4.0);
} else if (newPhase === PHASES.VERSE) {
rainAlpha = 0.85;
} else if (newPhase === PHASES.COVER_REVEAL) {
rainAlpha = 0.60;
} else if (newPhase === PHASES.CALM) {
rainAlpha = 0.35;
} else if (newPhase === PHASES.CLIMAX) {
rainAlpha = 0.45;
} else if (newPhase === PHASES.DROP) {
rainAlpha = 0.25;
} else if (newPhase === PHASES.WHISPER) {
const fadeProgress = Math.min(1.0, (t - TL.coverFadeOut.start) / 2.5);
rainAlpha = Math.max(0.0, 0.25 * (1.0 - fadeProgress));
}
if (rainAlpha > 0.05) {
rainCanvas.style.opacity = '1';
} else {
rainCanvas.style.opacity = '0';
}
setElementVisibility(introText, t >= TL.introText.start && t <= TL.introText.end);
setElementVisibility(verseLine1, t >= TL.verseLine1.start && t <= TL.verseLine1.end);
setElementVisibility(verseLine2, t >= TL.verseLine2.start && t <= TL.verseLine2.end);
if (TL.swimText) {
setElementVisibility(swimText, t >= TL.swimText.start && t <= TL.swimText.end);
}
setElementVisibility(harborText, t >= TL.harborText.start && t <= TL.harborText.end);
setElementVisibility(climaxText, t >= TL.climaxText.start && t <= TL.climaxText.end);
setElementVisibility(whisperText, t >= TL.whisperText.start && t <= TL.whisperText.end);
updateLyricsHighlight(t);
// Появление и уход обложки
let coverOpacity = 0.0;
const revealStart = TL.coverReveal.start;
const revealDur = TL.coverReveal.duration;
const fadeOutStart = TL.coverFadeOut.start;
const fadeOutDur = TL.coverFadeOut.duration;
if (t < revealStart) {
coverOpacity = 0.0;
} else if (t >= revealStart && t < revealStart + revealDur) {
coverOpacity = (t - revealStart) / revealDur;
} else if (t >= revealStart + revealDur && t < fadeOutStart) {
coverOpacity = 1.0;
} else if (t >= fadeOutStart && t < fadeOutStart + fadeOutDur) {
coverOpacity = Math.max(0.0, 1.0 - (t - fadeOutStart) / fadeOutDur);
} else {
coverOpacity = 0.0;
}
coverWrapper.style.opacity = coverOpacity.toFixed(3);
coverGlow.style.opacity = (coverOpacity * (CONFIG.visuals.glow?.intensity ?? 0.9)).toFixed(3);
// Дыхание и пульсация
if (coverOpacity > 0.05) {
if (newPhase === PHASES.CALM || newPhase === PHASES.COVER_REVEAL) {
const breathePhase = (t * CONFIG.visuals.breathing.speed) % (Math.PI * 2);
const breatheOffset = (Math.sin(breathePhase) + 1) * 0.5;
baseScale = CONFIG.visuals.breathing.minScale + breatheOffset * (CONFIG.visuals.breathing.maxScale - CONFIG.visuals.breathing.minScale);
} else if (newPhase === PHASES.DROP) {
const extraScale = isWebAudioReady
? Math.min(CONFIG.visuals.beatPulse.maxScaleAddition, smoothedBass * 0.04)
: Math.abs(Math.sin(t * Math.PI * 2)) * 0.015;
baseScale = 1.0 + extraScale;
} else {
baseScale = 1.0;
}
currentScale = baseScale;
}
if (!isScrubbing) {
if (timelineSlider) {
timelineSlider.value = t;
updateSliderBackground(timelineSlider, t, totalDuration, isScrubberHovered);
}
const pct = (t / totalDuration) * 100;
if (scrubberProgress) scrubberProgress.style.width = `${Math.min(100, Math.max(0, pct))}%`;
if (timeCurrent) timeCurrent.textContent = formatTime(t);
}
// Письмо не открывается по приблизительной отметке таймлайна.
// Его показывают только ended + естественная пауза или явная кнопка.
// Не скрываем здесь письмо, открытое напрямую с экрана реакции.
}
function setElementVisibility(el, visible) {
if (!el) return;
if (visible) {
el.classList.add('visible');
} else {
el.classList.remove('visible');
}
}
// ------------------------------------------------------------------
// 14. ГЛАВНЫЙ АНИМАЦИОННЫЙ ЦИКЛ (60 FPS)
// ------------------------------------------------------------------
let lastFrameTime = performance.now();
function animationLoop(now) {
const dt = Math.min(0.1, (now - lastFrameTime) / 1000);
lastFrameTime = now;
if (!isViewportReady) {
animFrameId = requestAnimationFrame(animationLoop);
return;
}
// Плавная органическая интерполяция акцентного цвета UI (кнопка лайка, скраббер, громкость)
const uiSpeed = Math.min(1.0, dt * 2.2);
uiAccentColorCurrent.r += (uiAccentColorTarget.r - uiAccentColorCurrent.r) * uiSpeed;
uiAccentColorCurrent.g += (uiAccentColorTarget.g - uiAccentColorCurrent.g) * uiSpeed;
uiAccentColorCurrent.b += (uiAccentColorTarget.b - uiAccentColorCurrent.b) * uiSpeed;
const dynAccent = getActiveDynamicAccentString();
const dynGlow = getActiveDynamicGlowString();
document.documentElement.style.setProperty('--current-dynamic-accent', dynAccent);
document.documentElement.style.setProperty('--current-dynamic-glow', dynGlow);
if (volumeSlider) {
const curVol = (audio.muted || isVolumeMuted) ? 0 : audio.volume * 100;
updateSliderBackground(volumeSlider, curVol, 100, isVolumeHovered);
}
updateAudioAnalysis();
update3DParallax(dt);
updateAndDrawFluidSilk(dt);
if (isStarted && !audio.paused) {
updateAndDrawRain(dt);
updateAndDrawGlass(dt);
updateAndDrawAtmosphereFx(dt);
updateAndDrawWaveform(dt);
updateAndDrawAuraPulse(dt);
updateAndDrawRipples(dt, audio.currentTime);
renderTimelineState(audio.currentTime);
} else if (isStarted && audio.paused) {
updateAndDrawGlass(dt);
updateAndDrawAtmosphereFx(dt);
updateAndDrawAuraPulse(dt);
updateAndDrawRipples(dt, audio.currentTime);
renderTimelineState(audio.currentTime);
} else {
// Даже до нажатия Play холст спецэффектов и шёлк плавно живут в фоне
updateAndDrawAtmosphereFx(dt);
}
if (isSealParticlesActive) {
updateAndDrawSealParticles(dt);
}
renderHeartFxLoop(now, dt);
animFrameId = requestAnimationFrame(animationLoop);
}
// ------------------------------------------------------------------
// 15. УПРАВЛЕНИЕ ВОСПРОИЗВЕДЕНИЕМ И ИГРОВЫМ ЭКРАНОМ
// ------------------------------------------------------------------
let isPlayfulNoticeActive = false;
const playfulNoticeTimers = [];
function triggerPlayfulLetterNotice() {
if (isPlayfulNoticeActive) return;
isPlayfulNoticeActive = true;
if (directLetterBtn) {
directLetterBtn.classList.add('playful-shake');
}
playfulNoticeTimers.push(setTimeout(() => {
if (minimalMainView) minimalMainView.classList.add('fade-out-morph');
}, 400));
playfulNoticeTimers.push(setTimeout(() => {
if (minimalMainView) minimalMainView.classList.add('hidden');
if (playfulLetterNotice) {
playfulLetterNotice.classList.remove('hidden');
}
}, 800));
playfulNoticeTimers.push(setTimeout(() => {
if (pLine1) pLine1.classList.add('visible');
}, 1050));
playfulNoticeTimers.push(setTimeout(() => {
if (pLine2) pLine2.classList.add('visible');
}, 2350));
playfulNoticeTimers.push(setTimeout(() => {
if (pLine3) pLine3.classList.add('visible');
}, 3650));
playfulNoticeTimers.push(setTimeout(() => {
if (pActions) pActions.classList.add('visible');
}, 4850));
}
function startExperience() {
if (isStarted || isStarting) return;
isStarting = true;
playfulNoticeTimers.forEach(t => clearTimeout(t));
initWebAudio();
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
audio.play().then(() => {
isStarting = false;
isStarted = true;
audioErrorMessage.classList.add('hidden');
waitingScreen.classList.add('fade-out');
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
lastFrameTime = performance.now();
restoreUi();
if (!animFrameId) {
animFrameId = requestAnimationFrame(animationLoop);
}
}).catch(err => {
isStarting = false;
console.warn('Не удалось запустить воспроизведение:', err);
if (audioErrorMessage) {
audioErrorMessage.classList.remove('hidden');
}
});
}
function togglePlayPause() {
if (!isStarted) {
startExperience();
return;
}
if (audio.paused) {
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
audio.play().then(() => {
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
restoreUi();
}).catch(console.error);
} else {
audio.pause();
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
document.body.classList.remove('ui-idle');
}
}
function seekTo(targetTime) {
const maxTime = audio.duration || CONFIG.audio.expectedDuration;
const clampedTime = Math.max(0, Math.min(maxTime, targetTime));
audio.currentTime = clampedTime;
if (clampedTime < maxTime) {
hideFinalLetter();
}
renderTimelineState(clampedTime);
restoreUi();
}
function handleTrackEnded() {
isEnded = true;
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
document.body.classList.remove('ui-idle');
if (endTransitionTimer) clearTimeout(endTransitionTimer);
endTransitionTimer = setTimeout(() => {
endTransitionTimer = null;
if (audio.ended && isEnded) triggerFinalLetter();
}, CONFIG.timeline.letterDelayAfterAudio * 1000);
}
function triggerFinalLetter() {
letterRevealed = true;
finalLetterScreen.classList.remove('hidden');
requestAnimationFrame(() => {
finalLetterScreen.classList.add('active');
});
}
function hideFinalLetter() {
sealAnimationEpoch++;
isSealParticlesActive = false;
sealParticles = [];
letterRevealed = false;
isEnded = false;
if (endTransitionTimer) clearTimeout(endTransitionTimer);
finalLetterScreen.classList.remove('active');
finalLetterScreen.classList.add('hidden');
if (waxSealBlock) {
waxSealBlock.style.display = '';
waxSealBlock.classList.remove('broken');
}
if (waxSealBtn) waxSealBtn.classList.remove('broken');
const sealWrap = document.querySelector('.wax-seal-wrapper');
if (sealWrap) sealWrap.classList.remove('broken');
if (editorialTagText) editorialTagText.textContent = CONFIG.letter.sealedTag || 'ПЕРСОНАЛЬНОЕ ПОСЛАНИЕ';
letterContent.classList.add('sealed-blur');
isLetterSealed = true;
if (telegramBtn) telegramBtn.classList.add('hidden');
}
function restartExperience() {
hideFinalLetter();
seekTo(0);
if (audio.paused) {
togglePlayPause();
}
}
// ------------------------------------------------------------------
// 16. ОБРАБОТЧИКИ СОБЫТИЙ И ИНТЕРАКТИВНОСТИ
// ------------------------------------------------------------------
function bindEvents() {
// Интеллектуальный сброс таймера бездействия интерфейса при любом движении / клике
['mousemove', 'mousedown', 'touchstart', 'touchmove', 'keydown'].forEach(evt => {
window.addEventListener(evt, restoreUi, { passive: true });
});
// Нажатие на кнопку возврата меню
if (wakeMenuBtn) {
wakeMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
restoreUi();
});
}
// Клик по фону сцены возвращает интерфейс
stage.addEventListener('click', (e) => {
if (e.target.closest('#coverArtBox')) return;
restoreUi();
});
// Защита от скрытия при наведении курсора на плеер или шапку
[appTopbar, spotifyPlayerBar].forEach(el => {
if (!el) return;
el.addEventListener('mouseenter', () => {
if (idleTimer) clearTimeout(idleTimer);
document.body.classList.remove('ui-idle');
});
el.addEventListener('mouseleave', () => {
restoreUi();
});
});
// Режим ожидания (Минимализм: кнопки «Слушать» и «Открыть письмо»)
const retryAudioBtn = document.getElementById('retryAudioBtn');
if (retryAudioBtn) {
retryAudioBtn.addEventListener('click', () => {
audioErrorMessage.classList.add('hidden');
if (audio.error) audio.load();
startExperience();
});
}
if (startPortalBtn) {
startPortalBtn.addEventListener('click', (e) => {
e.stopPropagation();
startExperience();
});
}
if (directLetterBtn) {
directLetterBtn.addEventListener('click', (e) => {
e.stopPropagation();
triggerPlayfulLetterNotice();
});
}
if (naughtyListenBtn) {
naughtyListenBtn.addEventListener('click', (e) => {
e.stopPropagation();
startExperience();
});
}
if (forceLetterBtn) {
forceLetterBtn.addEventListener('click', (e) => {
e.stopPropagation();
initWebAudio();
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
waitingScreen.classList.add('fade-out');
isStarted = true;
restoreUi();
triggerFinalLetter();
});
}
// Запуск только явной кнопкой. Клик по фону и Enter на «Открыть письмо»
// не должны обходить ласковую реакцию и внезапно включать музыку.
// 3D-параллакс мышью и движение светового луча
stage.addEventListener('mousemove', (e) => {
const rect = stage.getBoundingClientRect();
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1;
const pStrength = CONFIG.visuals.parallaxStrength ?? 15;
targetRotY = nx * pStrength;
targetRotX = -ny * pStrength;
if (coverArtBox) {
const cRect = coverArtBox.getBoundingClientRect();
const mx = Math.round(((e.clientX - cRect.left) / cRect.width) * 100);
const my = Math.round(((e.clientY - cRect.top) / cRect.height) * 100);
coverLightSheen.style.setProperty('--mouse-x', `${mx}%`);
coverLightSheen.style.setProperty('--mouse-y', `${my}%`);
}
});
stage.addEventListener('mouseleave', () => {
targetRotX = 0;
targetRotY = 0;
});
// Двойной клик по сцене — переключение Zen-режима
stage.addEventListener('dblclick', (e) => {
if (e.target.closest('#coverArtBox')) return;
toggleZenMode();
});
// Интерактивная водная микро-рябь и брызги по клику на обложку
coverArtBox.addEventListener('click', (e) => {
const rect = coverArtBox.getBoundingClientRect();
const lx = e.clientX - rect.left;
const ly = e.clientY - rect.top;
spawnCoverRipple(lx, ly);
spawnGlobalRipple(e.clientX, e.clientY);
});
// Переключение атмосфер вручную
atmoButtons.forEach(btn => {
btn.addEventListener('click', () => {
const atmo = btn.getAttribute('data-atmo');
setAtmosphere(atmo, false);
});
});
// Переключатель Авто-режима атмосфер
if (atmoAutoBtn) {
atmoAutoBtn.addEventListener('click', () => {
isAutoAtmosphere = !isAutoAtmosphere;
atmoAutoBtn.classList.toggle('active', isAutoAtmosphere);
if (isAutoAtmosphere) {
checkAutoAtmosphere(audio.currentTime);
}
});
}
// Spotify Player Bar Controls
if (playPauseBtn) playPauseBtn.addEventListener('click', togglePlayPause);
if (seekBackBtn) seekBackBtn.addEventListener('click', () => seekTo(audio.currentTime - 5));
if (seekForwardBtn) seekForwardBtn.addEventListener('click', () => seekTo(audio.currentTime + 5));
if (likeTrackBtn) {
likeTrackBtn.addEventListener('click', () => {
isLiked = !isLiked;
likeTrackBtn.classList.toggle('liked', isLiked);
likeTrackBtn.classList.add('popping');
setTimeout(() => likeTrackBtn.classList.remove('popping'), 600);
if (isLiked) {
// Локальный отклик у кнопки, без полноэкранных волн и смены атмосферы.
spawnButtonSparkles();
}
});
}
// Скраббер таймлайна
if (timelineSlider) {
timelineSlider.addEventListener('input', (e) => {
isScrubbing = true;
const val = parseFloat(e.target.value);
timeCurrent.textContent = formatTime(val);
const dur = audio.duration || CONFIG.audio.expectedDuration;
if (scrubberProgress) scrubberProgress.style.width = `${(val / dur) * 100}%`;
updateSliderBackground(timelineSlider, val, dur, true);
renderTimelineState(val);
});
timelineSlider.addEventListener('change', (e) => {
isScrubbing = false;
seekTo(parseFloat(e.target.value));
});
}
if (scrubberWrapper) {
scrubberWrapper.addEventListener('mouseenter', () => {
isScrubberHovered = true;
const dur = audio.duration || CONFIG.audio.expectedDuration;
if (timelineSlider) updateSliderBackground(timelineSlider, timelineSlider.value, dur, true);
});
scrubberWrapper.addEventListener('mouseleave', () => {
isScrubberHovered = false;
const dur = audio.duration || CONFIG.audio.expectedDuration;
if (timelineSlider) updateSliderBackground(timelineSlider, timelineSlider.value, dur, false);
});
}
// Громкость с мак-плавностью (Glide & WebAudio Gain ramping)
if (muteBtn) {
muteBtn.addEventListener('click', () => {
if (isVolumeMuted || audioTargetVolume <= 0.01) {
isVolumeMuted = false;
const target = preMuteVolumeLevel > 0.05 ? preMuteVolumeLevel : 0.85;
glideVolume(audioTargetVolume, target, 220);
} else {
preMuteVolumeLevel = audioTargetVolume;
isVolumeMuted = true;
glideVolume(audioTargetVolume, 0, 220);
}
});
}
if (volumeSlider) {
volumeSlider.addEventListener('input', (e) => {
if (volumeGlideRaf) cancelAnimationFrame(volumeGlideRaf);
const val = parseFloat(e.target.value);
isVolumeMuted = (val === 0);
if (val > 0) preMuteVolumeLevel = val;
applySmoothAudioVolume(val, 0.035);
updateVolumeIcons();
updateSliderBackground(volumeSlider, val * 100, 100, true);
});
}
if (volumeBlock) {
volumeBlock.addEventListener('mouseenter', () => {
isVolumeHovered = true;
if (volumeSlider) {
const curVol = audio.muted ? 0 : audio.volume * 100;
updateSliderBackground(volumeSlider, curVol, 100, true);
}
});
volumeBlock.addEventListener('mouseleave', () => {
isVolumeHovered = false;
if (volumeSlider) {
const curVol = audio.muted ? 0 : audio.volume * 100;
updateSliderBackground(volumeSlider, curVol, 100, false);
}
});
}
// Полноэкранный режим
if (fullscreenBtn) {
fullscreenBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
});
}
// Режим погружения «Только мы с тобой» (Zen Mode)
function toggleZenMode() {
isZenMode = !isZenMode;
document.body.classList.toggle('zen-mode', isZenMode);
if (zenModeBtn) zenModeBtn.classList.toggle('active', isZenMode);
if (zenModeNavBtn) zenModeNavBtn.classList.toggle('active', isZenMode);
}
if (zenModeBtn) zenModeBtn.addEventListener('click', toggleZenMode);
if (zenModeNavBtn) zenModeNavBtn.addEventListener('click', toggleZenMode);
// Spotify Synced Lyrics View
function toggleLyrics() {
if (!spotifyLyricsOverlay) return;
spotifyLyricsOverlay.classList.toggle('hidden');
document.body.classList.toggle('lyrics-open', !spotifyLyricsOverlay.classList.contains('hidden'));
if (!spotifyLyricsOverlay.classList.contains('hidden')) {
updateLyricsHighlight(audio.currentTime, true);
document.body.classList.remove('ui-idle');
} else {
restoreUi();
}
}
if (toggleLyricsBtn) toggleLyricsBtn.addEventListener('click', toggleLyrics);
if (toggleLyricsPlayerBtn) toggleLyricsPlayerBtn.addEventListener('click', toggleLyrics);
if (closeLyricsBtn) closeLyricsBtn.addEventListener('click', toggleLyrics);
lyricLines.forEach(line => {
line.addEventListener('click', () => {
const startSec = parseFloat(line.getAttribute('data-start'));
if (!isNaN(startSec)) {
seekTo(startSec);
if (audio.paused) audio.play();
}
});
});
// Тактильная сургучная печать Claude
if (waxSealBtn) {
waxSealBtn.addEventListener('click', breakWaxSeal);
}
// Клавиатурные шорткаты
window.addEventListener('keydown', (e) => {
if (e.defaultPrevented || !waitingScreen.classList.contains('fade-out')) return;
if (e.target.closest('input, textarea, select, [contenteditable="true"]')) return;
if ((e.code === 'Space' || e.key === 'Enter') && e.target.closest('button, a')) return;
if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32) {
e.preventDefault();
togglePlayPause();
} else if (e.code === 'ArrowLeft' || e.key === 'ArrowLeft' || e.keyCode === 37) {
e.preventDefault();
seekTo(audio.currentTime - 5);
} else if (e.code === 'ArrowRight' || e.key === 'ArrowRight' || e.keyCode === 39) {
e.preventDefault();
seekTo(audio.currentTime + 5);
} else if (e.code === 'KeyM' || e.key === 'm' || e.key === 'M' || e.key === 'ь' || e.key === 'Ь') {
muteBtn.click();
} else if (e.code === 'KeyL' || e.key === 'l' || e.key === 'L' || e.key === 'д' || e.key === 'Д') {
toggleLyrics();
} else if (e.code === 'KeyZ' || e.key === 'z' || e.key === 'Z' || e.key === 'я' || e.key === 'Я') {
toggleZenMode();
} else if (e.code === 'KeyF' || e.key === 'f' || e.key === 'F' || e.key === 'а' || e.key === 'А') {
fullscreenBtn.click();
} else if (e.code === 'Escape' || e.key === 'Escape') {
if (spotifyLyricsOverlay && !spotifyLyricsOverlay.classList.contains('hidden')) {
spotifyLyricsOverlay.classList.add('hidden');
document.body.classList.remove('lyrics-open');
} else if (isZenMode) {
toggleZenMode();
} else if (!audio.paused) {
togglePlayPause();
}
}
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
lastFrameTime = performance.now();
renderTimelineState(audio.currentTime);
}
});
}
function formatTime(sec) {
if (isNaN(sec) || sec < 0) return '00:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
window.setAtmosphere = setAtmosphere;
window.triggerHeartExperience = triggerHeartExperience;
window.getCornerOrigin = getCornerOrigin;
window.triggerAtmosphereWave = triggerAtmosphereWave;
window.seekTo = seekTo;
window.isAtmoWaveActive = () => activeAtmoWave !== null;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>