重构client,拆分组件
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user