mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
1
This commit is contained in:
@@ -64,6 +64,11 @@ export const playbackAPI = {
|
||||
listFavouriteIDs: () =>
|
||||
api.get<{ ids: string[] }>('/favourites', { params: { ids: 1 } }).then((r) => r.data.ids ?? []),
|
||||
|
||||
favouriteStatus: (mediaId: string) =>
|
||||
api
|
||||
.get<{ favourite: boolean }>(`/media/${encodeURIComponent(mediaId)}/favorite/status`)
|
||||
.then((r) => r.data.favourite),
|
||||
|
||||
listPlaylists: () =>
|
||||
api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items),
|
||||
|
||||
|
||||
@@ -112,16 +112,25 @@ export function HomePage() {
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(batch, limit)
|
||||
loaded = true
|
||||
const accepted = rows.filter(
|
||||
(row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit,
|
||||
)
|
||||
accepted.forEach((row) => {
|
||||
fetchedPreviewLimitsRef.current.set(
|
||||
row.id,
|
||||
Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit),
|
||||
)
|
||||
})
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
for (const row of accepted) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
items: [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
return accepted.length > 0 ? next : prev
|
||||
})
|
||||
} catch {
|
||||
// 单个批次失败不影响其他批次;导航回来时会重试。
|
||||
@@ -155,8 +164,13 @@ export function HomePage() {
|
||||
.slice(0, 20)
|
||||
.filter((l) => !l.cover_url)
|
||||
.map((l) => l.id)
|
||||
const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds, ...topGridLibIds]))
|
||||
void fetchPreviews(initialTargets, 10)
|
||||
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
|
||||
const shelfTargetSet = new Set(shelfTargets)
|
||||
const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id))
|
||||
// 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免
|
||||
// 为暂时不会出现的货架预取完整卡片窗口。
|
||||
void fetchPreviews(shelfTargets, 10)
|
||||
void fetchPreviews(gridTargets, 2)
|
||||
}, [sortedLibraries, fetchPreviews])
|
||||
|
||||
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
||||
|
||||
@@ -359,13 +359,13 @@ export function HomeLibrariesSection({
|
||||
libraries: Library[]
|
||||
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
|
||||
libraryCounts: Record<string, number>
|
||||
onNeedPreviews?: (ids: string[]) => void
|
||||
onNeedPreviews?: (ids: string[], limit?: number) => 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 queuePreview = useLazyPreviewBatch(onNeedPreviews)
|
||||
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
|
||||
|
||||
const pagedLibraries = useMemo<Library[]>(() => {
|
||||
const start = (effectivePage - 1) * PAGE_SIZE
|
||||
|
||||
@@ -26,37 +26,58 @@ export function LibrariesPage() {
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
|
||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
const fetchingPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
const fetchPreviews = useCallback(async (ids: string[], limit = 10) => {
|
||||
const targets = ids.filter(
|
||||
(id) =>
|
||||
(fetchedPreviewLimitsRef.current.get(id) ?? 0) < limit &&
|
||||
(fetchingPreviewLimitsRef.current.get(id) ?? 0) < limit,
|
||||
)
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
|
||||
|
||||
const batches = partitionPreviewIDs(targets)
|
||||
await Promise.allSettled(
|
||||
batches.map(async (batch) => {
|
||||
let loaded = false
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(batch, 10)
|
||||
const rows = await libraryAPI.listPreviews(batch, limit)
|
||||
loaded = true
|
||||
const accepted = rows.filter(
|
||||
(row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit,
|
||||
)
|
||||
accepted.forEach((row) => {
|
||||
fetchedPreviewLimitsRef.current.set(
|
||||
row.id,
|
||||
Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit),
|
||||
)
|
||||
})
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
for (const row of accepted) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
return accepted.length > 0 ? next : prev
|
||||
})
|
||||
} catch {
|
||||
// 单个批次失败不影响其他批次。
|
||||
} finally {
|
||||
batch.forEach((id) => {
|
||||
if (loaded) fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
if (loaded) {
|
||||
fetchedPreviewLimitsRef.current.set(
|
||||
id,
|
||||
Math.max(fetchedPreviewLimitsRef.current.get(id) ?? 0, limit),
|
||||
)
|
||||
}
|
||||
if (fetchingPreviewLimitsRef.current.get(id) === limit) {
|
||||
fetchingPreviewLimitsRef.current.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -112,10 +112,10 @@ export function LibrariesContent({
|
||||
previews: LibraryPreview[]
|
||||
pinnedIds: string[]
|
||||
onTogglePin: (libraryId: string) => void
|
||||
onNeedPreviews?: (ids: string[]) => void
|
||||
onNeedPreviews?: (ids: string[], limit?: number) => void
|
||||
}) {
|
||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
||||
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
|
||||
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
|
||||
|
||||
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
|
||||
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
|
||||
@@ -126,7 +126,7 @@ export function LibrariesContent({
|
||||
|
||||
useEffect(() => {
|
||||
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
|
||||
onNeedPreviews?.(currentTargets)
|
||||
onNeedPreviews?.(currentTargets, 10)
|
||||
}, [previews, visibleCount, onNeedPreviews])
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
|
||||
@@ -196,7 +196,7 @@ export function LibrariesContent({
|
||||
useEffect(() => {
|
||||
const ids = pagedPreviews.filter((preview) => !preview.library.cover_url).map((preview) => preview.library.id)
|
||||
if (ids.length > 0) {
|
||||
onNeedPreviews?.(ids)
|
||||
onNeedPreviews?.(ids, 2)
|
||||
}
|
||||
}, [pagedPreviews, onNeedPreviews])
|
||||
|
||||
|
||||
@@ -87,8 +87,14 @@ export function LibrarySeriesEpisodes({
|
||||
>
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, {
|
||||
maxWidth: 320,
|
||||
maxHeight: 180,
|
||||
quality: 76,
|
||||
})}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
@@ -14,8 +14,14 @@ export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
|
||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||
{media.backdrop_url || media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at)}
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, {
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
quality: 76,
|
||||
})}
|
||||
alt=""
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
@@ -36,8 +42,14 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
|
||||
>
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url, media.updated_at)}
|
||||
src={imageURL(media.poster_url, media.updated_at, {
|
||||
maxWidth: 560,
|
||||
maxHeight: 840,
|
||||
quality: 84,
|
||||
})}
|
||||
alt={media.title}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function libraryArtworkItems(cards: SeriesCard[] = []): Array<{ src: stri
|
||||
version: card.rep.updated_at,
|
||||
}))
|
||||
.filter((item) => Boolean(item.src))
|
||||
.slice(0, 4)
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
export function getLibraryArtworks(
|
||||
|
||||
@@ -148,7 +148,7 @@ function useMediaDetailRefresh({
|
||||
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
|
||||
const nextMediaPromise = mediaAPI.get(id)
|
||||
const favouritesPromise = playbackAPI.listFavourites().catch(() => [])
|
||||
const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false)
|
||||
const episodesPromise = mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((r) => r.items ?? [])
|
||||
@@ -158,8 +158,7 @@ function useMediaDetailRefresh({
|
||||
setMedia(nextMedia)
|
||||
setLoading(false)
|
||||
|
||||
const favourites = await favouritesPromise
|
||||
setFavourite(favourites.some((item) => item.id === nextMedia.id))
|
||||
setFavourite(await favouritePromise)
|
||||
|
||||
const episodes = await episodesPromise
|
||||
setEpisodes(episodes)
|
||||
|
||||
@@ -33,7 +33,8 @@ import type { Media } from '../types'
|
||||
export type SeriesCard = {
|
||||
key: string
|
||||
rep: Media
|
||||
linkMedia: Media
|
||||
linkMedia?: Media
|
||||
linkLibraryId?: string
|
||||
count: number
|
||||
last_added_at?: string
|
||||
}
|
||||
@@ -220,12 +221,13 @@ export function seasonLabel(season: number): string {
|
||||
}
|
||||
|
||||
export function isSeriesCard(card: SeriesCard): boolean {
|
||||
const linkMedia = card.linkMedia ?? card.rep
|
||||
return (
|
||||
card.count > 1 ||
|
||||
isEpisodeLike(card.rep) ||
|
||||
isEpisodeLike(card.linkMedia) ||
|
||||
isEpisodeLike(linkMedia) ||
|
||||
pathLooksEpisodic(card.rep) ||
|
||||
pathLooksEpisodic(card.linkMedia)
|
||||
pathLooksEpisodic(linkMedia)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -474,10 +476,11 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
||||
}
|
||||
// Repeated movie IDs represent alternate locations/encodes, not
|
||||
// episodes. Fold the versions but keep the card in movie mode.
|
||||
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) {
|
||||
const currentLinkMedia = g.linkMedia ?? g.rep
|
||||
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(currentLinkMedia) || pathLooksEpisodic(currentLinkMedia)) {
|
||||
g.count += 1
|
||||
}
|
||||
if (betterSeriesLinkMedia(m, g.linkMedia)) {
|
||||
if (betterSeriesLinkMedia(m, currentLinkMedia)) {
|
||||
g.linkMedia = m
|
||||
}
|
||||
if (betterSeriesRepresentative(m, g.rep)) {
|
||||
@@ -534,7 +537,8 @@ function mediaParentLooksLikeCollection(path?: string): boolean {
|
||||
|
||||
export function seriesCardLink(card: SeriesCard): string {
|
||||
if (isSeriesCard(card)) {
|
||||
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`
|
||||
const targetLibrary = card.linkLibraryId || targetLibraryID(card.linkMedia ?? card.rep)
|
||||
return `/library/${targetLibrary}?series=${encodeURIComponent(card.key)}`
|
||||
}
|
||||
return `/media/${card.rep.id}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user