mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
feat: improve shorts playback with buffer-aware preload and back-swipe cache
- Active video loads with priority; next video preloads only after the active one has 12s of forward buffer or is buffered to the end - Add high/low watermark hysteresis (12s grant / 4s revoke) so the preload grant no longer thrashes around the threshold, discarding already-preloaded data - Treat buffered-to-end as healthy to avoid revoking preload near the end of short clips - Mark sources as cacheable on first canplay and keep src bound for cached adjacent slides in both directions, so swiping back (and forward again) reuses the browser buffer instead of reloading Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+177
-29
@@ -32,8 +32,17 @@ const BATCH_SIZE = 5;
|
||||
// 当队列里"还没看过的视频"少于这个数时,提前请求下一批。
|
||||
const PREFETCH_THRESHOLD = 2;
|
||||
|
||||
// 距离 activeIndex 多少屏内的视频会被 mount 真实 <video>。
|
||||
// =1 表示上一屏 / 当前 / 下一屏 都加载,这样切换时几乎无空白。
|
||||
// 当前视频至少有这么多秒的前向缓冲后,才允许下一条开始预加载。
|
||||
const ACTIVE_PRELOAD_BUFFER_SECONDS = 12;
|
||||
|
||||
// 预加载授权一旦发出,只有当前视频前向缓冲跌破这个秒数(或发生 stall)
|
||||
// 才收回。高低水位之间不动作,避免缓冲量在 12s 附近波动时
|
||||
// 反复绑定/剥离下一条的 src、丢弃已预加载的数据。
|
||||
const ACTIVE_PRELOAD_KEEP_SECONDS = 4;
|
||||
|
||||
// 距离 activeIndex 多少屏内的视频会被 mount 真实 <video> 壳。
|
||||
// 当前屏先绑定 src;下一屏要等当前屏缓冲健康后才预加载。
|
||||
// 上一屏如果已经可播放过,保留 src 复用浏览器缓冲。
|
||||
const MOUNT_RADIUS = 1;
|
||||
|
||||
function loadSeenIds(): string[] {
|
||||
@@ -130,6 +139,10 @@ export default function ShortsPage() {
|
||||
const activeIndexRef = useRef(0);
|
||||
const ignoreIntersectionUntilRef = useRef(0);
|
||||
const fullscreenRestoreTimersRef = useRef<number[]>([]);
|
||||
const [activeReadyForPreload, setActiveReadyForPreload] = useState(false);
|
||||
const [cacheableSourceIds, setCacheableSourceIds] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
// 当前是否处在浏览器全屏(Fullscreen API)状态。
|
||||
// iOS Safari 不支持元素级 Fullscreen API,这里会一直保持 false,
|
||||
@@ -147,6 +160,29 @@ export default function ShortsPage() {
|
||||
activeIndexRef.current = activeIndex;
|
||||
}, [activeIndex]);
|
||||
|
||||
const handleActiveReadyForPreload = useCallback((index: number) => {
|
||||
if (index === activeIndexRef.current) {
|
||||
setActiveReadyForPreload(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActiveNeedsPriority = useCallback((index: number) => {
|
||||
if (index === activeIndexRef.current) {
|
||||
setActiveReadyForPreload(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 标记某条视频"浏览器里已有可复用的缓冲"。之后只要它还在相邻屏内,
|
||||
// 就保留 src 不剥离,回滑/再前滑时直接续用已缓冲数据,秒开不卡顿。
|
||||
const handleSourceCached = useCallback((videoId: string) => {
|
||||
setCacheableSourceIds((prev) => {
|
||||
if (prev.has(videoId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(videoId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换点赞状态。
|
||||
* - liked=true:发 POST /api/video/:id/like
|
||||
@@ -280,7 +316,11 @@ export default function ShortsPage() {
|
||||
if (!Number.isNaN(idx)) bestIndex = idx;
|
||||
}
|
||||
}
|
||||
if (bestIndex >= 0) setActiveIndex(bestIndex);
|
||||
if (bestIndex >= 0 && bestIndex !== activeIndexRef.current) {
|
||||
activeIndexRef.current = bestIndex;
|
||||
setActiveReadyForPreload(false);
|
||||
setActiveIndex(bestIndex);
|
||||
}
|
||||
},
|
||||
{
|
||||
root,
|
||||
@@ -652,26 +692,43 @@ export default function ShortsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => (
|
||||
<ShortsSlide
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
isActive={index === activeIndex}
|
||||
// 距离 active 在 MOUNT_RADIUS 之内才挂载真正的 <video>,
|
||||
// 其它槽位用海报占位以节省内存和带宽
|
||||
shouldMount={Math.abs(index - activeIndex) <= MOUNT_RADIUS}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
setMuted={setMuted}
|
||||
setVolume={setVolume}
|
||||
videoRef={setVideoRef(index)}
|
||||
onLikeToggle={handleLikeToggle}
|
||||
hasLiked={hasLiked}
|
||||
onHideSuccess={handleHideSuccess}
|
||||
showHud={showHud}
|
||||
/>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
const isActiveSlide = index === activeIndex;
|
||||
const shouldMount = Math.abs(index - activeIndex) <= MOUNT_RADIUS;
|
||||
const shouldPreload = activeReadyForPreload && index === activeIndex + 1;
|
||||
// 相邻屏内已经缓冲过的视频保留 src:
|
||||
// 回滑上一条、或回滑后再滑回来,都直接复用浏览器已缓冲数据。
|
||||
const shouldRetainCached =
|
||||
shouldMount && !isActiveSlide && cacheableSourceIds.has(item.id);
|
||||
const shouldLoad = isActiveSlide || shouldPreload || shouldRetainCached;
|
||||
const shouldEagerLoad = isActiveSlide || shouldPreload;
|
||||
return (
|
||||
<ShortsSlide
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
isActive={isActiveSlide}
|
||||
// 距离 active 在 MOUNT_RADIUS 之内才挂载 <video> 壳;
|
||||
// 当前屏先绑定 src;下一屏等当前屏缓冲健康后再预加载;
|
||||
// 已缓冲过的相邻屏保留 src,便于回滑复用缓存。
|
||||
shouldMount={shouldMount}
|
||||
shouldLoad={shouldLoad}
|
||||
shouldEagerLoad={shouldEagerLoad}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
setMuted={setMuted}
|
||||
setVolume={setVolume}
|
||||
videoRef={setVideoRef(index)}
|
||||
onLikeToggle={handleLikeToggle}
|
||||
hasLiked={hasLiked}
|
||||
onHideSuccess={handleHideSuccess}
|
||||
onActiveReadyForPreload={handleActiveReadyForPreload}
|
||||
onActiveNeedsPriority={handleActiveNeedsPriority}
|
||||
onSourceCached={handleSourceCached}
|
||||
showHud={showHud}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!empty && items.length > 0 && loading && (
|
||||
<div className="shorts-loading">
|
||||
@@ -689,6 +746,8 @@ type SlideProps = {
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
shouldMount: boolean;
|
||||
shouldLoad: boolean;
|
||||
shouldEagerLoad: boolean;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
setMuted: (muted: boolean) => void;
|
||||
@@ -702,6 +761,10 @@ type SlideProps = {
|
||||
/** 父组件查询某 id 是否已经在本次会话内点过赞 */
|
||||
hasLiked: (videoId: string) => boolean;
|
||||
onHideSuccess: (index: number) => void;
|
||||
onActiveReadyForPreload: (index: number) => void;
|
||||
onActiveNeedsPriority: (index: number) => void;
|
||||
/** 本条视频在浏览器里已有可复用缓冲,之后在相邻屏内保留 src */
|
||||
onSourceCached: (videoId: string) => void;
|
||||
showHud: (text: string, icon?: React.ReactNode) => void;
|
||||
};
|
||||
|
||||
@@ -717,6 +780,8 @@ function ShortsSlide({
|
||||
index,
|
||||
isActive,
|
||||
shouldMount,
|
||||
shouldLoad,
|
||||
shouldEagerLoad,
|
||||
muted,
|
||||
volume,
|
||||
setMuted,
|
||||
@@ -725,6 +790,9 @@ function ShortsSlide({
|
||||
onLikeToggle,
|
||||
hasLiked,
|
||||
onHideSuccess,
|
||||
onActiveReadyForPreload,
|
||||
onActiveNeedsPriority,
|
||||
onSourceCached,
|
||||
showHud,
|
||||
}: SlideProps) {
|
||||
const localRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -778,6 +846,23 @@ function ShortsSlide({
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
// 非当前屏/下一屏/缓存上一屏不保留媒体源,确保离开窗口后浏览器中止原始网盘流。
|
||||
useEffect(() => {
|
||||
if (shouldLoad) return;
|
||||
const video = localRef.current;
|
||||
if (!video) return;
|
||||
try {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setDuration(0);
|
||||
setCurrentTime(0);
|
||||
setIsBuffering(false);
|
||||
}, [shouldLoad, item.id]);
|
||||
|
||||
// 离开活跃后清掉本地的暂停状态,避免回来时 UI 还显示着 paused
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
@@ -810,7 +895,8 @@ function ShortsSlide({
|
||||
}, [isMarkedHidden]);
|
||||
|
||||
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化。
|
||||
// MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位,之后才挂载 video;
|
||||
// MOUNT_RADIUS 会让远离当前屏的 slide 先以海报占位,之后才挂载 video 壳;
|
||||
// 只有 shouldLoad=true 的当前屏/下一屏会绑定 src,因此不会一次拉完整队列。
|
||||
// 因此这里必须跟随 shouldMount 重新绑定,否则后续视频没有 timeupdate 事件。
|
||||
useEffect(() => {
|
||||
if (!shouldMount) {
|
||||
@@ -832,14 +918,29 @@ function ShortsSlide({
|
||||
const handleTime = () => {
|
||||
// 拖动期间不要被 timeupdate 覆盖 UI
|
||||
if (!scrubbingRef.current) setCurrentTime(video.currentTime);
|
||||
syncActivePreloadReadiness(video);
|
||||
};
|
||||
const handleWaiting = () => {
|
||||
setIsBuffering(true);
|
||||
if (isActive) onActiveNeedsPriority(index);
|
||||
};
|
||||
const handlePlayingOrCanPlay = () => {
|
||||
setIsBuffering(false);
|
||||
// 已经能解码播放,说明浏览器里有了值得复用的数据;
|
||||
// 正在观看的视频从此回滑可秒开。
|
||||
if (isActive) onSourceCached(item.id);
|
||||
syncActivePreloadReadiness(video);
|
||||
};
|
||||
const handleProgress = () => {
|
||||
syncActivePreloadReadiness(video);
|
||||
// 预加载中的下一条积累到足够缓冲后也记为可复用,
|
||||
// 之后预加载授权被收回时不再丢弃它的 src 和已缓冲数据。
|
||||
if (!isActive && shouldLoad && videoHasComfortableBuffer(video)) {
|
||||
onSourceCached(item.id);
|
||||
}
|
||||
};
|
||||
const handleVolumeChange = () => {
|
||||
if (!isActive) return;
|
||||
// 当检测到 video 自身的 mute 状态或 volume 改变时,同步更新 React 状态。
|
||||
// 这可以在移动端浏览器支持物理音量键调整时,自动反向取消静音并展示音量 HUD。
|
||||
if (video.muted !== muted) {
|
||||
@@ -850,6 +951,17 @@ function ShortsSlide({
|
||||
}
|
||||
};
|
||||
|
||||
function syncActivePreloadReadiness(currentVideo: HTMLVideoElement) {
|
||||
if (!isActive) return;
|
||||
if (videoHasComfortableBuffer(currentVideo)) {
|
||||
onActiveReadyForPreload(index);
|
||||
} else if (videoBufferIsCritical(currentVideo)) {
|
||||
// 高低水位滞回:只有缓冲真正告急才收回预加载授权,
|
||||
// 在两个水位之间维持现状,避免阈值附近来回抖动。
|
||||
onActiveNeedsPriority(index);
|
||||
}
|
||||
}
|
||||
|
||||
handleLoaded();
|
||||
handleTime();
|
||||
video.addEventListener("loadedmetadata", handleLoaded);
|
||||
@@ -858,6 +970,7 @@ function ShortsSlide({
|
||||
video.addEventListener("waiting", handleWaiting);
|
||||
video.addEventListener("playing", handlePlayingOrCanPlay);
|
||||
video.addEventListener("canplay", handlePlayingOrCanPlay);
|
||||
video.addEventListener("progress", handleProgress);
|
||||
video.addEventListener("volumechange", handleVolumeChange);
|
||||
|
||||
// 挂载时如果已经在播放但是状态不到 ready 则置 buffering
|
||||
@@ -872,9 +985,10 @@ function ShortsSlide({
|
||||
video.removeEventListener("waiting", handleWaiting);
|
||||
video.removeEventListener("playing", handlePlayingOrCanPlay);
|
||||
video.removeEventListener("canplay", handlePlayingOrCanPlay);
|
||||
video.removeEventListener("progress", handleProgress);
|
||||
video.removeEventListener("volumechange", handleVolumeChange);
|
||||
};
|
||||
}, [shouldMount, item.id, muted, volume, setMuted, setVolume]);
|
||||
}, [shouldMount, shouldLoad, item.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached]);
|
||||
|
||||
// 长按 2 倍速:直接绑原生事件
|
||||
useEffect(() => {
|
||||
@@ -1175,9 +1289,9 @@ function ShortsSlide({
|
||||
<video
|
||||
ref={setRef}
|
||||
className="shorts-slide__video"
|
||||
src={item.videoSrc}
|
||||
src={shouldLoad ? item.videoSrc : undefined}
|
||||
poster={item.poster}
|
||||
preload="auto"
|
||||
preload={shouldLoad ? (shouldEagerLoad ? "auto" : "metadata") : "none"}
|
||||
playsInline
|
||||
loop
|
||||
muted={muted}
|
||||
@@ -1210,7 +1324,7 @@ function ShortsSlide({
|
||||
)}
|
||||
|
||||
{/* 视频加载/缓冲旋转器 */}
|
||||
{isBuffering && isActive && shouldMount && !isMarkedHidden && (
|
||||
{isBuffering && isActive && shouldLoad && !isMarkedHidden && (
|
||||
<div className="shorts-slide__buffering" aria-hidden="true">
|
||||
<Loader2 size={30} className="shorts-slide__buffering-icon" />
|
||||
</div>
|
||||
@@ -1309,7 +1423,7 @@ function ShortsSlide({
|
||||
)}
|
||||
|
||||
{/* 进度条 */}
|
||||
{shouldMount && !isMarkedHidden && (
|
||||
{isActive && shouldLoad && !isMarkedHidden && (
|
||||
<div
|
||||
className={`shorts-slide__progress ${
|
||||
scrubbing ? "is-scrubbing" : ""
|
||||
@@ -1347,6 +1461,40 @@ function clamp(n: number, min: number, max: number) {
|
||||
return n < min ? min : n > max ? max : n;
|
||||
}
|
||||
|
||||
/** 已经缓冲到片尾(含误差余量),不会再因网络卡顿 */
|
||||
function videoBufferedToEnd(video: HTMLVideoElement) {
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
if (duration <= 0) return false;
|
||||
const remaining = Math.max(0, duration - (video.currentTime || 0));
|
||||
return bufferedAheadSeconds(video) >= remaining - 0.25;
|
||||
}
|
||||
|
||||
/** 前向缓冲健康(达到高水位或已缓冲到结尾),可以放心预加载下一条 */
|
||||
function videoHasComfortableBuffer(video: HTMLVideoElement) {
|
||||
if (video.readyState < 3) return false;
|
||||
if (videoBufferedToEnd(video)) return true;
|
||||
return bufferedAheadSeconds(video) >= ACTIVE_PRELOAD_BUFFER_SECONDS;
|
||||
}
|
||||
|
||||
/** 前向缓冲告急(跌破低水位且没缓冲到结尾),应收回预加载授权 */
|
||||
function videoBufferIsCritical(video: HTMLVideoElement) {
|
||||
if (video.readyState < 3) return true;
|
||||
if (videoBufferedToEnd(video)) return false;
|
||||
return bufferedAheadSeconds(video) < ACTIVE_PRELOAD_KEEP_SECONDS;
|
||||
}
|
||||
|
||||
function bufferedAheadSeconds(video: HTMLVideoElement) {
|
||||
const current = video.currentTime || 0;
|
||||
for (let i = 0; i < video.buffered.length; i += 1) {
|
||||
const start = video.buffered.start(i);
|
||||
const end = video.buffered.end(i);
|
||||
if (start <= current + 0.25 && end > current) {
|
||||
return Math.max(0, end - current);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatClock(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
|
||||
const total = Math.floor(seconds);
|
||||
|
||||
@@ -34,15 +34,73 @@ test("shorts progress dragging uses immediate pointer state", () => {
|
||||
test("shorts progress listeners rebind when deferred videos mount", () => {
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位/
|
||||
/MOUNT_RADIUS 会让远离当前屏的 slide 先以海报占位/
|
||||
);
|
||||
assert.match(shortsPageSource, /if \(!shouldMount\) \{\s*setDuration\(0\);\s*setCurrentTime\(0\);/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/\}, \[shouldMount, item\.id, muted, volume, setMuted, setVolume\]\);/
|
||||
/\}, \[shouldMount, shouldLoad, item\.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached\]\);/
|
||||
);
|
||||
});
|
||||
|
||||
test("shorts preloads the next original video only after the active video has comfortable buffer", () => {
|
||||
assert.match(shortsPageSource, /const \[activeReadyForPreload, setActiveReadyForPreload\] = useState\(false\);/);
|
||||
assert.match(shortsPageSource, /const ACTIVE_PRELOAD_BUFFER_SECONDS = 12;/);
|
||||
assert.match(shortsPageSource, /const shouldPreload = activeReadyForPreload && index === activeIndex \+ 1;/);
|
||||
assert.match(shortsPageSource, /const shouldLoad = isActiveSlide \|\| shouldPreload \|\| shouldRetainCached;/);
|
||||
assert.match(shortsPageSource, /shouldLoad=\{shouldLoad\}/);
|
||||
assert.match(shortsPageSource, /setActiveReadyForPreload\(false\);\s*setActiveIndex\(bestIndex\);/);
|
||||
assert.match(shortsPageSource, /function syncActivePreloadReadiness\(currentVideo: HTMLVideoElement\)/);
|
||||
assert.match(shortsPageSource, /if \(videoHasComfortableBuffer\(currentVideo\)\) \{\s*onActiveReadyForPreload\(index\);/);
|
||||
assert.match(shortsPageSource, /if \(isActive\) onActiveNeedsPriority\(index\);/);
|
||||
assert.match(shortsPageSource, /video\.addEventListener\("progress", handleProgress\);/);
|
||||
assert.match(shortsPageSource, /src=\{shouldLoad \? item\.videoSrc : undefined\}/);
|
||||
assert.match(shortsPageSource, /video\.removeAttribute\("src"\)/);
|
||||
assert.doesNotMatch(shortsPageSource, /src=\{shouldLoad \? item\.previewSrc/);
|
||||
});
|
||||
|
||||
test("shorts preload grant uses high/low watermark hysteresis", () => {
|
||||
// 高水位 12s 授权、低水位 4s 收回,之间维持现状,避免阈值附近抖动
|
||||
assert.match(shortsPageSource, /const ACTIVE_PRELOAD_KEEP_SECONDS = 4;/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/\} else if \(videoBufferIsCritical\(currentVideo\)\) \{[\s\S]*?onActiveNeedsPriority\(index\);/
|
||||
);
|
||||
assert.match(shortsPageSource, /function videoBufferIsCritical\(video: HTMLVideoElement\)/);
|
||||
// 已缓冲到片尾时既视为健康也不视为告急,避免临近结尾误收回授权
|
||||
assert.match(shortsPageSource, /function videoBufferedToEnd\(video: HTMLVideoElement\)/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/if \(videoBufferedToEnd\(video\)\) return true;[\s\S]*?>= ACTIVE_PRELOAD_BUFFER_SECONDS;/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/if \(videoBufferedToEnd\(video\)\) return false;[\s\S]*?< ACTIVE_PRELOAD_KEEP_SECONDS;/
|
||||
);
|
||||
});
|
||||
|
||||
test("shorts keeps adjacent buffered sources as a lightweight cache", () => {
|
||||
assert.match(shortsPageSource, /const \[cacheableSourceIds, setCacheableSourceIds\] = useState<Set<string>>/);
|
||||
assert.match(shortsPageSource, /setCacheableSourceIds\(\(prev\) => \{/);
|
||||
// 相邻屏内(前一条或后一条)已缓冲过的视频都保留 src,回滑/再前滑均复用缓存
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/const shouldRetainCached =\s*shouldMount && !isActiveSlide && cacheableSourceIds\.has\(item\.id\);/
|
||||
);
|
||||
// 活跃视频一旦 canplay 就标记可复用,快速划走的视频回滑也有缓存
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/if \(isActive\) onSourceCached\(item\.id\);/
|
||||
);
|
||||
// 预加载中的下一条积累到足够缓冲后同样标记,授权收回时不丢弃其数据
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/if \(!isActive && shouldLoad && videoHasComfortableBuffer\(video\)\) \{\s*onSourceCached\(item\.id\);/
|
||||
);
|
||||
assert.match(shortsPageSource, /shouldEagerLoad=\{shouldEagerLoad\}/);
|
||||
assert.match(shortsPageSource, /preload=\{shouldLoad \? \(shouldEagerLoad \? "auto" : "metadata"\) : "none"\}/);
|
||||
});
|
||||
|
||||
test("shorts fullscreen changes preserve the active slide", () => {
|
||||
assert.match(shortsPageSource, /const activeIndexRef = useRef\(0\)/);
|
||||
assert.match(shortsPageSource, /const ignoreIntersectionUntilRef = useRef\(0\)/);
|
||||
|
||||
Reference in New Issue
Block a user