From a4a2bde1a4943174288a70da519dc6164d645c1e Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:05:27 +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/components/MediaSortDropdown.tsx | 128 ++++++++++++++ web/src/pages/LibraryPage.tsx | 76 ++++++++- web/src/pages/LibraryPageHeader.tsx | 66 +++++--- web/src/utils/mediaSort.ts | 205 +++++++++++++++++++++++ 4 files changed, 446 insertions(+), 29 deletions(-) create mode 100644 web/src/components/MediaSortDropdown.tsx create mode 100644 web/src/utils/mediaSort.ts diff --git a/web/src/components/MediaSortDropdown.tsx b/web/src/components/MediaSortDropdown.tsx new file mode 100644 index 0000000..8ab4142 --- /dev/null +++ b/web/src/components/MediaSortDropdown.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+
+ 排序方式 +
+
+ {SORT_OPTIONS.map((option) => { + const isSelected = value === option.id + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index beca73a..bda920f 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,10 +1,17 @@ -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useLocation, useParams, useSearchParams } from 'react-router-dom' import { motion } from 'framer-motion' +import { historyAPI } from '../api/history' import type { Media } from '../types' import { useAuthStore } from '../stores/auth' import type { SeriesCard } from '../utils/groupSeries' +import { + sortMediaList, + sortSeriesList, + type SortField, + type SortOrder, +} from '../utils/mediaSort' import { LibraryPageDialogs } from './LibraryPageDialogs' import { LibraryPageHeader } from './LibraryPageHeader' import { LibraryMediaSections } from './LibraryMediaSections' @@ -25,6 +32,53 @@ export function LibraryPage() { const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false) const [manualMovie, setManualMovie] = useState(null) + // 排序状态(支持按库记忆) + const [sortField, setSortField] = useState(() => { + const saved = localStorage.getItem(`mmtl_lib_sort_field_${id}`) || localStorage.getItem('mmtl_lib_sort_field') + return (saved as SortField) || 'title' + }) + const [sortOrder, setSortOrder] = useState(() => { + 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>(new Map()) + + useEffect(() => { + historyAPI + .list(1000) + .then((historyItems) => { + const map = new Map() + 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(null) const [selectedSeason, setSelectedSeason] = useState(null) @@ -43,6 +97,14 @@ export function LibraryPage() { reloadCurrentLibrary, } = 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 { scanning, scanProgress, @@ -66,7 +128,7 @@ export function LibraryPage() { isSeriesLibrary, isSeries, loading, - seriesCards, + seriesCards: sortedSeriesCards, searchParams, setSearchParams, selectedSeries, @@ -116,7 +178,7 @@ export function LibraryPage() { {!selectedSeries && ( setScrapeDialogOpen(true)} @@ -133,8 +199,8 @@ export function LibraryPage() { void + onReshuffle: () => void onScrapeEpisodeArtworkChange: (checked: boolean) => void onScan: () => void onScrape: () => void @@ -28,6 +34,10 @@ export function LibraryPageHeader({ scanning, scraping, repairing, + sortField, + sortOrder, + onSortChange, + onReshuffle, onScrapeEpisodeArtworkChange, onScan, onScrape, @@ -46,30 +56,38 @@ export function LibraryPageHeader({ {loadingAllText &&

{loadingAllText}

} {scanProgress &&

{scanProgress}

} - {isAdmin && ( -
- - - - -
- )} +
+ + {isAdmin && ( + <> + + + + + + )} +
) } diff --git a/web/src/utils/mediaSort.ts b/web/src/utils/mediaSort.ts new file mode 100644 index 0000000..41657be --- /dev/null +++ b/web/src/utils/mediaSort.ts @@ -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(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, +): 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, +): 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 +}