diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index cb6d1c1..cb47c26 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -7,6 +7,7 @@ import "../globals.css"; import { AppShell } from "@/components/app-shell"; import { GoogleAnalytics } from "@/components/google-analytics"; +import { PoyrazBottomRightFollower } from "@/components/poyraz-bottom-right-follower"; export async function generateMetadata({ params, @@ -115,6 +116,7 @@ export default async function LocaleLayout({ {children} + diff --git a/app/globals.css b/app/globals.css index b3b5f74..29a3fda 100644 --- a/app/globals.css +++ b/app/globals.css @@ -13,6 +13,19 @@ } } +html[data-poyraz-theme="light"] { + --poyraz-background: #ffffff; + background-color: #ffffff; +} + +html[data-poyraz-theme="light"] body { + background-color: #ffffff; +} + +html[data-poyraz-theme="dark"] [data-cursor-portrait] { + display: none; +} + @layer utilities { @keyframes marquee { 0% { transform: translateX(0%); } diff --git a/components/poyraz-bottom-right-follower.tsx b/components/poyraz-bottom-right-follower.tsx new file mode 100644 index 0000000..5a757d1 --- /dev/null +++ b/components/poyraz-bottom-right-follower.tsx @@ -0,0 +1,295 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { + clamp, + DEFAULT_TIME, + mapPointerYToTime, + SMOOTHING, + TOTAL_DURATION, +} from "@/lib/cursor-portrait"; + +const VIDEO_SRC = "/media/cursor-portrait/poyraz-bottom-right.mp4"; +const POSTER_SRC = "/media/cursor-portrait/poyraz-bottom-right-poster.webp"; + +const SEEK_INTERVAL_MS = 1000 / 60; +const MIN_TIME_DELTA = 0.002; +const MIN_SEEK_DELTA = 1 / 120; + +type DisplayMode = + | "pending" + | "interactive" + | "poster-reduced" + | "hidden-mobile" + | "hidden-dark"; + +function subscribeToMediaQuery(query: MediaQueryList, listener: () => void) { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); + } + + query.addListener(listener); + return () => query.removeListener(listener); +} + +export function PoyrazBottomRightFollower() { + const videoRef = useRef(null); + const pointerYRef = useRef(null); + const targetTimeRef = useRef(DEFAULT_TIME); + const currentTimeRef = useRef(DEFAULT_TIME); + const rafIdRef = useRef(null); + const lastSeekTimestampRef = useRef(0); + const metadataReadyRef = useRef(false); + const primedRef = useRef(false); + const [displayMode, setDisplayMode] = useState("pending"); + const [videoFailed, setVideoFailed] = useState(false); + + useEffect(() => { + const finePointerQuery = window.matchMedia("(pointer: fine)"); + const desktopQuery = window.matchMedia("(min-width: 840px)"); + const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + + const updateDisplayMode = () => { + if (document.documentElement.dataset.poyrazTheme === "dark") { + setDisplayMode("hidden-dark"); + return; + } + + if (!desktopQuery.matches || !finePointerQuery.matches) { + setDisplayMode("hidden-mobile"); + return; + } + + setDisplayMode(reducedMotionQuery.matches ? "poster-reduced" : "interactive"); + }; + + updateDisplayMode(); + const unsubscribeFinePointer = subscribeToMediaQuery( + finePointerQuery, + updateDisplayMode, + ); + const unsubscribeDesktop = subscribeToMediaQuery(desktopQuery, updateDisplayMode); + const unsubscribeReducedMotion = subscribeToMediaQuery( + reducedMotionQuery, + updateDisplayMode, + ); + const themeObserver = new MutationObserver(updateDisplayMode); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-poyraz-theme"], + }); + + return () => { + unsubscribeFinePointer(); + unsubscribeDesktop(); + unsubscribeReducedMotion(); + themeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + if (displayMode !== "interactive" || videoFailed) return; + + const video = videoRef.current; + if (!video) return; + + let disposed = false; + + const stopAnimationLoop = () => { + if (rafIdRef.current === null) return; + window.cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + }; + + const seekVideo = (timestamp: number) => { + if ( + !metadataReadyRef.current || + video.seeking || + timestamp - lastSeekTimestampRef.current < SEEK_INTERVAL_MS + ) { + return; + } + + const duration = Number.isFinite(video.duration) + ? Math.min(video.duration, TOTAL_DURATION) + : TOTAL_DURATION; + const nextTime = clamp(currentTimeRef.current, 0, duration); + + if (Math.abs(video.currentTime - nextTime) < MIN_SEEK_DELTA) return; + + try { + video.currentTime = nextTime; + lastSeekTimestampRef.current = timestamp; + } catch { + // The poster remains visible until the browser exposes seekable metadata. + } + }; + + const runAnimationFrame = (timestamp: number) => { + rafIdRef.current = null; + if (disposed || document.hidden) return; + + const difference = targetTimeRef.current - currentTimeRef.current; + const settled = Math.abs(difference) <= MIN_TIME_DELTA; + currentTimeRef.current = settled + ? targetTimeRef.current + : currentTimeRef.current + difference * SMOOTHING; + + seekVideo(timestamp); + + const videoNeedsSeek = + metadataReadyRef.current && + (video.seeking || + Math.abs(video.currentTime - targetTimeRef.current) >= MIN_SEEK_DELTA); + + if (!settled || videoNeedsSeek) { + rafIdRef.current = window.requestAnimationFrame(runAnimationFrame); + } + }; + + const startAnimationLoop = () => { + if ( + disposed || + document.hidden || + !metadataReadyRef.current || + rafIdRef.current !== null + ) { + return; + } + + rafIdRef.current = window.requestAnimationFrame(runAnimationFrame); + }; + + const returnToDefault = () => { + pointerYRef.current = null; + targetTimeRef.current = DEFAULT_TIME; + startAnimationLoop(); + }; + + const primeVideo = () => { + if (primedRef.current || !metadataReadyRef.current) return; + primedRef.current = true; + + const resumeTime = currentTimeRef.current; + video.muted = true; + void video + .play() + .then(() => { + if (disposed) return; + video.pause(); + video.currentTime = resumeTime; + }) + .catch(() => { + video.pause(); + }); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (event.pointerType !== "mouse") return; + + pointerYRef.current = event.clientY; + targetTimeRef.current = mapPointerYToTime(event.clientY, window.innerHeight); + primeVideo(); + startAnimationLoop(); + }; + + const handleResize = () => { + if (pointerYRef.current === null) return; + targetTimeRef.current = mapPointerYToTime( + pointerYRef.current, + window.innerHeight, + ); + startAnimationLoop(); + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + stopAnimationLoop(); + return; + } + + startAnimationLoop(); + }; + + const handleLoadedMetadata = () => { + video.pause(); + metadataReadyRef.current = true; + targetTimeRef.current = DEFAULT_TIME; + currentTimeRef.current = DEFAULT_TIME; + video.currentTime = clamp(DEFAULT_TIME, 0, video.duration); + }; + + if (video.readyState >= HTMLMediaElement.HAVE_METADATA) { + handleLoadedMetadata(); + } + + video.addEventListener("loadedmetadata", handleLoadedMetadata); + window.addEventListener("pointermove", handlePointerMove, { passive: true }); + document.documentElement.addEventListener("pointerleave", returnToDefault); + window.addEventListener("blur", returnToDefault); + window.addEventListener("resize", handleResize, { passive: true }); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + disposed = true; + stopAnimationLoop(); + video.pause(); + metadataReadyRef.current = false; + primedRef.current = false; + pointerYRef.current = null; + targetTimeRef.current = DEFAULT_TIME; + currentTimeRef.current = DEFAULT_TIME; + lastSeekTimestampRef.current = 0; + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + window.removeEventListener("pointermove", handlePointerMove); + document.documentElement.removeEventListener("pointerleave", returnToDefault); + window.removeEventListener("blur", returnToDefault); + window.removeEventListener("resize", handleResize); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [displayMode, videoFailed]); + + const showVideo = displayMode === "interactive" && !videoFailed; + + if ( + displayMode === "pending" || + displayMode === "hidden-mobile" || + displayMode === "hidden-dark" + ) { + return null; + } + + return ( + + ); +} diff --git a/lib/cursor-portrait.ts b/lib/cursor-portrait.ts new file mode 100644 index 0000000..db0296e --- /dev/null +++ b/lib/cursor-portrait.ts @@ -0,0 +1,16 @@ +export const TOTAL_DURATION = 4; +export const ACTIVE_START = 0.25; +export const ACTIVE_END = 3.75; +export const DEFAULT_TIME = 2; +export const SMOOTHING = 0.12; + +export function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum); +} + +export function mapPointerYToTime(pointerY: number, viewportHeight: number) { + if (viewportHeight <= 0) return DEFAULT_TIME; + + const progress = clamp(1 - pointerY / viewportHeight, 0, 1); + return ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START); +}