feat(ui): add compact desktop cursor portrait
This commit is contained in:
@@ -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<HTMLVideoElement>(null);
|
||||
const pointerYRef = useRef<number | null>(null);
|
||||
const targetTimeRef = useRef(DEFAULT_TIME);
|
||||
const currentTimeRef = useRef(DEFAULT_TIME);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const lastSeekTimestampRef = useRef(0);
|
||||
const metadataReadyRef = useRef(false);
|
||||
const primedRef = useRef(false);
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>("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 (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-cursor-portrait
|
||||
className="pointer-events-none fixed right-6 bottom-0 z-40 aspect-square w-[clamp(110px,11vw,170px)] select-none bg-white"
|
||||
>
|
||||
{showVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={VIDEO_SRC}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
poster={POSTER_SRC}
|
||||
draggable={false}
|
||||
disablePictureInPicture
|
||||
className="h-full w-full bg-white object-contain"
|
||||
onError={() => setVideoFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={POSTER_SRC}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1545px) 11vw, 170px"
|
||||
draggable={false}
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user