mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
优化
This commit is contained in:
@@ -100,14 +100,21 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
|
|||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cards := groupMediaSeriesCards(items)
|
cards := groupMediaSeriesCards(items)
|
||||||
if len(cards) > cardLimit {
|
// 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数,
|
||||||
cards = cards[:cardLimit]
|
// 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。
|
||||||
}
|
if len(cards) < cardLimit && out[i].Total > int64(len(items)) {
|
||||||
if cards == nil {
|
if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 {
|
||||||
cards = []SeriesCard{}
|
cards = fullCards
|
||||||
}
|
}
|
||||||
out[i].Cards = cards
|
}
|
||||||
|
if len(cards) > cardLimit {
|
||||||
|
cards = cards[:cardLimit]
|
||||||
|
}
|
||||||
|
if cards == nil {
|
||||||
|
cards = []SeriesCard{}
|
||||||
|
}
|
||||||
|
out[i].Cards = cards
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cache != nil {
|
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)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+69
-16
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { libraryAPI, type LibraryWithPreview } from '../api/library'
|
import { libraryAPI, type LibraryWithPreview } from '../api/library'
|
||||||
import { historyAPI } from '../api/history'
|
import { historyAPI } from '../api/history'
|
||||||
@@ -34,7 +34,7 @@ export function HomePage() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
libraryAPI
|
libraryAPI
|
||||||
.list({ withPreview: true, previewLimit: 20 })
|
.list({ withPreview: true, previewLimit: 10 })
|
||||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||||
.catch(() => [] as LibraryWithPreview[])
|
.catch(() => [] as LibraryWithPreview[])
|
||||||
.then((libs) => {
|
.then((libs) => {
|
||||||
@@ -140,6 +140,50 @@ export function HomePage() {
|
|||||||
|
|
||||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||||
|
|
||||||
|
// 过滤出有内容的媒体库,避免空库空占名额
|
||||||
|
const activeLibraries = useMemo(() => {
|
||||||
|
return sortedLibraries.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
|
||||||
|
}, [sortedLibraries, libraryData])
|
||||||
|
|
||||||
|
// 媒体库展示行支持向下滑动渐进流式加载:默认先展示前 3 个库,
|
||||||
|
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库。
|
||||||
|
const INITIAL_ROWS = 3
|
||||||
|
const STEP_ROWS = 2
|
||||||
|
const [visibleRowCount, setVisibleRowCount] = useState(INITIAL_ROWS)
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current
|
||||||
|
if (!sentinel) return
|
||||||
|
|
||||||
|
const scrollParent = document.getElementById('app-main-scroll')
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const [entry] = entries
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
setVisibleRowCount((prev) => {
|
||||||
|
if (prev >= activeLibraries.length) return prev
|
||||||
|
return Math.min(prev + STEP_ROWS, activeLibraries.length)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: scrollParent,
|
||||||
|
rootMargin: '400px 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [activeLibraries.length])
|
||||||
|
|
||||||
|
const visibleLibraries = useMemo(() => {
|
||||||
|
return activeLibraries.slice(0, visibleRowCount)
|
||||||
|
}, [activeLibraries, visibleRowCount])
|
||||||
|
|
||||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||||
@@ -174,20 +218,29 @@ export function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 4. 各媒体库内容展示行 */}
|
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载) */}
|
||||||
<div className="space-y-10">
|
{visibleLibraries.length > 0 && (
|
||||||
{sortedLibraries.map((lib) => {
|
<div className="space-y-10">
|
||||||
const cards = libraryData[lib.id]?.cards || []
|
{visibleLibraries.map((lib) => {
|
||||||
if (cards.length === 0) return null
|
const cards = libraryData[lib.id]?.cards || []
|
||||||
return (
|
return (
|
||||||
<HomeLibraryRowSection
|
<HomeLibraryRowSection
|
||||||
key={lib.id}
|
key={lib.id}
|
||||||
library={lib}
|
library={lib}
|
||||||
cards={cards}
|
cards={cards}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
{visibleRowCount < activeLibraries.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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||||
@@ -114,6 +114,45 @@ export function LibrariesContent({
|
|||||||
}) {
|
}) {
|
||||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
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 sentinel = sentinelRef.current
|
||||||
|
if (!sentinel) return
|
||||||
|
|
||||||
|
const scrollParent = document.getElementById('app-main-scroll')
|
||||||
|
const 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: '400px 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [previews.length])
|
||||||
|
|
||||||
|
const visiblePreviews = useMemo(() => {
|
||||||
|
return previews.slice(0, visibleCount)
|
||||||
|
}, [previews, visibleCount])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -142,21 +181,32 @@ export function LibrariesContent({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-6">
|
{visiblePreviews.length > 0 && (
|
||||||
{previews.map((preview, index) => (
|
<section className="space-y-6">
|
||||||
<motion.div
|
{visiblePreviews.map((preview, index) => (
|
||||||
key={preview.library.id}
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
key={preview.library.id}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
transition={{ delay: index * 0.03 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
transition={{ delay: Math.min(index, 3) * 0.03 }}
|
||||||
<LibraryShelf
|
>
|
||||||
preview={preview}
|
<LibraryShelf
|
||||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
preview={preview}
|
||||||
/>
|
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||||
</motion.div>
|
/>
|
||||||
))}
|
</motion.div>
|
||||||
</section>
|
))}
|
||||||
|
|
||||||
|
{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