Download index.html from VSPAN/Fish: direct link, hf CLI and curl.
- Browser
- Download file 197 kB
-
https://huggingface.co/spaces/VSPAN/Fish/resolve/main/index.html
- Command line
-
hf download hf://spaces/VSPAN/Fish/index.html
-
curl -L -o index.html https://huggingface.co/spaces/VSPAN/Fish/resolve/main/index.html
197 kB
| <!-- 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 ; | |
| } | |
| /* ==================================================================== | |
| ХОЛСТЫ И ДИНАМИЧЕСКИЙ ФОН | |
| ==================================================================== */ | |
| .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 ; | |
| opacity: 1 ; | |
| } | |
| .ambient-backdrop.wave-outgoing { | |
| z-index: 2 ; | |
| opacity: 1 ; | |
| } | |
| .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 ; | |
| 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 ; | |
| 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)) ; | |
| } | |
| .like-btn.liked svg { | |
| fill: var(--current-dynamic-accent, var(--sp-accent)) ; | |
| stroke: var(--current-dynamic-accent, var(--sp-accent)) ; | |
| filter: drop-shadow(0 0 12px var(--current-dynamic-glow, var(--sp-accent-glow))) ; | |
| 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 ; | |
| transform: translateY(-35px) scale(1.12) ; | |
| transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1), transform 0.8s cubic-bezier(0.16, 1, 0.3, 1) ; | |
| } | |
| @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 ; | |
| transform: translateY(-20px) ; | |
| pointer-events: none ; | |
| transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1) ; | |
| } | |
| body.zen-mode .spotify-player-bar { | |
| transform: translateY(20px) ; | |
| } | |
| body.zen-mode.show-ui .app-topbar, | |
| body.zen-mode.show-ui .spotify-player-bar { | |
| opacity: 0.95 ; | |
| transform: translateY(0) ; | |
| pointer-events: auto ; | |
| } | |
| /* ==================================================================== | |
| МУЗЫКАЛЬНАЯ ЛАБОРАТОРИЯ (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>и всё же сразу к письму →</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="Закрыть текст">×</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> | |