mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 05:46:38 +08:00
优化
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown, Check, Shuffle } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortField,
|
||||||
|
type SortOption,
|
||||||
|
type SortOrder,
|
||||||
|
} from '../utils/mediaSort'
|
||||||
|
|
||||||
|
type MediaSortDropdownProps = {
|
||||||
|
value: SortField
|
||||||
|
order: SortOrder
|
||||||
|
onChange: (field: SortField, order: SortOrder) => void
|
||||||
|
onReshuffle?: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaSortDropdown({
|
||||||
|
value,
|
||||||
|
order,
|
||||||
|
onChange,
|
||||||
|
onReshuffle,
|
||||||
|
className = '',
|
||||||
|
}: MediaSortDropdownProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const currentOption = SORT_OPTIONS.find((opt) => opt.id === value) ?? SORT_OPTIONS[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSelect = (option: SortOption) => {
|
||||||
|
if (option.id === 'random') {
|
||||||
|
if (value === 'random') {
|
||||||
|
onReshuffle?.()
|
||||||
|
} else {
|
||||||
|
onChange('random', 'desc')
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === option.id) {
|
||||||
|
// 再次点击相同项:切换升降序
|
||||||
|
const nextOrder: SortOrder = order === 'asc' ? 'desc' : 'asc'
|
||||||
|
onChange(option.id, nextOrder)
|
||||||
|
} else {
|
||||||
|
// 点击新项:应用默认排序方向
|
||||||
|
onChange(option.id, option.defaultOrder)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700"
|
||||||
|
title="更改排序方式"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="text-sand-500" />
|
||||||
|
<span>排序: {currentOption.label}</span>
|
||||||
|
{value === 'random' ? (
|
||||||
|
<Shuffle size={12} className="text-brand-600" />
|
||||||
|
) : order === 'asc' ? (
|
||||||
|
<ArrowUp size={13} className="text-brand-600 font-bold" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={13} className="text-brand-600 font-bold" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 z-50 mt-1.5 w-52 origin-top-right rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95">
|
||||||
|
<div className="px-2.5 py-1.5 text-[11px] font-bold text-sand-500 border-b border-sand-100">
|
||||||
|
排序方式
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto py-1 space-y-0.5">
|
||||||
|
{SORT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = value === option.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-brand-50 text-brand-700 font-semibold'
|
||||||
|
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||||
|
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center text-brand-600">
|
||||||
|
{option.id === 'random' ? (
|
||||||
|
<Shuffle size={13} />
|
||||||
|
) : order === 'asc' ? (
|
||||||
|
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
import { historyAPI } from '../api/history'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
sortMediaList,
|
||||||
|
sortSeriesList,
|
||||||
|
type SortField,
|
||||||
|
type SortOrder,
|
||||||
|
} from '../utils/mediaSort'
|
||||||
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
||||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||||
@@ -25,6 +32,53 @@ export function LibraryPage() {
|
|||||||
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||||
|
|
||||||
|
// 排序状态(支持按库记忆)
|
||||||
|
const [sortField, setSortField] = useState<SortField>(() => {
|
||||||
|
const saved = localStorage.getItem(`mmtl_lib_sort_field_${id}`) || localStorage.getItem('mmtl_lib_sort_field')
|
||||||
|
return (saved as SortField) || 'title'
|
||||||
|
})
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>(() => {
|
||||||
|
const saved = localStorage.getItem(`mmtl_lib_sort_order_${id}`) || localStorage.getItem('mmtl_lib_sort_order')
|
||||||
|
return (saved as SortOrder) || 'asc'
|
||||||
|
})
|
||||||
|
const [randomSeed, setRandomSeed] = useState(() => Date.now())
|
||||||
|
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
historyAPI
|
||||||
|
.list(1000)
|
||||||
|
.then((historyItems) => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const item of historyItems ?? []) {
|
||||||
|
if (item.media_id && item.watched_at) {
|
||||||
|
if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) {
|
||||||
|
map.set(item.media_id, item.watched_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHistoryMap(map)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSortChange = (field: SortField, order: SortOrder) => {
|
||||||
|
setSortField(field)
|
||||||
|
setSortOrder(order)
|
||||||
|
if (id) {
|
||||||
|
localStorage.setItem(`mmtl_lib_sort_field_${id}`, field)
|
||||||
|
localStorage.setItem(`mmtl_lib_sort_order_${id}`, order)
|
||||||
|
}
|
||||||
|
localStorage.setItem('mmtl_lib_sort_field', field)
|
||||||
|
localStorage.setItem('mmtl_lib_sort_order', order)
|
||||||
|
if (field === 'random') {
|
||||||
|
setRandomSeed(Date.now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReshuffle = () => {
|
||||||
|
setRandomSeed(Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
// 剧集模式:选中某个剧集后展开详情
|
// 剧集模式:选中某个剧集后展开详情
|
||||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||||
@@ -43,6 +97,14 @@ export function LibraryPage() {
|
|||||||
reloadCurrentLibrary,
|
reloadCurrentLibrary,
|
||||||
} = useLibraryData(id, selectedSeries)
|
} = useLibraryData(id, selectedSeries)
|
||||||
|
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap)
|
||||||
|
}, [items, sortField, sortOrder, randomSeed, historyMap])
|
||||||
|
|
||||||
|
const sortedSeriesCards = useMemo(() => {
|
||||||
|
return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap)
|
||||||
|
}, [seriesCards, sortField, sortOrder, randomSeed, historyMap])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scanning,
|
scanning,
|
||||||
scanProgress,
|
scanProgress,
|
||||||
@@ -66,7 +128,7 @@ export function LibraryPage() {
|
|||||||
isSeriesLibrary,
|
isSeriesLibrary,
|
||||||
isSeries,
|
isSeries,
|
||||||
loading,
|
loading,
|
||||||
seriesCards,
|
seriesCards: sortedSeriesCards,
|
||||||
searchParams,
|
searchParams,
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
selectedSeries,
|
selectedSeries,
|
||||||
@@ -116,7 +178,7 @@ export function LibraryPage() {
|
|||||||
{!selectedSeries && (
|
{!selectedSeries && (
|
||||||
<LibraryPageHeader
|
<LibraryPageHeader
|
||||||
library={library}
|
library={library}
|
||||||
itemCount={isSeries ? seriesCards.length : total}
|
itemCount={isSeries ? sortedSeriesCards.length : total}
|
||||||
loadingAllText={loadingAllText}
|
loadingAllText={loadingAllText}
|
||||||
scanProgress={scanProgress}
|
scanProgress={scanProgress}
|
||||||
isAdmin={role === 'admin'}
|
isAdmin={role === 'admin'}
|
||||||
@@ -124,6 +186,10 @@ export function LibraryPage() {
|
|||||||
scanning={scanning}
|
scanning={scanning}
|
||||||
scraping={scraping}
|
scraping={scraping}
|
||||||
repairing={repairing}
|
repairing={repairing}
|
||||||
|
sortField={sortField}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
onReshuffle={handleReshuffle}
|
||||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||||
onScan={handleScan}
|
onScan={handleScan}
|
||||||
onScrape={() => setScrapeDialogOpen(true)}
|
onScrape={() => setScrapeDialogOpen(true)}
|
||||||
@@ -133,8 +199,8 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<LibraryMediaSections
|
<LibraryMediaSections
|
||||||
isSeries={isSeries}
|
isSeries={isSeries}
|
||||||
items={items}
|
items={sortedItems}
|
||||||
seriesCards={seriesCards}
|
seriesCards={sortedSeriesCards}
|
||||||
selectedSeries={selectedSeries}
|
selectedSeries={selectedSeries}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
movieActions={movieActions}
|
movieActions={movieActions}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||||
|
import { MediaSortDropdown } from '../components/MediaSortDropdown'
|
||||||
|
import type { SortField, SortOrder } from '../utils/mediaSort'
|
||||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||||
|
|
||||||
type LibraryPageHeaderProps = {
|
type LibraryPageHeaderProps = {
|
||||||
@@ -12,6 +14,10 @@ type LibraryPageHeaderProps = {
|
|||||||
scanning: boolean
|
scanning: boolean
|
||||||
scraping: boolean
|
scraping: boolean
|
||||||
repairing: boolean
|
repairing: boolean
|
||||||
|
sortField: SortField
|
||||||
|
sortOrder: SortOrder
|
||||||
|
onSortChange: (field: SortField, order: SortOrder) => void
|
||||||
|
onReshuffle: () => void
|
||||||
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
onScrape: () => void
|
onScrape: () => void
|
||||||
@@ -28,6 +34,10 @@ export function LibraryPageHeader({
|
|||||||
scanning,
|
scanning,
|
||||||
scraping,
|
scraping,
|
||||||
repairing,
|
repairing,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
onSortChange,
|
||||||
|
onReshuffle,
|
||||||
onScrapeEpisodeArtworkChange,
|
onScrapeEpisodeArtworkChange,
|
||||||
onScan,
|
onScan,
|
||||||
onScrape,
|
onScrape,
|
||||||
@@ -46,30 +56,38 @@ export function LibraryPageHeader({
|
|||||||
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
|
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
|
||||||
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
|
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<MediaSortDropdown
|
||||||
<EpisodeArtworkToggle
|
value={sortField}
|
||||||
checked={scrapeEpisodeArtwork}
|
order={sortOrder}
|
||||||
onChange={onScrapeEpisodeArtworkChange}
|
onChange={onSortChange}
|
||||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
onReshuffle={onReshuffle}
|
||||||
className="h-10"
|
/>
|
||||||
/>
|
{isAdmin && (
|
||||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
<>
|
||||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
<EpisodeArtworkToggle
|
||||||
</button>
|
checked={scrapeEpisodeArtwork}
|
||||||
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
onChange={onScrapeEpisodeArtworkChange}
|
||||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||||
</button>
|
className="h-9"
|
||||||
<button
|
/>
|
||||||
onClick={onRepairRescrape}
|
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||||
disabled={repairing}
|
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||||
className="btn-outline"
|
</button>
|
||||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||||
>
|
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
</button>
|
||||||
</button>
|
<button
|
||||||
</div>
|
onClick={onRepairRescrape}
|
||||||
)}
|
disabled={repairing}
|
||||||
|
className="btn-outline"
|
||||||
|
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||||
|
>
|
||||||
|
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import type { Media } from '../types'
|
||||||
|
import { seriesTitle, type SeriesCard } from './groupSeries'
|
||||||
|
|
||||||
|
export type SortField =
|
||||||
|
| 'title'
|
||||||
|
| 'release_date'
|
||||||
|
| 'year'
|
||||||
|
| 'created_at'
|
||||||
|
| 'updated_at'
|
||||||
|
| 'rating'
|
||||||
|
| 'imdb_rating'
|
||||||
|
| 'last_played'
|
||||||
|
| 'duration'
|
||||||
|
| 'bitrate'
|
||||||
|
| 'random'
|
||||||
|
|
||||||
|
export type SortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export type SortOption = {
|
||||||
|
id: SortField
|
||||||
|
label: string
|
||||||
|
defaultOrder: SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SORT_OPTIONS: SortOption[] = [
|
||||||
|
{ id: 'title', label: '标题', defaultOrder: 'asc' },
|
||||||
|
{ id: 'release_date', label: '发行日期', defaultOrder: 'desc' },
|
||||||
|
{ id: 'year', label: '年份', defaultOrder: 'desc' },
|
||||||
|
{ id: 'created_at', label: '加入日期', defaultOrder: 'desc' },
|
||||||
|
{ id: 'updated_at', label: '最后一集添加日期', defaultOrder: 'desc' },
|
||||||
|
{ id: 'rating', label: '影评人评分', defaultOrder: 'desc' },
|
||||||
|
{ id: 'imdb_rating', label: 'IMDB评分', defaultOrder: 'desc' },
|
||||||
|
{ id: 'last_played', label: '播放日期', defaultOrder: 'desc' },
|
||||||
|
{ id: 'duration', label: '播放时长', defaultOrder: 'desc' },
|
||||||
|
{ id: 'bitrate', label: '比特率', defaultOrder: 'desc' },
|
||||||
|
{ id: 'random', label: '随机', defaultOrder: 'desc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getSortOption(field: SortField): SortOption {
|
||||||
|
return SORT_OPTIONS.find((opt) => opt.id === field) ?? SORT_OPTIONS[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单伪随机洗牌(基于 seed 保证同一渲染周期内稳定)
|
||||||
|
function pseudoRandomShuffle<T>(array: T[], seed = 1): T[] {
|
||||||
|
const result = [...array]
|
||||||
|
let currentSeed = seed
|
||||||
|
const random = () => {
|
||||||
|
currentSeed = (currentSeed * 9301 + 49297) % 233280
|
||||||
|
return currentSeed / 233280
|
||||||
|
}
|
||||||
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1))
|
||||||
|
const temp = result[i]
|
||||||
|
result[i] = result[j]
|
||||||
|
result[j] = temp
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareStrings(a?: string, b?: string, order: SortOrder = 'asc'): number {
|
||||||
|
const valA = (a ?? '').trim()
|
||||||
|
const valB = (b ?? '').trim()
|
||||||
|
if (!valA && !valB) return 0
|
||||||
|
if (!valA) return 1
|
||||||
|
if (!valB) return -1
|
||||||
|
const cmp = valA.localeCompare(valB, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||||
|
return order === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNumbers(a: number, b: number, order: SortOrder = 'asc'): number {
|
||||||
|
if (isNaN(a) && isNaN(b)) return 0
|
||||||
|
if (isNaN(a)) return 1
|
||||||
|
if (isNaN(b)) return -1
|
||||||
|
return order === 'asc' ? a - b : b - a
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDates(a?: string, b?: string, order: SortOrder = 'asc'): number {
|
||||||
|
const timeA = a ? new Date(a).getTime() : 0
|
||||||
|
const timeB = b ? new Date(b).getTime() : 0
|
||||||
|
const safeTimeA = isNaN(timeA) ? 0 : timeA
|
||||||
|
const safeTimeB = isNaN(timeB) ? 0 : timeB
|
||||||
|
if (!safeTimeA && !safeTimeB) return 0
|
||||||
|
if (!safeTimeA) return 1
|
||||||
|
if (!safeTimeB) return -1
|
||||||
|
return order === 'asc' ? safeTimeA - safeTimeB : safeTimeB - safeTimeA
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortMediaList(
|
||||||
|
items: Media[],
|
||||||
|
field: SortField,
|
||||||
|
order: SortOrder,
|
||||||
|
randomSeed = 1,
|
||||||
|
historyMap?: Map<string, string>,
|
||||||
|
): Media[] {
|
||||||
|
if (!items || items.length === 0) return []
|
||||||
|
if (field === 'random') {
|
||||||
|
return pseudoRandomShuffle(items, randomSeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = [...items]
|
||||||
|
list.sort((a, b) => {
|
||||||
|
switch (field) {
|
||||||
|
case 'title':
|
||||||
|
return compareStrings(a.title || a.original_name, b.title || b.original_name, order)
|
||||||
|
case 'release_date': {
|
||||||
|
const dateA = a.release_date || (a.year ? `${a.year}-01-01` : '')
|
||||||
|
const dateB = b.release_date || (b.year ? `${b.year}-01-01` : '')
|
||||||
|
return compareDates(dateA, dateB, order) || compareStrings(a.title, b.title, 'asc')
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
const cmp = compareNumbers(a.year || 0, b.year || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||||
|
}
|
||||||
|
case 'created_at':
|
||||||
|
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||||
|
case 'updated_at':
|
||||||
|
return compareDates(a.updated_at || a.created_at, b.updated_at || b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||||
|
case 'rating':
|
||||||
|
case 'imdb_rating': {
|
||||||
|
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||||
|
}
|
||||||
|
case 'last_played': {
|
||||||
|
const playedA = historyMap?.get(a.id)
|
||||||
|
const playedB = historyMap?.get(b.id)
|
||||||
|
return compareDates(playedA, playedB, order) || compareStrings(a.title, b.title, 'asc')
|
||||||
|
}
|
||||||
|
case 'duration': {
|
||||||
|
const cmp = compareNumbers(a.duration_sec || 0, b.duration_sec || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||||
|
}
|
||||||
|
case 'bitrate': {
|
||||||
|
// 比特率 / 大小
|
||||||
|
const cmp = compareNumbers(a.size_bytes || 0, b.size_bytes || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(a.title, b.title, 'asc')
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortSeriesList(
|
||||||
|
cards: SeriesCard[],
|
||||||
|
field: SortField,
|
||||||
|
order: SortOrder,
|
||||||
|
randomSeed = 1,
|
||||||
|
historyMap?: Map<string, string>,
|
||||||
|
): SeriesCard[] {
|
||||||
|
if (!cards || cards.length === 0) return []
|
||||||
|
if (field === 'random') {
|
||||||
|
return pseudoRandomShuffle(cards, randomSeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = [...cards]
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const repA = a.rep
|
||||||
|
const repB = b.rep
|
||||||
|
const titleA = seriesTitle(repA)
|
||||||
|
const titleB = seriesTitle(repB)
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'title':
|
||||||
|
return compareStrings(titleA, titleB, order)
|
||||||
|
case 'release_date': {
|
||||||
|
const dateA = repA.release_date || (repA.year ? `${repA.year}-01-01` : '')
|
||||||
|
const dateB = repB.release_date || (repB.year ? `${repB.year}-01-01` : '')
|
||||||
|
return compareDates(dateA, dateB, order) || compareStrings(titleA, titleB, 'asc')
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
const cmp = compareNumbers(repA.year || 0, repB.year || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||||
|
}
|
||||||
|
case 'created_at':
|
||||||
|
return compareDates(repA.created_at, repB.created_at, order) || compareStrings(titleA, titleB, 'asc')
|
||||||
|
case 'updated_at': {
|
||||||
|
const updateA = repA.updated_at || repA.created_at
|
||||||
|
const updateB = repB.updated_at || repB.created_at
|
||||||
|
return compareDates(updateA, updateB, order) || compareStrings(titleA, titleB, 'asc')
|
||||||
|
}
|
||||||
|
case 'rating':
|
||||||
|
case 'imdb_rating': {
|
||||||
|
const cmp = compareNumbers(repA.rating || 0, repB.rating || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||||
|
}
|
||||||
|
case 'last_played': {
|
||||||
|
const playedA = historyMap?.get(repA.id)
|
||||||
|
const playedB = historyMap?.get(repB.id)
|
||||||
|
return compareDates(playedA, playedB, order) || compareStrings(titleA, titleB, 'asc')
|
||||||
|
}
|
||||||
|
case 'duration': {
|
||||||
|
const cmp = compareNumbers(repA.duration_sec || 0, repB.duration_sec || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||||
|
}
|
||||||
|
case 'bitrate': {
|
||||||
|
const cmp = compareNumbers(repA.size_bytes || 0, repB.size_bytes || 0, order)
|
||||||
|
return cmp !== 0 ? cmp : compareStrings(titleA, titleB, 'asc')
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user