From 1f14a1e85b42d6b1f210f039e0a33da8a714be1a Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:44:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/hooks/useLibraryListSort.ts | 38 +++++++++++ web/src/pages/HomePage.tsx | 11 +++- web/src/pages/LibrariesPage.tsx | 12 ++-- web/src/utils/libraryListSort.test.ts | 92 +++++++++++++++++++++++++++ web/src/utils/libraryListSort.ts | 48 ++++++++++---- web/src/utils/pinnedLibraries.ts | 28 -------- 6 files changed, 178 insertions(+), 51 deletions(-) create mode 100644 web/src/hooks/useLibraryListSort.ts create mode 100644 web/src/utils/libraryListSort.test.ts diff --git a/web/src/hooks/useLibraryListSort.ts b/web/src/hooks/useLibraryListSort.ts new file mode 100644 index 0000000..f97bbd9 --- /dev/null +++ b/web/src/hooks/useLibraryListSort.ts @@ -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 } +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 05c3358..5ade32b 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -11,7 +11,8 @@ import { LibraryTagBar } from '../components/LibraryTagBar' import { ManageLibraryTagsDialogView } from '../components/ManageLibraryTagsDialogView' import { useLibraryTags } from '../hooks/useLibraryTags' 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 { ContinueWatchingSection, @@ -34,6 +35,7 @@ export function HomePage() { const [librariesLoading, setLibrariesLoading] = useState(true) const [historyLoading, setHistoryLoading] = useState(true) const { pinnedIds } = usePinnedLibraries() + const { field: sortField, order: sortOrder } = useLibraryListSort() const libraryTags = useLibraryTags() // 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 diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 69bcb52..9c4986f 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -8,6 +8,7 @@ import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference' import { useLibraryTags } from '../hooks/useLibraryTags' import { usePinnedLibraries } from '../hooks/usePinnedLibraries' +import { useLibraryListSort } from '../hooks/useLibraryListSort' import { useAuthStore } from '../stores/auth' import { LibrariesContent, @@ -18,9 +19,7 @@ import type { LibraryPreview } from './librariesPageModel' import type { Library } from '../types' import type { SeriesCard } from '../utils/groupSeries' import { - readLibraryListSort, sortLibraryPreviewsByField, - writeLibraryListSort, type LibraryListSortField, type LibraryListSortOrder, } from '../utils/libraryListSort' @@ -41,8 +40,7 @@ export function LibrariesPage() { const [repairing, setRepairing] = useState(false) const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference() const [repairMsg, setRepairMsg] = useState('') - const [sortField, setSortField] = useState(() => readLibraryListSort().field) - const [sortOrder, setSortOrder] = useState(() => readLibraryListSort().order) + const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort() // 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。 const fetchedPreviewLimitsRef = useRef>(new Map()) @@ -162,10 +160,8 @@ export function LibrariesPage() { ) const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => { - setSortField(field) - setSortOrder(order) - writeLibraryListSort(field, order) - }, []) + setLibrarySort(field, order) + }, [setLibrarySort]) const handleTogglePin = useCallback((libraryId: string) => { void togglePin(libraryId) diff --git a/web/src/utils/libraryListSort.test.ts b/web/src/utils/libraryListSort.test.ts new file mode 100644 index 0000000..1707591 --- /dev/null +++ b/web/src/utils/libraryListSort.test.ts @@ -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') diff --git a/web/src/utils/libraryListSort.ts b/web/src/utils/libraryListSort.ts index 1655185..1407e65 100644 --- a/web/src/utils/libraryListSort.ts +++ b/web/src/utils/libraryListSort.ts @@ -76,7 +76,32 @@ function compareLibraries(a: Library, b: Library, field: LibraryListSortField, o /** * 置顶组始终在前;组内按 field/order 排序。 * 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( items: T[], pinnedIds: string[], @@ -84,17 +109,14 @@ export function sortLibraryPreviewsByField( order: LibraryListSortOrder, ): T[] { if (items.length < 2) return items - - const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index])) - const pinned: T[] = [] - const rest: T[] = [] - for (const item of items) { - if (pinnedRank.has(item.library.id)) pinned.push(item) - else rest.push(item) - } - - const byField = (a: T, b: T) => compareLibraries(a.library, b.library, field, order) - pinned.sort(byField) - rest.sort(byField) - return [...pinned, ...rest] + const ordered = sortLibrariesByField( + items.map((item) => item.library), + pinnedIds, + field, + order, + ) + const byId = new Map(items.map((item) => [item.library.id, item])) + return ordered + .map((library) => byId.get(library.id)) + .filter((item): item is T => item !== undefined) } diff --git a/web/src/utils/pinnedLibraries.ts b/web/src/utils/pinnedLibraries.ts index 7e0d1f1..3328be5 100644 --- a/web/src/utils/pinnedLibraries.ts +++ b/web/src/utils/pinnedLibraries.ts @@ -67,31 +67,3 @@ export function togglePinnedLibraryId(current: string[], id: string): string[] { export function isLibraryPinned(id: string, pinnedIds: string[]): boolean { return pinnedIds.includes(id) } - -export function sortByPinnedIds(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(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 - }) -}