This commit is contained in:
truewhile
2026-09-16 21:44:42 +08:00
parent f0a6b630c6
commit 1f14a1e85b
6 changed files with 178 additions and 51 deletions
+38
View File
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useState } from 'react'
import {
readLibraryListSort,
writeLibraryListSort,
type LibraryListSortField,
type LibraryListSortOrder,
} from '../utils/libraryListSort'
export type UseLibraryListSortResult = {
field: LibraryListSortField
order: LibraryListSortOrder
setSort: (field: LibraryListSortField, order: LibraryListSortOrder) => void
}
/**
* 媒体库列表的排序偏好(localStorage 持久化)。
*
* 首页和 /libraries 读的是同一份偏好,这样在 /libraries 改了排序后回到首页
* 顺序也一致;默认「库名倒序」。另外监听 storage 事件,多标签页同时打开时
* 一边改动另一边也能跟上。
*/
export function useLibraryListSort(): UseLibraryListSortResult {
const [sort, setSort] = useState(() => readLibraryListSort())
useEffect(() => {
const sync = () => setSort(readLibraryListSort())
window.addEventListener('storage', sync)
return () => window.removeEventListener('storage', sync)
}, [])
const update = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
writeLibraryListSort(field, order)
setSort({ field, order })
}, [])
return { field: sort.field, order: sort.order, setSort: update }
}
+9 -2
View File
@@ -11,7 +11,8 @@ import { LibraryTagBar } from '../components/LibraryTagBar'
import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView' import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView'
import { useLibraryTags } from '../hooks/useLibraryTags' import { useLibraryTags } from '../hooks/useLibraryTags'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { sortByPinnedIds } from '../utils/pinnedLibraries' import { useLibraryListSort } from '../hooks/useLibraryListSort'
import { sortLibrariesByField } from '../utils/libraryListSort'
import { partitionPreviewIDs } from '../utils/remoteEmby' import { partitionPreviewIDs } from '../utils/remoteEmby'
import { import {
ContinueWatchingSection, ContinueWatchingSection,
@@ -34,6 +35,7 @@ export function HomePage() {
const [librariesLoading, setLibrariesLoading] = useState(true) const [librariesLoading, setLibrariesLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(true) const [historyLoading, setHistoryLoading] = useState(true)
const { pinnedIds } = usePinnedLibraries() const { pinnedIds } = usePinnedLibraries()
const { field: sortField, order: sortOrder } = useLibraryListSort()
const libraryTags = useLibraryTags() const libraryTags = useLibraryTags()
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。 // 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
@@ -90,7 +92,12 @@ export function HomePage() {
} }
}, []) }, [])
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds]) // 与 /libraries 共用同一份排序偏好(默认库名倒序):置顶库始终在前,
// 组内按偏好排序字段排。这样在媒体库页改过排序后,首页顺序也一致。
const sortedLibraries = useMemo(
() => sortLibrariesByField(libraries, pinnedIds, sortField, sortOrder),
[libraries, pinnedIds, sortField, sortOrder],
)
// 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。 // 标签过滤只作用于展示层:选中的标签栏决定哪部分媒体库参与轮播/入口网格/内容行。
const effectiveTagId = libraryTags.selectedTagId const effectiveTagId = libraryTags.selectedTagId
+4 -8
View File
@@ -8,6 +8,7 @@ import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference' import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import { useLibraryTags } from '../hooks/useLibraryTags' import { useLibraryTags } from '../hooks/useLibraryTags'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { useLibraryListSort } from '../hooks/useLibraryListSort'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { import {
LibrariesContent, LibrariesContent,
@@ -18,9 +19,7 @@ import type { LibraryPreview } from './librariesPageModel'
import type { Library } from '../types' import type { Library } from '../types'
import type { SeriesCard } from '../utils/groupSeries' import type { SeriesCard } from '../utils/groupSeries'
import { import {
readLibraryListSort,
sortLibraryPreviewsByField, sortLibraryPreviewsByField,
writeLibraryListSort,
type LibraryListSortField, type LibraryListSortField,
type LibraryListSortOrder, type LibraryListSortOrder,
} from '../utils/libraryListSort' } from '../utils/libraryListSort'
@@ -41,8 +40,7 @@ export function LibrariesPage() {
const [repairing, setRepairing] = useState(false) const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference() const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairMsg, setRepairMsg] = useState('') const [repairMsg, setRepairMsg] = useState('')
const [sortField, setSortField] = useState<LibraryListSortField>(() => readLibraryListSort().field) const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort()
const [sortOrder, setSortOrder] = useState<LibraryListSortOrder>(() => readLibraryListSort().order)
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。 // 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map()) const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
@@ -162,10 +160,8 @@ export function LibrariesPage() {
) )
const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => { const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
setSortField(field) setLibrarySort(field, order)
setSortOrder(order) }, [setLibrarySort])
writeLibraryListSort(field, order)
}, [])
const handleTogglePin = useCallback((libraryId: string) => { const handleTogglePin = useCallback((libraryId: string) => {
void togglePin(libraryId) void togglePin(libraryId)
+92
View File
@@ -0,0 +1,92 @@
import {
DEFAULT_LIBRARY_LIST_SORT_FIELD,
DEFAULT_LIBRARY_LIST_SORT_ORDER,
readLibraryListSort,
sortLibrariesByField,
sortLibraryPreviewsByField,
} from './libraryListSort.ts'
import type { Library } from '../types/index.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`libraryListSort: ${name}`)
}
function lib(id: string, name: string, sortOrder = 0): Library {
return { id, name, sort_order: sortOrder } as Library
}
const ordered = (libs: Library[]) => libs.map((item) => item.id).join(',')
// 首页默认就是「库名倒序」,所以没写过偏好时要拿到 name/desc。
const fallback = readLibraryListSort()
check(
'default preference is name descending',
fallback.field === 'name' && fallback.order === 'desc',
)
check(
'default constants stay in sync with the fallback',
DEFAULT_LIBRARY_LIST_SORT_FIELD === 'name' && DEFAULT_LIBRARY_LIST_SORT_ORDER === 'desc',
)
const alpha = [lib('a', '动画'), lib('b', '电影'), lib('c', '纪录片')]
// zh-CN 拼音升序是 电影(b) → 动画(a) → 纪录片(c)。
check(
'name descending is the default order',
ordered(sortLibrariesByField(alpha, [], 'name', 'desc')) === 'c,a,b',
)
check(
'name ascending reverses it',
ordered(sortLibrariesByField(alpha, [], 'name', 'asc')) === 'b,a,c',
)
const manual = [lib('a', '动画', 2), lib('b', '电影', 0), lib('c', '纪录片', 1)]
check(
'manual order ascending follows sort_order',
ordered(sortLibrariesByField(manual, [], 'sort_order', 'asc')) === 'b,c,a',
)
check(
'manual order descending flips sort_order',
ordered(sortLibrariesByField(manual, [], 'sort_order', 'desc')) === 'a,c,b',
)
// 置顶只决定分组:置顶库整体在前,组内仍然按当前字段/方向排。
check(
'pinned libraries always come first',
ordered(sortLibrariesByField(alpha, ['c'], 'name', 'desc')) === 'c,a,b',
)
check(
'pinned group is sorted by the active field too',
ordered(sortLibrariesByField(alpha, ['a', 'c'], 'name', 'desc')) === 'c,a,b',
)
check(
'pinned ids that no longer exist are ignored',
ordered(sortLibrariesByField(alpha, ['missing'], 'name', 'desc')) === 'c,a,b',
)
// 同名时按 sort_order 兜底,再按 id 兜底,保证顺序稳定。
const sameName = [lib('b', '动画', 1), lib('a', '动画', 1), lib('c', '动画', 0)]
check(
'equal names fall back to sort_order then id',
ordered(sortLibrariesByField(sameName, [], 'name', 'desc')) === 'c,a,b',
)
check('single library is returned as-is', sortLibrariesByField([lib('a', '动画')], [], 'name', 'desc').length === 1)
const previews = [
{ library: lib('a', '动画'), total: 1 },
{ library: lib('b', '电影'), total: 2 },
]
check(
'previews follow the library order',
sortLibraryPreviewsByField(previews, [], 'name', 'desc').map((item) => item.library.id).join(',') === 'a,b',
)
check(
'previews keep their payload after sorting',
sortLibraryPreviewsByField(previews, [], 'name', 'desc')[0].total === 1,
)
check(
'previews respect pinning',
sortLibraryPreviewsByField(previews, ['b'], 'name', 'desc').map((item) => item.library.id).join(',') === 'b,a',
)
console.log('libraryListSort.test.ts ok')
+35 -13
View File
@@ -76,7 +76,32 @@ function compareLibraries(a: Library, b: Library, field: LibraryListSortField, o
/** /**
* 置顶组始终在前;组内按 field/order 排序。 * 置顶组始终在前;组内按 field/order 排序。
* pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。 * pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
*
* 首页与 /libraries 共用这份排序,保证两处的媒体库顺序一致。
*/ */
export function sortLibrariesByField(
libraries: Library[],
pinnedIds: string[],
field: LibraryListSortField,
order: LibraryListSortOrder,
): Library[] {
if (libraries.length < 2) return libraries
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
const pinned: Library[] = []
const rest: Library[] = []
for (const library of libraries) {
if (pinnedRank.has(library.id)) pinned.push(library)
else rest.push(library)
}
const byField = (a: Library, b: Library) => compareLibraries(a, b, field, order)
pinned.sort(byField)
rest.sort(byField)
return [...pinned, ...rest]
}
/** 预览列表按库排序:先排库,再按排好的库顺序取回对应预览。 */
export function sortLibraryPreviewsByField<T extends { library: Library }>( export function sortLibraryPreviewsByField<T extends { library: Library }>(
items: T[], items: T[],
pinnedIds: string[], pinnedIds: string[],
@@ -84,17 +109,14 @@ export function sortLibraryPreviewsByField<T extends { library: Library }>(
order: LibraryListSortOrder, order: LibraryListSortOrder,
): T[] { ): T[] {
if (items.length < 2) return items if (items.length < 2) return items
const ordered = sortLibrariesByField(
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index])) items.map((item) => item.library),
const pinned: T[] = [] pinnedIds,
const rest: T[] = [] field,
for (const item of items) { order,
if (pinnedRank.has(item.library.id)) pinned.push(item) )
else rest.push(item) const byId = new Map(items.map((item) => [item.library.id, item]))
} return ordered
.map((library) => byId.get(library.id))
const byField = (a: T, b: T) => compareLibraries(a.library, b.library, field, order) .filter((item): item is T => item !== undefined)
pinned.sort(byField)
rest.sort(byField)
return [...pinned, ...rest]
} }
-28
View File
@@ -67,31 +67,3 @@ export function togglePinnedLibraryId(current: string[], id: string): string[] {
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean { export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
return pinnedIds.includes(id) return pinnedIds.includes(id)
} }
export function sortByPinnedIds<T extends { id: string }>(items: T[], pinnedIds: string[]): T[] {
if (pinnedIds.length === 0) return items
const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index]))
return [...items].sort((a, b) => {
const aRank = rank.get(a.id)
const bRank = rank.get(b.id)
const aPinned = aRank !== undefined
const bPinned = bRank !== undefined
if (aPinned !== bPinned) return aPinned ? -1 : 1
if (aPinned && bPinned) return aRank - bRank
return 0
})
}
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
if (pinnedIds.length === 0) return items
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
return [...items].sort((a, b) => {
const aRank = rank.get(a.library.id)
const bRank = rank.get(b.library.id)
const aPinned = aRank !== undefined
const bPinned = bRank !== undefined
if (aPinned !== bPinned) return aPinned ? -1 : 1
if (aPinned && bPinned) return aRank - bRank
return 0
})
}