mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-16 01:05:42 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2437fbd779 | |||
| 4dd66b8120 | |||
| 30b736cf36 | |||
| 57391e0e98 |
@@ -5,7 +5,13 @@
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="91" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="91 视频站" />
|
||||
<title>91</title>
|
||||
<!-- Premium Fonts Preconnect & Links -->
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "video-site",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "video-site",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"artplayer": "^5.4.0",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "video-site",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "91",
|
||||
"short_name": "91",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+320
-81
@@ -7,11 +7,8 @@ import {
|
||||
Minimize,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Play,
|
||||
Pause,
|
||||
EyeOff,
|
||||
Info,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
@@ -89,16 +86,34 @@ export default function ShortsPage() {
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
const stopHeaderControlPropagation = useCallback((e: React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleVolumeButtonClick = useCallback(() => {
|
||||
const activeVideo = videoRefs.current.get(activeIndex);
|
||||
const canResumeActiveVideo = () =>
|
||||
Boolean(activeVideo) &&
|
||||
videoRefs.current.get(activeIndexRef.current) === activeVideo &&
|
||||
userPausedIndexRef.current !== activeIndexRef.current;
|
||||
const wasPlaying = Boolean(activeVideo) && canResumeActiveVideo() && !activeVideo?.paused;
|
||||
setMuted((v) => {
|
||||
const next = !v;
|
||||
if (activeVideo) {
|
||||
normalizeVideoPlaybackRate(activeVideo);
|
||||
applyVideoAudioState(activeVideo, next, volume);
|
||||
stabilizeVideoAfterAudioToggle(
|
||||
activeVideo,
|
||||
() => wasPlaying && canResumeActiveVideo()
|
||||
);
|
||||
}
|
||||
showHud(
|
||||
next ? "已静音" : "音量已开启",
|
||||
next ? <VolumeX size={16} /> : <Volume2 size={16} />
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}, [showHud]);
|
||||
}, [activeIndex, showHud, volume]);
|
||||
|
||||
const handleVolumeSliderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
@@ -111,8 +126,19 @@ export default function ShortsPage() {
|
||||
// Update active video volume directly
|
||||
const activeVideo = videoRefs.current.get(activeIndex);
|
||||
if (activeVideo) {
|
||||
activeVideo.volume = val;
|
||||
activeVideo.muted = val === 0;
|
||||
normalizeVideoPlaybackRate(activeVideo);
|
||||
applyVideoAudioState(activeVideo, val === 0, val);
|
||||
const wasPlaying =
|
||||
videoRefs.current.get(activeIndexRef.current) === activeVideo &&
|
||||
userPausedIndexRef.current !== activeIndexRef.current &&
|
||||
!activeVideo.paused;
|
||||
stabilizeVideoAfterAudioToggle(
|
||||
activeVideo,
|
||||
() =>
|
||||
wasPlaying &&
|
||||
videoRefs.current.get(activeIndexRef.current) === activeVideo &&
|
||||
userPausedIndexRef.current !== activeIndexRef.current
|
||||
);
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
@@ -139,20 +165,24 @@ export default function ShortsPage() {
|
||||
// index → video element,用来精确控制播放/暂停
|
||||
const videoRefs = useRef<Map<number, HTMLVideoElement>>(new Map());
|
||||
const activeIndexRef = useRef(0);
|
||||
const userPausedIndexRef = useRef<number | null>(null);
|
||||
const ignoreIntersectionUntilRef = useRef(0);
|
||||
const fullscreenRestoreTimersRef = useRef<number[]>([]);
|
||||
const fullscreenPointerHandledRef = useRef(false);
|
||||
const [activeReadyForPreload, setActiveReadyForPreload] = useState(false);
|
||||
const [userPausedIndex, setUserPausedIndexState] = useState<number | null>(null);
|
||||
const [cacheableSourceIds, setCacheableSourceIds] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const [cacheWindowHighIndex, setCacheWindowHighIndex] = useState(-1);
|
||||
|
||||
// 当前是否处在浏览器全屏(Fullscreen API)状态。
|
||||
// iOS Safari 不支持元素级 Fullscreen API,这里会一直保持 false,
|
||||
// 全屏按钮在那种环境下点了也无效(按钮仍展示"进入全屏"图标)。
|
||||
// iPhone Safari 不支持网页元素级全屏;那种环境下改用页面滚动让浏览器栏随刷动收起。
|
||||
const useDocumentScroll = shouldUseDocumentScrollForShorts();
|
||||
const [canRequestFullscreen, setCanRequestFullscreen] = useState(() =>
|
||||
supportsElementFullscreenAPI()
|
||||
);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// 自动尝试进入全屏只做一次,避免反复打扰用户
|
||||
const autoFullscreenAttemptedRef = useRef(false);
|
||||
|
||||
// 本次会话内已经点过赞的视频 id 集合。
|
||||
// 与后端的真实 likes 字段同步——后端是单纯计数器,前端在这里防重避免连发。
|
||||
@@ -163,6 +193,38 @@ export default function ShortsPage() {
|
||||
activeIndexRef.current = activeIndex;
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = pageRef.current;
|
||||
if (page && supportsElementFullscreenAPI(page)) {
|
||||
setCanRequestFullscreen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateUserPausedIndex = useCallback((index: number | null) => {
|
||||
userPausedIndexRef.current = index;
|
||||
setUserPausedIndexState(index);
|
||||
}, []);
|
||||
|
||||
const setUserPausedForIndex = useCallback(
|
||||
(index: number, isPaused: boolean) => {
|
||||
if (isPaused) {
|
||||
updateUserPausedIndex(index);
|
||||
} else if (userPausedIndexRef.current === index) {
|
||||
updateUserPausedIndex(null);
|
||||
}
|
||||
},
|
||||
[updateUserPausedIndex]
|
||||
);
|
||||
|
||||
const isVideoPausedByUser = useCallback(
|
||||
(index: number) => userPausedIndexRef.current === index,
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateUserPausedIndex(null);
|
||||
}, [activeIndex, updateUserPausedIndex]);
|
||||
|
||||
const handleActiveReadyForPreload = useCallback((index: number) => {
|
||||
if (index === activeIndexRef.current) {
|
||||
setActiveReadyForPreload(true);
|
||||
@@ -294,7 +356,8 @@ export default function ShortsPage() {
|
||||
}
|
||||
}, [activeIndex, items, loading, roundComplete, loadMore]);
|
||||
|
||||
// 用 IntersectionObserver 找出当前进入视口的 item
|
||||
// 用 IntersectionObserver 找出当前进入视口的 item。
|
||||
// root 直接用 viewport:普通模式和 iPhone 页面滚动模式都能正确观测。
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
@@ -321,7 +384,7 @@ export default function ShortsPage() {
|
||||
}
|
||||
},
|
||||
{
|
||||
root,
|
||||
root: null,
|
||||
threshold: [0.6, 0.85],
|
||||
}
|
||||
);
|
||||
@@ -331,20 +394,28 @@ export default function ShortsPage() {
|
||||
return () => observer.disconnect();
|
||||
}, [items.length]);
|
||||
|
||||
// 控制每个 video 的播放状态与音量:只有 activeIndex 对应的在播
|
||||
// 控制每个 video 的播放状态:只有 activeIndex 对应的在播。
|
||||
// 声音切换不要进入这里,否则移动端切换 muted 时可能额外触发 play/pause。
|
||||
useEffect(() => {
|
||||
videoRefs.current.forEach((video, idx) => {
|
||||
if (idx === activeIndex) {
|
||||
video.muted = muted;
|
||||
video.volume = volume;
|
||||
if (video.paused) {
|
||||
if (userPausedIndex === idx) {
|
||||
if (!video.paused) video.pause();
|
||||
} else if (video.paused) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
if (!video.paused) video.pause();
|
||||
}
|
||||
});
|
||||
}, [activeIndex, muted, volume, items.length]);
|
||||
}, [activeIndex, items.length, userPausedIndex]);
|
||||
|
||||
// 单独同步音频属性。这里不做 play/pause,避免手机端切换静音时打断播放节奏。
|
||||
useEffect(() => {
|
||||
videoRefs.current.forEach((video) => {
|
||||
applyVideoAudioState(video, muted, volume);
|
||||
});
|
||||
}, [muted, volume, items.length]);
|
||||
|
||||
// 键盘快捷键监听
|
||||
useEffect(() => {
|
||||
@@ -376,12 +447,15 @@ export default function ShortsPage() {
|
||||
e.preventDefault();
|
||||
const activeVideo = videoRefs.current.get(activeIndex);
|
||||
if (activeVideo) {
|
||||
if (activeVideo.paused) {
|
||||
const shouldResume =
|
||||
userPausedIndexRef.current === activeIndex ||
|
||||
(activeVideo.paused && activeVideo.readyState >= 3);
|
||||
if (shouldResume) {
|
||||
setUserPausedForIndex(activeIndex, false);
|
||||
activeVideo.play().catch(() => undefined);
|
||||
showHud("播放", <Play size={16} fill="currentColor" />);
|
||||
} else {
|
||||
setUserPausedForIndex(activeIndex, true);
|
||||
activeVideo.pause();
|
||||
showHud("暂停", <Pause size={16} fill="currentColor" />);
|
||||
}
|
||||
}
|
||||
} else if (e.key === "m" || e.key === "M") {
|
||||
@@ -417,7 +491,7 @@ export default function ShortsPage() {
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [activeIndex, items, toggleFullscreen, showHud, handleVolumeButtonClick]);
|
||||
}, [activeIndex, items, toggleFullscreen, showHud, handleVolumeButtonClick, setUserPausedForIndex]);
|
||||
|
||||
// 页面卸载时暂停所有
|
||||
useEffect(() => {
|
||||
@@ -444,16 +518,21 @@ export default function ShortsPage() {
|
||||
document.title = "短视频 · 91";
|
||||
}, []);
|
||||
|
||||
// 沉浸式:进入页面后锁住 body 滚动 + 把主题色改黑(Android Chrome 状态栏会变黑)
|
||||
// 沉浸式:默认锁住 body 滚动;iPhone 浏览器里放开根页面滚动,让 Safari 工具栏能随刷动收起。
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const prevHtmlOverflow = html.style.overflow;
|
||||
const prevBodyOverflow = body.style.overflow;
|
||||
const prevBodyBg = body.style.background;
|
||||
html.style.overflow = "hidden";
|
||||
body.style.overflow = "hidden";
|
||||
body.style.background = "#000";
|
||||
if (useDocumentScroll) {
|
||||
html.classList.add("shorts-document-scroll");
|
||||
body.classList.add("shorts-document-scroll");
|
||||
} else {
|
||||
html.style.overflow = "hidden";
|
||||
body.style.overflow = "hidden";
|
||||
body.style.background = "#000";
|
||||
}
|
||||
|
||||
let prevThemeColor: string | null = null;
|
||||
let themeMeta = document.querySelector<HTMLMetaElement>(
|
||||
@@ -470,6 +549,8 @@ export default function ShortsPage() {
|
||||
themeMeta.content = "#000000";
|
||||
|
||||
return () => {
|
||||
html.classList.remove("shorts-document-scroll");
|
||||
body.classList.remove("shorts-document-scroll");
|
||||
html.style.overflow = prevHtmlOverflow;
|
||||
body.style.overflow = prevBodyOverflow;
|
||||
body.style.background = prevBodyBg;
|
||||
@@ -481,7 +562,7 @@ export default function ShortsPage() {
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [useDocumentScroll]);
|
||||
|
||||
function clearFullscreenRestoreTimers() {
|
||||
for (const timer of fullscreenRestoreTimersRef.current) {
|
||||
@@ -543,29 +624,8 @@ export default function ShortsPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 进入页面后第一次任意触摸时尝试自动进入全屏。
|
||||
// 浏览器要求 requestFullscreen 必须在用户手势内调用;进页面时直接调
|
||||
// 一定会被拒绝,所以挂在 pointerdown 上利用第一次手势。
|
||||
// iOS Safari 不支持元素级 Fullscreen API,这里 catch 后保持原样,
|
||||
// 退化为已经做的 100svh 沉浸样式。
|
||||
useEffect(() => {
|
||||
const page = pageRef.current;
|
||||
if (!page) return;
|
||||
function onFirstPointer() {
|
||||
if (autoFullscreenAttemptedRef.current) return;
|
||||
autoFullscreenAttemptedRef.current = true;
|
||||
requestPageFullscreen();
|
||||
}
|
||||
page.addEventListener("pointerdown", onFirstPointer, {
|
||||
once: true,
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
page.removeEventListener("pointerdown", onFirstPointer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function requestPageFullscreen() {
|
||||
if (!canRequestFullscreen) return;
|
||||
const page = pageRef.current;
|
||||
if (!page) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -604,8 +664,33 @@ export default function ShortsPage() {
|
||||
|
||||
function toggleFullscreen() {
|
||||
scheduleFullscreenActiveRestore();
|
||||
if (isFullscreen) exitPageFullscreen();
|
||||
else requestPageFullscreen();
|
||||
if (canRequestFullscreen) {
|
||||
if (isFullscreen) exitPageFullscreen();
|
||||
else requestPageFullscreen();
|
||||
return;
|
||||
}
|
||||
if (useDocumentScroll) {
|
||||
restoreActiveSlideIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFullscreenButtonPointerDown(
|
||||
e: React.PointerEvent<HTMLButtonElement>
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fullscreenPointerHandledRef.current = true;
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
||||
function handleFullscreenButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (fullscreenPointerHandledRef.current) {
|
||||
fullscreenPointerHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
||||
const handleHideSuccess = useCallback((idx: number) => {
|
||||
@@ -624,7 +709,10 @@ export default function ShortsPage() {
|
||||
const videoWindow = getVideoWindowBounds(cacheWindowHighIndex, items.length);
|
||||
|
||||
return (
|
||||
<div className="shorts-page" ref={pageRef}>
|
||||
<div
|
||||
className={`shorts-page${useDocumentScroll ? " is-document-scroll" : ""}`}
|
||||
ref={pageRef}
|
||||
>
|
||||
<header className="shorts-header">
|
||||
<Link to="/" className="shorts-header__back" aria-label="返回首页">
|
||||
<ChevronLeft size={22} />
|
||||
@@ -634,7 +722,8 @@ export default function ShortsPage() {
|
||||
type="button"
|
||||
className="shorts-header__icon-btn"
|
||||
aria-label={isFullscreen ? "退出全屏" : "进入全屏"}
|
||||
onClick={toggleFullscreen}
|
||||
onPointerDown={handleFullscreenButtonPointerDown}
|
||||
onClick={handleFullscreenButtonClick}
|
||||
>
|
||||
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
|
||||
</button>
|
||||
@@ -656,7 +745,16 @@ export default function ShortsPage() {
|
||||
type="button"
|
||||
className="shorts-header__icon-btn"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
onClick={handleVolumeButtonClick}
|
||||
onPointerDownCapture={stopHeaderControlPropagation}
|
||||
onTouchStartCapture={stopHeaderControlPropagation}
|
||||
onMouseDownCapture={stopHeaderControlPropagation}
|
||||
onPointerDown={stopHeaderControlPropagation}
|
||||
onTouchStart={stopHeaderControlPropagation}
|
||||
onMouseDown={stopHeaderControlPropagation}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleVolumeButtonClick();
|
||||
}}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
@@ -720,17 +818,12 @@ export default function ShortsPage() {
|
||||
onActiveReadyForPreload={handleActiveReadyForPreload}
|
||||
onActiveNeedsPriority={handleActiveNeedsPriority}
|
||||
onSourceCached={handleSourceCached}
|
||||
onUserPausedChange={setUserPausedForIndex}
|
||||
isVideoPausedByUser={isVideoPausedByUser}
|
||||
showHud={showHud}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!empty && items.length > 0 && loading && (
|
||||
<div className="shorts-loading">
|
||||
<Loader2 size={16} className="shorts-slide__buffering-icon" />
|
||||
<span>加载中…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -760,6 +853,8 @@ type SlideProps = {
|
||||
onActiveNeedsPriority: (index: number) => void;
|
||||
/** 本条视频在浏览器里已有可复用缓冲,之后在视频窗口内保留 src */
|
||||
onSourceCached: (videoId: string) => void;
|
||||
onUserPausedChange: (index: number, isPaused: boolean) => void;
|
||||
isVideoPausedByUser: (index: number) => boolean;
|
||||
showHud: (text: string, icon?: React.ReactNode) => void;
|
||||
};
|
||||
|
||||
@@ -788,6 +883,8 @@ function ShortsSlide({
|
||||
onActiveReadyForPreload,
|
||||
onActiveNeedsPriority,
|
||||
onSourceCached,
|
||||
onUserPausedChange,
|
||||
isVideoPausedByUser,
|
||||
showHud,
|
||||
}: SlideProps) {
|
||||
const localRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -796,8 +893,6 @@ function ShortsSlide({
|
||||
|
||||
// 视频缓冲状态
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
// 单击播放暂停的瞬间 HUD 动效
|
||||
const [playPauseHud, setPlayPauseHud] = useState<{ id: number; type: "play" | "pause" } | null>(null);
|
||||
// 是否已经被隐藏/拉黑
|
||||
const [isMarkedHidden, setIsMarkedHidden] = useState(false);
|
||||
|
||||
@@ -865,7 +960,6 @@ function ShortsSlide({
|
||||
setScrubbing(false);
|
||||
scrubbingRef.current = false;
|
||||
setIsBuffering(false);
|
||||
setPlayPauseHud(null);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
@@ -873,8 +967,7 @@ function ShortsSlide({
|
||||
useEffect(() => {
|
||||
const video = localRef.current;
|
||||
if (video && isActive) {
|
||||
video.muted = muted;
|
||||
video.volume = volume;
|
||||
applyVideoAudioState(video, muted, volume);
|
||||
}
|
||||
}, [muted, volume, isActive]);
|
||||
|
||||
@@ -916,13 +1009,23 @@ function ShortsSlide({
|
||||
syncActivePreloadReadiness(video);
|
||||
};
|
||||
const handleWaiting = () => {
|
||||
if (video.paused || isVideoPausedByUser(index)) {
|
||||
setIsBuffering(false);
|
||||
return;
|
||||
}
|
||||
setIsBuffering(true);
|
||||
if (isActive) onActiveNeedsPriority(index);
|
||||
};
|
||||
const handlePlayingOrCanPlay = () => {
|
||||
setIsBuffering(false);
|
||||
// 已经能解码播放,说明浏览器里有了值得复用的数据。
|
||||
if (shouldLoad) onSourceCached(item.id);
|
||||
if (isActive && isVideoPausedByUser(index)) {
|
||||
video.pause();
|
||||
setPaused(true);
|
||||
setIsBuffering(false);
|
||||
return;
|
||||
}
|
||||
setIsBuffering(false);
|
||||
syncActivePreloadReadiness(video);
|
||||
};
|
||||
const handleProgress = () => {
|
||||
@@ -944,6 +1047,21 @@ function ShortsSlide({
|
||||
setVolume(video.volume);
|
||||
}
|
||||
};
|
||||
const handlePlay = () => {
|
||||
if (!isActive) return;
|
||||
if (isVideoPausedByUser(index)) {
|
||||
video.pause();
|
||||
setPaused(true);
|
||||
setIsBuffering(false);
|
||||
return;
|
||||
}
|
||||
setPaused(false);
|
||||
};
|
||||
const handlePause = () => {
|
||||
if (!isActive || video.ended) return;
|
||||
setPaused(true);
|
||||
setIsBuffering(false);
|
||||
};
|
||||
|
||||
function syncActivePreloadReadiness(currentVideo: HTMLVideoElement) {
|
||||
if (!isActive) return;
|
||||
@@ -966,6 +1084,8 @@ function ShortsSlide({
|
||||
video.addEventListener("canplay", handlePlayingOrCanPlay);
|
||||
video.addEventListener("progress", handleProgress);
|
||||
video.addEventListener("volumechange", handleVolumeChange);
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
|
||||
// 挂载时如果已经在播放但是状态不到 ready 则置 buffering
|
||||
if (video.readyState < 3 && !video.paused) {
|
||||
@@ -981,8 +1101,10 @@ function ShortsSlide({
|
||||
video.removeEventListener("canplay", handlePlayingOrCanPlay);
|
||||
video.removeEventListener("progress", handleProgress);
|
||||
video.removeEventListener("volumechange", handleVolumeChange);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
};
|
||||
}, [shouldMount, shouldLoad, item.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached]);
|
||||
}, [shouldMount, shouldLoad, item.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached, isVideoPausedByUser]);
|
||||
|
||||
// 长按 2 倍速:直接绑原生事件
|
||||
useEffect(() => {
|
||||
@@ -1047,16 +1169,18 @@ function ShortsSlide({
|
||||
function togglePlayInternal() {
|
||||
const video = localRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
const shouldResume =
|
||||
isVideoPausedByUser(index) || (video.paused && paused && !isBuffering);
|
||||
if (shouldResume) {
|
||||
onUserPausedChange(index, false);
|
||||
video.play().catch(() => undefined);
|
||||
setPaused(false);
|
||||
setPlayPauseHud({ id: Date.now(), type: "play" });
|
||||
setTimeout(() => setPlayPauseHud(null), 450);
|
||||
if (video.readyState < 3) setIsBuffering(true);
|
||||
} else {
|
||||
onUserPausedChange(index, true);
|
||||
video.pause();
|
||||
setPaused(true);
|
||||
setPlayPauseHud({ id: Date.now(), type: "pause" });
|
||||
setTimeout(() => setPlayPauseHud(null), 450);
|
||||
setIsBuffering(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1311,7 +1435,7 @@ function ShortsSlide({
|
||||
|
||||
|
||||
|
||||
{paused && isActive && !scrubbing && !playPauseHud && (
|
||||
{paused && isActive && !scrubbing && (
|
||||
<div className="shorts-slide__paused" aria-hidden="true">
|
||||
▶
|
||||
</div>
|
||||
@@ -1320,14 +1444,7 @@ function ShortsSlide({
|
||||
{/* 视频加载/缓冲旋转器 */}
|
||||
{isBuffering && isActive && shouldLoad && !isMarkedHidden && (
|
||||
<div className="shorts-slide__buffering" aria-hidden="true">
|
||||
<Loader2 size={30} className="shorts-slide__buffering-icon" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放暂停瞬间 HUD 动效 */}
|
||||
{playPauseHud && isActive && (
|
||||
<div key={playPauseHud.id} className="shorts-slide__hud-pulse" aria-hidden="true">
|
||||
{playPauseHud.type === "play" ? <Play size={30} fill="currentColor" /> : <Pause size={30} fill="currentColor" />}
|
||||
<ShortsLoadingSpinner size={30} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1451,6 +1568,128 @@ function ShortsSlide({
|
||||
);
|
||||
}
|
||||
|
||||
function ShortsLoadingSpinner({ size }: { size: number }) {
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
const startedAt = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const spinner = ref.current;
|
||||
if (spinner) {
|
||||
const rotation = ((now - startedAt) / 800) * 360;
|
||||
spinner.style.transform = `rotate(${rotation}deg)`;
|
||||
}
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className="shorts-slide__loading-spinner"
|
||||
style={{
|
||||
"--shorts-spinner-size": `${size}px`,
|
||||
} as React.CSSProperties}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function applyVideoAudioState(
|
||||
video: HTMLVideoElement,
|
||||
nextMuted: boolean,
|
||||
nextVolume: number
|
||||
) {
|
||||
const safeVolume = clamp(nextVolume, 0, 1);
|
||||
const syncVolume = () => {
|
||||
try {
|
||||
if (Math.abs(video.volume - safeVolume) > 0.001) {
|
||||
video.volume = safeVolume;
|
||||
}
|
||||
} catch {
|
||||
// Some mobile browsers expose volume as effectively read-only.
|
||||
}
|
||||
};
|
||||
|
||||
if (!nextMuted) syncVolume();
|
||||
try {
|
||||
if (video.muted !== nextMuted) {
|
||||
video.muted = nextMuted;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (nextMuted) syncVolume();
|
||||
}
|
||||
|
||||
function normalizeVideoPlaybackRate(video: HTMLVideoElement) {
|
||||
try {
|
||||
if (video.defaultPlaybackRate !== 1) {
|
||||
video.defaultPlaybackRate = 1;
|
||||
}
|
||||
if (video.playbackRate !== 1) {
|
||||
video.playbackRate = 1;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function stabilizeVideoAfterAudioToggle(
|
||||
video: HTMLVideoElement,
|
||||
shouldResume: () => boolean
|
||||
) {
|
||||
const stabilize = () => {
|
||||
normalizeVideoPlaybackRate(video);
|
||||
if (shouldResume() && video.paused && !video.ended) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
stabilize();
|
||||
for (const delay of [80, 240, 600]) {
|
||||
window.setTimeout(stabilize, delay);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseDocumentScrollForShorts() {
|
||||
return isIPhoneBrowserShell();
|
||||
}
|
||||
|
||||
function isIPhoneBrowserShell() {
|
||||
if (typeof window === "undefined" || typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const ua = navigator.userAgent || "";
|
||||
return /\biPhone\b|\biPod\b/.test(ua) && !isStandaloneDisplayMode();
|
||||
}
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === "undefined" || typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const nav = navigator as Navigator & { standalone?: boolean };
|
||||
return (
|
||||
nav.standalone === true ||
|
||||
window.matchMedia?.("(display-mode: standalone)").matches === true ||
|
||||
window.matchMedia?.("(display-mode: fullscreen)").matches === true
|
||||
);
|
||||
}
|
||||
|
||||
function supportsElementFullscreenAPI(target?: Element | null) {
|
||||
if (typeof document === "undefined") return false;
|
||||
const el = (target ?? document.documentElement) as HTMLElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void> | void;
|
||||
};
|
||||
return (
|
||||
typeof el.requestFullscreen === "function" ||
|
||||
typeof el.webkitRequestFullscreen === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return n < min ? min : n > max ? max : n;
|
||||
}
|
||||
|
||||
+81
-61
@@ -2,8 +2,14 @@
|
||||
滚动靠原生 scroll-snap 实现 TikTok 式吸附切屏。 */
|
||||
|
||||
.shorts-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* 不能用 fixed 定位:iOS Safari/WebKit 不会合成(渲染)嵌套在 fixed
|
||||
祖先里的内联 <video>,视频能解码播放但画面始终不显示(黑屏)。
|
||||
桌面 Chrome 正常,所以只在 iOS 上表现为黑屏。
|
||||
这里改用普通流里的满屏块(高度锁视口),沉浸态的滚动锁定已由组件在
|
||||
mount 时给 html/body 设置 overflow:hidden 实现,效果等价。 */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100svh;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
z-index: 50;
|
||||
@@ -18,6 +24,59 @@
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html.shorts-document-scroll {
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
scrollbar-gutter: auto;
|
||||
overscroll-behavior-y: none;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.shorts-document-scroll {
|
||||
min-height: 100%;
|
||||
overflow-y: visible;
|
||||
scroll-snap-type: y mandatory;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.shorts-document-scroll::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shorts-page.is-document-scroll {
|
||||
min-height: 100dvh;
|
||||
height: auto;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.shorts-page.is-document-scroll .shorts-feed {
|
||||
min-height: 100dvh;
|
||||
overflow-y: visible;
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
||||
.shorts-page.is-document-scroll .shorts-header,
|
||||
.shorts-page.is-document-scroll .shorts-hud-toast {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.shorts-page.is-document-scroll .shorts-slide {
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
@supports not (height: 100svh) {
|
||||
.shorts-page {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
@supports not (height: 100dvh) {
|
||||
.shorts-page {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 顶部条 (高阶毛玻璃渐变遮罩) ---------- */
|
||||
.shorts-header {
|
||||
position: absolute;
|
||||
@@ -214,48 +273,6 @@
|
||||
filter: drop-shadow(0 20px 50px rgba(0, 0, 0, 0.95));
|
||||
}
|
||||
|
||||
/* 播放状态脉冲 HUD (磨砂玻璃大图标弹出动效) */
|
||||
.shorts-slide__hud-pulse {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
animation: shorts-hud-pop 450ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes shorts-hud-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.85;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* 视频加载/卡顿缓冲磨砂提示圈 */
|
||||
.shorts-slide__buffering {
|
||||
position: absolute;
|
||||
@@ -278,13 +295,27 @@
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.shorts-slide__buffering-icon {
|
||||
animation: shorts-spin 1.1s linear infinite;
|
||||
.shorts-slide__loading-spinner {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: var(--shorts-spinner-size, 30px);
|
||||
height: var(--shorts-spinner-size, 30px);
|
||||
border: 3px solid rgba(255, 255, 255, 0.24);
|
||||
border-top-color: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes shorts-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
@media (max-width: 640px) {
|
||||
.shorts-slide__buffering {
|
||||
--shorts-spinner-size: 24px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.shorts-slide__buffering .shorts-slide__loading-spinner {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 原始大三角形的遗留样式(做兼容处理) */
|
||||
@@ -765,7 +796,7 @@
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
@@ -810,14 +841,3 @@
|
||||
.shorts-empty__link:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.shorts-loading {
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const shortsCss = readFileSync(
|
||||
new URL("../src/styles/shorts.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const shortsPageSource = readFileSync(
|
||||
new URL("../src/pages/ShortsPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const indexHtml = readFileSync(
|
||||
new URL("../index.html", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// iOS Safari/WebKit does not composite an inline <video> nested inside a
|
||||
// `position: fixed` ancestor — the video decodes and plays but never paints
|
||||
// (black screen on iOS only). The shorts page wrapper must therefore not be
|
||||
// position:fixed; it locks the viewport via html/body overflow + 100svh height.
|
||||
test("shorts page wrapper is not position:fixed (breaks iOS <video> compositing)", () => {
|
||||
const pageRule = /\.shorts-page \{[\s\S]*?\}/.exec(shortsCss);
|
||||
assert.ok(pageRule, ".shorts-page rule should exist");
|
||||
assert.doesNotMatch(pageRule[0], /position:\s*fixed/);
|
||||
assert.match(pageRule[0], /position:\s*relative/);
|
||||
assert.match(pageRule[0], /height:\s*100svh/);
|
||||
});
|
||||
|
||||
test("iPhone browser uses document scrolling and only explicit fullscreen", () => {
|
||||
assert.match(shortsPageSource, /function shouldUseDocumentScrollForShorts\(\)/);
|
||||
assert.match(shortsPageSource, /function isIPhoneBrowserShell\(\)/);
|
||||
assert.match(shortsPageSource, /root:\s*null/);
|
||||
assert.match(shortsPageSource, /supportsElementFullscreenAPI\(page\)/);
|
||||
assert.match(shortsPageSource, /setCanRequestFullscreen\(true\)/);
|
||||
assert.doesNotMatch(shortsPageSource, /showFullscreenButton/);
|
||||
assert.match(shortsPageSource, /aria-label=\{isFullscreen \? "退出全屏" : "进入全屏"\}/);
|
||||
assert.match(shortsPageSource, /function handleFullscreenButtonPointerDown/);
|
||||
assert.match(shortsPageSource, /onPointerDown=\{handleFullscreenButtonPointerDown\}/);
|
||||
assert.doesNotMatch(shortsPageSource, /onFirstPointer/);
|
||||
assert.doesNotMatch(shortsPageSource, /currentPage\.addEventListener\("pointerdown"/);
|
||||
assert.match(shortsCss, /html\.shorts-document-scroll[\s\S]*scroll-snap-type:\s*y mandatory/);
|
||||
assert.match(shortsCss, /\.shorts-page\.is-document-scroll \.shorts-feed[\s\S]*overflow-y:\s*visible/);
|
||||
assert.match(shortsCss, /\.shorts-page\.is-document-scroll \.shorts-header,[\s\S]*\.shorts-page\.is-document-scroll \.shorts-hud-toast[\s\S]*position:\s*fixed/);
|
||||
});
|
||||
|
||||
test("app has standalone display metadata for iPhone home-screen launch", () => {
|
||||
assert.match(indexHtml, /<link rel="manifest" href="\/manifest\.webmanifest" \/>/);
|
||||
assert.match(indexHtml, /<meta name="apple-mobile-web-app-capable" content="yes" \/>/);
|
||||
assert.match(indexHtml, /<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" \/>/);
|
||||
});
|
||||
@@ -6,6 +6,10 @@ const shortsPageSource = readFileSync(
|
||||
new URL("../src/pages/ShortsPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const shortsCssSource = readFileSync(
|
||||
new URL("../src/styles/shorts.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const videosDataSource = readFileSync(
|
||||
new URL("../src/data/videos.ts", import.meta.url),
|
||||
"utf8"
|
||||
@@ -43,7 +47,103 @@ test("shorts progress listeners rebind when deferred videos mount", () => {
|
||||
assert.match(shortsPageSource, /if \(!shouldMount\) \{\s*setDuration\(0\);\s*setCurrentTime\(0\);/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/\}, \[shouldMount, shouldLoad, item\.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached\]\);/
|
||||
/\}, \[shouldMount, shouldLoad, item\.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached, isVideoPausedByUser\]\);/
|
||||
);
|
||||
});
|
||||
|
||||
test("shorts paused overlay follows native video playback events", () => {
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/const handlePlay = \(\) => \{[\s\S]*?if \(isVideoPausedByUser\(index\)\) \{[\s\S]*?video\.pause\(\);[\s\S]*?setPaused\(true\);[\s\S]*?return;[\s\S]*?setPaused\(false\);/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/const handlePause = \(\) => \{[\s\S]*?if \(!isActive \|\| video\.ended\) return;[\s\S]*?setPaused\(true\);[\s\S]*?setIsBuffering\(false\);/
|
||||
);
|
||||
assert.match(shortsPageSource, /video\.addEventListener\("play", handlePlay\);/);
|
||||
assert.match(shortsPageSource, /video\.addEventListener\("pause", handlePause\);/);
|
||||
assert.match(shortsPageSource, /video\.removeEventListener\("play", handlePlay\);/);
|
||||
assert.match(shortsPageSource, /video\.removeEventListener\("pause", handlePause\);/);
|
||||
});
|
||||
|
||||
test("shorts preserves a user pause while the active video is still loading", () => {
|
||||
assert.match(shortsPageSource, /const userPausedIndexRef = useRef<number \| null>\(null\);/);
|
||||
assert.match(shortsPageSource, /const \[userPausedIndex, setUserPausedIndexState\] = useState<number \| null>\(null\);/);
|
||||
assert.match(shortsPageSource, /const setUserPausedForIndex = useCallback/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/if \(userPausedIndex === idx\) \{\s*if \(!video\.paused\) video\.pause\(\);\s*\} else if \(video\.paused\) \{\s*video\.play\(\)\.catch/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/userPausedIndexRef\.current === activeIndex \|\|\s*\(activeVideo\.paused && activeVideo\.readyState >= 3\)/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/setUserPausedForIndex\(activeIndex, false\);\s*activeVideo\.play\(\)\.catch/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/setUserPausedForIndex\(activeIndex, true\);\s*activeVideo\.pause\(\);/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/const shouldResume =\s*isVideoPausedByUser\(index\) \|\| \(video\.paused && paused && !isBuffering\);/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/onUserPausedChange\(index, true\);\s*video\.pause\(\);\s*setPaused\(true\);\s*setIsBuffering\(false\);/
|
||||
);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/const handlePlayingOrCanPlay = \(\) => \{[\s\S]*?if \(isActive && isVideoPausedByUser\(index\)\) \{[\s\S]*?video\.pause\(\);[\s\S]*?setPaused\(true\);[\s\S]*?return;/
|
||||
);
|
||||
});
|
||||
|
||||
test("shorts keyboard play pause does not show a toast", () => {
|
||||
const keyboardBlock = /else if \(e\.key === " "\) \{[\s\S]*?\} else if \(e\.key === "m"/.exec(shortsPageSource);
|
||||
assert.ok(keyboardBlock, "space key handler should be present");
|
||||
assert.doesNotMatch(keyboardBlock[0], /showHud\("播放"|showHud\("暂停"/);
|
||||
});
|
||||
|
||||
test("shorts play pause does not render transient center hud", () => {
|
||||
assert.doesNotMatch(shortsPageSource, /function shouldShowPlayPauseHud\(\)/);
|
||||
assert.doesNotMatch(shortsPageSource, /setPlayPauseHud/);
|
||||
assert.doesNotMatch(shortsPageSource, /playPauseHud/);
|
||||
assert.doesNotMatch(shortsPageSource, /shorts-slide__hud-pulse/);
|
||||
assert.doesNotMatch(shortsCssSource, /\.shorts-slide__hud-pulse/);
|
||||
assert.doesNotMatch(shortsCssSource, /@keyframes shorts-hud-pop/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/\{paused && isActive && !scrubbing && \(\s*<div className="shorts-slide__paused"/
|
||||
);
|
||||
});
|
||||
|
||||
test("shorts hud toast keeps icon and text close together", () => {
|
||||
assert.match(
|
||||
shortsCssSource,
|
||||
/\.shorts-hud-toast\s*\{[\s\S]*gap:\s*4px;/
|
||||
);
|
||||
});
|
||||
|
||||
test("shorts loading spinner uses a dedicated animated ring", () => {
|
||||
assert.match(shortsPageSource, /function ShortsLoadingSpinner/);
|
||||
assert.match(shortsPageSource, /requestAnimationFrame\(tick\)/);
|
||||
assert.match(shortsPageSource, /spinner\.style\.transform = `rotate\(\$\{rotation\}deg\)`;/);
|
||||
assert.match(shortsPageSource, /"--shorts-spinner-size": `\$\{size\}px`/);
|
||||
assert.match(shortsPageSource, /<ShortsLoadingSpinner size=\{30\} \/>/);
|
||||
assert.doesNotMatch(shortsPageSource, /<ShortsLoadingSpinner size=\{16\} \/>/);
|
||||
assert.doesNotMatch(shortsPageSource, /加载中…/);
|
||||
assert.doesNotMatch(shortsPageSource, /className="shorts-loading"/);
|
||||
assert.match(
|
||||
shortsCssSource,
|
||||
/\.shorts-slide__loading-spinner\s*\{[\s\S]*width:\s*var\(--shorts-spinner-size,\s*30px\);[\s\S]*height:\s*var\(--shorts-spinner-size,\s*30px\);[\s\S]*border:\s*3px solid rgba\(255,\s*255,\s*255,\s*0\.24\);[\s\S]*border-top-color:\s*rgba\(255,\s*255,\s*255,\s*0\.98\);[\s\S]*border-radius:\s*50%;/
|
||||
);
|
||||
assert.doesNotMatch(shortsCssSource, /\.shorts-loading\s*\{/);
|
||||
assert.doesNotMatch(shortsCssSource, /\.shorts-loading \.shorts-slide__loading-spinner/);
|
||||
assert.match(
|
||||
shortsCssSource,
|
||||
/@media \(max-width:\s*640px\)\s*\{[\s\S]*\.shorts-slide__buffering\s*\{[\s\S]*--shorts-spinner-size:\s*24px;[\s\S]*width:\s*56px;[\s\S]*height:\s*56px;/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,13 +229,40 @@ test("shorts keeps buffered sources inside a six video window", () => {
|
||||
shortsPageSource,
|
||||
/if \(shouldLoad && videoHasBufferedData\(video\)\) \{\s*onSourceCached\(item\.id\);/
|
||||
);
|
||||
const playbackBlock = /\/\/ 控制每个 video 的播放状态与音量[\s\S]*?\}, \[activeIndex, muted, volume, items\.length\]\);/.exec(shortsPageSource);
|
||||
const playbackBlock = /\/\/ 控制每个 video 的播放状态[\s\S]*?\}, \[activeIndex, items\.length, userPausedIndex\]\);/.exec(shortsPageSource);
|
||||
assert.ok(playbackBlock, "parent playback effect should be present");
|
||||
assert.doesNotMatch(playbackBlock[0], /currentTime\s*=\s*0/);
|
||||
assert.doesNotMatch(playbackBlock[0], /video\.muted|video\.volume|applyVideoAudioState/);
|
||||
assert.match(shortsPageSource, /shouldEagerLoad=\{shouldEagerLoad\}/);
|
||||
assert.match(shortsPageSource, /preload=\{shouldLoad \? \(shouldEagerLoad \? "auto" : "metadata"\) : "none"\}/);
|
||||
});
|
||||
|
||||
test("shorts volume changes do not trigger playback control", () => {
|
||||
assert.match(shortsPageSource, /function applyVideoAudioState/);
|
||||
assert.doesNotMatch(shortsPageSource, /onFirstPointer/);
|
||||
assert.doesNotMatch(shortsPageSource, /currentPage\.addEventListener\("pointerdown"/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/const stopHeaderControlPropagation = useCallback\(\(e: React\.SyntheticEvent\) => \{\s*e\.stopPropagation\(\);/
|
||||
);
|
||||
assert.match(shortsPageSource, /onPointerDownCapture=\{stopHeaderControlPropagation\}/);
|
||||
assert.match(shortsPageSource, /onTouchStartCapture=\{stopHeaderControlPropagation\}/);
|
||||
assert.match(shortsPageSource, /onPointerDown=\{stopHeaderControlPropagation\}/);
|
||||
assert.match(shortsPageSource, /onTouchStart=\{stopHeaderControlPropagation\}/);
|
||||
assert.match(shortsPageSource, /function normalizeVideoPlaybackRate/);
|
||||
assert.match(shortsPageSource, /function stabilizeVideoAfterAudioToggle/);
|
||||
assert.match(shortsPageSource, /normalizeVideoPlaybackRate\(activeVideo\);/);
|
||||
assert.match(shortsPageSource, /videoRefs\.current\.get\(activeIndexRef\.current\) === activeVideo/);
|
||||
assert.match(shortsPageSource, /stabilizeVideoAfterAudioToggle\(\s*activeVideo,\s*\(\) => wasPlaying && canResumeActiveVideo\(\)\s*\);/);
|
||||
assert.match(shortsPageSource, /if \(shouldResume\(\) && video\.paused && !video\.ended\) \{/);
|
||||
assert.match(shortsPageSource, /for \(const delay of \[80, 240, 600\]\)/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/useEffect\(\(\) => \{\s*videoRefs\.current\.forEach\(\(video\) => \{\s*applyVideoAudioState\(video, muted, volume\);/
|
||||
);
|
||||
assert.match(shortsPageSource, /\}, \[muted, volume, items\.length\]\);/);
|
||||
});
|
||||
|
||||
test("shorts fullscreen changes preserve the active slide", () => {
|
||||
assert.match(shortsPageSource, /const activeIndexRef = useRef\(0\)/);
|
||||
assert.match(shortsPageSource, /const ignoreIntersectionUntilRef = useRef\(0\)/);
|
||||
@@ -147,7 +274,8 @@ test("shorts fullscreen changes preserve the active slide", () => {
|
||||
assert.match(shortsPageSource, /scheduleFullscreenActiveRestore\(\);\s*setIsFullscreen/);
|
||||
assert.match(
|
||||
shortsPageSource,
|
||||
/function toggleFullscreen\(\) \{\s*scheduleFullscreenActiveRestore\(\);/
|
||||
/function toggleFullscreen\(\) \{\s*scheduleFullscreenActiveRestore\(\);\s*if \(canRequestFullscreen\) \{/
|
||||
);
|
||||
assert.match(shortsPageSource, /if \(useDocumentScroll\) \{\s*restoreActiveSlideIntoView\(\);/);
|
||||
assert.match(shortsPageSource, /scrollIntoView\(\{ block: "start", inline: "nearest", behavior: "auto" \}\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user