704 lines
26 KiB
TypeScript
704 lines
26 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
|
|
|
'use client';
|
|
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { useEffect, useState, useRef, useMemo } from 'react';
|
|
|
|
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
|
import PageLayout from '@/components/PageLayout';
|
|
import VideoCard from '@/components/VideoCard';
|
|
|
|
type LibrarySourceType = 'openlist' | 'emby' | 'xiaoya' | `emby:${string}` | `emby_${string}`;
|
|
|
|
interface EmbySourceOption {
|
|
key: string;
|
|
name: string;
|
|
}
|
|
|
|
interface Video {
|
|
id: string;
|
|
folder?: string;
|
|
tmdbId?: number;
|
|
title: string;
|
|
poster: string;
|
|
releaseDate?: string;
|
|
year?: string;
|
|
overview?: string;
|
|
voteAverage?: number;
|
|
rating?: number;
|
|
mediaType: 'movie' | 'tv';
|
|
}
|
|
|
|
interface EmbyView {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
}
|
|
|
|
export default function PrivateLibraryPage() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
// 获取运行时配置
|
|
const runtimeConfig = useMemo(() => {
|
|
if (typeof window !== 'undefined' && (window as any).RUNTIME_CONFIG) {
|
|
return (window as any).RUNTIME_CONFIG;
|
|
}
|
|
return { OPENLIST_ENABLED: false, EMBY_ENABLED: false, XIAOYA_ENABLED: false };
|
|
}, []);
|
|
|
|
// 解析URL中的source参数(支持 emby:emby1 格式)
|
|
const parseSourceParam = (sourceParam: string | null): { sourceType: LibrarySourceType; embyKey?: string } => {
|
|
if (!sourceParam) return { sourceType: 'openlist' };
|
|
|
|
if (sourceParam.includes(':')) {
|
|
const [type, key] = sourceParam.split(':');
|
|
return { sourceType: type as LibrarySourceType, embyKey: key };
|
|
}
|
|
|
|
return { sourceType: sourceParam as LibrarySourceType };
|
|
};
|
|
|
|
const [sourceType, setSourceType] = useState<LibrarySourceType>('openlist');
|
|
const [embyKey, setEmbyKey] = useState<string | undefined>();
|
|
const [embySourceOptions, setEmbySourceOptions] = useState<EmbySourceOption[]>([]);
|
|
const [videos, setVideos] = useState<Video[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [embyViews, setEmbyViews] = useState<EmbyView[]>([]);
|
|
const [selectedView, setSelectedView] = useState<string>('all');
|
|
const [loadingViews, setLoadingViews] = useState(false);
|
|
// 小雅相关状态
|
|
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
|
|
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
|
|
const [xiaoyaFiles, setXiaoyaFiles] = useState<Array<{ name: string; path: string }>>([]);
|
|
const pageSize = 20;
|
|
const observerTarget = useRef<HTMLDivElement>(null);
|
|
const isFetchingRef = useRef(false);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const embyScrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const isDraggingRef = useRef(false);
|
|
const startXRef = useRef(0);
|
|
const scrollLeftRef = useRef(0);
|
|
const isInitializedRef = useRef(false);
|
|
const hasRestoredViewRef = useRef(false);
|
|
|
|
// 从URL初始化状态,并检查配置自动跳转
|
|
useEffect(() => {
|
|
const urlSourceParam = searchParams.get('source');
|
|
|
|
// 解析source参数
|
|
const parsed = parseSourceParam(urlSourceParam);
|
|
|
|
// 如果 OpenList 未配置但 Emby 已配置,强制使用 Emby
|
|
if (!runtimeConfig.OPENLIST_ENABLED && runtimeConfig.EMBY_ENABLED) {
|
|
setSourceType('emby');
|
|
} else if (parsed.sourceType) {
|
|
setSourceType(parsed.sourceType);
|
|
if (parsed.embyKey) {
|
|
setEmbyKey(parsed.embyKey);
|
|
}
|
|
}
|
|
|
|
isInitializedRef.current = true;
|
|
}, [searchParams, runtimeConfig]);
|
|
|
|
// 获取Emby源列表
|
|
useEffect(() => {
|
|
const fetchEmbySources = async () => {
|
|
try {
|
|
const response = await fetch('/api/emby/sources');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setEmbySourceOptions(data.sources || []);
|
|
|
|
// 如果没有设置embyKey,使用第一个源
|
|
if (!embyKey && data.sources && data.sources.length > 0) {
|
|
setEmbyKey(data.sources[0].key);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('获取Emby源列表失败:', error);
|
|
}
|
|
};
|
|
|
|
if (sourceType === 'emby') {
|
|
fetchEmbySources();
|
|
}
|
|
}, [sourceType]);
|
|
|
|
// 更新URL参数
|
|
useEffect(() => {
|
|
if (!isInitializedRef.current) return;
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
// 构建source参数
|
|
if (sourceType === 'emby' && embyKey && embySourceOptions.length > 1) {
|
|
params.set('source', `emby:${embyKey}`);
|
|
} else {
|
|
params.set('source', sourceType);
|
|
}
|
|
|
|
if (sourceType === 'emby' && selectedView !== 'all') {
|
|
params.set('view', selectedView);
|
|
}
|
|
|
|
router.replace(`/private-library?${params.toString()}`, { scroll: false });
|
|
}, [sourceType, embyKey, selectedView, router, embySourceOptions.length]);
|
|
|
|
// 切换源类型时重置所有状态(但不在初始化时执行)
|
|
useEffect(() => {
|
|
if (!isInitializedRef.current) return;
|
|
|
|
setPage(1);
|
|
setVideos([]);
|
|
setHasMore(true);
|
|
setError('');
|
|
setSelectedView('all');
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
isFetchingRef.current = false;
|
|
}, [sourceType, embyKey]);
|
|
|
|
// 切换分类时重置状态(但不在初始化时执行)
|
|
useEffect(() => {
|
|
if (!isInitializedRef.current) return;
|
|
|
|
setPage(1);
|
|
setVideos([]);
|
|
setHasMore(true);
|
|
setError('');
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
isFetchingRef.current = false;
|
|
}, [selectedView]);
|
|
|
|
// 获取 Emby 媒体库列表
|
|
useEffect(() => {
|
|
if (sourceType !== 'emby' || !embyKey) return;
|
|
|
|
const fetchEmbyViews = async () => {
|
|
setLoadingViews(true);
|
|
try {
|
|
const params = new URLSearchParams({ embyKey });
|
|
const response = await fetch(`/api/emby/views?${params.toString()}`);
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
console.error('获取 Emby 媒体库列表失败:', data.error);
|
|
setEmbyViews([]);
|
|
} else {
|
|
setEmbyViews(data.views || []);
|
|
|
|
// 分类加载完成后,检查URL中是否有view参数(只在第一次加载时恢复)
|
|
if (!hasRestoredViewRef.current) {
|
|
const urlView = searchParams.get('view');
|
|
if (urlView && data.views && data.views.length > 0) {
|
|
// 检查该view是否存在于分类列表中
|
|
const viewExists = data.views.some((v: EmbyView) => v.id === urlView);
|
|
if (viewExists) {
|
|
setSelectedView(urlView);
|
|
}
|
|
}
|
|
hasRestoredViewRef.current = true;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('获取 Emby 媒体库列表失败:', err);
|
|
setEmbyViews([]);
|
|
} finally {
|
|
setLoadingViews(false);
|
|
}
|
|
};
|
|
|
|
fetchEmbyViews();
|
|
}, [sourceType, embyKey]);
|
|
|
|
// 鼠标拖动滚动
|
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!scrollContainerRef.current) return;
|
|
isDraggingRef.current = true;
|
|
startXRef.current = e.pageX - scrollContainerRef.current.offsetLeft;
|
|
scrollLeftRef.current = scrollContainerRef.current.scrollLeft;
|
|
scrollContainerRef.current.style.cursor = 'grabbing';
|
|
scrollContainerRef.current.style.userSelect = 'none';
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (!scrollContainerRef.current) return;
|
|
isDraggingRef.current = false;
|
|
scrollContainerRef.current.style.cursor = 'grab';
|
|
scrollContainerRef.current.style.userSelect = 'auto';
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (!scrollContainerRef.current) return;
|
|
isDraggingRef.current = false;
|
|
scrollContainerRef.current.style.cursor = 'grab';
|
|
scrollContainerRef.current.style.userSelect = 'auto';
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!isDraggingRef.current || !scrollContainerRef.current) return;
|
|
e.preventDefault();
|
|
const x = e.pageX - scrollContainerRef.current.offsetLeft;
|
|
const walk = (x - startXRef.current) * 2; // 滚动速度倍数
|
|
scrollContainerRef.current.scrollLeft = scrollLeftRef.current - walk;
|
|
};
|
|
|
|
// 加载数据的函数
|
|
useEffect(() => {
|
|
const fetchVideos = async () => {
|
|
const isInitial = page === 1;
|
|
|
|
// 取消之前的请求
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
|
|
// 如果选择了 openlist 但未配置,不发起请求
|
|
if (sourceType === 'openlist' && !runtimeConfig.OPENLIST_ENABLED) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// 如果选择了 emby 但未配置或没有embyKey,不发起请求
|
|
if (sourceType === 'emby' && (!runtimeConfig.EMBY_ENABLED || !embyKey)) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// 如果选择了 xiaoya 但未配置,不发起请求
|
|
if (sourceType === 'xiaoya' && !runtimeConfig.XIAOYA_ENABLED) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// 创建新的 AbortController
|
|
const abortController = new AbortController();
|
|
abortControllerRef.current = abortController;
|
|
isFetchingRef.current = true;
|
|
|
|
try {
|
|
if (isInitial) {
|
|
setLoading(true);
|
|
} else {
|
|
setLoadingMore(true);
|
|
}
|
|
setError('');
|
|
|
|
const endpoint = sourceType === 'openlist'
|
|
? `/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}`;
|
|
|
|
const response = await fetch(endpoint, { signal: abortController.signal });
|
|
|
|
if (!response.ok) {
|
|
throw new Error('获取视频列表失败');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
setError(data.error);
|
|
if (isInitial) {
|
|
setVideos([]);
|
|
}
|
|
} else {
|
|
// 小雅返回的是文件夹和文件列表
|
|
if (sourceType === 'xiaoya') {
|
|
setXiaoyaFolders(data.folders || []);
|
|
setXiaoyaFiles(data.files || []);
|
|
setVideos([]); // 小雅不使用 videos 状态
|
|
setHasMore(false); // 小雅不需要分页
|
|
} else {
|
|
const newVideos = data.list || [];
|
|
|
|
if (isInitial) {
|
|
setVideos(newVideos);
|
|
} else {
|
|
setVideos((prev) => [...prev, ...newVideos]);
|
|
}
|
|
|
|
// 检查是否还有更多数据
|
|
const currentPage = data.page || page;
|
|
const totalPages = data.totalPages || 1;
|
|
const hasMoreData = currentPage < totalPages;
|
|
setHasMore(hasMoreData);
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
// 忽略取消请求的错误
|
|
if (err.name === 'AbortError') {
|
|
return;
|
|
}
|
|
console.error('获取视频列表失败:', err);
|
|
setError('获取视频列表失败');
|
|
if (isInitial) {
|
|
setVideos([]);
|
|
}
|
|
} finally {
|
|
// 只有当这个请求没有被取消时才更新状态
|
|
if (!abortController.signal.aborted) {
|
|
if (isInitial) {
|
|
setLoading(false);
|
|
} else {
|
|
setLoadingMore(false);
|
|
}
|
|
isFetchingRef.current = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchVideos();
|
|
|
|
// 清理函数:组件卸载时取消请求
|
|
return () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig]);
|
|
|
|
const handleVideoClick = (video: Video) => {
|
|
// 构建source参数
|
|
let sourceParam = sourceType;
|
|
if (sourceType === 'emby' && embyKey && embySourceOptions.length > 1) {
|
|
sourceParam = `emby:${embyKey}`;
|
|
}
|
|
|
|
// 跳转到播放页面
|
|
router.push(`/play?source=${sourceParam}&id=${encodeURIComponent(video.id)}`);
|
|
};
|
|
|
|
// 使用 Intersection Observer 监听滚动
|
|
useEffect(() => {
|
|
if (!observerTarget.current) {
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const entry = entries[0];
|
|
|
|
// 当目标元素可见且还有更多数据且没有正在加载时,加载下一页
|
|
if (entry.isIntersecting && hasMore && !loadingMore && !loading && !isFetchingRef.current) {
|
|
setPage((prev) => prev + 1);
|
|
}
|
|
},
|
|
{ threshold: 0.1, rootMargin: '100px' }
|
|
);
|
|
|
|
const currentTarget = observerTarget.current;
|
|
observer.observe(currentTarget);
|
|
|
|
return () => {
|
|
if (currentTarget) {
|
|
observer.unobserve(currentTarget);
|
|
}
|
|
};
|
|
}, [hasMore, loadingMore, loading, page]);
|
|
|
|
return (
|
|
<PageLayout activePath='/private-library'>
|
|
<div className='container mx-auto px-4 py-6'>
|
|
<div className='mb-6'>
|
|
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
|
|
私人影库
|
|
</h1>
|
|
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
|
观看自我收藏的高清视频吧
|
|
</p>
|
|
</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>
|
|
|
|
{/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */}
|
|
{sourceType === 'emby' && embySourceOptions.length > 1 && (
|
|
<div className='mb-6'>
|
|
<div className='text-xs text-gray-500 dark:text-gray-400 mb-2 px-4'>
|
|
服务
|
|
</div>
|
|
<div className='relative'>
|
|
<div
|
|
ref={embyScrollContainerRef}
|
|
className='overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing'
|
|
onMouseDown={(e) => {
|
|
if (!embyScrollContainerRef.current) return;
|
|
isDraggingRef.current = true;
|
|
startXRef.current = e.pageX - embyScrollContainerRef.current.offsetLeft;
|
|
scrollLeftRef.current = embyScrollContainerRef.current.scrollLeft;
|
|
embyScrollContainerRef.current.style.cursor = 'grabbing';
|
|
embyScrollContainerRef.current.style.userSelect = 'none';
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (!embyScrollContainerRef.current) return;
|
|
isDraggingRef.current = false;
|
|
embyScrollContainerRef.current.style.cursor = 'grab';
|
|
embyScrollContainerRef.current.style.userSelect = 'auto';
|
|
}}
|
|
onMouseUp={() => {
|
|
if (!embyScrollContainerRef.current) return;
|
|
isDraggingRef.current = false;
|
|
embyScrollContainerRef.current.style.cursor = 'grab';
|
|
embyScrollContainerRef.current.style.userSelect = 'auto';
|
|
}}
|
|
onMouseMove={(e) => {
|
|
if (!isDraggingRef.current || !embyScrollContainerRef.current) return;
|
|
e.preventDefault();
|
|
const x = e.pageX - embyScrollContainerRef.current.offsetLeft;
|
|
const walk = (x - startXRef.current) * 2;
|
|
embyScrollContainerRef.current.scrollLeft = scrollLeftRef.current - walk;
|
|
}}
|
|
>
|
|
<div className='flex gap-2 px-4 min-w-min'>
|
|
{embySourceOptions.map((option) => (
|
|
<button
|
|
key={option.key}
|
|
onClick={() => setEmbyKey(option.key)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
|
|
embyKey === option.key
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{option.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 第三级:Emby 媒体库分类选择器 */}
|
|
{sourceType === 'emby' && (
|
|
<div className='mb-6'>
|
|
<div className='text-xs text-gray-500 dark:text-gray-400 mb-2 px-4'>
|
|
分类
|
|
</div>
|
|
{loadingViews ? (
|
|
<div className='flex justify-center'>
|
|
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
|
|
</div>
|
|
) : embyViews.length > 0 ? (
|
|
<div className='relative'>
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className='overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing'
|
|
onMouseDown={handleMouseDown}
|
|
onMouseLeave={handleMouseLeave}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseMove={handleMouseMove}
|
|
>
|
|
<div className='flex gap-2 px-4 min-w-min'>
|
|
<button
|
|
onClick={() => setSelectedView('all')}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
|
|
selectedView === 'all'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
全部
|
|
</button>
|
|
{embyViews.map((view) => (
|
|
<button
|
|
key={view.id}
|
|
onClick={() => setSelectedView(view.id)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
|
|
selectedView === view.id
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{view.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
</div>
|
|
)}
|
|
|
|
{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='flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400'>
|
|
<button
|
|
onClick={() => setXiaoyaPath('/')}
|
|
className='hover:text-blue-600 dark:hover:text-blue-400'
|
|
>
|
|
根目录
|
|
</button>
|
|
{xiaoyaPath.split('/').filter(Boolean).map((part, index, arr) => {
|
|
const path = '/' + arr.slice(0, index + 1).join('/');
|
|
return (
|
|
<span key={path} className='flex items-center gap-2'>
|
|
<span>/</span>
|
|
<button
|
|
onClick={() => setXiaoyaPath(path)}
|
|
className='hover:text-blue-600 dark:hover:text-blue-400'
|
|
>
|
|
{part}
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 文件夹列表 */}
|
|
{xiaoyaFolders.length > 0 && (
|
|
<div className='space-y-2'>
|
|
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'>文件夹</h3>
|
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2'>
|
|
{xiaoyaFolders.map((folder) => (
|
|
<button
|
|
key={folder.path}
|
|
onClick={() => setXiaoyaPath(folder.path)}
|
|
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'
|
|
>
|
|
<svg className='w-5 h-5 text-blue-600' 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>
|
|
<span className='text-sm truncate'>{folder.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 视频文件列表 */}
|
|
{xiaoyaFiles.length > 0 && (
|
|
<div className='space-y-2'>
|
|
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'>视频文件</h3>
|
|
<div className='grid grid-cols-1 gap-2'>
|
|
{xiaoyaFiles.map((file) => {
|
|
// 从当前路径提取文件夹名作为标题
|
|
const pathParts = xiaoyaPath.split('/').filter(Boolean);
|
|
const folderName = pathParts[pathParts.length - 1] || '';
|
|
// 清理文件夹名(移除年份和 TMDb ID)
|
|
const title = folderName
|
|
.replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '')
|
|
.trim() || file.name;
|
|
|
|
return (
|
|
<button
|
|
key={file.path}
|
|
onClick={() => router.push(`/play?source=xiaoya&id=${encodeURIComponent(file.path)}&title=${encodeURIComponent(title)}`)}
|
|
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'
|
|
>
|
|
<svg className='w-5 h-5 text-green-600' 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>
|
|
<span className='text-sm truncate'>{file.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{xiaoyaFolders.length === 0 && xiaoyaFiles.length === 0 && (
|
|
<div className='text-center py-12'>
|
|
<p className='text-gray-500 dark:text-gray-400'>此目录为空</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : videos.length === 0 ? (
|
|
<div className='text-center py-12'>
|
|
<p className='text-gray-500 dark:text-gray-400'>
|
|
{sourceType === 'openlist'
|
|
? '暂无视频,请在管理面板配置 OpenList 并刷新'
|
|
: '暂无视频,请在管理面板配置 Emby'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
|
{videos.map((video) => {
|
|
// 构建source参数用于VideoCard
|
|
// 如果是emby源且有embyKey,使用下划线格式
|
|
let sourceParam = sourceType;
|
|
if (sourceType === 'emby' && embyKey) {
|
|
sourceParam = `emby_${embyKey}`;
|
|
}
|
|
|
|
return (
|
|
<VideoCard
|
|
key={video.id}
|
|
id={video.id}
|
|
source={sourceParam}
|
|
title={video.title}
|
|
poster={video.poster}
|
|
year={video.year || (video.releaseDate ? video.releaseDate.split('-')[0] : '')}
|
|
rate={
|
|
video.rating
|
|
? video.rating.toFixed(1)
|
|
: video.voteAverage && video.voteAverage > 0
|
|
? video.voteAverage.toFixed(1)
|
|
: ''
|
|
}
|
|
from='search'
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 滚动加载指示器 - 始终渲染以便 observer 可以监听 */}
|
|
<div ref={observerTarget} className='flex justify-center items-center py-8 min-h-[100px]'>
|
|
{loadingMore && (
|
|
<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>
|
|
)}
|
|
{!hasMore && videos.length > 0 && !loadingMore && (
|
|
<div className='text-gray-500 dark:text-gray-400'>
|
|
已加载全部内容
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|