完善小雅

This commit is contained in:
mtvpls
2026-01-11 00:52:52 +08:00
parent 03a59b07a9
commit e04e1ce3bc
9 changed files with 566 additions and 65 deletions

View File

@@ -76,6 +76,10 @@ export default function PrivateLibraryPage() {
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
const [xiaoyaFiles, setXiaoyaFiles] = useState<Array<{ name: string; path: string }>>([]);
const [xiaoyaSearchKeyword, setXiaoyaSearchKeyword] = useState<string>('');
const [xiaoyaSearchResults, setXiaoyaSearchResults] = useState<Array<{ name: string; path: string }>>([]);
const [isSearching, setIsSearching] = useState(false);
const [mounted, setMounted] = useState(false);
const pageSize = 20;
const observerTarget = useRef<HTMLDivElement>(null);
const isFetchingRef = useRef(false);
@@ -88,6 +92,38 @@ export default function PrivateLibraryPage() {
const isInitializedRef = useRef(false);
const hasRestoredViewRef = useRef(false);
// 客户端挂载标记
useEffect(() => {
setMounted(true);
}, []);
// 小雅搜索处理函数
const handleXiaoyaSearch = async () => {
if (!xiaoyaSearchKeyword.trim()) return;
setIsSearching(true);
try {
const response = await fetch(`/api/xiaoya/search?keyword=${encodeURIComponent(xiaoyaSearchKeyword)}`);
if (!response.ok) {
throw new Error('搜索失败');
}
const data = await response.json();
if (data.error) {
setError(data.error);
setXiaoyaSearchResults([]);
} else {
setXiaoyaSearchResults(data.videos || []);
}
} catch (err) {
console.error('搜索失败:', err);
setError('搜索失败');
setXiaoyaSearchResults([]);
} finally {
setIsSearching(false);
}
};
// 从URL初始化状态并检查配置自动跳转
useEffect(() => {
const urlSourceParam = searchParams.get('source');
@@ -420,17 +456,19 @@ export default function PrivateLibraryPage() {
</div>
{/* 第一级源类型选择OpenList / Emby / 小雅) */}
<div className='mb-6 flex justify-center'>
<CapsuleSwitch
options={[
...(runtimeConfig.OPENLIST_ENABLED ? [{ label: 'OpenList', value: 'openlist' }] : []),
...(runtimeConfig.EMBY_ENABLED ? [{ label: 'Emby', value: 'emby' }] : []),
...(runtimeConfig.XIAOYA_ENABLED ? [{ label: '小雅', value: 'xiaoya' }] : []),
]}
active={sourceType}
onChange={(value) => setSourceType(value as LibrarySourceType)}
/>
</div>
{mounted && (
<div className='mb-6 flex justify-center'>
<CapsuleSwitch
options={[
...(runtimeConfig.OPENLIST_ENABLED ? [{ label: 'OpenList', value: 'openlist' }] : []),
...(runtimeConfig.EMBY_ENABLED ? [{ label: 'Emby', value: 'emby' }] : []),
...(runtimeConfig.XIAOYA_ENABLED ? [{ label: '小雅', value: 'xiaoya' }] : []),
]}
active={sourceType}
onChange={(value) => setSourceType(value as LibrarySourceType)}
/>
</div>
)}
{/* 第二级Emby源选择仅当选择Emby且有多个源时显示 */}
{sourceType === 'emby' && embySourceOptions.length > 1 && (
@@ -548,17 +586,150 @@ export default function PrivateLibraryPage() {
)}
{loading ? (
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
{Array.from({ length: pageSize }).map((_, index) => (
<div
key={index}
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
/>
))}
</div>
sourceType === 'xiaoya' ? (
// 小雅加载骨架屏 - 文件夹列表样式
<div className='space-y-4'>
{/* 文件夹骨架屏 */}
<div className='space-y-2'>
<div className='h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse' />
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2'>
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
className='h-12 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse'
/>
))}
</div>
</div>
</div>
) : (
// OpenList/Emby 加载骨架屏 - 海报卡片样式
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
{Array.from({ length: pageSize }).map((_, index) => (
<div
key={index}
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
/>
))}
</div>
)
) : sourceType === 'xiaoya' ? (
// 小雅浏览模式
<div className='space-y-4'>
{/* 搜索框 */}
<div className='flex justify-center md:justify-end'>
<div className='relative w-full max-w-md'>
<input
type='text'
placeholder='搜索视频...'
value={xiaoyaSearchKeyword}
onChange={(e) => setXiaoyaSearchKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && xiaoyaSearchKeyword.trim()) {
handleXiaoyaSearch();
}
}}
className='w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
{xiaoyaSearchKeyword ? (
<button
onClick={() => {
setXiaoyaSearchKeyword('');
setXiaoyaSearchResults([]);
}}
className='absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
>
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z' clipRule='evenodd' />
</svg>
</button>
) : (
<button
onClick={handleXiaoyaSearch}
disabled={!xiaoyaSearchKeyword.trim() || isSearching}
className='absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed'
>
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z' clipRule='evenodd' />
</svg>
</button>
)}
</div>
</div>
{/* 搜索结果 */}
{xiaoyaSearchResults.length > 0 ? (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
({xiaoyaSearchResults.length})
</h3>
<button
onClick={() => {
setXiaoyaSearchKeyword('');
setXiaoyaSearchResults([]);
}}
className='text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300'
>
</button>
</div>
<div className='grid grid-cols-1 gap-2'>
{xiaoyaSearchResults.map((item) => {
// 判断是否为视频文件
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
const isVideoFile = videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
// 从路径中提取文件夹名作为标题
const pathParts = item.path.split('/').filter(Boolean);
const folderName = pathParts[pathParts.length - (isVideoFile ? 2 : 1)] || '';
const title = folderName
.replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '')
.trim() || item.name;
return (
<button
key={item.path}
onClick={() => {
if (isVideoFile) {
// 视频文件:直接播放
router.push(`/play?source=xiaoya&id=${encodeURIComponent(item.path)}&title=${encodeURIComponent(title)}`);
} else {
// 文件夹:进入浏览
setXiaoyaPath(item.path);
setXiaoyaSearchKeyword('');
setXiaoyaSearchResults([]);
}
}}
className='flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-left'
>
{isVideoFile ? (
<svg className='w-5 h-5 text-green-600 flex-shrink-0' fill='currentColor' viewBox='0 0 20 20'>
<path d='M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z' />
</svg>
) : (
<svg className='w-5 h-5 text-blue-600 flex-shrink-0' fill='currentColor' viewBox='0 0 20 20'>
<path d='M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z' />
</svg>
)}
<div className='flex-1 min-w-0'>
<div className='text-sm truncate'>{item.name}</div>
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>{item.path}</div>
</div>
</button>
);
})}
</div>
</div>
) : isSearching ? (
<div className='flex justify-center py-8'>
<div className='flex items-center gap-2 text-gray-600 dark:text-gray-400'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
<span>...</span>
</div>
</div>
) : (
<>
{/* 面包屑导航 */}
<div className='flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400'>
<button
@@ -640,6 +811,8 @@ export default function PrivateLibraryPage() {
<p className='text-gray-500 dark:text-gray-400'></p>
</div>
)}
</>
)}
</div>
) : videos.length === 0 ? (
<div className='text-center py-12'>