mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51d0f5010e | |||
| b9dd09a5d2 | |||
| e79f393969 | |||
| e422ecce53 | |||
| d0e29a0d8d | |||
| a7eeb8fbeb |
+60
-20
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -58,17 +59,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
rawIDs := strings.TrimSpace(c.Query("ids"))
|
||||
var targetSet map[string]struct{}
|
||||
if rawIDs != "" {
|
||||
targetSet = make(map[string]struct{})
|
||||
for _, id := range strings.Split(rawIDs, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targetSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if _, ok := targetSet[lib.ID]; ok {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||
limit := 10
|
||||
if withPreview {
|
||||
@@ -89,22 +110,41 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
for _, p := range previews {
|
||||
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
|
||||
}
|
||||
} else {
|
||||
for _, l := range libs {
|
||||
out = append(out, webLibraryPayload{Library: l})
|
||||
} else {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
libIDs := make([]string, len(libs))
|
||||
for i, l := range libs {
|
||||
libIDs[i] = l.ID
|
||||
}
|
||||
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
for _, l := range libs {
|
||||
var total int64
|
||||
if counts != nil {
|
||||
total = counts[l.ID]
|
||||
}
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
|
||||
if svc.EmbyRemote != nil {
|
||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
continue
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
continue
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
if _, ok := targetSet[v.Library.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
remotePayloads := make([]webLibraryPayload, len(allowedViews))
|
||||
for i, v := range allowedViews {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
|
||||
@@ -104,11 +104,16 @@ func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T)
|
||||
t.Fatalf("watching library list should hide adult directories, got %#v", visible)
|
||||
}
|
||||
|
||||
all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1")
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("admin include_hidden list should keep management access, got %#v", all)
|
||||
all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1")
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("admin include_hidden list should keep management access, got %#v", all)
|
||||
}
|
||||
|
||||
filtered := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1&ids="+safe.ID)
|
||||
if len(filtered) != 1 || filtered[0].ID != safe.ID {
|
||||
t.Fatalf("ids filter should return only requested library, got %#v", filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLibraryAllowsEmptyLibrary(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -100,14 +100,21 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) > cardLimit {
|
||||
cards = cards[:cardLimit]
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
out[i].Cards = cards
|
||||
cards := groupMediaSeriesCards(items)
|
||||
// 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数,
|
||||
// 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。
|
||||
if len(cards) < cardLimit && out[i].Total > int64(len(items)) {
|
||||
if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 {
|
||||
cards = fullCards
|
||||
}
|
||||
}
|
||||
if len(cards) > cardLimit {
|
||||
cards = cards[:cardLimit]
|
||||
}
|
||||
if cards == nil {
|
||||
cards = []SeriesCard{}
|
||||
}
|
||||
out[i].Cards = cards
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
|
||||
@@ -92,3 +92,66 @@ func TestListLibrariesWithPreview(t *testing.T) {
|
||||
t.Errorf("preview[1].Cards[0].Count = %d, want 12", previews[1].Cards[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLibrariesWithPreviewLongSeriesCompletion(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
|
||||
lib := model.Library{Name: "剧集库", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||
var rows []model.Media
|
||||
|
||||
// 1. 添加一部更早入库的短剧(2 集),如果按窗口 60 条限制,排在第 70 集之后,无法被初始窗口截取
|
||||
for i := 1; i <= 2; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("short-ep-%02d", i), CreatedAt: now.Add(-time.Duration(100-i) * time.Minute)},
|
||||
LibraryID: lib.ID,
|
||||
Title: fmt.Sprintf("经典短剧 第%d集", i),
|
||||
Path: fmt.Sprintf("/media/tv/经典短剧/Season 01/经典短剧.S01E%02d.mp4", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 添加一部最新入库的 70 集长篇连续剧,这 70 篇全在新入库时间线上
|
||||
for i := 1; i <= 70; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("long-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||
LibraryID: lib.ID,
|
||||
Title: fmt.Sprintf("大长篇连续剧 第%d集", i),
|
||||
Path: fmt.Sprintf("/media/tv/大长篇连续剧/Season 01/大长篇连续剧.S01E%02d.mp4", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
})
|
||||
}
|
||||
|
||||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
|
||||
// 请求 10 部卡片
|
||||
previews, err := svc.ListLibrariesWithPreview(t.Context(), []model.Library{lib}, MediaVisibility{IncludeNSFW: true}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLibrariesWithPreview failed: %v", err)
|
||||
}
|
||||
|
||||
if len(previews) != 1 {
|
||||
t.Fatalf("got %d previews, want 1", len(previews))
|
||||
}
|
||||
|
||||
// 总媒体行数是 72 行
|
||||
if previews[0].Total != 72 {
|
||||
t.Errorf("previews[0].Total = %d, want 72", previews[0].Total)
|
||||
}
|
||||
|
||||
// 验证自适应补全机制:必须包含 2 部剧集(长篇 + 短剧),而不是因为长篇 70 集占满窗口只显示 1 部
|
||||
if len(previews[0].Cards) != 2 {
|
||||
t.Fatalf("previews[0].Cards count = %d, want 2", len(previews[0].Cards))
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -87,17 +87,31 @@ export interface LibraryWithPreview extends Library {
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number }) =>
|
||||
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number; ids?: string[] }) =>
|
||||
api
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
...(options?.includeHidden ? { include_hidden: 1 } : {}),
|
||||
...(options?.withPreview ? { with_preview: 1 } : {}),
|
||||
...(options?.previewLimit ? { preview_limit: options.previewLimit } : {}),
|
||||
...(options?.ids && options.ids.length > 0 ? { ids: options.ids.join(',') } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
listPreviews: (ids: string[], previewLimit = 10) => {
|
||||
if (ids.length === 0) return Promise.resolve<LibraryWithPreview[]>([])
|
||||
return api
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
with_preview: 1,
|
||||
preview_limit: previewLimit,
|
||||
ids: ids.join(','),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data)
|
||||
},
|
||||
|
||||
get: (id: string, options?: { includeHidden?: boolean }) =>
|
||||
api
|
||||
.get<Library>(`/libraries/${id}`, {
|
||||
|
||||
+153
-32
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { libraryAPI, type LibraryWithPreview } from '../api/library'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
@@ -30,26 +30,17 @@ export function HomePage() {
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
|
||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
libraryAPI
|
||||
.list({ withPreview: true, previewLimit: 20 })
|
||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||
.catch(() => [] as LibraryWithPreview[])
|
||||
.list()
|
||||
.then((rows) => asArray<Library>(rows))
|
||||
.catch(() => [] as Library[])
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
|
||||
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
|
||||
for (const lib of libs) {
|
||||
mapData[lib.id] = {
|
||||
cards: lib.cards ?? [],
|
||||
items: [],
|
||||
total: lib.total ?? 0,
|
||||
}
|
||||
}
|
||||
setLibraryData(mapData)
|
||||
setLibrariesLoading(false)
|
||||
})
|
||||
|
||||
@@ -58,6 +49,7 @@ export function HomePage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 2. 继续观看记录独立渐进加载
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -85,6 +77,127 @@ export function HomePage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 按需拉取卡片预览管理
|
||||
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
items: [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
setFetchedLibIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
targets.forEach((id) => {
|
||||
next.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [fetchedLibIds])
|
||||
|
||||
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const carouselLibIds = sortedLibraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
const topGridLibIds = sortedLibraries.slice(0, 20).map((l) => l.id)
|
||||
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
|
||||
const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds]))
|
||||
void fetchPreviews(initialTargets)
|
||||
}, [sortedLibraries, fetchPreviews])
|
||||
|
||||
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
||||
const INITIAL_ROWS = 3
|
||||
const STEP_ROWS = 2
|
||||
const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||
void fetchPreviews(currentTargets)
|
||||
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
|
||||
|
||||
// 当前已拉取并确认有内容的媒体库行
|
||||
const visibleLibraries = useMemo(() => {
|
||||
return sortedLibraries
|
||||
.slice(0, visibleTargetCount)
|
||||
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
|
||||
}, [sortedLibraries, visibleTargetCount, libraryData])
|
||||
|
||||
const hasMoreLibraries = visibleTargetCount < sortedLibraries.length
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库行)
|
||||
useEffect(() => {
|
||||
const scrollParent = document.getElementById('app-main-scroll')
|
||||
if (!scrollParent) return
|
||||
|
||||
const handleCheckBottom = () => {
|
||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||
if (remaining < 600) {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= sortedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true })
|
||||
|
||||
const sentinel = sentinelRef.current
|
||||
let observer: IntersectionObserver | null = null
|
||||
if (sentinel) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= sortedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollParent,
|
||||
rootMargin: '600px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
|
||||
handleCheckBottom()
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length])
|
||||
|
||||
// Quick lookup map for libraries
|
||||
const libraryMap = useMemo(() => {
|
||||
const map = new Map<string, Library>()
|
||||
@@ -138,8 +251,6 @@ export function HomePage() {
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
@@ -165,29 +276,39 @@ export function HomePage() {
|
||||
{historyLoading && <ContinueWatchingSkeleton />}
|
||||
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区 */}
|
||||
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
|
||||
{sortedLibraries.length > 0 && (
|
||||
<HomeLibrariesSection
|
||||
libraries={sortedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 4. 各媒体库内容展示行 */}
|
||||
<div className="space-y-10">
|
||||
{sortedLibraries.map((lib) => {
|
||||
const cards = libraryData[lib.id]?.cards || []
|
||||
if (cards.length === 0) return null
|
||||
return (
|
||||
<HomeLibraryRowSection
|
||||
key={lib.id}
|
||||
library={lib}
|
||||
cards={cards}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
|
||||
{visibleLibraries.length > 0 && (
|
||||
<div className="space-y-10">
|
||||
{visibleLibraries.map((lib) => {
|
||||
const cards = libraryData[lib.id]?.cards || []
|
||||
return (
|
||||
<HomeLibraryRowSection
|
||||
key={lib.id}
|
||||
library={lib}
|
||||
cards={cards}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{hasMoreLibraries && (
|
||||
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
|
||||
<span>加载更多媒体库…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
@@ -366,11 +366,28 @@ export function HomeLibrariesSection({
|
||||
libraries,
|
||||
libraryData,
|
||||
libraryCounts,
|
||||
onNeedPreviews,
|
||||
}: {
|
||||
libraries: Library[]
|
||||
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
|
||||
libraryCounts: Record<string, number>
|
||||
onNeedPreviews?: (ids: string[]) => void
|
||||
}) {
|
||||
const PAGE_SIZE = 20
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
|
||||
const effectivePage = Math.min(currentPage, totalPages)
|
||||
|
||||
const pagedLibraries = useMemo<Library[]>(() => {
|
||||
const start = (effectivePage - 1) * PAGE_SIZE
|
||||
return libraries.slice(start, start + PAGE_SIZE)
|
||||
}, [libraries, effectivePage])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = pagedLibraries.map((l) => l.id)
|
||||
onNeedPreviews?.(ids)
|
||||
}, [pagedLibraries, onNeedPreviews])
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
|
||||
@@ -379,27 +396,62 @@ export function HomeLibrariesSection({
|
||||
<LibraryIcon size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
媒体库
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
媒体库
|
||||
</h2>
|
||||
{libraries.length > PAGE_SIZE && (
|
||||
<span className="rounded-md border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[11px] font-semibold text-[var(--app-muted)]">
|
||||
共 {libraries.length} 个 · 每页 {PAGE_SIZE} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--app-muted)]">
|
||||
点击卡片浏览对应媒体库精选内容
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/libraries"
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>全部媒体库</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={effectivePage <= 1}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
|
||||
{effectivePage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={effectivePage >= totalPages}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/libraries"
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>全部媒体库</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Libraries Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6">
|
||||
{libraries.map((lib) => {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
{pagedLibraries.map((lib) => {
|
||||
const count = libraryCounts[lib.id] ?? 0
|
||||
const cards = libraryData?.[lib.id]?.cards || []
|
||||
const artwork = getLibraryArtworks(lib, cards)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
@@ -10,32 +10,65 @@ import {
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import type { Library } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
|
||||
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
setFetchedLibIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
targets.forEach((id) => {
|
||||
next.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [fetchedLibIds])
|
||||
|
||||
const loadLibraries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const libs = await libraryAPI.list({ withPreview: true })
|
||||
setPreviews(
|
||||
libs.map((library) => ({
|
||||
library,
|
||||
items: [],
|
||||
total: library.total ?? 0,
|
||||
cards: library.cards ?? [],
|
||||
})),
|
||||
)
|
||||
const libs = await libraryAPI.list()
|
||||
setLibraries(libs)
|
||||
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
|
||||
const topIds = libs.slice(0, 20).map((l) => l.id)
|
||||
void fetchPreviews(topIds)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [fetchPreviews])
|
||||
|
||||
async function handleRepairRescrape() {
|
||||
if (repairing) return
|
||||
@@ -60,6 +93,18 @@ export function LibrariesPage() {
|
||||
loadLibraries().catch(() => undefined)
|
||||
}, [loadLibraries])
|
||||
|
||||
const previews: LibraryPreview[] = useMemo(() => {
|
||||
return libraries.map((library) => {
|
||||
const data = libraryData[library.id]
|
||||
return {
|
||||
library,
|
||||
items: [],
|
||||
total: data?.total ?? 0,
|
||||
cards: data?.cards ?? [],
|
||||
}
|
||||
})
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||
|
||||
const handleTogglePin = useCallback((libraryId: string) => {
|
||||
@@ -88,7 +133,12 @@ export function LibrariesPage() {
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState />
|
||||
) : (
|
||||
<LibrariesContent previews={sortedPreviews} pinnedIds={pinnedIds} onTogglePin={handleTogglePin} />
|
||||
<LibrariesContent
|
||||
previews={sortedPreviews}
|
||||
pinnedIds={pinnedIds}
|
||||
onTogglePin={handleTogglePin}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
@@ -107,25 +107,141 @@ export function LibrariesContent({
|
||||
previews,
|
||||
pinnedIds,
|
||||
onTogglePin,
|
||||
onNeedPreviews,
|
||||
}: {
|
||||
previews: LibraryPreview[]
|
||||
pinnedIds: string[]
|
||||
onTogglePin: (libraryId: string) => void
|
||||
onNeedPreviews?: (ids: string[]) => void
|
||||
}) {
|
||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
||||
|
||||
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
|
||||
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
|
||||
const INITIAL_SHELVES = 3
|
||||
const STEP_SHELVES = 2
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
|
||||
onNeedPreviews?.(currentTargets)
|
||||
}, [previews, visibleCount, onNeedPreviews])
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
|
||||
useEffect(() => {
|
||||
const scrollParent = document.getElementById('app-main-scroll')
|
||||
if (!scrollParent) return
|
||||
|
||||
const handleCheckBottom = () => {
|
||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||
if (remaining < 600) {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true })
|
||||
|
||||
const sentinel = sentinelRef.current
|
||||
let observer: IntersectionObserver | null = null
|
||||
if (sentinel) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollParent,
|
||||
rootMargin: '600px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
|
||||
handleCheckBottom()
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [visibleCount, previews.length])
|
||||
|
||||
const visiblePreviews = useMemo(() => {
|
||||
return previews.slice(0, visibleCount)
|
||||
}, [previews, visibleCount])
|
||||
|
||||
const ENTRY_PAGE_SIZE = 20
|
||||
const [entryPage, setEntryPage] = useState(1)
|
||||
const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE))
|
||||
const effectiveEntryPage = Math.min(entryPage, totalEntryPages)
|
||||
const pagedPreviews = useMemo<LibraryPreview[]>(() => {
|
||||
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
|
||||
return previews.slice(start, start + ENTRY_PAGE_SIZE)
|
||||
}, [previews, effectiveEntryPage])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = pagedPreviews.map((p) => p.library.id)
|
||||
onNeedPreviews?.(ids)
|
||||
}, [pagedPreviews, onNeedPreviews])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
<p className="text-sm text-ink-50">
|
||||
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
|
||||
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
{previews.length > ENTRY_PAGE_SIZE && (
|
||||
<span className="rounded-md border border-sand-200 bg-sand-50 px-2 py-0.5 text-xs font-semibold text-sand-700">
|
||||
共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-ink-50">
|
||||
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
|
||||
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{totalEntryPages > 1 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-sand-200 bg-white px-2.5 py-1 text-xs shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryPage((p) => Math.max(1, p - 1))}
|
||||
disabled={effectiveEntryPage <= 1}
|
||||
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="font-mono text-xs font-semibold text-ink-600">
|
||||
{effectiveEntryPage} / {totalEntryPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryPage((p) => Math.min(totalEntryPages, p + 1))}
|
||||
disabled={effectiveEntryPage >= totalEntryPages}
|
||||
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
{pagedPreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview.library.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -142,21 +258,32 @@ export function LibrariesContent({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
{previews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview.library.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<LibraryShelf
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</section>
|
||||
{visiblePreviews.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
{visiblePreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview.library.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index, 3) * 0.03 }}
|
||||
>
|
||||
<LibraryShelf
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{visibleCount < previews.length && (
|
||||
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
|
||||
<span>加载更多媒体库货架…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user