707 lines
23 KiB
HTML
707 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Andrey Flat — Space</title>
|
|
<meta name="description" content="Personal Space of Digital Media Artist Andrey Flat. VFX, Generative Art, Houdini, Touchdesigner works." />
|
|
<meta name="keywords" content="Andrey Flat, CG Artist, VFX, Houdini, Touchdesigner, Generative Art, Motion Design" />
|
|
<meta name="author" content="Andrey Flat" />
|
|
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:title" content="Andrey Flat — Digital Media Artist" />
|
|
<meta property="og:description" content="Personal Space. A curated selection of VFX and Generative Art." />
|
|
<meta property="og:image" content="https://andreyflat.space/cover.jpg" />
|
|
<meta property="og:url" content="https://andreyflat.space/" />
|
|
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:image" content="https://andreyflat.space/cover.jpg" />
|
|
|
|
<link rel="icon" href="favicon.ico" type="image/x-icon" />
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
/* --- CSS RESET & BASE --- */
|
|
body {
|
|
background: #000;
|
|
color: #eee;
|
|
font-family: 'Space Grotesk', sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow-x: hidden; /* Prevent horizontal scroll */
|
|
}
|
|
|
|
/* --- HEADER --- */
|
|
header {
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
background-color: rgba(10, 10, 10, 0.8);
|
|
padding: 0.5em;
|
|
text-align: center;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1000;
|
|
border-bottom: 0px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
header h1 {
|
|
display: inline-block;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-align: center;
|
|
max-width: 100vw;
|
|
text-overflow: clip;
|
|
margin: 0.2em 0;
|
|
cursor: pointer;
|
|
/* Default transition */
|
|
transition: letter-spacing 0.5s ease-out;
|
|
}
|
|
|
|
header h1:hover {
|
|
letter-spacing: 0.5em; /* Reduced excessive spacing for better UX */
|
|
transition: letter-spacing 2s ease;
|
|
}
|
|
|
|
header p {
|
|
margin: 0 0 0.5em 0;
|
|
color: #888;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* --- MAIN CONTENT --- */
|
|
main {
|
|
max-width: 1280px;
|
|
margin: 1em auto;
|
|
padding: 0 1em;
|
|
}
|
|
|
|
section {
|
|
margin-bottom: 2em;
|
|
}
|
|
|
|
p {
|
|
line-height: 1.6;
|
|
}
|
|
|
|
a {
|
|
color: #fff;
|
|
text-decoration: underline;
|
|
text-underline-offset: 4px;
|
|
transition: color 0.3s;
|
|
}
|
|
a:hover {
|
|
color: #aaa;
|
|
}
|
|
|
|
/* --- VIDEO LAYOUT --- */
|
|
.video-container {
|
|
position: relative;
|
|
width: 100%;
|
|
max-width: 1280px;
|
|
margin: 0 auto 2em;
|
|
aspect-ratio: 16 / 9;
|
|
background: #111;
|
|
}
|
|
|
|
.video-container iframe {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
}
|
|
|
|
.video-grid {
|
|
display: grid;
|
|
/* Responsive grid: min 300px on mobile, larger on desktop */
|
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
gap: 1px; /* Small gap for aesthetic */
|
|
justify-content: center;
|
|
background: #000;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.video-grid {
|
|
grid-template-columns: 1fr; /* 1 column on mobile */
|
|
}
|
|
}
|
|
|
|
.video-tile {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 1 / 1;
|
|
overflow: hidden;
|
|
background: #050505;
|
|
display: block;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.video-tile video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
opacity: 0.8;
|
|
transition: opacity 0.3s ease, transform 0.5s ease;
|
|
}
|
|
|
|
.video-tile:hover video {
|
|
opacity: 1;
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
/* Animation classes */
|
|
.video-tile.fade-in {
|
|
opacity: 0;
|
|
transform: translateY(30px);
|
|
transition: opacity 0.6s ease, transform 0.6s ease;
|
|
}
|
|
|
|
.video-tile.fade-in.visible {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
/* --- CAPTIONS --- */
|
|
.caption {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
padding: 20px;
|
|
box-sizing: border-box;
|
|
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0) 100%);
|
|
color: #fff;
|
|
opacity: 0; /* Hidden by default on desktop */
|
|
transition: opacity 0.3s ease;
|
|
pointer-events: none; /* Let clicks pass through to the link */
|
|
z-index: 2;
|
|
}
|
|
|
|
.caption h3 {
|
|
margin: 0;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.caption p {
|
|
margin: 4px 0 0;
|
|
font-size: 0.85rem;
|
|
color: #ccc;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Desktop Hover */
|
|
.video-tile:hover .caption {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* --- MOBILE FIX: Always show caption on touch devices/small screens --- */
|
|
@media (hover: none), (max-width: 768px) {
|
|
.caption {
|
|
opacity: 1;
|
|
background: linear-gradient(to top, rgba(0,0,0,0.95) 10%, rgba(0,0,0,0.4) 60%, transparent 100%);
|
|
padding-bottom: 25px;
|
|
}
|
|
|
|
/* Reset video transform on mobile to save battery/performance */
|
|
.video-tile:hover video {
|
|
transform: none;
|
|
}
|
|
}
|
|
/* --- FOOTER & SOCIALS --- */
|
|
footer {
|
|
text-align: center;
|
|
padding: 1em 1em 2em; /* Чуть больше отступа снизу */
|
|
background: #111;
|
|
color: #555;
|
|
font-size: 0.9em;
|
|
margin-top: 2em;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1.5em; /* Расстояние между копирайтом и иконками */
|
|
}
|
|
|
|
.social-links {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 20px; /* Расстояние между иконками */
|
|
margin-top: 0.5em;
|
|
}
|
|
|
|
.social-icon {
|
|
display: block;
|
|
width: 24px;
|
|
height: 24px;
|
|
fill: #555; /* Цвет иконок в покое (как текст) */
|
|
transition: fill 0.3s ease, transform 0.3s ease;
|
|
}
|
|
|
|
.social-icon:hover {
|
|
fill: #fff; /* Цвет при наведении */
|
|
transform: translateY(-3px); /* Легкое всплытие */
|
|
}
|
|
|
|
#scrollToTopBtn {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
z-index: 9999;
|
|
background-color: rgba(255,255,255,0.1);
|
|
color: #fff;
|
|
font-size: 1.2rem;
|
|
width: 50px;
|
|
height: 50px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
transition: opacity 0.4s ease, transform 0.4s ease, background 0.3s;
|
|
pointer-events: none;
|
|
backdrop-filter: blur(5px);
|
|
}
|
|
|
|
#scrollToTopBtn.show {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
#scrollToTopBtn:hover {
|
|
background-color: rgba(255,255,255,0.3);
|
|
}
|
|
|
|
/* Loading Spinner for infinite scroll */
|
|
#loading-indicator {
|
|
text-align: center;
|
|
padding: 20px;
|
|
color: #555;
|
|
font-size: 0.8rem;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
#loading-indicator.active {
|
|
opacity: 1;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1 id="mainTitle" onclick="window.scrollTo({ top: 0, behavior: 'smooth' })">
|
|
flat space
|
|
</h1>
|
|
<p>a small space in a huge universe</p>
|
|
</header>
|
|
|
|
<main>
|
|
<section style="text-align: justify; max-width: 800px; margin: 0 auto;">
|
|
<p>Welcome to the personal space of CG artist <strong>Andrey Flat</strong>. This is a curated selection of works spanning the past 20 years.</p>
|
|
<p>Andrey began his creative journey in <strong>2003</strong> by experimenting with game footage, using tools like <strong>Sony Vegas, Daz 3D, and 3ds Max</strong>. By 2008, he was working in television as a video editor, focusing on <strong>After Effects</strong>.</p>
|
|
<p>In <strong>2011</strong>, he joined a film studio, advancing to VFX supervisor while mastering tools like <strong>Final Cut, DaVinci Resolve, Cinema 4D, Naiad, and Houdini</strong>.</p>
|
|
<p>Since <strong>2014</strong>, he has worked independently, specializing in procedural and generative effects with <strong>Houdini, Nuke, Touchdesigner and ComfyUI</strong>.</p>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="video-container">
|
|
<iframe
|
|
src="https://www.youtube.com/embed/GwuMJhSXcj0"
|
|
title="Showreel 2023"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowfullscreen
|
|
loading="lazy">
|
|
</iframe>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="video-grid" id="videoGrid"></div>
|
|
<div id="loading-indicator">loading works...</div>
|
|
</section>
|
|
</main>
|
|
<footer>
|
|
<div class="social-links">
|
|
|
|
<a href="https://www.behance.net/andrey_flat" target="_blank" aria-label="Behance">
|
|
<svg class="social-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M22 7h-5v2h5V7zm0 10h-5v2h5v-2zm-5-6h5v2h-5v-2zM4.02 12.06c.6.09 1.13.3 1.56.62.43.32.74.74.93 1.25.19.51.29 1.1.29 1.77 0 .84-.15 1.57-.45 2.19-.3.62-.73 1.11-1.29 1.48-.56.37-1.24.56-2.04.56H.5v-8h3.52zM3.48 9.32h-3v-5h3c.72 0 1.32.17 1.79.51.48.34.72.82.72 1.44 0 .39-.1.74-.31 1.05-.2.31-.5.56-.88.74.52.19.92.5 1.2.94.28.44.42.98.42 1.62h2.24c0-1.07-.26-1.96-.79-2.69-.53-.73-1.26-1.1-2.19-1.12.72-.25 1.27-.63 1.64-1.13.37-.5.56-1.12.56-1.87 0-.96-.28-1.74-.83-2.32C6.49 1.11 5.71.82 4.7.82H.5v8.5h2.98zM3.1 2.89h1.34c.48 0 .84.1.98.31.2.29.3.62.3.99 0 .43-.11.78-.34 1.04-.23.26-.62.39-1.17.39H3.1V2.89zm.1 9.87v3.29h1.12c.56 0 .98-.16 1.26-.47.28-.31.42-.74.42-1.29 0-.48-.13-.88-.38-1.18-.25-.3-.64-.45-1.17-.45H3.2z"/>
|
|
</svg>
|
|
</a>
|
|
|
|
<a href="https://www.instagram.com/andrey.flat" target="_blank" aria-label="Instagram">
|
|
<svg class="social-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.069-4.85.069-3.204 0-3.584-.011-4.849-.069-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
|
</svg>
|
|
</a>
|
|
|
|
<a href="https://youtube.com/@andreyflat" target="_blank" aria-label="YouTube">
|
|
<svg class="social-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
|
</svg>
|
|
</a>
|
|
|
|
<a href="mailto:andrey.flat.post@gmail.com" aria-label="Email">
|
|
<svg class="social-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
|
</svg>
|
|
</a>
|
|
|
|
</div>
|
|
|
|
<p>© <span id="year"></span> Andrey Flat</p>
|
|
</footer>
|
|
|
|
<button id="scrollToTopBtn" title="Up">⇧</button>
|
|
|
|
<script>
|
|
// --- DATA SOURCE ---
|
|
const videoSources = [
|
|
{
|
|
src: "videos/project_01.mp4",
|
|
title: "EUCARIOTE (2021)",
|
|
description: "Personal project. Consciousness of a living organism.",
|
|
link: "https://www.behance.net/gallery/100474835/Eucariote"
|
|
},
|
|
{
|
|
src: "videos/project_02.mp4",
|
|
title: "SALUKI (2023)",
|
|
description: "Music video. Large water FLIP simulation.",
|
|
link: "https://www.behance.net/gallery/181128113/Large-Water"
|
|
},
|
|
{
|
|
src: "videos/project_03.mp4",
|
|
title: "MACROPLASTIC (2024)",
|
|
description: "Personal project. Trapped by generated waste.",
|
|
link: "https://www.behance.net/gallery/214236927/MACROPLASTIC"
|
|
},
|
|
{
|
|
src: "videos/project_04.mp4",
|
|
title: "Water Drop (2020)",
|
|
description: "Personal project. Unit-M5 Empire-V tribute.",
|
|
link: "https://www.instagram.com/p/Cye_mjiSt31/"
|
|
},
|
|
{
|
|
src: "videos/project_05.mp4",
|
|
title: "Experiment №80/26 (2021)",
|
|
description: "Personal project. Iron Kettlebell in Mercury.",
|
|
link: "https://www.behance.net/gallery/130228053/Experiment-8026"
|
|
},
|
|
{
|
|
src: "videos/project_06.mp4",
|
|
title: "Waterfall (2022)",
|
|
description: "Exhibition. Led screen visual.",
|
|
link: "https://www.behance.net/gallery/146506521/WATERFALL"
|
|
},
|
|
{
|
|
src: "videos/project_07.mp4",
|
|
title: "Dry Season (2023)",
|
|
description: "Simulation and visualisation experiment.",
|
|
link: "https://www.instagram.com/p/Cy0Cg8OytWN/"
|
|
},
|
|
{
|
|
src: "videos/project_08.mp4",
|
|
title: "HiPhi Z Rollout (2022)",
|
|
description: "AR Electric car factory launch show.",
|
|
link: "https://www.behance.net/gallery/148168593/HiPhi-Z-Rollout-Show"
|
|
},
|
|
{
|
|
src: "videos/project_09.mp4",
|
|
title: "Infinix HOT 10 (2021)",
|
|
description: "Advertising video for phone.",
|
|
link: "https://www.behance.net/gallery/116302519/Infinix-Hot-X-Play"
|
|
},
|
|
{
|
|
src: "videos/project_10.mp4",
|
|
title: "2000x2036 (2021)",
|
|
description: "Personal project. Destructive behavior.",
|
|
link: "https://www.behance.net/gallery/119929513/2000x2036"
|
|
},
|
|
{
|
|
src: "videos/project_11.mp4",
|
|
title: "EX vs REX (2020)",
|
|
description: "Collaboration. What came first?",
|
|
link: "https://www.behance.net/gallery/104782579/EX-vs-REX"
|
|
},
|
|
{
|
|
src: "videos/project_12.mp4",
|
|
title: "Bloemstilleven (2021)",
|
|
description: "Confrontation between humans and AI.",
|
|
link: "https://www.behance.net/gallery/198703355/Bloemstilleven"
|
|
},
|
|
{
|
|
src: "videos/project_13.mp4",
|
|
title: "cohærens (2024)",
|
|
description: "Personal project. A state of united whole.",
|
|
link: "https://youtu.be/tZphaJ_MWXI"
|
|
},
|
|
{
|
|
src: "videos/project_14.mp4",
|
|
title: "Magic Animals (2024)",
|
|
description: "Exposition. Festival of light. Holographic screens.",
|
|
link: "https://www.behance.net/gallery/208881221/Magic-Animals"
|
|
},
|
|
{
|
|
src: "videos/project_15.mp4",
|
|
title: "ELEMENTS (2024)",
|
|
description: "LED screens. Water, emotions, intuition.",
|
|
link: "https://www.behance.net/gallery/209820423/ELEMENTS"
|
|
},
|
|
{
|
|
src: "videos/project_16.mp4",
|
|
title: "MURAQQA - Experiment (2023)",
|
|
description: "Experimental visuals.",
|
|
link: "https://youtu.be/oEwYm7WJNL0"
|
|
},
|
|
{
|
|
src: "videos/project_17.mp4",
|
|
title: "MURAQQA - Genesis (2023)",
|
|
description: "Generative art piece.",
|
|
link: "https://youtu.be/ZyfbAwPa-5Q"
|
|
},
|
|
{
|
|
src: "videos/project_18.mp4",
|
|
title: "Scene Change (2018)",
|
|
description: "Venice Biennale 2017. Motion Designer.",
|
|
link: "https://www.behance.net/gallery/202101993/Scene-change-Prologue"
|
|
},
|
|
{
|
|
src: "videos/project_19.mp4",
|
|
title: "7 AV-Performance (2021)",
|
|
description: "Art project. Scene: Prologue, Spectrum",
|
|
link: "https://www.behance.net/gallery/128538543/7-AV-Performance"
|
|
},
|
|
{
|
|
src: "videos/project_20.mp4",
|
|
title: "Pre-match show (2025)",
|
|
description: "Multimedia performance. Particles workflow.",
|
|
link: "https://www.behance.net/gallery/224229141/Winline-Match-Tour"
|
|
},
|
|
{
|
|
src: "videos/project_21.mp4",
|
|
title: "Black Hole",
|
|
description: "timeline test.",
|
|
link: "https://andreyflat.space/timeline"
|
|
},
|
|
];
|
|
|
|
// --- LOGIC ---
|
|
|
|
// 1. Dynamic Year
|
|
document.getElementById('year').textContent = new Date().getFullYear();
|
|
|
|
// 2. Improved Shuffling (Fisher-Yates)
|
|
// Replaces Math.random() - 0.5 which is biased
|
|
function shuffleArray(array) {
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
}
|
|
shuffleArray(videoSources);
|
|
|
|
// 3. Grid & Loading Logic
|
|
const videoGrid = document.getElementById('videoGrid');
|
|
const loadingIndicator = document.getElementById('loading-indicator');
|
|
let loadedCount = 0;
|
|
const BATCH_SIZE = 4; // Increased batch size slightly
|
|
let isLoading = false;
|
|
|
|
function loadVideos(startIndex, count) {
|
|
isLoading = true;
|
|
loadingIndicator.classList.add('active');
|
|
|
|
// Simulate small delay for UI feel or remove setTimeOut for instant
|
|
setTimeout(() => {
|
|
const limit = Math.min(startIndex + count, videoSources.length);
|
|
|
|
for (let i = startIndex; i < limit; i++) {
|
|
const { src, title, description, link } = videoSources[i];
|
|
|
|
const tile = document.createElement('a');
|
|
tile.href = link || `https://andreyflat.space/projects/${i + 1}`;
|
|
tile.target = '_blank';
|
|
tile.className = 'video-tile fade-in';
|
|
tile.rel = "noopener noreferrer"; // Security best practice
|
|
|
|
const video = document.createElement('video');
|
|
// Fallback to jpg if poster exists, otherwise browser shows black
|
|
video.poster = src.replace(/\.mp4$/, '.jpg');
|
|
video.muted = true;
|
|
video.loop = true;
|
|
video.playsInline = true; // Critical for iOS
|
|
video.dataset.src = src;
|
|
|
|
const caption = document.createElement('div');
|
|
caption.className = 'caption';
|
|
caption.innerHTML = `<h3>${title}</h3><p>${description}</p>`;
|
|
|
|
tile.appendChild(video);
|
|
tile.appendChild(caption);
|
|
videoGrid.appendChild(tile);
|
|
|
|
// Observe for animation and playback
|
|
fadeObserver.observe(tile);
|
|
videoObserver.observe(video);
|
|
|
|
// Desktop Hover Logic (Mouse)
|
|
tile.addEventListener('mouseenter', () => {
|
|
if (!video.src) {
|
|
video.src = video.dataset.src;
|
|
video.preload = 'auto';
|
|
}
|
|
video.play().catch(() => { /* Auto-play prevention handler */ });
|
|
});
|
|
|
|
tile.addEventListener('mouseleave', () => {
|
|
video.pause();
|
|
});
|
|
}
|
|
|
|
loadedCount = limit;
|
|
isLoading = false;
|
|
loadingIndicator.classList.remove('active');
|
|
|
|
if (loadedCount >= videoSources.length) {
|
|
loadingIndicator.style.display = 'none';
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
// 4. Observers
|
|
const fadeObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('visible');
|
|
fadeObserver.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.1 });
|
|
|
|
const videoObserver = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const video = entry.target;
|
|
// Lazy load src
|
|
if (!video.src) {
|
|
video.src = video.dataset.src;
|
|
video.preload = 'auto';
|
|
}
|
|
// Mobile/Touch Autoplay logic:
|
|
// If it's a touch device, we might want to auto-play when visible
|
|
// combined with the CSS caption fix.
|
|
if (window.matchMedia("(hover: none)").matches) {
|
|
video.play().catch(()=>{});
|
|
}
|
|
|
|
observer.unobserve(video);
|
|
}
|
|
});
|
|
}, { rootMargin: '200px 0px' });
|
|
|
|
|
|
// 5. Scroll Performance (requestAnimationFrame)
|
|
const h1 = document.querySelector("header h1");
|
|
const scrollToTopBtn = document.getElementById("scrollToTopBtn");
|
|
let ticking = false;
|
|
|
|
window.addEventListener("scroll", () => {
|
|
if (!ticking) {
|
|
window.requestAnimationFrame(() => {
|
|
performScrollLogic();
|
|
ticking = false;
|
|
});
|
|
ticking = true;
|
|
}
|
|
});
|
|
|
|
function performScrollLogic() {
|
|
const scrollY = window.scrollY;
|
|
|
|
// Header Animation
|
|
if (!h1.matches(':hover') && window.innerWidth > 768) {
|
|
// Only animate letter spacing on desktop to avoid breakage on mobile
|
|
const baseMaxSpacing = 0.0012;
|
|
const maxScroll = 3000;
|
|
const dynamicMaxSpacing = window.innerWidth * baseMaxSpacing;
|
|
const spacing = Math.min(scrollY / maxScroll * dynamicMaxSpacing, dynamicMaxSpacing);
|
|
h1.style.letterSpacing = spacing + "em";
|
|
}
|
|
|
|
// Scroll To Top Button
|
|
if (scrollY > 500) {
|
|
scrollToTopBtn.classList.add("show");
|
|
} else {
|
|
scrollToTopBtn.classList.remove("show");
|
|
}
|
|
|
|
// Infinite Scroll
|
|
if (!isLoading && loadedCount < videoSources.length) {
|
|
const gridBottom = videoGrid.offsetTop + videoGrid.offsetHeight;
|
|
const viewBottom = scrollY + window.innerHeight;
|
|
if (viewBottom > gridBottom - 300) {
|
|
loadVideos(loadedCount, BATCH_SIZE);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scroll Button Click
|
|
scrollToTopBtn.addEventListener("click", () => {
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
});
|
|
|
|
// Init
|
|
window.addEventListener('load', () => {
|
|
loadVideos(loadedCount, BATCH_SIZE);
|
|
});
|
|
|
|
// Header interaction for desktop
|
|
h1.addEventListener("mouseenter", () => {
|
|
if (window.innerWidth > 768) {
|
|
h1.style.letterSpacing = "0.8em";
|
|
h1.style.transition = "letter-spacing 4s ease";
|
|
}
|
|
});
|
|
h1.addEventListener("mouseleave", () => {
|
|
h1.style.letterSpacing = "";
|
|
h1.style.transition = "letter-spacing 1s ease";
|
|
});
|
|
</script>
|
|
|
|
<script>
|
|
window.addEventListener("load", () => {
|
|
try {
|
|
if (sessionStorage.getItem("af_logged")) return;
|
|
sessionStorage.setItem("af_logged", "1");
|
|
|
|
// WARNING: Exposing secret keys in frontend code is risky.
|
|
// Anyone can see this key. Use environment variables in Netlify if possible.
|
|
const secret = "flat_secret_123";
|
|
|
|
const url = "/.netlify/functions/privateViewLogger"
|
|
+ "?key=" + encodeURIComponent(secret)
|
|
+ "&path=" + encodeURIComponent(location.pathname)
|
|
+ "&t=" + Date.now();
|
|
|
|
if (navigator.sendBeacon) {
|
|
const body = JSON.stringify({ path: location.pathname });
|
|
navigator.sendBeacon(url, body);
|
|
} else {
|
|
fetch(url, {
|
|
method: "POST",
|
|
keepalive: true,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ path: location.pathname })
|
|
}).catch(() => {});
|
|
}
|
|
} catch {}
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|