mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
优化
This commit is contained in:
@@ -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 { 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 排序。
|
* 置顶组始终在前;组内按 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]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user