refactor: 重构作品播放页性能、媒体同步与内存管理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,74 +1,90 @@
|
||||
import { useEffect } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface UseBackgroundCanvasProps {
|
||||
isVideo: boolean;
|
||||
idx: number;
|
||||
isPlaying: boolean;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
||||
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
|
||||
}
|
||||
|
||||
const CANVAS_DRAW_INTERVAL_MS = 100; // 10fps 对 1/10 分辨率 + blur(40px) 已完全平滑,大幅降低 GPU 负载
|
||||
|
||||
export function useBackgroundCanvas({
|
||||
isVideo,
|
||||
idx,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
scrollerRef,
|
||||
backgroundCanvasRef,
|
||||
}: UseBackgroundCanvasProps) {
|
||||
}: UseBackgroundCanvasProps): void {
|
||||
// 初始化尺寸与卸载清理
|
||||
useEffect(() => {
|
||||
const canvas = backgroundCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!isVideo) {
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
if (!ctx || !isVideo) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCanvasSize = () => {
|
||||
canvas.width = Math.floor(window.innerWidth / 10);
|
||||
canvas.height = Math.floor(window.innerHeight / 10);
|
||||
const updateCanvasSize = (): void => {
|
||||
canvas.width = Math.max(1, Math.floor(window.innerWidth / 10));
|
||||
canvas.height = Math.max(1, Math.floor(window.innerHeight / 10));
|
||||
};
|
||||
updateCanvasSize();
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout;
|
||||
const debouncedResize = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const debouncedResize = (): void => {
|
||||
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(updateCanvasSize, 300);
|
||||
};
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
|
||||
const drawMediaToCanvas = () => {
|
||||
if (!ctx) return;
|
||||
return () => {
|
||||
window.removeEventListener("resize", debouncedResize);
|
||||
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
};
|
||||
}, [isVideo, backgroundCanvasRef]);
|
||||
|
||||
// 仅在视频实际播放时进行低频采样绘制,暂停时不占 CPU/GPU
|
||||
useEffect(() => {
|
||||
if (!isVideo || !isPlaying) return;
|
||||
const canvas = backgroundCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const drawMediaToCanvas = (): void => {
|
||||
if (document.hidden) return;
|
||||
const sourceElement = videoRef.current;
|
||||
|
||||
if (
|
||||
!sourceElement ||
|
||||
(sourceElement instanceof HTMLVideoElement &&
|
||||
sourceElement.readyState < 2)
|
||||
)
|
||||
sourceElement.readyState < 2 ||
|
||||
sourceElement.paused
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasWidth = canvas.width;
|
||||
const canvasHeight = canvas.height;
|
||||
const sourceWidth = sourceElement.videoWidth;
|
||||
const sourceHeight = sourceElement.videoHeight;
|
||||
|
||||
if (!sourceWidth || !sourceHeight) return;
|
||||
if (!canvasWidth || !canvasHeight || !sourceWidth || !sourceHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasRatio = canvasWidth / canvasHeight;
|
||||
const sourceRatio = sourceWidth / sourceHeight;
|
||||
|
||||
let drawWidth: number,
|
||||
drawHeight: number,
|
||||
offsetX: number,
|
||||
offsetY: number;
|
||||
let drawWidth: number;
|
||||
let drawHeight: number;
|
||||
let offsetX: number;
|
||||
let offsetY: number;
|
||||
|
||||
if (canvasRatio > sourceRatio) {
|
||||
drawWidth = canvasWidth;
|
||||
@@ -85,12 +101,14 @@ export function useBackgroundCanvas({
|
||||
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(drawMediaToCanvas, 34);
|
||||
drawMediaToCanvas();
|
||||
const intervalId = window.setInterval(
|
||||
drawMediaToCanvas,
|
||||
CANVAS_DRAW_INTERVAL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener("resize", debouncedResize);
|
||||
clearTimeout(resizeTimeout);
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [isVideo, idx, videoRef, scrollerRef, backgroundCanvasRef]);
|
||||
}, [isVideo, isPlaying, videoRef, backgroundCanvasRef]);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
|
||||
import { releaseMediaElement } from "../utils";
|
||||
|
||||
const MIN_SEGMENT_MS = 250;
|
||||
const UI_UPDATE_INTERVAL_MS = 33;
|
||||
const PROGRESS_EPSILON = 0.0001;
|
||||
const PROGRESS_EPSILON = 0.001;
|
||||
|
||||
function normalizeDuration(
|
||||
duration: number | null | undefined,
|
||||
fallback: number,
|
||||
) {
|
||||
): number {
|
||||
if (
|
||||
typeof duration !== "number" ||
|
||||
!Number.isFinite(duration) ||
|
||||
@@ -21,7 +22,7 @@ function normalizeDuration(
|
||||
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
|
||||
}
|
||||
|
||||
function sumDurations(durations: number[], end: number) {
|
||||
function sumDurations(durations: number[], end: number): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
|
||||
return total;
|
||||
@@ -39,6 +40,26 @@ interface UseImageCarouselProps {
|
||||
segmentMs?: number;
|
||||
}
|
||||
|
||||
export interface UseImageCarouselReturn {
|
||||
idx: number;
|
||||
setIdx: (idx: number) => void;
|
||||
segProgress: number;
|
||||
segProgressRef: RefObject<number>;
|
||||
segmentDurations: number[];
|
||||
totalDurationMs: number;
|
||||
mediaSyncToken: number;
|
||||
segStartRef: RefObject<number | null>;
|
||||
idxRef: RefObject<number>;
|
||||
failedAnimatedMap: Record<string, boolean>;
|
||||
setSegmentDuration: (imageId: string, durationMs: number) => void;
|
||||
markMediaReady: (imageId: string) => void;
|
||||
markAnimatedFailed: (imageId: string) => void;
|
||||
handleAnimatedProgress: (imageId: string, progress: number) => void;
|
||||
handleAnimatedEnded: (imageId: string) => void;
|
||||
goToIndex: (index: number, segmentProgress?: number) => void;
|
||||
seekTo: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export function useImageCarousel({
|
||||
images,
|
||||
isPlaying,
|
||||
@@ -48,13 +69,19 @@ export function useImageCarousel({
|
||||
audioRef,
|
||||
setProgress,
|
||||
segmentMs = 5000,
|
||||
}: UseImageCarouselProps) {
|
||||
}: UseImageCarouselProps): UseImageCarouselReturn {
|
||||
const router = useRouter();
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [segProgress, setSegProgress] = useState(0);
|
||||
const [durationOverrides, setDurationOverrides] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [readyMediaMap, setReadyMediaMap] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [failedAnimatedMap, setFailedAnimatedMap] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [mediaSyncToken, setMediaSyncToken] = useState(0);
|
||||
|
||||
const segStartRef = useRef<number | null>(null);
|
||||
@@ -85,7 +112,7 @@ export function useImageCarousel({
|
||||
);
|
||||
|
||||
const syncProgress = useCallback(
|
||||
(index: number, segmentProgress: number, force = false) => {
|
||||
(index: number, segmentProgress: number, force = false): void => {
|
||||
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
|
||||
const completedMs = sumDurations(segmentDurations, index);
|
||||
const totalProgress =
|
||||
@@ -116,7 +143,7 @@ export function useImageCarousel({
|
||||
);
|
||||
|
||||
const setSegmentDuration = useCallback(
|
||||
(imageId: string, durationMs: number) => {
|
||||
(imageId: string, durationMs: number): void => {
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
|
||||
const normalizedDuration = normalizeDuration(
|
||||
durationMs,
|
||||
@@ -131,8 +158,95 @@ export function useImageCarousel({
|
||||
[fallbackDuration],
|
||||
);
|
||||
|
||||
const markMediaReady = useCallback((imageId: string): void => {
|
||||
setReadyMediaMap((current) => {
|
||||
if (current[imageId]) return current;
|
||||
return { ...current, [imageId]: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markAnimatedFailed = useCallback((imageId: string): void => {
|
||||
setFailedAnimatedMap((current) => {
|
||||
if (current[imageId]) return current;
|
||||
return { ...current, [imageId]: true };
|
||||
});
|
||||
setReadyMediaMap((current) => {
|
||||
if (current[imageId]) return current;
|
||||
return { ...current, [imageId]: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const advanceSegment = useCallback(
|
||||
(fromIndex: number): void => {
|
||||
if (!images.length || idxRef.current !== fromIndex) return;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
if (loopMode === "single") {
|
||||
segStartRef.current = now;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(fromIndex, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromIndex >= images.length - 1) {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
return;
|
||||
}
|
||||
idxRef.current = 0;
|
||||
setIdx(0);
|
||||
segStartRef.current = now;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(0, 0, true);
|
||||
if (images.length === 1) {
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = fromIndex + 1;
|
||||
idxRef.current = nextIndex;
|
||||
setIdx(nextIndex);
|
||||
segStartRef.current = now;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(nextIndex, 0, true);
|
||||
},
|
||||
[images.length, loopMode, neighbors?.next, router, syncProgress],
|
||||
);
|
||||
|
||||
const handleAnimatedProgress = useCallback(
|
||||
(imageId: string, progress: number): void => {
|
||||
const currentImage = images[idxRef.current];
|
||||
if (!currentImage || currentImage.id !== imageId) return;
|
||||
|
||||
const now = performance.now();
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
const wrapped = clamped < segProgressRef.current - 0.2;
|
||||
|
||||
if (
|
||||
wrapped ||
|
||||
clamped >= 0.995 ||
|
||||
now - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
|
||||
) {
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(idxRef.current, clamped, wrapped);
|
||||
}
|
||||
},
|
||||
[images, syncProgress],
|
||||
);
|
||||
|
||||
const handleAnimatedEnded = useCallback(
|
||||
(imageId: string): void => {
|
||||
const currentImage = images[idxRef.current];
|
||||
if (!currentImage || currentImage.id !== imageId) return;
|
||||
advanceSegment(idxRef.current);
|
||||
},
|
||||
[advanceSegment, images],
|
||||
);
|
||||
|
||||
const goToIndex = useCallback(
|
||||
(index: number, segmentProgress = 0) => {
|
||||
(index: number, segmentProgress = 0): void => {
|
||||
if (!images.length) return;
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(images.length - 1, index));
|
||||
@@ -151,7 +265,7 @@ export function useImageCarousel({
|
||||
);
|
||||
|
||||
const seekTo = useCallback(
|
||||
(ratio: number) => {
|
||||
(ratio: number): void => {
|
||||
if (!images.length || totalDurationMs <= 0) return;
|
||||
|
||||
const clampedRatio = Math.max(0, Math.min(1, ratio));
|
||||
@@ -197,25 +311,9 @@ export function useImageCarousel({
|
||||
idxRef.current = idx;
|
||||
}, [idx]);
|
||||
|
||||
// 切换作品或图片集合变化时重置所有轮播状态
|
||||
useEffect(() => {
|
||||
const validIds = new Set(images.map((img) => img.id));
|
||||
setDurationOverrides((current) => {
|
||||
let changed = false;
|
||||
const next: Record<string, number> = {};
|
||||
|
||||
for (const [id, duration] of Object.entries(current)) {
|
||||
if (validIds.has(id)) {
|
||||
next[id] = duration;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [images]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageKey) return;
|
||||
idxRef.current = 0;
|
||||
setIdx(0);
|
||||
segStartRef.current = null;
|
||||
@@ -223,6 +321,9 @@ export function useImageCarousel({
|
||||
totalProgressRef.current = 0;
|
||||
setSegProgress(0);
|
||||
setProgress(0);
|
||||
setDurationOverrides({});
|
||||
setReadyMediaMap({});
|
||||
setFailedAnimatedMap({});
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
}, [imageKey, setProgress]);
|
||||
|
||||
@@ -239,90 +340,77 @@ export function useImageCarousel({
|
||||
}, [audioRef, isPlaying, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
releaseMediaElement(el);
|
||||
};
|
||||
}, [audioRef]);
|
||||
|
||||
// 自动切页
|
||||
// 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由 <video> 自身进度与 onEnded 驱动)
|
||||
const currentImage = images[idx];
|
||||
const isCurrentAnimated =
|
||||
Boolean(currentImage?.animated) &&
|
||||
!failedAnimatedMap[currentImage?.id ?? ""];
|
||||
const isCurrentStaticReady = currentImage
|
||||
? Boolean(readyMediaMap[currentImage.id])
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0)
|
||||
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 当前页为正常动图时,交由 ImageCarousel 内 <video> 的真实播放进度和 ended 事件推进
|
||||
if (isCurrentAnimated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态图尚未加载完成或处于暂停态时,冻结分段进度,避免未加载完就偷跑进度裁切展示时长
|
||||
if (!isPlaying || !isCurrentStaticReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const currentSegmentDuration =
|
||||
segmentDurations[idxRef.current] ?? fallbackDuration;
|
||||
const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration;
|
||||
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
||||
lastUiUpdateRef.current = now;
|
||||
|
||||
if (!isPlaying) return;
|
||||
|
||||
const tick = () => {
|
||||
const tick = (): void => {
|
||||
if (!images?.length) return;
|
||||
|
||||
const ts = performance.now();
|
||||
const start = segStartRef.current ?? ts;
|
||||
const activeIdx = idxRef.current;
|
||||
const duration = segmentDurations[activeIdx] ?? fallbackDuration;
|
||||
const elapsed = ts - start;
|
||||
|
||||
let start = segStartRef.current!;
|
||||
let localIdx = idxRef.current;
|
||||
|
||||
let elapsed = ts - start;
|
||||
let currentSegmentDuration =
|
||||
segmentDurations[localIdx] ?? fallbackDuration;
|
||||
|
||||
while (elapsed >= currentSegmentDuration) {
|
||||
if (loopMode === "single") {
|
||||
elapsed %= currentSegmentDuration;
|
||||
break;
|
||||
}
|
||||
|
||||
elapsed -= currentSegmentDuration;
|
||||
|
||||
if (localIdx >= images.length - 1) {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
return;
|
||||
}
|
||||
localIdx = 0;
|
||||
} else {
|
||||
localIdx = localIdx + 1;
|
||||
}
|
||||
|
||||
currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration;
|
||||
}
|
||||
segStartRef.current = ts - elapsed;
|
||||
|
||||
const indexChanged = localIdx !== idxRef.current;
|
||||
if (indexChanged) {
|
||||
idxRef.current = localIdx;
|
||||
setIdx(localIdx);
|
||||
if (elapsed >= duration) {
|
||||
advanceSegment(activeIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
const localSeg = Math.max(
|
||||
0,
|
||||
Math.min(1, elapsed / currentSegmentDuration),
|
||||
);
|
||||
|
||||
if (
|
||||
indexChanged ||
|
||||
ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
|
||||
) {
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / duration));
|
||||
if (ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS) {
|
||||
lastUiUpdateRef.current = ts;
|
||||
syncProgress(localIdx, localSeg, indexChanged);
|
||||
syncProgress(activeIdx, localSeg, false);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
advanceSegment,
|
||||
fallbackDuration,
|
||||
idx,
|
||||
images,
|
||||
isCurrentAnimated,
|
||||
isCurrentStaticReady,
|
||||
isPlaying,
|
||||
loopMode,
|
||||
neighbors?.next,
|
||||
router,
|
||||
segmentDurations,
|
||||
syncProgress,
|
||||
totalDurationMs,
|
||||
@@ -332,12 +420,18 @@ export function useImageCarousel({
|
||||
idx,
|
||||
setIdx,
|
||||
segProgress,
|
||||
segProgressRef,
|
||||
segmentDurations,
|
||||
totalDurationMs,
|
||||
mediaSyncToken,
|
||||
segStartRef,
|
||||
idxRef,
|
||||
failedAnimatedMap,
|
||||
setSegmentDuration,
|
||||
markMediaReady,
|
||||
markAnimatedFailed,
|
||||
handleAnimatedProgress,
|
||||
handleAnimatedEnded,
|
||||
goToIndex,
|
||||
seekTo,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Neighbors } from "../types.ts";
|
||||
|
||||
interface UseNavigationProps {
|
||||
@@ -21,7 +21,7 @@ export function useNavigation({
|
||||
prevImg,
|
||||
nextImg,
|
||||
togglePlay,
|
||||
}: UseNavigationProps) {
|
||||
}: UseNavigationProps): void {
|
||||
const router = useRouter();
|
||||
const wheelCooldownRef = useRef<number>(0);
|
||||
|
||||
@@ -37,7 +37,7 @@ export function useNavigation({
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
if (e.ctrlKey) return;
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
@@ -58,17 +58,18 @@ export function useNavigation({
|
||||
};
|
||||
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel as any);
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [neighbors, mediaContainerRef, router]);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -121,17 +122,25 @@ export function useNavigation({
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
|
||||
|
||||
// 浏览器返回事件
|
||||
// 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 history 栈
|
||||
useEffect(() => {
|
||||
window.history.pushState({ interceptBack: true }, "");
|
||||
const currentState = window.history.state as
|
||||
| { interceptBack?: boolean }
|
||||
| null
|
||||
| undefined;
|
||||
if (window.history.length <= 1 && !currentState?.interceptBack) {
|
||||
window.history.pushState({ ...currentState, interceptBack: true }, "");
|
||||
}
|
||||
|
||||
const handlePopState = () => {
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
if (!document.hidden) {
|
||||
router.push("/");
|
||||
}
|
||||
}, 100);
|
||||
const handlePopState = (): void => {
|
||||
if (window.history.length <= 1) {
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
if (!document.hidden) {
|
||||
router.push("/");
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { LoopMode, Neighbors } from "../types.ts";
|
||||
import { cleanupExpiredProgressStorage, releaseMediaElement } from "../utils";
|
||||
|
||||
interface UseVideoPlayerProps {
|
||||
enabled?: boolean;
|
||||
@@ -29,18 +30,27 @@ export function useVideoPlayer({
|
||||
setIsPlaying,
|
||||
setProgress,
|
||||
setProgressRestored,
|
||||
}: UseVideoPlayerProps) {
|
||||
}: UseVideoPlayerProps): void {
|
||||
const router = useRouter();
|
||||
const restoredAwemeIdRef = useRef<string | null>(null);
|
||||
const lastProgressRef = useRef(0);
|
||||
|
||||
// 挂载时清理过期的播放进度缓存
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
cleanupExpiredProgressStorage();
|
||||
}, [enabled]);
|
||||
|
||||
// 恢复播放进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (progressRestored) return;
|
||||
if (progressRestored && restoredAwemeIdRef.current === awemeId) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (progressRestored) return;
|
||||
const onLoadedMetadata = (): void => {
|
||||
if (restoredAwemeIdRef.current === awemeId) return;
|
||||
restoredAwemeIdRef.current = awemeId;
|
||||
|
||||
try {
|
||||
const key = `aweme_progress_${awemeId}`;
|
||||
@@ -50,7 +60,13 @@ export function useVideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
const { time, timestamp } = JSON.parse(saved);
|
||||
const parsed = JSON.parse(saved) as {
|
||||
time?: unknown;
|
||||
timestamp?: unknown;
|
||||
};
|
||||
const time = typeof parsed.time === "number" ? parsed.time : 0;
|
||||
const timestamp =
|
||||
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
@@ -60,7 +76,6 @@ export function useVideoPlayer({
|
||||
time < v.duration - 1
|
||||
) {
|
||||
v.currentTime = time;
|
||||
console.log(`恢复播放进度: ${Math.round(time)}s`);
|
||||
} else if (now - timestamp >= fiveMinutes) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
@@ -73,19 +88,20 @@ export function useVideoPlayer({
|
||||
|
||||
if (v.readyState >= 1) {
|
||||
onLoadedMetadata();
|
||||
} else {
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
return;
|
||||
}
|
||||
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||
|
||||
// 保存播放进度
|
||||
// 保存播放进度并在卸载/切页时主动释放媒体解码缓冲区
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const saveProgress = () => {
|
||||
const saveProgress = (): void => {
|
||||
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
||||
|
||||
try {
|
||||
@@ -100,14 +116,15 @@ export function useVideoPlayer({
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(saveProgress, 2000);
|
||||
const onBeforeUnload = () => saveProgress();
|
||||
const interval = window.setInterval(saveProgress, 2000);
|
||||
const onBeforeUnload = (): void => saveProgress();
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
saveProgress();
|
||||
releaseMediaElement(v);
|
||||
};
|
||||
}, [awemeId, enabled, videoRef]);
|
||||
|
||||
@@ -117,13 +134,17 @@ export function useVideoPlayer({
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onTime = () => {
|
||||
const onTime = (): void => {
|
||||
if (!v.duration || Number.isNaN(v.duration)) return;
|
||||
setProgress(v.currentTime / v.duration);
|
||||
const nextProgress = Math.max(0, Math.min(1, v.currentTime / v.duration));
|
||||
if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) {
|
||||
lastProgressRef.current = nextProgress;
|
||||
setProgress(nextProgress);
|
||||
}
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => {
|
||||
const onPlay = (): void => setIsPlaying(true);
|
||||
const onPause = (): void => setIsPlaying(false);
|
||||
const onEnded = (): void => {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
}
|
||||
@@ -158,23 +179,26 @@ export function useVideoPlayer({
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const checkAutoplay = async () => {
|
||||
const checkAutoplay = async (): Promise<void> => {
|
||||
try {
|
||||
await v.play();
|
||||
setIsPlaying(true);
|
||||
} catch {
|
||||
console.log("自动播放被阻止,需要用户交互");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (v.readyState >= 1) {
|
||||
checkAutoplay();
|
||||
} else {
|
||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
||||
void checkAutoplay();
|
||||
return;
|
||||
}
|
||||
}, [enabled, videoRef, setIsPlaying]);
|
||||
|
||||
const onLoaded = (): void => {
|
||||
void checkAutoplay();
|
||||
};
|
||||
v.addEventListener("loadedmetadata", onLoaded, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoaded);
|
||||
}, [awemeId, enabled, videoRef, setIsPlaying]);
|
||||
|
||||
// 更新音量和倍速
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user