mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
fix: improve shorts preference and scrubbing
This commit is contained in:
@@ -440,8 +440,8 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// shortsNextReq 客户端把当前轮已看过的 video id 列表传上来,
|
||||
// 服务器从未在列表中的视频里随机抽 count 个返回。
|
||||
// shortsNextReq 客户端把当前轮已看过的 video id 列表传上来。
|
||||
// PreferredFromVideoID 来自短视频页最近一次点赞成功的视频,用于优先推荐相似标签。
|
||||
type shortsNextReq struct {
|
||||
SeenIDs []string `json:"seenIds"`
|
||||
Count int `json:"count"`
|
||||
|
||||
+2
-1
@@ -93,7 +93,8 @@ export type ShortsNextResponse = {
|
||||
|
||||
/**
|
||||
* 拉取短视频流的下一批候选。把当前轮已看过的 video id 列表传给后端,
|
||||
* 服务器从未在列表中的视频里随机抽 count 条返回。
|
||||
* 服务器从未在列表中的视频里随机抽 count 条返回。preferredFromVideoId
|
||||
* 来自用户最近一次点赞成功的视频,用于按相似标签优先推荐。
|
||||
*
|
||||
* 失败时返回空批 + roundComplete=false,由调用方决定是否重试。
|
||||
*/
|
||||
|
||||
+39
-37
@@ -121,7 +121,6 @@ export default function ShortsPage() {
|
||||
// seenIds 用 ref 维护,方便在异步 callback 里读到最新值
|
||||
const seenIdsRef = useRef<string[]>(loadSeenIds());
|
||||
const preferredFromVideoIdRef = useRef<string | null>(null);
|
||||
const reportedPreferenceIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
// 整个页面根元素,用于 requestFullscreen
|
||||
@@ -165,6 +164,11 @@ export default function ShortsPage() {
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { likes?: number };
|
||||
if (liked) {
|
||||
preferredFromVideoIdRef.current = videoId;
|
||||
} else if (preferredFromVideoIdRef.current === videoId) {
|
||||
preferredFromVideoIdRef.current = null;
|
||||
}
|
||||
return typeof data.likes === "number" ? data.likes : null;
|
||||
} catch {
|
||||
// 请求失败:回滚集合,让 Slide 自己回滚 UI
|
||||
@@ -185,12 +189,6 @@ export default function ShortsPage() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePreferenceReady = useCallback((item: ShortsItem) => {
|
||||
if (reportedPreferenceIdsRef.current.has(item.id)) return;
|
||||
reportedPreferenceIdsRef.current.add(item.id);
|
||||
preferredFromVideoIdRef.current = item.id;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 向后端请求下一批不重复的短视频,追加到 items 末尾。
|
||||
*/
|
||||
@@ -634,7 +632,6 @@ export default function ShortsPage() {
|
||||
onLikeToggle={handleLikeToggle}
|
||||
hasLiked={hasLiked}
|
||||
onHideSuccess={handleHideSuccess}
|
||||
onPreferenceReady={handlePreferenceReady}
|
||||
showHud={showHud}
|
||||
/>
|
||||
))}
|
||||
@@ -668,7 +665,6 @@ type SlideProps = {
|
||||
/** 父组件查询某 id 是否已经在本次会话内点过赞 */
|
||||
hasLiked: (videoId: string) => boolean;
|
||||
onHideSuccess: (index: number) => void;
|
||||
onPreferenceReady: (item: ShortsItem) => void;
|
||||
showHud: (text: string, icon?: React.ReactNode) => void;
|
||||
};
|
||||
|
||||
@@ -692,7 +688,6 @@ function ShortsSlide({
|
||||
onLikeToggle,
|
||||
hasLiked,
|
||||
onHideSuccess,
|
||||
onPreferenceReady,
|
||||
showHud,
|
||||
}: SlideProps) {
|
||||
const localRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -710,11 +705,10 @@ function ShortsSlide({
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [scrubbing, setScrubbing] = useState(false);
|
||||
const scrubbingRef = useRef(false);
|
||||
// 拖动开始时是否在播:用于拖完后判断要不要 resume
|
||||
const wasPlayingRef = useRef(true);
|
||||
|
||||
const preferenceFiredRef = useRef(false);
|
||||
|
||||
// 点赞数和"是否已点过赞"状态。
|
||||
// 初始 likes 取自后端返回的列表项;isLiked 仅控制视觉态,
|
||||
// 真正的防重在父组件 likedIdsRef 里,这里只信任父返回的回执。
|
||||
@@ -739,10 +733,6 @@ function ShortsSlide({
|
||||
setIsLiked(hasLiked(item.id));
|
||||
}, [item.id, item.likes, hasLiked]);
|
||||
|
||||
useEffect(() => {
|
||||
preferenceFiredRef.current = false;
|
||||
}, [item.id]);
|
||||
|
||||
const setRef = useCallback(
|
||||
(el: HTMLVideoElement | null) => {
|
||||
localRef.current = el;
|
||||
@@ -756,6 +746,7 @@ function ShortsSlide({
|
||||
if (!isActive) {
|
||||
setPaused(false);
|
||||
setScrubbing(false);
|
||||
scrubbingRef.current = false;
|
||||
setIsBuffering(false);
|
||||
setPlayPauseHud(null);
|
||||
}
|
||||
@@ -790,17 +781,7 @@ function ShortsSlide({
|
||||
};
|
||||
const handleTime = () => {
|
||||
// 拖动期间不要被 timeupdate 覆盖 UI
|
||||
if (!scrubbing) setCurrentTime(video.currentTime);
|
||||
if (
|
||||
isActive &&
|
||||
shouldMount &&
|
||||
!isMarkedHidden &&
|
||||
!preferenceFiredRef.current &&
|
||||
video.currentTime >= 3
|
||||
) {
|
||||
preferenceFiredRef.current = true;
|
||||
onPreferenceReady(item);
|
||||
}
|
||||
if (!scrubbingRef.current) setCurrentTime(video.currentTime);
|
||||
};
|
||||
const handleWaiting = () => {
|
||||
setIsBuffering(true);
|
||||
@@ -842,7 +823,7 @@ function ShortsSlide({
|
||||
video.removeEventListener("canplay", handlePlayingOrCanPlay);
|
||||
video.removeEventListener("volumechange", handleVolumeChange);
|
||||
};
|
||||
}, [shouldMount, scrubbing, muted, volume, setMuted, setVolume, isActive, isMarkedHidden, item, onPreferenceReady]);
|
||||
}, [muted, volume, setMuted, setVolume]);
|
||||
|
||||
// 长按 2 倍速:直接绑原生事件
|
||||
useEffect(() => {
|
||||
@@ -1052,11 +1033,16 @@ function ShortsSlide({
|
||||
// ---- 进度条拖动 ----
|
||||
// 触摸进度条时:暂停 → 跟随手指更新 currentTime → 松手 resume
|
||||
function handleProgressPointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
const video = localRef.current;
|
||||
if (!video || !duration) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const video = localRef.current;
|
||||
const seekDuration = getSeekDuration(video);
|
||||
if (!video || !seekDuration) return;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
wasPlayingRef.current = !video.paused;
|
||||
if (!video.paused) {
|
||||
try {
|
||||
@@ -1065,17 +1051,19 @@ function ShortsSlide({
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
scrubbingRef.current = true;
|
||||
setScrubbing(true);
|
||||
applyProgressFromEvent(e);
|
||||
applyProgressFromEvent(e, seekDuration);
|
||||
}
|
||||
function handleProgressPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!scrubbing) return;
|
||||
if (!scrubbingRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
applyProgressFromEvent(e);
|
||||
}
|
||||
function handleProgressPointerEnd(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!scrubbing) return;
|
||||
if (!scrubbingRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
@@ -1083,17 +1071,30 @@ function ShortsSlide({
|
||||
// ignore
|
||||
}
|
||||
const video = localRef.current;
|
||||
scrubbingRef.current = false;
|
||||
setScrubbing(false);
|
||||
if (video && wasPlayingRef.current) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
function applyProgressFromEvent(e: React.PointerEvent<HTMLDivElement>) {
|
||||
function getSeekDuration(video: HTMLVideoElement | null) {
|
||||
if (duration > 0) return duration;
|
||||
if (video && Number.isFinite(video.duration) && video.duration > 0) {
|
||||
setDuration(video.duration);
|
||||
return video.duration;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function applyProgressFromEvent(
|
||||
e: React.PointerEvent<HTMLDivElement>,
|
||||
knownDuration?: number
|
||||
) {
|
||||
const video = localRef.current;
|
||||
if (!video || !duration) return;
|
||||
const seekDuration = knownDuration ?? getSeekDuration(video);
|
||||
if (!video || !seekDuration) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = clamp((e.clientX - rect.left) / rect.width, 0, 1);
|
||||
const next = ratio * duration;
|
||||
const next = ratio * seekDuration;
|
||||
setCurrentTime(next);
|
||||
try {
|
||||
video.currentTime = next;
|
||||
@@ -1266,6 +1267,7 @@ function ShortsSlide({
|
||||
onPointerMove={handleProgressPointerMove}
|
||||
onPointerUp={handleProgressPointerEnd}
|
||||
onPointerCancel={handleProgressPointerEnd}
|
||||
onLostPointerCapture={handleProgressPointerEnd}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const shortsPageSource = readFileSync(
|
||||
new URL("../src/pages/ShortsPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("shorts recommendation preference follows successful likes instead of watch time", () => {
|
||||
assert.doesNotMatch(shortsPageSource, /currentTime\s*>=\s*3/);
|
||||
assert.doesNotMatch(shortsPageSource, /onPreferenceReady/);
|
||||
|
||||
const match = /const handleLikeToggle[\s\S]*?const hasLiked/.exec(
|
||||
shortsPageSource
|
||||
);
|
||||
assert.ok(match, "handleLikeToggle block should be present");
|
||||
|
||||
assert.match(
|
||||
match[0],
|
||||
/if \(liked\) \{\s*preferredFromVideoIdRef\.current = videoId;\s*\} else if \(preferredFromVideoIdRef\.current === videoId\) \{\s*preferredFromVideoIdRef\.current = null;/
|
||||
);
|
||||
});
|
||||
|
||||
test("shorts progress dragging uses immediate pointer state", () => {
|
||||
assert.match(shortsPageSource, /const scrubbingRef = useRef\(false\)/);
|
||||
assert.match(shortsPageSource, /scrubbingRef\.current = true;/);
|
||||
assert.match(shortsPageSource, /if \(!scrubbingRef\.current\) return;/);
|
||||
assert.doesNotMatch(shortsPageSource, /if \(!scrubbing\) return;/);
|
||||
assert.match(shortsPageSource, /function getSeekDuration/);
|
||||
assert.match(shortsPageSource, /onLostPointerCapture=\{handleProgressPointerEnd\}/);
|
||||
});
|
||||
Reference in New Issue
Block a user