优化图文页

This commit is contained in:
2026-04-28 17:11:42 +08:00
parent 674c202264
commit 040273586b
69 changed files with 3939 additions and 2302 deletions
@@ -23,6 +23,13 @@ export function useBackgroundCanvas({
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!isVideo) {
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
return;
}
const updateCanvasSize = () => {
canvas.width = Math.floor(window.innerWidth / 10);
canvas.height = Math.floor(window.innerHeight / 10);
@@ -39,40 +46,29 @@ export function useBackgroundCanvas({
const drawMediaToCanvas = () => {
if (!ctx) return;
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
const sourceElement = videoRef.current;
if (isVideo) {
sourceElement = videoRef.current;
} else {
const scroller = scrollerRef.current;
if (scroller) {
// 虚拟滚动:查找所有图片容器,找到当前显示的那个
const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
for (const container of containers) {
const img = container.querySelector("img, video");
if (img && container.style.transform.includes(`${idx * 100}%`)) {
sourceElement = img as HTMLImageElement | HTMLVideoElement;
break;
}
}
}
}
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
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;
const sourceWidth = sourceElement.videoWidth;
const sourceHeight = sourceElement.videoHeight;
if (!sourceWidth || !sourceHeight) return;
const canvasRatio = canvasWidth / canvasHeight;
const sourceRatio = sourceWidth / sourceHeight;
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
let drawWidth: number,
drawHeight: number,
offsetX: number,
offsetY: number;
if (canvasRatio > sourceRatio) {
drawWidth = canvasWidth;
+252 -40
View File
@@ -1,8 +1,32 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
const MIN_SEGMENT_MS = 250;
const UI_UPDATE_INTERVAL_MS = 33;
const PROGRESS_EPSILON = 0.0001;
function normalizeDuration(
duration: number | null | undefined,
fallback: number,
) {
if (
typeof duration !== "number" ||
!Number.isFinite(duration) ||
duration <= 0
) {
return fallback;
}
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
}
function sumDurations(durations: number[], end: number) {
let total = 0;
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
return total;
}
interface UseImageCarouselProps {
images: ImageData["images"];
isPlaying: boolean;
@@ -10,7 +34,6 @@ interface UseImageCarouselProps {
neighbors: Neighbors;
volume: number;
audioRef: RefObject<HTMLAudioElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
setProgress: (progress: number) => void;
/** 单张图片显示时长(毫秒),默认 5000ms */
segmentMs?: number;
@@ -23,22 +46,186 @@ export function useImageCarousel({
neighbors,
volume,
audioRef,
scrollerRef,
setProgress,
segmentMs = 5000,
}: UseImageCarouselProps) {
const router = useRouter();
const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0);
const [durationOverrides, setDurationOverrides] = useState<
Record<string, number>
>({});
const [mediaSyncToken, setMediaSyncToken] = useState(0);
const segStartRef = useRef<number | null>(null);
const idxRef = useRef<number>(0);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<number | null>(null);
const lastUiUpdateRef = useRef(0);
const segProgressRef = useRef(0);
const totalProgressRef = useRef(0);
const fallbackDuration = normalizeDuration(segmentMs, 5000);
const segmentDurations = useMemo(
() =>
images.map((img) =>
normalizeDuration(
durationOverrides[img.id] ?? img.duration,
fallbackDuration,
),
),
[durationOverrides, fallbackDuration, images],
);
const imageKey = useMemo(
() => images.map((img) => img.id).join("|"),
[images],
);
const totalDurationMs = useMemo(
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
[segmentDurations],
);
const syncProgress = useCallback(
(index: number, segmentProgress: number, force = false) => {
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
const completedMs = sumDurations(segmentDurations, index);
const totalProgress =
totalDurationMs > 0
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
: 0;
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
if (
force ||
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
) {
segProgressRef.current = nextSegProgress;
setSegProgress(nextSegProgress);
}
if (
force ||
Math.abs(nextTotalProgress - totalProgressRef.current) >
PROGRESS_EPSILON
) {
totalProgressRef.current = nextTotalProgress;
setProgress(nextTotalProgress);
}
},
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
);
const setSegmentDuration = useCallback(
(imageId: string, durationMs: number) => {
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
const normalizedDuration = normalizeDuration(
durationMs,
fallbackDuration,
);
setDurationOverrides((current) => {
if (current[imageId] === normalizedDuration) return current;
return { ...current, [imageId]: normalizedDuration };
});
},
[fallbackDuration],
);
const goToIndex = useCallback(
(index: number, segmentProgress = 0) => {
if (!images.length) return;
const nextIndex = Math.max(0, Math.min(images.length - 1, index));
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
const now = performance.now();
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
idxRef.current = nextIndex;
setIdx(nextIndex);
segStartRef.current = now - nextSegmentProgress * segmentDuration;
lastUiUpdateRef.current = now;
syncProgress(nextIndex, nextSegmentProgress, true);
setMediaSyncToken((token) => token + 1);
},
[fallbackDuration, images.length, segmentDurations, syncProgress],
);
const seekTo = useCallback(
(ratio: number) => {
if (!images.length || totalDurationMs <= 0) return;
const clampedRatio = Math.max(0, Math.min(1, ratio));
const targetMs = Math.min(
totalDurationMs - 1,
clampedRatio * totalDurationMs,
);
let accumulatedMs = 0;
let targetIndex = 0;
let segmentElapsedMs = 0;
for (let i = 0; i < segmentDurations.length; i++) {
const duration = segmentDurations[i] ?? fallbackDuration;
if (
targetMs < accumulatedMs + duration ||
i === segmentDurations.length - 1
) {
targetIndex = i;
segmentElapsedMs = Math.max(
0,
Math.min(duration, targetMs - accumulatedMs),
);
break;
}
accumulatedMs += duration;
}
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
const nextSegmentProgress =
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
goToIndex(targetIndex, nextSegmentProgress);
},
[
fallbackDuration,
goToIndex,
images.length,
segmentDurations,
totalDurationMs,
],
);
useEffect(() => {
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(() => {
idxRef.current = 0;
setIdx(0);
segStartRef.current = null;
segProgressRef.current = 0;
totalProgressRef.current = 0;
setSegProgress(0);
setProgress(0);
setMediaSyncToken((token) => token + 1);
}, [imageKey, setProgress]);
// BGM 控制
useEffect(() => {
const el = audioRef.current;
@@ -51,33 +238,42 @@ export function useImageCarousel({
}
}, [audioRef, isPlaying, volume]);
useEffect(() => {
return () => {
audioRef.current?.pause();
};
}, [audioRef]);
// 自动切页
useEffect(() => {
if (!images?.length) return;
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0)
return;
if (segStartRef.current == null) segStartRef.current = performance.now();
let lastTs = performance.now();
const now = performance.now();
const currentSegmentDuration =
segmentDurations[idxRef.current] ?? fallbackDuration;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
const tick = (ts: number) => {
if (!isPlaying) return;
const tick = () => {
if (!images?.length) return;
if (!isPlaying) segStartRef.current! += ts - lastTs;
lastTs = ts;
const ts = performance.now();
let start = segStartRef.current!;
let localIdx = idxRef.current;
let elapsed = ts - start;
// 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs)
const getCurrentSegmentDuration = (index: number) => {
const img = images[index];
return img?.duration ?? segmentMs;
};
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
let currentSegmentDuration =
segmentDurations[localIdx] ?? fallbackDuration;
while (elapsed >= currentSegmentDuration) {
if (loopMode === "single") {
elapsed %= currentSegmentDuration;
break;
}
elapsed -= currentSegmentDuration;
if (localIdx >= images.length - 1) {
@@ -89,44 +285,60 @@ export function useImageCarousel({
} else {
localIdx = localIdx + 1;
}
// 更新下一张图片的时长
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration;
}
segStartRef.current = ts - elapsed;
if (localIdx !== idxRef.current) {
const indexChanged = localIdx !== idxRef.current;
if (indexChanged) {
idxRef.current = localIdx;
setIdx(localIdx);
// 虚拟滚动不需要实际滚动 DOM
}
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
setSegProgress(localSeg);
// 计算总进度:已完成的图片 + 当前图片的进度
let totalProgress = 0;
for (let i = 0; i < localIdx; i++) {
totalProgress += 1;
}
totalProgress += localSeg;
setProgress(totalProgress / images.length);
const localSeg = Math.max(
0,
Math.min(1, elapsed / currentSegmentDuration),
);
rafRef.current = requestAnimationFrame(tick);
if (
indexChanged ||
ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
) {
lastUiUpdateRef.current = ts;
syncProgress(localIdx, localSeg, indexChanged);
}
};
rafRef.current = requestAnimationFrame(tick);
tick();
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
if (timerRef.current) window.clearInterval(timerRef.current);
timerRef.current = null;
};
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
}, [
fallbackDuration,
images,
isPlaying,
loopMode,
neighbors?.next,
router,
segmentDurations,
syncProgress,
totalDurationMs,
]);
return {
idx,
setIdx,
segProgress,
segmentDurations,
totalDurationMs,
mediaSyncToken,
segStartRef,
idxRef,
setSegmentDuration,
goToIndex,
seekTo,
};
}
+5 -1
View File
@@ -65,7 +65,11 @@ export function useNavigation({
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
+13 -4
View File
@@ -1,19 +1,28 @@
import { useEffect, useState } from "react";
import type { LoopMode, ObjectFit } from "../types.ts";
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
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 [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";
if (saved === "single" || saved === "sequential") return saved;
return "loop";
});
// 持久化音量
+6 -2
View File
@@ -51,7 +51,11 @@ export function useVideoPlayer({
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
if (
now - timestamp < fiveMinutes &&
time > 1 &&
time < v.duration - 1
) {
v.currentTime = time;
console.log(`恢复播放进度: ${Math.round(time)}s`);
} else if (now - timestamp >= fiveMinutes) {
@@ -125,7 +129,7 @@ export function useVideoPlayer({
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("ended", onEnded);
return () => {
v.removeEventListener("timeupdate", onTime);
v.removeEventListener("loadedmetadata", onTime);