重构client,拆分组件

This commit is contained in:
2025-10-22 20:14:11 +08:00
parent 5868407216
commit fe9bc8fd6c
23 changed files with 1601 additions and 1137 deletions
+7
View File
@@ -0,0 +1,7 @@
// Hooks 导出
export { useBackgroundCanvas } from "./useBackgroundCanvas";
export { useCommentState } from "./useCommentState";
export { useImageCarousel } from "./useImageCarousel";
export { useNavigation } from "./useNavigation";
export { usePlayerState } from "./usePlayerState";
export { useVideoPlayer } from "./useVideoPlayer";
@@ -0,0 +1,95 @@
import { useEffect } from "react";
import type { RefObject } from "react";
interface UseBackgroundCanvasProps {
isVideo: boolean;
idx: number;
videoRef: RefObject<HTMLVideoElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
}
export function useBackgroundCanvas({
isVideo,
idx,
videoRef,
scrollerRef,
backgroundCanvasRef,
}: UseBackgroundCanvasProps) {
useEffect(() => {
const canvas = backgroundCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const updateCanvasSize = () => {
canvas.width = Math.floor(window.innerWidth / 10);
canvas.height = Math.floor(window.innerHeight / 10);
};
updateCanvasSize();
let resizeTimeout: NodeJS.Timeout;
const debouncedResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateCanvasSize, 300);
};
window.addEventListener("resize", debouncedResize);
const drawMediaToCanvas = () => {
if (!ctx) return;
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
if (isVideo) {
sourceElement = videoRef.current;
} else {
const scroller = scrollerRef.current;
if (scroller) {
const currentImgContainer = scroller.children[idx] as HTMLElement;
if (currentImgContainer) {
sourceElement = currentImgContainer.querySelector("img");
}
}
}
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const sourceWidth =
sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
const sourceHeight =
sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
if (!sourceWidth || !sourceHeight) return;
const canvasRatio = canvasWidth / canvasHeight;
const sourceRatio = sourceWidth / sourceHeight;
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
if (canvasRatio > sourceRatio) {
drawWidth = canvasWidth;
drawHeight = canvasWidth / sourceRatio;
offsetX = 0;
offsetY = (canvasHeight - drawHeight) / 2;
} else {
drawHeight = canvasHeight;
drawWidth = canvasHeight * sourceRatio;
offsetX = (canvasWidth - drawWidth) / 2;
offsetY = 0;
}
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
};
const intervalId = setInterval(drawMediaToCanvas, 20);
return () => {
clearInterval(intervalId);
window.removeEventListener("resize", debouncedResize);
clearTimeout(resizeTimeout);
};
}, [isVideo, idx, videoRef, scrollerRef, backgroundCanvasRef]);
}
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { getStringFromStorage, saveToStorage } from "../utils";
export function useCommentState() {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// 从 localStorage 恢复评论区状态
useEffect(() => {
if (typeof window === "undefined") return;
const saved = getStringFromStorage("aweme_player_comments_open", "false");
if (saved === "true") {
setOpen(true);
}
// 短暂延迟后标记为已挂载,启用动画
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setMounted(true);
});
});
}, []);
// 持久化评论区状态
useEffect(() => {
if (typeof window === "undefined") return;
saveToStorage("aweme_player_comments_open", open.toString());
}, [open]);
return { open, setOpen, mounted };
}
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { ImageData, LoopMode, Neighbors } from "../types";
const SEGMENT_MS = 5000;
interface UseImageCarouselProps {
images: ImageData["images"];
isPlaying: boolean;
loopMode: LoopMode;
neighbors: Neighbors;
volume: number;
audioRef: RefObject<HTMLAudioElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
setProgress: (progress: number) => void;
}
export function useImageCarousel({
images,
isPlaying,
loopMode,
neighbors,
volume,
audioRef,
scrollerRef,
setProgress,
}: UseImageCarouselProps) {
const router = useRouter();
const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0);
const segStartRef = useRef<number | null>(null);
const idxRef = useRef<number>(0);
const rafRef = useRef<number | null>(null);
useEffect(() => {
idxRef.current = idx;
}, [idx]);
// BGM 控制
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.volume = volume;
if (isPlaying) {
el.play().catch(() => {});
} else {
el.pause();
}
}, [audioRef, isPlaying, volume]);
// 自动切页
useEffect(() => {
if (!images?.length) return;
if (segStartRef.current == null) segStartRef.current = performance.now();
let lastTs = performance.now();
const tick = (ts: number) => {
if (!images?.length) return;
if (!isPlaying) segStartRef.current! += ts - lastTs;
lastTs = ts;
let start = segStartRef.current!;
let localIdx = idxRef.current;
let elapsed = ts - start;
while (elapsed >= SEGMENT_MS) {
elapsed -= SEGMENT_MS;
if (localIdx >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
return;
}
localIdx = 0;
} else {
localIdx = localIdx + 1;
}
}
segStartRef.current = ts - elapsed;
if (localIdx !== idxRef.current) {
idxRef.current = localIdx;
setIdx(localIdx);
const el = scrollerRef.current;
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
}
const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS));
setSegProgress(localSeg);
setProgress((localIdx + localSeg) / images.length);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress]);
return {
idx,
setIdx,
segProgress,
segStartRef,
idxRef,
};
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { Neighbors } from "../types";
interface UseNavigationProps {
neighbors: Neighbors;
isVideo: boolean;
mediaContainerRef: RefObject<HTMLDivElement | null>;
videoRef?: RefObject<HTMLVideoElement | null>;
prevImg?: () => void;
nextImg?: () => void;
togglePlay: () => void;
}
export function useNavigation({
neighbors,
isVideo,
mediaContainerRef,
videoRef,
prevImg,
nextImg,
togglePlay,
}: UseNavigationProps) {
const router = useRouter();
const wheelCooldownRef = useRef<number>(0);
// 预取路由
useEffect(() => {
if (!neighbors) return;
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`);
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`);
}, [neighbors, router]);
// 鼠标滚轮切换
useEffect(() => {
const el = mediaContainerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey) return;
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
const dy = e.deltaY;
if (Math.abs(dy) < 40) return;
if ((dy > 0 && neighbors?.next) || (dy < 0 && neighbors?.prev)) {
e.preventDefault();
}
if (dy > 0 && neighbors?.next) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`);
} else if (dy < 0 && neighbors?.prev) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`);
}
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel as any);
}, [neighbors, mediaContainerRef, router]);
// 键盘快捷键
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return;
}
const key = e.key.toLowerCase();
if (key === "arrowup" || key === "w") {
e.preventDefault();
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
if (neighbors?.prev) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`);
}
} else if (key === "arrowdown" || key === "s") {
e.preventDefault();
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
if (neighbors?.next) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`);
}
} else if (key === "arrowleft" || key === "a") {
e.preventDefault();
if (isVideo) {
const v = videoRef?.current;
if (v && v.duration) {
v.currentTime = Math.max(0, v.currentTime - 5);
}
} else {
prevImg?.();
}
} else if (key === "arrowright" || key === "d") {
e.preventDefault();
if (isVideo) {
const v = videoRef?.current;
if (v && v.duration) {
v.currentTime = Math.min(v.duration, v.currentTime + 5);
}
} else {
nextImg?.();
}
} else if (key === " ") {
e.preventDefault();
togglePlay();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
// 浏览器返回事件
useEffect(() => {
window.history.pushState({ interceptBack: true }, "");
const handlePopState = () => {
window.close();
setTimeout(() => {
if (!document.hidden) {
router.push("/");
}
}, 100);
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [router]);
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import type { LoopMode, ObjectFit } from "../types";
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
export function usePlayerState() {
const [isPlaying, setIsPlaying] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1));
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1));
const [progress, setProgress] = useState(0);
const [rotation, setRotation] = useState(0);
const [progressRestored, setProgressRestored] = useState(false);
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
return saved === "sequential" ? "sequential" : "loop";
});
// 持久化音量
useEffect(() => {
saveToStorage("aweme_player_volume", volume);
}, [volume]);
// 持久化倍速
useEffect(() => {
saveToStorage("aweme_player_rate", rate);
}, [rate]);
// 持久化循环模式
useEffect(() => {
saveToStorage("aweme_player_loop_mode", loopMode);
}, [loopMode]);
// 监听全屏状态
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onFsChange);
return () => document.removeEventListener("fullscreenchange", onFsChange);
}, []);
return {
isPlaying,
setIsPlaying,
isFullscreen,
setIsFullscreen,
volume,
setVolume,
rate,
setRate,
progress,
setProgress,
rotation,
setRotation,
progressRestored,
setProgressRestored,
objectFit,
setObjectFit,
loopMode,
setLoopMode,
};
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { LoopMode, Neighbors } from "../types";
interface UseVideoPlayerProps {
awemeId: string;
videoRef: RefObject<HTMLVideoElement | null>;
volume: number;
rate: number;
loopMode: LoopMode;
neighbors: Neighbors;
progressRestored: boolean;
setIsPlaying: (playing: boolean) => void;
setProgress: (progress: number) => void;
setProgressRestored: (restored: boolean) => void;
}
export function useVideoPlayer({
awemeId,
videoRef,
volume,
rate,
loopMode,
neighbors,
progressRestored,
setIsPlaying,
setProgress,
setProgressRestored,
}: UseVideoPlayerProps) {
const router = useRouter();
// 恢复播放进度
useEffect(() => {
if (progressRestored) return;
const v = videoRef.current;
if (!v) return;
const onLoadedMetadata = () => {
if (progressRestored) return;
try {
const key = `aweme_progress_${awemeId}`;
const saved = localStorage.getItem(key);
if (!saved) {
setProgressRestored(true);
return;
}
const { time, timestamp } = JSON.parse(saved);
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
v.currentTime = time;
console.log(`恢复播放进度: ${Math.round(time)}s`);
} else if (now - timestamp >= fiveMinutes) {
localStorage.removeItem(key);
}
} catch (e) {
console.error("恢复播放进度失败", e);
}
setProgressRestored(true);
};
if (v.readyState >= 1) {
onLoadedMetadata();
} else {
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
}
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
// 保存播放进度
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const saveProgress = () => {
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
try {
const key = `aweme_progress_${awemeId}`;
const value = JSON.stringify({
time: v.currentTime,
timestamp: Date.now(),
});
localStorage.setItem(key, value);
} catch (e) {
console.error("保存播放进度失败", e);
}
};
const interval = setInterval(saveProgress, 2000);
const onBeforeUnload = () => saveProgress();
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
clearInterval(interval);
window.removeEventListener("beforeunload", onBeforeUnload);
saveProgress();
};
}, [awemeId, videoRef]);
// 监听播放状态和进度
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onTime = () => {
if (!v.duration || Number.isNaN(v.duration)) return;
setProgress(v.currentTime / v.duration);
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
}
};
v.addEventListener("timeupdate", onTime);
v.addEventListener("loadedmetadata", onTime);
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("ended", onEnded);
return () => {
v.removeEventListener("timeupdate", onTime);
v.removeEventListener("loadedmetadata", onTime);
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
v.removeEventListener("ended", onEnded);
};
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
// 自动播放检测
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const checkAutoplay = async () => {
try {
await v.play();
setIsPlaying(true);
} catch (error) {
console.log("自动播放被阻止,需要用户交互");
setIsPlaying(false);
}
};
if (v.readyState >= 1) {
checkAutoplay();
} else {
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
}
}, [awemeId, videoRef, setIsPlaying]);
// 更新音量和倍速
useEffect(() => {
const v = videoRef.current;
if (v) v.volume = volume;
}, [volume, videoRef]);
useEffect(() => {
const v = videoRef.current;
if (v) v.playbackRate = rate;
}, [rate, videoRef]);
}