mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f14a1e85b |
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<LibraryListSortField>(() => readLibraryListSort().field)
|
||||
const [sortOrder, setSortOrder] = useState<LibraryListSortOrder>(() => readLibraryListSort().order)
|
||||
const { field: sortField, order: sortOrder, setSort: setLibrarySort } = useLibraryListSort()
|
||||
|
||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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<T extends { library: Library }>(
|
||||
items: T[],
|
||||
pinnedIds: string[],
|
||||
@@ -84,17 +109,14 @@ export function sortLibraryPreviewsByField<T extends { library: Library }>(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user