4 Commits

Author SHA1 Message Date
nianzhibai 2437fbd779 发布v0.1.9 版本
1. 修复Apple设备短视频页面无法播放问题
2. 优化短视频播放页面交互
2026-06-15 20:23:08 +08:00
nianzhibai 4dd66b8120 fix: stabilize shorts playback and iPhone fullscreen controls 2026-06-15 20:19:28 +08:00
nianzhibai 30b736cf36 Merge pull request #54 2026-06-15 17:38:32 +08:00
Long Chen 57391e0e98 fix: render short videos on iOS by dropping fixed positioning
Short-video mode showed a black screen on iOS Safari/WebKit while working
on desktop. iOS does not composite an inline <video> nested inside a
`position: fixed` ancestor: the video decodes and plays but its layer
never paints. `.shorts-page` used `position: fixed; inset: 0`, trapping
every shorts video.

Make `.shorts-page` a normal-flow full-viewport block
(`position: relative; height: 100svh` with dvh/vh fallbacks) instead.
The immersive scroll lock is already provided by the component setting
html/body `overflow: hidden` on mount, so the look and behavior are
unchanged. Fixes the iOS black screen; desktop is unaffected.
2026-06-15 17:20:35 +08:00
8 changed files with 610 additions and 148 deletions
+6
View File
@@ -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 -->
+2 -2
View File
@@ -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
View File
@@ -2,7 +2,7 @@
"name": "video-site",
"private": true,
"license": "MIT",
"version": "0.1.8",
"version": "0.1.9",
"type": "module",
"scripts": {
"dev": "vite",
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+51
View File
@@ -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" \/>/);
});
+131 -3
View File
@@ -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" \}\)/);
});