mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 389cb99bcf | |||
| 1b611a6181 | |||
| 25c03f2b0d | |||
| a56b1801f9 | |||
| 99c755dc29 | |||
| 086c0307c3 | |||
| 51d0f5010e | |||
| b9dd09a5d2 |
@@ -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"
|
||||
)
|
||||
|
||||
@@ -109,11 +110,25 @@ 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 {
|
||||
|
||||
@@ -189,19 +189,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有聚合到多集,尝试同父目录匹配
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
|
||||
dirMatches = append(dirMatches, row)
|
||||
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
parentBase := filepath.Base(targetDir)
|
||||
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
|
||||
targetTitleNorm := normalizeSeriesTitle(target.Title)
|
||||
targetDirNorm := normalizeSeriesTitle(parentBase)
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
|
||||
continue
|
||||
}
|
||||
if row.ID == target.ID {
|
||||
dirMatches = append(dirMatches, row)
|
||||
continue
|
||||
}
|
||||
rowTitleNorm := normalizeSeriesTitle(row.Title)
|
||||
allowMatch := false
|
||||
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
|
||||
allowMatch = true
|
||||
}
|
||||
if allowMatch {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
out = []model.Media{*target}
|
||||
|
||||
@@ -11,6 +11,16 @@ import (
|
||||
|
||||
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
|
||||
|
||||
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
|
||||
|
||||
func isGenericMovieTitle(title string) bool {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return true
|
||||
}
|
||||
return genericMovieTitleRE.MatchString(title)
|
||||
}
|
||||
|
||||
func mediaSeriesKey(media model.Media) string {
|
||||
return compactSeriesKey(mediaSeriesRawKey(media))
|
||||
}
|
||||
@@ -54,7 +64,11 @@ func mediaSeriesRawKey(media model.Media) string {
|
||||
return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID))
|
||||
}
|
||||
if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) {
|
||||
return seriesFingerprint("library-path", media.LibraryID, fromPath)
|
||||
titleNorm := normalizeSeriesTitle(media.Title)
|
||||
fromPathNorm := normalizeSeriesTitle(fromPath)
|
||||
if titleNorm == "" || isGenericMovieTitle(titleNorm) || titleNorm == fromPathNorm {
|
||||
return seriesFingerprint("library-path", media.LibraryID, fromPath)
|
||||
}
|
||||
}
|
||||
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
|
||||
}
|
||||
|
||||
@@ -445,3 +445,67 @@ func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testi
|
||||
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory(t *testing.T) {
|
||||
// 同一分类子目录下存放多部不同标题的独立电影,不应被强制折叠成 1 部
|
||||
items := []model.Media{
|
||||
{LibraryID: "movies", Title: "老师2024偷窥篇", Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`},
|
||||
{LibraryID: "movies", Title: "紫光灯下的肉体诱惑", Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`},
|
||||
{LibraryID: "movies", Title: "修洗衣机", Path: `/media/小姐姐/国产/nana/修洗衣机.strm`},
|
||||
}
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("got %d cards, want 3 independent movie cards", len(cards))
|
||||
}
|
||||
|
||||
// 但同一部电影的 CD1 和 CD2 仍应正确折叠为 1 部
|
||||
cdItems := []model.Media{
|
||||
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
|
||||
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m1 := model.Media{
|
||||
Base: model.Base{ID: "m1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "老师2024偷窥篇",
|
||||
Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`,
|
||||
}
|
||||
m2 := model.Media{
|
||||
Base: model.Base{ID: "m2"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "紫光灯下的肉体诱惑",
|
||||
Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`,
|
||||
}
|
||||
m3 := model.Media{
|
||||
Base: model.Base{ID: "m3"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "修洗衣机",
|
||||
Path: `/media/小姐姐/国产/nana/修洗衣机.strm`,
|
||||
}
|
||||
if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(eps) != 1 || eps[0].ID != "m1" {
|
||||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-rea
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { favouriteMediaLink } from '../utils/mediaNavigation'
|
||||
import { resolveHeaderBack } from './layoutNavigation'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
@@ -149,17 +150,17 @@ function LayoutHeaderSearch() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mediaId: string) => {
|
||||
const handleSelect = (item: Media) => {
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
navigate(`/media/${mediaId}`)
|
||||
navigate(favouriteMediaLink(item))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0].id)
|
||||
handleSelect(results[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +221,7 @@ function LayoutHeaderSearch() {
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
onClick={() => handleSelect(item)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
|
||||
+63
-45
@@ -80,11 +80,11 @@ export function HomePage() {
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 按需拉取卡片预览管理
|
||||
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
|
||||
const fetchedLibIdsRef = useRef<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))
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
@@ -104,25 +104,22 @@ export function HomePage() {
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
setFetchedLibIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
targets.forEach((id) => {
|
||||
next.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
return next
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
}, [fetchedLibIds])
|
||||
}, [])
|
||||
|
||||
// 3. 首屏视口媒体库:优先加载轮播图指定的库和前 3 个库
|
||||
// 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, ...topRowLibIds]))
|
||||
const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds]))
|
||||
void fetchPreviews(initialTargets)
|
||||
}, [sortedLibraries, fetchPreviews])
|
||||
|
||||
@@ -139,34 +136,63 @@ export function HomePage() {
|
||||
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 sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
|
||||
const scrollParent = document.getElementById('app-main-scroll')
|
||||
const 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: '400px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
if (!scrollParent) return
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [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(() => {
|
||||
@@ -221,15 +247,6 @@ export function HomePage() {
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [libraries, libraryData])
|
||||
|
||||
// 当前已拉取并确认有内容的媒体库行
|
||||
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
|
||||
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
@@ -261,6 +278,7 @@ export function HomePage() {
|
||||
libraries={sortedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -366,10 +366,12 @@ 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)
|
||||
@@ -381,6 +383,11 @@ export function HomeLibrariesSection({
|
||||
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">
|
||||
|
||||
@@ -23,11 +23,11 @@ export function LibrariesPage() {
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
|
||||
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
|
||||
const fetchedLibIdsRef = useRef<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))
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
@@ -46,24 +46,20 @@ export function LibrariesPage() {
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
setFetchedLibIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
targets.forEach((id) => {
|
||||
next.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
return next
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
}, [fetchedLibIds])
|
||||
}, [])
|
||||
|
||||
const loadLibraries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const libs = await libraryAPI.list()
|
||||
setLibraries(libs)
|
||||
// 优先拉取前 3 个库的卡片预览
|
||||
const topIds = libs.slice(0, 3).map((l) => l.id)
|
||||
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
|
||||
const topIds = libs.slice(0, 20).map((l) => l.id)
|
||||
void fetchPreviews(topIds)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
||||
@@ -128,33 +128,54 @@ export function LibrariesContent({
|
||||
onNeedPreviews?.(currentTargets)
|
||||
}, [previews, visibleCount, onNeedPreviews])
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
|
||||
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,
|
||||
},
|
||||
)
|
||||
if (!scrollParent) return
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [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)
|
||||
@@ -169,6 +190,11 @@ export function LibrariesContent({
|
||||
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">
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MediaDetailPage() {
|
||||
media={media}
|
||||
isAdmin={role === 'admin'}
|
||||
favourite={detail.favourite}
|
||||
playTargetId={detail.firstPlayableEpisode?.id}
|
||||
playTargetId={media.id || detail.firstPlayableEpisode?.id}
|
||||
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
|
||||
onToggleFavourite={detail.toggleFavourite}
|
||||
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function MediaDetailPlaybackActions({
|
||||
onToggleFavourite,
|
||||
playTargetId,
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = playTargetId || media.id
|
||||
const targetId = media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
|
||||
Reference in New Issue
Block a user