Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions backend/cmd/seed-videos/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// 本地开发用的一次性数据放大工具:以现有的某条视频为模板生成大量可列出的
// 行,用来压测列表页的无限滚动 / 虚拟列表。只做 INSERT,不改动既有数据。
package main

import (
"database/sql"
"flag"
"fmt"
"log"
"time"

_ "modernc.org/sqlite"
)

func main() {
dbPath := flag.String("db", "./data/video-site.db", "sqlite path")
count := flag.Int("count", 0, "how many synthetic rows to insert")
prefix := flag.String("prefix", "seed", "id prefix for generated rows")
purge := flag.Bool("purge", false, "delete previously generated rows with this prefix")
flag.Parse()

db, err := sql.Open("sqlite", *dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
log.Fatal(err)
}
defer db.Close()

like := *prefix + "-%"

if *purge {
res, err := db.Exec(`DELETE FROM videos WHERE id LIKE ?`, like)
if err != nil {
log.Fatal(err)
}
removed, _ := res.RowsAffected()
fmt.Printf("purged %d generated rows\n", removed)
}

if *count > 0 {
now := time.Now().UnixMilli()
// content_hash / sampled_sha256 置空 + file_name 唯一,避免被去重触发器
// 判成同一条视频的副本而丢掉 is_canonical。
res, err := db.Exec(`
WITH RECURSIVE seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < ?
),
template AS (
SELECT * FROM videos WHERE id NOT LIKE ? ORDER BY created_at LIMIT 1
)
INSERT INTO videos (
id, drive_id, file_id, file_name, content_hash, sampled_sha256,
fingerprint_status, parent_id, dir_name, title, author, tags,
duration_seconds, size_bytes, ext, quality, thumbnail_url,
thumbnail_updated_at, thumbnail_status, preview_local, preview_updated_at,
preview_status, views, favorites, comments, likes, dislikes, hidden,
is_canonical, badges, description, published_at, created_at, updated_at
)
SELECT
? || '-' || printf('%05d', seq.n),
template.drive_id,
-- 复用模板行的真实 file_id,生成的视频才真的能播放和出封面。
template.file_id,
? || '-' || printf('%05d', seq.n) || '.mp4',
'', '', 'pending',
template.parent_id, template.dir_name,
'压测视频 ' || printf('%05d', seq.n),
template.author, template.tags,
template.duration_seconds,
COALESCE(template.size_bytes, 0) + seq.n,
template.ext, template.quality, template.thumbnail_url,
template.thumbnail_updated_at, template.thumbnail_status,
template.preview_local, template.preview_updated_at, template.preview_status,
seq.n % 97, 0, 0, seq.n % 53, 0, 0, 1,
template.badges, template.description,
? - seq.n * 60000, ?, ?
FROM seq, template`,
*count, like, *prefix, *prefix, now, now, now)
if err != nil {
log.Fatal(err)
}
inserted, _ := res.RowsAffected()
fmt.Printf("inserted %d rows\n", inserted)
}

var total, listable int
if err := db.QueryRow(`SELECT COUNT(*) FROM videos`).Scan(&total); err != nil {
log.Fatal(err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM videos WHERE COALESCE(hidden,0)=0 AND is_canonical=1`).Scan(&listable); err != nil {
log.Fatal(err)
}
fmt.Printf("total rows: %d, listable rows: %d\n", total, listable)
}
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.1",
"@noble/hashes": "^2.3.0",
"@tanstack/react-virtual": "^3.14.9",
"@uiw/react-codemirror": "^4.25.11",
"artplayer": "^5.4.0",
"hls.js": "^1.6.16",
Expand Down
10 changes: 9 additions & 1 deletion src/components/BackToTop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ type Props = {
onVisibilityChange?: (visible: boolean) => void;
};

/**
* 虚拟列表会在滚动过程中按实测行高做位置补偿,平滑滚动动画会被这些补偿
* 打断、停在半路,所以返回顶部直接落到 0。
*/
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "auto" });
}

export function BackToTop({ onVisibilityChange }: Props) {
const [visible, setVisible] = useState(false);

Expand All @@ -26,7 +34,7 @@ export function BackToTop({ onVisibilityChange }: Props) {
return (
<button
className={`back-to-top ${visible ? "is-visible" : ""}`}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
onClick={scrollToTop}
aria-label="返回顶部"
>
<ArrowUp size={18} />
Expand Down
35 changes: 35 additions & 0 deletions src/components/HomeFeedTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { HomeFeedKey } from "@/lib/listingSearchParams";

type Props = {
feed: HomeFeedKey;
onChange: (feed: HomeFeedKey) => void;
};

const HOME_FEED_TABS: { key: HomeFeedKey; label: string }[] = [
{ key: "recommend", label: "随机推荐" },
{ key: "latest", label: "最新视频" },
];

export function HomeFeedTabs({ feed, onChange }: Props) {
return (
<div className="home-feed-tabs" role="tablist" aria-label="首页视频">
{HOME_FEED_TABS.map((tab) => {
const active = tab.key === feed;
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={active}
className={`home-feed-tabs__tab ${active ? "is-active" : ""}`}
onClick={() => onChange(tab.key)}
>
{tab.label}
</button>
);
})}
</div>
);
}

export { HOME_FEED_TABS };
183 changes: 183 additions & 0 deletions src/components/VirtualVideoGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import {
virtualGridColumns,
virtualRowCount,
virtualRowRange,
} from "@/lib/virtualGrid";
import type { VideoItem } from "@/types";
import { VideoCard } from "./VideoCard";

/**
* 虚拟滚动的视频网格:以"整行"为虚拟单元交给 @tanstack/react-virtual 的
* window virtualizer 管理,只挂载可视区域附近的行,其余行由占位容器的
* 总高度顶出来,滚动条因此与完整列表等高。
*
* 每行本身仍是原来的 .video-grid,列数与列间距沿用既有 CSS;行高由
* measureElement 实测,卡片高度随断点或标题变化都不需要额外配置。
*/

const DEFAULT_OVERSCAN_ROWS = 2;
const ESTIMATED_ROW_HEIGHT = 260;
const ESTIMATED_COMPACT_ROW_HEIGHT = 120;

export type VirtualGridRange = {
startIndex: number;
endIndex: number;
columns: number;
};

type Props = {
videos: VideoItem[];
compact?: boolean;
eagerCount?: number;
highPriorityCount?: number;
overscanRows?: number;
refreshMode?: "blocking" | "background";
onRangeChange?: (range: VirtualGridRange) => void;
};

export function VirtualVideoGrid({
videos,
compact,
eagerCount = 0,
highPriorityCount = 0,
overscanRows = DEFAULT_OVERSCAN_ROWS,
refreshMode,
onRangeChange,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [columns, setColumns] = useState(1);
// 列表容器距文档顶部的距离:window virtualizer 用它把窗口滚动换算成列表内偏移。
const [scrollMargin, setScrollMargin] = useState(0);
const [rowHeight, setRowHeight] = useState(
compact ? ESTIMATED_COMPACT_ROW_HEIGHT : ESTIMATED_ROW_HEIGHT
);
const rowCount = virtualRowCount(videos.length, columns);

const virtualizer = useWindowVirtualizer({
count: rowCount,
estimateSize: () => rowHeight,
overscan: overscanRows,
scrollMargin,
getItemKey: (index) => videos[index * columns]?.id ?? index,
});

const measure = useCallback(() => {
const container = containerRef.current;
if (!container) return;

const rect = container.getBoundingClientRect();
const nextMargin = rect.top + window.scrollY;
setScrollMargin((current) =>
Math.abs(current - nextMargin) < 1 ? current : nextMargin
);

const row = container.querySelector<HTMLElement>(".video-grid--virtual-row");
if (!row) return;
const nextColumns = virtualGridColumns(window.getComputedStyle(row));
setColumns((current) => (current === nextColumns ? current : nextColumns));
const nextRowHeight = row.getBoundingClientRect().height;
setRowHeight((current) =>
nextRowHeight > 0 && Math.abs(current - nextRowHeight) >= 1
? nextRowHeight
: current
);
}, []);

// 布局阶段就要量出列数:首帧按单列渲染,浏览器绘制之前会被纠正过来。
useLayoutEffect(() => {
measure();
});

useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(container);
window.addEventListener("resize", measure);
return () => {
observer?.disconnect();
window.removeEventListener("resize", measure);
};
}, [measure]);

// 列数变化会重排每一行的内容,之前测到的行高全部失效。
useEffect(() => {
virtualizer.measure();
}, [columns, compact, virtualizer]);

const virtualRows = virtualizer.getVirtualItems();
const firstRow = virtualRows[0]?.index ?? 0;
const lastRow = virtualRows[virtualRows.length - 1]?.index ?? -1;

useEffect(() => {
if (lastRow < 0) return;
onRangeChange?.({
startIndex: firstRow * columns,
endIndex: Math.min((lastRow + 1) * columns, videos.length),
columns,
});
}, [columns, firstRow, lastRow, onRangeChange, videos.length]);

const blockingRefresh = refreshMode === "blocking";
const backgroundRefresh = refreshMode === "background";

return (
<div
ref={containerRef}
className={`video-grid-region ${blockingRefresh ? "is-busy" : ""}`}
aria-busy={blockingRefresh || backgroundRefresh || undefined}
>
<div
className="video-grid-virtual-canvas"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualRows.map((virtualRow) => {
const { start, end } = virtualRowRange(
virtualRow.index,
columns,
videos.length
);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={`video-grid video-grid--virtual-row ${
compact ? "is-compact" : ""
}`}
style={{
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
}}
>
{videos.slice(start, end).map((video, offset) => {
const index = start + offset;
return (
<VideoCard
key={video.id}
video={video}
eager={index < eagerCount}
highPriority={index < highPriorityCount}
/>
);
})}
</div>
);
})}
</div>
{blockingRefresh && (
<div className="video-grid-refresh-overlay" aria-hidden="true" />
)}
{backgroundRefresh && (
<div className="video-grid-background-status" role="status">
<span className="video-grid-refresh-overlay__spinner" aria-hidden="true" />
<span>正在同步</span>
</div>
)}
</div>
);
}
Loading
Loading