+ {/* Eyebrow 左上 · 紧贴导航下方 */}
+
Top 12 · Virtual Idol Debut Project
- {/* 浅紫边框倒计时 右上 */}
-
+ {/* 浅紫边框倒计时 右上 · 紧贴导航下方 */}
+
- {/* 中央 CYBER ✦ STAR */}
-
-
- CYBER
-
- ✦
-
- STAR
-
-
- 虚 拟 偶 像 出 道 企 划
-
-
-
{/* 声音按钮 右下 */}
diff --git a/src/components/artist/PerformanceVideo.tsx b/src/components/artist/PerformanceVideo.tsx
index ed33e7c..4a6f236 100644
--- a/src/components/artist/PerformanceVideo.tsx
+++ b/src/components/artist/PerformanceVideo.tsx
@@ -136,7 +136,8 @@ export default function PerformanceVideo({
需要额外上推的像素数。
+ *
+ * 返回值 push (px):
+ * - footer 未进视口 → 0,按钮保持原位
+ * - footer 部分/完全在视口 → 等于 footer 顶部进入视口的深度 + gap,
+ * 让按钮始终漂浮在 footer 顶部上方 gap 像素处
+ *
+ * 用法:
+ * const push = useFooterPush();
+ *
+ *
+ * 实现:scroll + resize 事件 + rAF 节流;比 IntersectionObserver 灵敏,
+ * 能给出连续的 push 数值(IO 只能给二值)。
+ */
+export function useFooterPush(gap = 16): number {
+ const [push, setPush] = useState(0);
+
+ useEffect(() => {
+ const footer = document.querySelector("footer");
+ if (!footer) return;
+
+ let raf = 0;
+ const update = () => {
+ const rect = footer.getBoundingClientRect();
+ // overlap = footer 顶部进入视口的深度。footer 未入视口时为负数,clamp 到 0
+ const overlap = window.innerHeight - rect.top + gap;
+ setPush(Math.max(0, overlap));
+ };
+
+ const schedule = () => {
+ if (raf) return;
+ raf = requestAnimationFrame(() => {
+ update();
+ raf = 0;
+ });
+ };
+
+ update();
+ window.addEventListener("scroll", schedule, { passive: true });
+ window.addEventListener("resize", schedule);
+ return () => {
+ window.removeEventListener("scroll", schedule);
+ window.removeEventListener("resize", schedule);
+ if (raf) cancelAnimationFrame(raf);
+ };
+ }, [gap]);
+
+ return push;
+}
diff --git a/src/hooks/useScrollRestore.ts b/src/hooks/useScrollRestore.ts
new file mode 100644
index 0000000..55edc55
--- /dev/null
+++ b/src/hooks/useScrollRestore.ts
@@ -0,0 +1,63 @@
+"use client";
+
+import { useEffect } from "react";
+
+/**
+ * 用 sessionStorage 在同一标签页内保存/恢复滚动位置。
+ *
+ * 用法:在希望被记忆的页面顶层 useScrollRestore("home")。
+ * - mount 时,如果有保存值,把 window 滚动到对应 y 并清除标记
+ * - 用户滚动时,节流写入当前 scrollY
+ * - 切到其他路由后再回来 → mount 重跑 → 恢复
+ *
+ * 注意:
+ * - sessionStorage 跨标签页隔离,所以不会跨窗口"漏读"
+ * - 用 rAF 节流,避免每帧写入卡顿
+ * - 用 requestAnimationFrame 在下一帧恢复,确保 DOM 已经渲染出真实高度
+ */
+export function useScrollRestore(key: string) {
+ useEffect(() => {
+ const storageKey = `scroll:${key}`;
+
+ // 恢复 · 用 rAF 等 DOM 真正布局完成后再滚
+ const saved = sessionStorage.getItem(storageKey);
+ if (saved) {
+ const y = parseInt(saved, 10);
+ if (!Number.isNaN(y) && y > 0) {
+ // 两次 rAF 跨过 next.js hydration/挂载首帧,DOM 高度才稳定
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ // 临时禁用 scroll-snap-type:首页用 "y mandatory",
+ // 直接 scrollTo 到 snap 点之外的位置会被浏览器强制吸到最近 snap 点。
+ const html = document.documentElement;
+ const prevSnap = html.style.scrollSnapType;
+ html.style.scrollSnapType = "none";
+ window.scrollTo({ top: y, behavior: "auto" });
+ // 多等一帧让 scrollTo 落定再恢复 snap
+ requestAnimationFrame(() => {
+ html.style.scrollSnapType = prevSnap;
+ });
+ });
+ });
+ }
+ }
+
+ // 保存 · scroll 事件高频,用 rAF 合并
+ let raf = 0;
+ const onScroll = () => {
+ if (raf) return;
+ raf = requestAnimationFrame(() => {
+ sessionStorage.setItem(storageKey, String(window.scrollY));
+ raf = 0;
+ });
+ };
+ window.addEventListener("scroll", onScroll, { passive: true });
+
+ return () => {
+ window.removeEventListener("scroll", onScroll);
+ if (raf) cancelAnimationFrame(raf);
+ // unmount 时再保存一次最终值
+ sessionStorage.setItem(storageKey, String(window.scrollY));
+ };
+ }, [key]);
+}