emby增加排序

This commit is contained in:
mtvpls
2026-01-14 21:37:13 +08:00
parent b0f10c34ad
commit c28dacd0f6
2 changed files with 230 additions and 11 deletions

View File

@@ -13,12 +13,19 @@ export async function GET(request: NextRequest) {
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const parentId = searchParams.get('parentId') || undefined;
const embyKey = searchParams.get('embyKey') || undefined;
const sortBy = searchParams.get('sortBy') || 'SortName';
const sortOrder = searchParams.get('sortOrder') || 'Ascending';
try {
// 检查缓存
const cached = getCachedEmbyList(page, pageSize, parentId, embyKey);
if (cached) {
return NextResponse.json(cached);
// 判断是否是默认排序(只有默认排序才使用缓存
const isDefaultSort = sortBy === 'SortName' && sortOrder === 'Ascending';
// 只有默认排序才检查缓存
if (isDefaultSort) {
const cached = getCachedEmbyList(page, pageSize, parentId, embyKey);
if (cached) {
return NextResponse.json(cached);
}
}
// 获取Emby客户端
@@ -30,8 +37,8 @@ export async function GET(request: NextRequest) {
IncludeItemTypes: 'Movie,Series',
Recursive: true,
Fields: 'Overview,ProductionYear',
SortBy: 'SortName',
SortOrder: 'Ascending',
SortBy: sortBy,
SortOrder: sortOrder,
StartIndex: (page - 1) * pageSize,
Limit: pageSize,
});
@@ -55,8 +62,10 @@ export async function GET(request: NextRequest) {
total: result.TotalRecordCount,
};
// 缓存结果
setCachedEmbyList(page, pageSize, response, parentId, embyKey);
// 只有默认排序才缓存结果
if (isDefaultSort) {
setCachedEmbyList(page, pageSize, response, parentId, embyKey);
}
return NextResponse.json(response);
} catch (error) {

View File

@@ -4,7 +4,8 @@
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useRef, useMemo } from 'react';
import { Film } from 'lucide-react';
import { Film, ArrowUpDown, ArrowDownWideNarrow, ArrowUpNarrowWide } from 'lucide-react';
import { createPortal } from 'react-dom';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import PageLayout from '@/components/PageLayout';
@@ -74,6 +75,13 @@ export default function PrivateLibraryPage() {
const [embyViews, setEmbyViews] = useState<EmbyView[]>([]);
const [selectedView, setSelectedView] = useState<string>('all');
const [loadingViews, setLoadingViews] = useState(false);
// Emby排序状态
const [sortBy, setSortBy] = useState<string>('SortName');
const [sortOrder, setSortOrder] = useState<'Ascending' | 'Descending'>('Ascending');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const [sortDropdownPosition, setSortDropdownPosition] = useState<{ x: number; y: number; width: number }>({ x: 0, y: 0, width: 0 });
const sortButtonRef = useRef<HTMLDivElement | null>(null);
const sortDropdownRef = useRef<HTMLDivElement | null>(null);
// 小雅相关状态
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
@@ -217,6 +225,20 @@ export default function PrivateLibraryPage() {
isFetchingRef.current = false;
}, [selectedView]);
// 切换排序时重置状态(但不在初始化时执行)
useEffect(() => {
if (!isInitializedRef.current) return;
if (sourceType !== 'emby') return;
setPage(1);
setVideos([]);
setHasMore(true);
setError('');
setLoading(false);
setLoadingMore(false);
isFetchingRef.current = false;
}, [sortBy, sortOrder, sourceType]);
// 获取 Emby 媒体库列表
useEffect(() => {
if (sourceType !== 'emby' || !embyKey) return;
@@ -290,6 +312,101 @@ export default function PrivateLibraryPage() {
scrollContainerRef.current.scrollLeft = scrollLeftRef.current - walk;
};
// 排序相关函数
const sortOptions = [
{ label: '名称', value: 'SortName' },
{ label: '加入时间', value: 'DateCreated' },
{ label: '发行日期', value: 'PremiereDate' },
{ label: '年份', value: 'ProductionYear' },
{ label: '评分', value: 'CommunityRating' },
];
const getSortDisplayText = () => {
const option = sortOptions.find((opt) => opt.value === sortBy);
return option?.label || '排序';
};
const isDefaultSort = () => {
return sortBy === 'SortName' && sortOrder === 'Ascending';
};
const calculateSortDropdownPosition = () => {
const element = sortButtonRef.current;
if (element) {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth < 768;
let x = rect.left;
const minWidth = 200;
let dropdownWidth = Math.max(rect.width, minWidth);
let useFixedWidth = false;
if (isMobile) {
const padding = 16;
const maxWidth = viewportWidth - padding * 2;
dropdownWidth = Math.min(dropdownWidth, maxWidth);
useFixedWidth = true;
if (x + dropdownWidth > viewportWidth - padding) {
x = viewportWidth - dropdownWidth - padding;
}
if (x < padding) {
x = padding;
}
}
setSortDropdownPosition({ x, y: rect.bottom + 4, width: useFixedWidth ? dropdownWidth : rect.width });
}
};
const handleSortButtonClick = () => {
if (showSortDropdown) {
setShowSortDropdown(false);
} else {
setShowSortDropdown(true);
calculateSortDropdownPosition();
}
};
const handleSortOptionSelect = (value: string) => {
setSortBy(value);
setShowSortDropdown(false);
};
const toggleSortOrder = () => {
setSortOrder(sortOrder === 'Ascending' ? 'Descending' : 'Ascending');
};
// 点击外部关闭排序下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
sortDropdownRef.current &&
!sortDropdownRef.current.contains(event.target as Node) &&
sortButtonRef.current &&
!sortButtonRef.current.contains(event.target as Node)
) {
setShowSortDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 滚动时关闭排序下拉框
useEffect(() => {
const handleScroll = () => {
if (showSortDropdown) {
setShowSortDropdown(false);
}
};
document.body.addEventListener('scroll', handleScroll, { passive: true });
return () => {
document.body.removeEventListener('scroll', handleScroll);
};
}, [showSortDropdown]);
// 加载数据的函数
useEffect(() => {
const fetchVideos = async () => {
@@ -335,7 +452,7 @@ export default function PrivateLibraryPage() {
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
: sourceType === 'xiaoya'
? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}`
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}`;
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
const response = await fetch(endpoint, { signal: abortController.signal });
@@ -404,7 +521,7 @@ export default function PrivateLibraryPage() {
abortControllerRef.current.abort();
}
};
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig]);
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig, sortBy, sortOrder]);
const handleVideoClick = (video: Video) => {
// 构建source参数
@@ -592,6 +709,99 @@ export default function PrivateLibraryPage() {
</div>
)}
{/* Emby 排序选择器 */}
{sourceType === 'emby' && (
<div className='mb-6'>
<div className='text-xs text-gray-500 dark:text-gray-400 mb-2 px-4'>
</div>
<div className='px-4'>
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
{/* 排序字段选择 */}
<div ref={sortButtonRef} className='relative'>
<button
onClick={handleSortButtonClick}
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
showSortDropdown
? isDefaultSort()
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-green-600 dark:text-green-400 cursor-default'
: isDefaultSort()
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
: 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 cursor-pointer'
}`}
>
<span>{getSortDisplayText()}</span>
<svg
className={`inline-block w-2.5 h-2.5 sm:w-3 sm:h-3 ml-0.5 sm:ml-1 transition-transform duration-200 ${
showSortDropdown ? 'rotate-180' : ''
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 9l-7 7-7-7' />
</svg>
</button>
</div>
{/* 排序方向切换 */}
<div className='relative'>
<button
onClick={toggleSortOrder}
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
isDefaultSort()
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
: 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 cursor-pointer'
}`}
aria-label={sortOrder === 'Ascending' ? '升序' : '降序'}
>
{sortOrder === 'Ascending' ? (
<ArrowUpNarrowWide className='inline-block w-4 h-4 sm:w-4 sm:h-4' />
) : (
<ArrowDownWideNarrow className='inline-block w-4 h-4 sm:w-4 sm:h-4' />
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* 排序下拉框 Portal */}
{mounted && showSortDropdown && createPortal(
<div
ref={sortDropdownRef}
className='fixed z-[9999] bg-white/95 dark:bg-gray-800/95 rounded-xl border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm max-h-[50vh] flex flex-col'
style={{
left: `${sortDropdownPosition.x}px`,
top: `${sortDropdownPosition.y}px`,
minWidth: `${Math.max(sortDropdownPosition.width, 200)}px`,
maxWidth: '300px',
position: 'fixed',
}}
>
<div className='p-2 sm:p-4 overflow-y-auto flex-1 min-h-0'>
<div className='grid grid-cols-2 gap-1 sm:gap-2'>
{sortOptions.map((option) => (
<button
key={option.value}
onClick={() => handleSortOptionSelect(option.value)}
className={`px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm rounded-lg transition-all duration-200 text-left ${
sortBy === option.value
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-gray-700/80'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>,
document.body
)}
{error && (
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'>
<p className='text-red-800 dark:text-red-200'>{error}</p>