mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 16:55:42 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc7d2a5de3 | |||
| 2f2bfbfcdc | |||
| 9def08b0c5 |
Generated
+37
-2
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "video-site",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "video-site",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"artplayer": "^5.4.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"lucide-react": "0.453.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
@@ -475,6 +477,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/artplayer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.4.0.tgz",
|
||||
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"option-validator": "^2.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -525,12 +536,27 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -832,6 +858,15 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/option-validator": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz",
|
||||
"integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kind-of": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
"name": "video-site",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,6 +13,8 @@
|
||||
"test": "node --import tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"artplayer": "^5.4.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"lucide-react": "0.453.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { MainNav } from "./MainNav";
|
||||
import { SubNav } from "./SubNav";
|
||||
@@ -7,14 +7,84 @@ import { BackToTop } from "./BackToTop";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
mobileAutoHideNav?: boolean;
|
||||
};
|
||||
|
||||
export function AppShell({ children }: Props) {
|
||||
const MOBILE_NAV_QUERY = "(max-width: 768px)";
|
||||
const SCROLL_DELTA_THRESHOLD = 6;
|
||||
const HIDE_AFTER_SCROLL_Y = 56;
|
||||
|
||||
export function AppShell({ children, mobileAutoHideNav = false }: Props) {
|
||||
const [mobileNavHidden, setMobileNavHidden] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileAutoHideNav) {
|
||||
setMobileNavHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(MOBILE_NAV_QUERY);
|
||||
let lastScrollY = Math.max(window.scrollY, 0);
|
||||
let ticking = false;
|
||||
|
||||
const showNav = () => setMobileNavHidden(false);
|
||||
|
||||
const updateNavVisibility = () => {
|
||||
ticking = false;
|
||||
const currentScrollY = Math.max(window.scrollY, 0);
|
||||
|
||||
if (!mediaQuery.matches || currentScrollY <= 0) {
|
||||
showNav();
|
||||
lastScrollY = currentScrollY;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = currentScrollY - lastScrollY;
|
||||
if (Math.abs(delta) < SCROLL_DELTA_THRESHOLD) return;
|
||||
|
||||
if (delta > 0 && currentScrollY > HIDE_AFTER_SCROLL_Y) {
|
||||
setMobileNavHidden(true);
|
||||
} else if (delta < 0) {
|
||||
showNav();
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
window.requestAnimationFrame(updateNavVisibility);
|
||||
};
|
||||
|
||||
const handleMediaChange = () => {
|
||||
lastScrollY = Math.max(window.scrollY, 0);
|
||||
showNav();
|
||||
};
|
||||
|
||||
handleMediaChange();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
mediaQuery.addEventListener("change", handleMediaChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
mediaQuery.removeEventListener("change", handleMediaChange);
|
||||
};
|
||||
}, [mobileAutoHideNav]);
|
||||
|
||||
const className = [
|
||||
"app-shell",
|
||||
mobileAutoHideNav ? "app-shell--mobile-auto-hide-nav" : "",
|
||||
mobileNavHidden ? "is-mobile-nav-hidden" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TopBar />
|
||||
<MainNav />
|
||||
<SubNav />
|
||||
<div className={className}>
|
||||
<div className="app-shell__nav-stack">
|
||||
<TopBar />
|
||||
<MainNav />
|
||||
<SubNav />
|
||||
</div>
|
||||
<main className="app-shell__main">{children}</main>
|
||||
<Footer />
|
||||
<BackToTop />
|
||||
|
||||
@@ -38,13 +38,11 @@ export function RecommendedRail({ videos }: Props) {
|
||||
return (
|
||||
<aside className="vd-rail" aria-label="推荐视频">
|
||||
<header className="vd-rail__head">
|
||||
<span className="vd-rail__head-bar" aria-hidden="true" />
|
||||
<div className="vd-rail__head-text">
|
||||
<h2 className="vd-rail__head-title">推荐视频</h2>
|
||||
<span className="vd-rail__head-sub">
|
||||
根据当前视频 · {videos.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<span className="vd-rail__head-icon" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<h2 className="vd-rail__head-title">推荐视频</h2>
|
||||
</header>
|
||||
<ul className="vd-rail__list">
|
||||
{videos.map((v) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { EyeOff, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
@@ -12,11 +12,11 @@ type Props = {
|
||||
/**
|
||||
* 视频操作工具条。
|
||||
* - 整体是一张浮起的圆角玻璃卡,比上一版的横线分隔更"成体"。
|
||||
* - 点赞 + 点踩组成一个胶囊(中间一道竖线分隔),两侧分别带计数。
|
||||
* - 点赞 + 点踩是两个独立按钮。
|
||||
* - "不再显示" 单独成一个次要按钮,hover 时露出 danger 色。
|
||||
*
|
||||
* 功能没变:
|
||||
* - 后端只有点赞接口(POST /api/video/:id/like),点踩仅本地 state。
|
||||
* - 后端只有点赞计数接口,点踩仅本地 state。
|
||||
* - 失败回滚已经处理。
|
||||
*/
|
||||
export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
@@ -25,11 +25,20 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
const [bursting, setBursting] = useState(false);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [disliked, setDisliked] = useState(false);
|
||||
const [likeSubmitted, setLikeSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLikes(video.likes ?? 0);
|
||||
setDislikes(video.dislikes ?? 0);
|
||||
setBursting(false);
|
||||
setLiked(false);
|
||||
setDisliked(false);
|
||||
setLikeSubmitted(false);
|
||||
}, [video.id, video.likes, video.dislikes]);
|
||||
|
||||
async function handleLike() {
|
||||
if (liked) return;
|
||||
setLiked(true);
|
||||
setLikes((n) => n + 1);
|
||||
setBursting(true);
|
||||
window.setTimeout(() => setBursting(false), 320);
|
||||
|
||||
@@ -38,6 +47,11 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
setDislikes((n) => Math.max(0, n - 1));
|
||||
}
|
||||
|
||||
if (likeSubmitted) return;
|
||||
|
||||
setLikeSubmitted(true);
|
||||
setLikes((n) => n + 1);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/video/${encodeURIComponent(video.id)}/like`,
|
||||
@@ -51,6 +65,7 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
} catch {
|
||||
setLikes((n) => Math.max(0, n - 1));
|
||||
setLiked(false);
|
||||
setLikeSubmitted(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +79,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
setDislikes((n) => n + 1);
|
||||
if (liked) {
|
||||
setLiked(false);
|
||||
setLikes((n) => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +97,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
<ThumbsUp size={18} fill={liked ? "currentColor" : "none"} />
|
||||
<span className="vd-actions__count">{formatCount(likes)}</span>
|
||||
</button>
|
||||
<span className="vd-actions__divider" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={`vd-actions__pill vd-actions__dislike${
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Hash, Pencil, X } from "lucide-react";
|
||||
import { Pencil, Tag, X } from "lucide-react";
|
||||
import type { TagItem, VideoDetail } from "@/types";
|
||||
|
||||
type Props = {
|
||||
@@ -17,7 +17,7 @@ type Props = {
|
||||
* 视觉上和上一版的"两张分离卡"相比,整体感更强:
|
||||
* - 一张大卡内分两个小区块,区块之间用细分隔线
|
||||
* - 简介区块加 "简介" 标题前缀
|
||||
* - 标签区块加 # 图标暗示
|
||||
* - 标签区块加标签轮廓图标暗示
|
||||
*/
|
||||
export function VideoInfoPanel({
|
||||
video,
|
||||
@@ -99,7 +99,7 @@ export function VideoInfoPanel({
|
||||
<div className="vd-info__tags">
|
||||
<div className="vd-info__section-head">
|
||||
<span className="vd-info__section-title">
|
||||
<Hash size={14} aria-hidden="true" />
|
||||
<Tag size={15} strokeWidth={2} aria-hidden="true" />
|
||||
标签
|
||||
</span>
|
||||
{onTagsChange && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarDays, Clock3, Eye } from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
@@ -9,12 +10,11 @@ type Props = {
|
||||
* 详情页标题块。
|
||||
*
|
||||
* 视觉:
|
||||
* - 标题:大、粗、最高两行
|
||||
* - meta:作者首字头像 + 名字 + 一组小胶囊(来源、画质、时长、观看数、发布时间)
|
||||
* - meta:一组小胶囊(来源、画质、时长、观看数、发布时间)
|
||||
* 每个胶囊有自己的语义色彩,避免传统 "·" 分隔列表的列表感。
|
||||
* - 标题:大、粗、最高两行,位于 meta 下方
|
||||
*/
|
||||
export function VideoMetaHeader({ video }: Props) {
|
||||
const author = (video.author ?? "").trim();
|
||||
const source = (video.sourceLabel ?? "").trim();
|
||||
const quality = (video.quality ?? "").trim();
|
||||
const duration = (video.duration ?? "").trim();
|
||||
@@ -23,20 +23,7 @@ export function VideoMetaHeader({ video }: Props) {
|
||||
|
||||
return (
|
||||
<header className="vd-header">
|
||||
<h1 className="vd-header__title" title={video.title}>
|
||||
{video.title}
|
||||
</h1>
|
||||
|
||||
<div className="vd-header__row">
|
||||
{author && (
|
||||
<div className="vd-author" aria-label={`作者 ${author}`}>
|
||||
<span className="vd-author__avatar" aria-hidden="true">
|
||||
{author.slice(0, 1)}
|
||||
</span>
|
||||
<span className="vd-author__name">{author}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="vd-meta" aria-label="视频信息">
|
||||
{source && (
|
||||
<li className="vd-meta__chip" data-tone={sourceKind || "neutral"}>
|
||||
@@ -52,13 +39,28 @@ export function VideoMetaHeader({ video }: Props) {
|
||||
{quality}
|
||||
</li>
|
||||
)}
|
||||
{duration && <li className="vd-meta__chip">{duration}</li>}
|
||||
<li className="vd-meta__chip">
|
||||
{duration && (
|
||||
<li className="vd-meta__chip vd-meta__chip--plain">
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
{duration}
|
||||
</li>
|
||||
)}
|
||||
<li className="vd-meta__chip vd-meta__chip--plain">
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
<strong>{formatCount(video.views)}</strong> 次观看
|
||||
</li>
|
||||
{published && <li className="vd-meta__chip">{published}</li>}
|
||||
{published && (
|
||||
<li className="vd-meta__chip vd-meta__chip--plain">
|
||||
<CalendarDays size={14} aria-hidden="true" />
|
||||
{published}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h1 className="vd-header__title" title={video.title}>
|
||||
{video.title}
|
||||
</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
+1451
-133
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ export default function HomePage() {
|
||||
const latest = latestVideos.slice(0, displayCount);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="container page-section">
|
||||
<PromoStrip />
|
||||
<SearchPanel />
|
||||
|
||||
@@ -84,14 +84,66 @@ export default function VideoDetailPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="vd-page">
|
||||
<div className="vd-ambient" aria-hidden="true" />
|
||||
<div className="container vd-page__inner">
|
||||
<div className="vd-skeleton">
|
||||
<div className="vd-skeleton__player" />
|
||||
<div className="vd-skeleton__title" />
|
||||
<div className="vd-skeleton__meta" />
|
||||
<div
|
||||
className="vd-layout vd-skeleton"
|
||||
aria-busy="true"
|
||||
aria-label="视频详情加载中"
|
||||
>
|
||||
<div className="vd-main">
|
||||
<div className="vd-skeleton__player" />
|
||||
|
||||
<div className="vd-skeleton__summary">
|
||||
<div className="vd-skeleton__chips">
|
||||
<span className="vd-skeleton__chip vd-skeleton__chip--source" />
|
||||
<span className="vd-skeleton__chip" />
|
||||
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
|
||||
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
|
||||
</div>
|
||||
<div className="vd-skeleton__title" />
|
||||
<div className="vd-skeleton__actions">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vd-skeleton__info">
|
||||
<span className="vd-skeleton__section-head" />
|
||||
<span className="vd-skeleton__line" />
|
||||
<span className="vd-skeleton__line vd-skeleton__line--short" />
|
||||
<div className="vd-skeleton__tag-row">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="vd-rail vd-skeleton__rail">
|
||||
<div className="vd-rail__head">
|
||||
<span className="vd-rail__head-icon" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span className="vd-skeleton__rail-head" />
|
||||
</div>
|
||||
<ul className="vd-rail__list vd-skeleton__rail-list">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<li key={index} className="vd-skeleton__rail-item">
|
||||
<span className="vd-skeleton__rail-thumb" />
|
||||
<span className="vd-skeleton__rail-body">
|
||||
<span className="vd-skeleton__rail-title" />
|
||||
<span className="vd-skeleton__rail-title vd-skeleton__rail-title--short" />
|
||||
<span className="vd-skeleton__rail-meta" />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +153,7 @@ export default function VideoDetailPage() {
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="vd-page">
|
||||
<div className="container vd-page__inner">
|
||||
<div className="vd-empty">视频不存在或已被移除</div>
|
||||
@@ -112,7 +164,7 @@ export default function VideoDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="vd-page">
|
||||
{/* Ambient 背景层:用海报作模糊底色,叠加渐变过渡到页面背景 */}
|
||||
<div
|
||||
@@ -131,21 +183,25 @@ export default function VideoDetailPage() {
|
||||
<div className="vd-player-wrap">
|
||||
<div className="vd-player">
|
||||
<VideoPlayer
|
||||
id={detail.id}
|
||||
src={detail.videoSrc}
|
||||
poster={detail.poster}
|
||||
previewSrc={detail.previewSrc}
|
||||
title={detail.title}
|
||||
onFirstPlay={handleFirstPlay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoMetaHeader video={detail} />
|
||||
<section className="vd-summary" aria-label="当前视频">
|
||||
<VideoMetaHeader video={detail} />
|
||||
|
||||
<VideoActions
|
||||
video={detail}
|
||||
onHideVideo={handleHideVideo}
|
||||
hideSaving={hideSaving}
|
||||
/>
|
||||
<VideoActions
|
||||
video={detail}
|
||||
onHideVideo={handleHideVideo}
|
||||
hideSaving={hideSaving}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<VideoInfoPanel
|
||||
video={detail}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.app-shell__nav-stack {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
@@ -179,6 +179,24 @@
|
||||
|
||||
/* ----- 响应式 ----- */
|
||||
@media (max-width: 768px) {
|
||||
.app-shell--mobile-auto-hide-nav .app-shell__nav-stack {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-nav);
|
||||
transform: translateY(0);
|
||||
transition: transform 220ms var(--ease-out);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.app-shell--mobile-auto-hide-nav.is-mobile-nav-hidden .app-shell__nav-stack {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.app-shell--mobile-auto-hide-nav .main-nav {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
.main-nav__inner {
|
||||
height: 56px;
|
||||
gap: var(--space-3);
|
||||
|
||||
+789
-292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const actionsSource = readFileSync(
|
||||
new URL("../src/components/VideoActions.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const detailCss = readFileSync(
|
||||
new URL("../src/styles/video-detail.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("detail dislike does not locally decrement persisted likes", () => {
|
||||
const match = /function handleDislike\(\) \{([\s\S]*?)\n return \(/.exec(
|
||||
actionsSource
|
||||
);
|
||||
assert.ok(match, "handleDislike block should be present");
|
||||
assert.match(match[1], /setDisliked\(true\)/);
|
||||
assert.doesNotMatch(match[1], /setLikes/);
|
||||
});
|
||||
|
||||
test("detail like and dislike buttons are visually separated", () => {
|
||||
assert.doesNotMatch(actionsSource, /vd-actions__divider/);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-actions__group\s*\{[^}]*gap:\s*var\(--space-2\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-actions__pill\s*\{[^}]*border:\s*1px solid var\(--border-subtle\)[^}]*border-radius:\s*var\(--radius-sm\)/s
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const detailCss = readFileSync(
|
||||
new URL("../src/styles/video-detail.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const playerSource = readFileSync(
|
||||
new URL("../src/components/VideoPlayer.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const detailPageSource = readFileSync(
|
||||
new URL("../src/pages/VideoDetailPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("detail player poster uses full-frame contain scaling", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player \.art-poster\s*\{[^}]*background-position:\s*center[^}]*background-repeat:\s*no-repeat[^}]*background-size:\s*contain/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail player does not keep playback resume state", () => {
|
||||
assert.doesNotMatch(playerSource, /ResumePrompt/);
|
||||
assert.doesNotMatch(playerSource, /PlaybackRecord/);
|
||||
assert.doesNotMatch(playerSource, /PLAYBACK_KEY_PREFIX/);
|
||||
assert.doesNotMatch(playerSource, /maybeOfferResume/);
|
||||
assert.doesNotMatch(playerSource, /savePlaybackRecord/);
|
||||
assert.doesNotMatch(playerSource, /clearPlaybackRecord/);
|
||||
assert.doesNotMatch(playerSource, /video-player__resume/);
|
||||
assert.doesNotMatch(detailCss, /video-player__resume/);
|
||||
});
|
||||
|
||||
test("detail loading skeleton matches current desktop video page layout", () => {
|
||||
assert.match(detailPageSource, /className="vd-layout vd-skeleton"/);
|
||||
assert.match(detailPageSource, /className="vd-skeleton__summary"/);
|
||||
assert.match(detailPageSource, /className="vd-skeleton__info"/);
|
||||
assert.match(detailPageSource, /className="vd-rail vd-skeleton__rail"/);
|
||||
assert.match(detailPageSource, /Array\.from\(\{ length: 6 \}\)/);
|
||||
assert.doesNotMatch(detailPageSource, /className="vd-skeleton__meta"/);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-skeleton__player\s*\{[^}]*aspect-ratio:\s*16 \/ 9[^}]*border-radius:\s*0/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-skeleton__summary,\s*\.vd-skeleton__info\s*\{[^}]*border:\s*1px solid var\(--border-default\)[^}]*border-radius:\s*var\(--radius-md\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-skeleton__rail-item\s*\{[^}]*grid-template-columns:\s*150px minmax\(0,\s*1fr\)/s
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailCss,
|
||||
/\.vd-skeleton__player\s*\{[^}]*box-shadow:\s*var\(--shadow-lg\)/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail video title uses a restrained size", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-xl\)[^}]*line-height:\s*1\.34/s
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailCss,
|
||||
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-2xl\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-base\)/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail player uses custom mobile gestures instead of ArtPlayer native gestures", () => {
|
||||
assert.match(playerSource, /gesture:\s*false/);
|
||||
assert.match(playerSource, /fastForward:\s*false/);
|
||||
assert.match(playerSource, /function bindMobilePlayerGestures/);
|
||||
assert.match(playerSource, /let suppressNextClick = false/);
|
||||
assert.match(playerSource, /endPress\(true\)/);
|
||||
assert.match(playerSource, /event\.stopImmediatePropagation\(\)/);
|
||||
assert.match(playerSource, /addEventListener\("click", handleClick, true\)/);
|
||||
assert.match(playerSource, /state\.mode = "seek"/);
|
||||
assert.match(playerSource, /state\.side === "right" \? "volume" : "brightness"/);
|
||||
assert.doesNotMatch(playerSource, /function isPlayerLandscapeExpanded/);
|
||||
assert.doesNotMatch(playerSource, /getEffectivePlayerOrientation\(art\) === "landscape"/);
|
||||
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) \{\s*resetGesture\(\);/);
|
||||
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) return;\s*onGestureHud\(seekGestureLabel/);
|
||||
assert.match(playerSource, /const FAST_RATE_CLASS = "art-fast-rate-active"/);
|
||||
assert.match(playerSource, /const FAST_RATE_HINT_CLASS = "video-player__art-rate-hint"/);
|
||||
assert.match(playerSource, /const PLAYER_GESTURE_HUD_CLASS = "video-player__art-gesture-hud"/);
|
||||
assert.match(playerSource, /setPlayerFastRateHint\(art, active\)/);
|
||||
assert.match(playerSource, /player\.appendChild\(hint\)/);
|
||||
assert.match(playerSource, /showPlayerGestureHud\(art, "volume", formatPercent\(normalized\)\)/);
|
||||
assert.match(playerSource, /showPlayerGestureHud\(art, "brightness", formatBrightnessPercent\(nextBrightness\)\)/);
|
||||
assert.match(playerSource, /stroke-width="1\.7"/);
|
||||
assert.match(playerSource, /M15\.4 9\.2a4\.2 4\.2 0 0 1 0 5\.6/);
|
||||
assert.match(playerSource, /M4\.8 9\.7h3l4\.3-3\.6v11\.8l-4\.3-3\.6h-3/);
|
||||
assert.doesNotMatch(playerSource, /stroke-width="2\.2"/);
|
||||
assert.doesNotMatch(playerSource, /onGestureHud\(`音量 /);
|
||||
assert.doesNotMatch(playerSource, /onGestureHud\(`亮度 /);
|
||||
assert.match(playerSource, /fullscreen:\s*true/);
|
||||
assert.match(playerSource, /fullscreenWeb:\s*!enableOrientationControl/);
|
||||
assert.doesNotMatch(playerSource, /addTextTrack\("captions", "Playback rate"/);
|
||||
assert.doesNotMatch(playerSource, /new VTTCue\(/);
|
||||
assert.doesNotMatch(playerSource, /onGestureHud\(`\$\{FAST_RATE\}x`/);
|
||||
assert.match(playerSource, /addEventListener\("touchmove", handleTouchMove, \{ passive: false \}\)/);
|
||||
});
|
||||
|
||||
test("detail player fullscreen long-press rate hint lives inside ArtPlayer", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__rate-hint,\s*\.video-player__art-rate-hint\s*\{[\s\S]*position:\s*absolute[\s\S]*top:\s*12px/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-rate-hint\s*\{[^}]*z-index:\s*130/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.art-video-player\.art-fullscreen \.video-player__art-rate-hint,[\s\S]*\.art-video-player\.art-fullscreen-web \.video-player__art-rate-hint,[\s\S]*position:\s*fixed/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail player mobile brightness gesture only filters the video surface", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player \.art-video,\s*\.video-player \.art-poster\s*\{[^}]*filter:\s*brightness\(var\(--video-player-brightness, 1\)\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/@media \(hover: none\) and \(pointer: coarse\)\s*\{[\s\S]*\.video-player \.art-video-player,[\s\S]*touch-action:\s*pan-y/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player \.art-video-player\.art-fullscreen,[\s\S]*\.video-player \.art-video-player\.art-fullscreen-web,[\s\S]*touch-action:\s*none/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-gesture-hud\s*\{[^}]*top:\s*16%[^}]*background:\s*rgba\(18,\s*18,\s*20,\s*0\.8\)[^}]*font-size:\s*18px/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-gesture-hud-icon\s*\{[^}]*width:\s*18px[^}]*height:\s*18px[^}]*transform:\s*translateY\(-1px\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-gesture-hud-icon svg\s*\{[^}]*width:\s*18px[^}]*height:\s*18px/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.art-video-player\.art-fullscreen \.video-player__art-gesture-hud,[\s\S]*\.art-video-player\.art-manual-orientation \.video-player__art-gesture-hud\s*\{[^}]*position:\s*fixed/s
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user