mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
t添加标签功能
This commit is contained in:
+69
-24
@@ -6,6 +6,10 @@ import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
|
||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
@@ -30,6 +34,7 @@ export function HomePage() {
|
||||
const [librariesLoading, setLibrariesLoading] = useState(true)
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
const libraryTags = useLibraryTags()
|
||||
|
||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||
@@ -87,6 +92,29 @@ export function HomePage() {
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
|
||||
const effectiveTagId = libraryTags.selectedTagId
|
||||
const tagTabs = useMemo(
|
||||
() => buildLibraryTagTabs(sortedLibraries, libraryTags.tags),
|
||||
[sortedLibraries, libraryTags.tags],
|
||||
)
|
||||
const taggedLibraries = useMemo(
|
||||
() => filterLibrariesByTag(sortedLibraries, libraryTags.tags, effectiveTagId),
|
||||
[sortedLibraries, libraryTags.tags, effectiveTagId],
|
||||
)
|
||||
|
||||
const handleManageTags = useCallback(() => {
|
||||
void openManageLibraryTagsDialog({
|
||||
tags: libraryTags.tags,
|
||||
libraries,
|
||||
saving: libraryTags.saving,
|
||||
onCreate: libraryTags.createTag,
|
||||
onRename: libraryTags.renameTag,
|
||||
onRemove: libraryTags.removeTag,
|
||||
onAssign: libraryTags.assignLibrary,
|
||||
})
|
||||
}, [libraries, libraryTags])
|
||||
|
||||
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
||||
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
@@ -153,14 +181,14 @@ export function HomePage() {
|
||||
|
||||
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后自行按批加载。
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const carouselLibIds = sortedLibraries
|
||||
if (taggedLibraries.length === 0) return
|
||||
const carouselLibIds = taggedLibraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
|
||||
const topRowLibIds = taggedLibraries.slice(0, 3).map((l) => l.id)
|
||||
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
|
||||
void fetchPreviews(shelfTargets, 10)
|
||||
}, [sortedLibraries, fetchPreviews])
|
||||
}, [taggedLibraries, fetchPreviews])
|
||||
|
||||
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
||||
const INITIAL_ROWS = 3
|
||||
@@ -171,26 +199,26 @@ export function HomePage() {
|
||||
|
||||
const revealMoreLibraries = useCallback(() => {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= sortedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
||||
if (prev >= taggedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, taggedLibraries.length)
|
||||
})
|
||||
}, [sortedLibraries.length])
|
||||
}, [taggedLibraries.length])
|
||||
|
||||
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||
if (taggedLibraries.length === 0) return
|
||||
const currentTargets = taggedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||
void fetchPreviews(currentTargets, 10)
|
||||
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
|
||||
}, [taggedLibraries, visibleTargetCount, fetchPreviews])
|
||||
|
||||
// 当前已拉取并确认有内容的媒体库行
|
||||
const visibleLibraries = useMemo(() => {
|
||||
return sortedLibraries
|
||||
return taggedLibraries
|
||||
.slice(0, visibleTargetCount)
|
||||
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
|
||||
}, [sortedLibraries, visibleTargetCount, libraryData])
|
||||
}, [taggedLibraries, visibleTargetCount, libraryData])
|
||||
|
||||
const hasMoreLibraries = visibleTargetCount < sortedLibraries.length
|
||||
const hasMoreLibraries = visibleTargetCount < taggedLibraries.length
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库行)
|
||||
useEffect(() => {
|
||||
@@ -258,7 +286,7 @@ export function HomePage() {
|
||||
// series-type libs (loaded via /series), so gate on cards instead.
|
||||
const carouselItems = useMemo(() => {
|
||||
const candidateMedia: Media[] = []
|
||||
const effectiveSelectedIds = libraries
|
||||
const effectiveSelectedIds = taggedLibraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
|
||||
@@ -275,7 +303,7 @@ export function HomePage() {
|
||||
|
||||
// Fallback to all loaded items with artwork
|
||||
if (candidateMedia.length === 0) {
|
||||
for (const lib of libraries) {
|
||||
for (const lib of taggedLibraries) {
|
||||
const data = libraryData[lib.id]
|
||||
if (data) {
|
||||
for (const card of data.cards) {
|
||||
@@ -286,14 +314,13 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [libraries, libraryData])
|
||||
}, [taggedLibraries, libraryData])
|
||||
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
return <HomeLoadingState />
|
||||
}
|
||||
|
||||
const empty =
|
||||
libraries.length === 0 &&
|
||||
history.length === 0
|
||||
@@ -313,14 +340,32 @@ export function HomePage() {
|
||||
{historyLoading && <ContinueWatchingSkeleton />}
|
||||
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
|
||||
{/* 3. 媒体库标签栏 + 卡片区(每页展示 20 个媒体库) */}
|
||||
{sortedLibraries.length > 0 && (
|
||||
<HomeLibrariesSection
|
||||
libraries={sortedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
<section className="space-y-6">
|
||||
<LibraryTagBar
|
||||
tabs={tagTabs}
|
||||
selectedTagId={effectiveTagId}
|
||||
onSelect={libraryTags.setSelectedTagId}
|
||||
onCreate={(name) => {
|
||||
void libraryTags.createTag(name)
|
||||
}}
|
||||
onManage={handleManageTags}
|
||||
busy={libraryTags.saving}
|
||||
/>
|
||||
{taggedLibraries.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-12 text-center text-sm text-[var(--app-muted)]">
|
||||
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||
</p>
|
||||
) : (
|
||||
<HomeLibrariesSection
|
||||
libraries={taggedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { LibraryTagBar } from '../components/LibraryTagBar'
|
||||
import { openManageLibraryTagsDialog } from '../components/manageLibraryTagsDialog'
|
||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import { useLibraryTags } from '../hooks/useLibraryTags'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import {
|
||||
@@ -15,6 +18,8 @@ import type { LibraryPreview } from './librariesPageModel'
|
||||
import type { Library } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||
import type { LibraryTag } from '../utils/libraryTags'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
|
||||
@@ -23,6 +28,7 @@ export function LibrariesPage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
const libraryTags = useLibraryTags()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
@@ -146,7 +152,31 @@ export function LibrariesPage() {
|
||||
void togglePin(libraryId)
|
||||
}, [togglePin])
|
||||
|
||||
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
|
||||
const handleManageTags = useCallback(() => {
|
||||
void openManageLibraryTagsDialog({
|
||||
tags: libraryTags.tags,
|
||||
libraries,
|
||||
saving: libraryTags.saving,
|
||||
onCreate: libraryTags.createTag,
|
||||
onRename: libraryTags.renameTag,
|
||||
onRemove: libraryTags.removeTag,
|
||||
onAssign: libraryTags.assignLibrary,
|
||||
})
|
||||
}, [libraries, libraryTags])
|
||||
|
||||
const effectiveTagId = libraryTags.selectedTagId
|
||||
const tagTabs = useMemo(
|
||||
() => buildLibraryTagTabs(sortedPreviews.map((preview) => preview.library), libraryTags.tags),
|
||||
[sortedPreviews, libraryTags.tags],
|
||||
)
|
||||
const taggedPreviews = useMemo(
|
||||
() => filterPreviewsByTag(sortedPreviews, libraryTags.tags, effectiveTagId),
|
||||
[sortedPreviews, libraryTags.tags, effectiveTagId],
|
||||
)
|
||||
const taggedTotal = useMemo(
|
||||
() => taggedPreviews.reduce((sum, preview) => sum + preview.total, 0),
|
||||
[taggedPreviews],
|
||||
)
|
||||
|
||||
if (loading || pinnedLoading) {
|
||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||
@@ -155,8 +185,8 @@ export function LibrariesPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<LibrariesHeader
|
||||
previewCount={previews.length}
|
||||
total={total}
|
||||
previewCount={taggedPreviews.length}
|
||||
total={taggedTotal}
|
||||
isAdmin={isAdmin}
|
||||
repairMsg={repairMsg}
|
||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||
@@ -166,11 +196,26 @@ export function LibrariesPage() {
|
||||
onManageLibraries={handleManageLibraries}
|
||||
/>
|
||||
|
||||
<LibraryTagBar
|
||||
tabs={tagTabs}
|
||||
selectedTagId={effectiveTagId}
|
||||
onSelect={libraryTags.setSelectedTagId}
|
||||
onCreate={(name) => {
|
||||
void libraryTags.createTag(name)
|
||||
}}
|
||||
onManage={handleManageTags}
|
||||
busy={libraryTags.saving}
|
||||
/>
|
||||
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState isAdmin={isAdmin} />
|
||||
) : taggedPreviews.length === 0 ? (
|
||||
<p className="rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-16 text-center text-sm text-[var(--app-muted)]">
|
||||
「{effectiveTagId}」标签下还没有媒体库,可在「管理标签」里把媒体库归入该标签。
|
||||
</p>
|
||||
) : (
|
||||
<LibrariesContent
|
||||
previews={sortedPreviews}
|
||||
previews={taggedPreviews}
|
||||
pinnedIds={pinnedIds}
|
||||
onTogglePin={handleTogglePin}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
@@ -179,3 +224,20 @@ export function LibrariesPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */
|
||||
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
||||
if (!tagId || tagId === ALL_TAG_ID) return previews
|
||||
const ordered = filterLibrariesByTag(
|
||||
previews.map((preview) => ({ id: preview.library.id })),
|
||||
tags,
|
||||
tagId,
|
||||
)
|
||||
const byId = new Map(previews.map((preview) => [preview.library.id, preview]))
|
||||
const out: LibraryPreview[] = []
|
||||
for (const item of ordered) {
|
||||
const preview = byId.get(item.id)
|
||||
if (preview) out.push(preview)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user